v2.2.15: Astra Office Refactor & Multi-Service Integration

This commit is contained in:
g1nation
2026-05-16 22:07:06 +09:00
parent 9dcc98ad33
commit 9ca95ab997
46 changed files with 5648 additions and 1299 deletions
+13
View File
@@ -0,0 +1,13 @@
export {
Task,
TaskStatus,
TaskStore,
readTaskStore,
writeTaskStore,
parseTaskStore,
renderTaskStore,
addTask,
updateTask,
completeTask,
summarizeActiveTasks,
} from './taskStore';
+245
View File
@@ -0,0 +1,245 @@
/**
* Task tracker — `.astra/company/_shared/tasks.md` 단일 파일.
*
* 설계 원칙:
* - 외부 DB 없이 *사람이 읽을 수 있는* 마크다운 테이블에 누적. git 으로 history 추적 가능.
* - 파싱은 regex 기반 (셀 구분자 `|`). 사용자가 손으로 편집해도 fault-tolerant.
* - 모든 task 는 안정적 id (t_001, t_002, ...) — agent 가 update/complete 시 식별자로 사용.
* - active / done 두 섹션. done 은 최근 N개만 보관 (오래된 건 archive).
*
* 파일 포맷:
*
* # Tasks
*
* ## Active
* | ID | Title | Owner | Due | Status | Notes |
* |---|---|---|---|---|---|
* | t_001 | 광고주 자료 정리 | @me | 2026-05-24T18:00 | in_progress | 자료 대기 |
* | t_002 | 디자인 리뷰 준비 | @planner | 2026-05-21T13:00 | open | |
*
* ## Done (recent 10)
* | ID | Title | Owner | Completed |
* |---|---|---|---|
* | t_000 | 일정 셋업 | @me | 2026-05-20T10:00 |
*/
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
export type TaskStatus = 'open' | 'in_progress' | 'blocked' | 'done';
export interface Task {
id: string;
title: string;
/** @me / @planner / @qa 같은 자유 형식. 정확한 agent id 와 무관해도 됨. */
owner: string;
/** ISO 'YYYY-MM-DDTHH:MM' 또는 빈 문자열. due 없는 task 도 허용. */
due: string;
status: TaskStatus;
notes: string;
/** done 으로 전환된 시각 ISO. status==='done' 일 때만 의미. */
completedAt?: string;
}
const DONE_KEEP_RECENT = 10;
function _tasksPath(context: vscode.ExtensionContext): string {
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (ws) return path.join(ws, '.astra', 'company', '_shared', 'tasks.md');
return path.join(context.globalStorageUri.fsPath, 'company', '_shared', 'tasks.md');
}
/** 전체 task store. active 와 done 분리. */
export interface TaskStore {
active: Task[];
done: Task[];
}
export function readTaskStore(context: vscode.ExtensionContext): TaskStore {
const p = _tasksPath(context);
try {
if (!fs.existsSync(p)) return { active: [], done: [] };
const md = fs.readFileSync(p, 'utf8');
return parseTaskStore(md);
} catch {
return { active: [], done: [] };
}
}
export function writeTaskStore(context: vscode.ExtensionContext, store: TaskStore): void {
const p = _tasksPath(context);
try {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, renderTaskStore(store), 'utf8');
} catch {
// silent fail — caller 가 다음 read 에서 빈 store 받게 됨.
}
}
// ────────────── parse ──────────────
export function parseTaskStore(md: string): TaskStore {
const active: Task[] = [];
const done: Task[] = [];
let section: 'active' | 'done' | null = null;
const lines = md.split(/\r?\n/);
for (const line of lines) {
if (/^##\s*Active/i.test(line)) { section = 'active'; continue; }
if (/^##\s*Done/i.test(line)) { section = 'done'; continue; }
if (!section) continue;
if (!line.trim().startsWith('|')) continue;
// header / separator 행 skip
if (/^\|\s*ID\b/i.test(line)) continue;
if (/^\|\s*[-:]+/.test(line)) continue;
const cells = _parseRow(line);
if (!cells || cells.length < 3) continue;
if (section === 'active') {
const [id, title, owner, due, status, notes] = cells;
if (!id || !id.startsWith('t_')) continue;
active.push({
id,
title: title ?? '',
owner: owner ?? '',
due: due ?? '',
status: _normalizeStatus(status),
notes: notes ?? '',
});
} else {
const [id, title, owner, completedAt] = cells;
if (!id || !id.startsWith('t_')) continue;
done.push({
id,
title: title ?? '',
owner: owner ?? '',
due: '',
status: 'done',
notes: '',
completedAt: completedAt ?? '',
});
}
}
return { active, done };
}
function _parseRow(line: string): string[] | null {
const t = line.trim();
if (!t.startsWith('|') || !t.endsWith('|')) return null;
const inner = t.slice(1, -1);
return inner.split('|').map((c) => c.trim().replace(/\\\|/g, '|'));
}
function _normalizeStatus(s: string | undefined): TaskStatus {
const v = String(s ?? 'open').trim().toLowerCase().replace(/\s+/g, '_');
if (v === 'in_progress' || v === 'inprogress' || v === 'progress') return 'in_progress';
if (v === 'blocked' || v === 'block') return 'blocked';
if (v === 'done' || v === 'completed' || v === 'closed') return 'done';
return 'open';
}
// ────────────── render ──────────────
export function renderTaskStore(store: TaskStore): string {
const lines: string[] = [
'# Tasks',
'',
'## Active',
'| ID | Title | Owner | Due | Status | Notes |',
'|---|---|---|---|---|---|',
];
for (const t of store.active) {
lines.push(`| ${t.id} | ${_esc(t.title)} | ${_esc(t.owner)} | ${_esc(t.due)} | ${t.status} | ${_esc(t.notes)} |`);
}
if (store.active.length === 0) lines.push('_(no active tasks)_');
lines.push('');
lines.push(`## Done (recent ${DONE_KEEP_RECENT})`);
lines.push('| ID | Title | Owner | Completed |');
lines.push('|---|---|---|---|');
const recentDone = store.done.slice(-DONE_KEEP_RECENT);
for (const t of recentDone) {
lines.push(`| ${t.id} | ${_esc(t.title)} | ${_esc(t.owner)} | ${_esc(t.completedAt ?? '')} |`);
}
if (recentDone.length === 0) lines.push('_(no completed tasks)_');
return lines.join('\n') + '\n';
}
function _esc(s: string): string {
return String(s ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
}
// ────────────── CRUD helpers ──────────────
/**
* 새 task 추가. id 는 active+done 전체 max + 1.
* 사용자가 같은 title 로 중복 요청해도 별개 task — dedup 책임은 agent.
*/
export function addTask(store: TaskStore, input: {
title: string;
owner?: string;
due?: string;
notes?: string;
status?: TaskStatus;
}): Task {
const allIds = [...store.active, ...store.done].map((t) => t.id);
const max = allIds.reduce((acc, id) => {
const m = id.match(/^t_(\d+)$/);
if (!m) return acc;
const n = parseInt(m[1], 10);
return n > acc ? n : acc;
}, 0);
const id = `t_${String(max + 1).padStart(3, '0')}`;
const task: Task = {
id,
title: input.title.trim(),
owner: (input.owner ?? '').trim(),
due: (input.due ?? '').trim(),
status: input.status ?? 'open',
notes: (input.notes ?? '').trim(),
};
store.active.push(task);
return task;
}
/** id 로 task 찾아 patch 적용. 못 찾으면 null. */
export function updateTask(store: TaskStore, id: string, patch: Partial<Task>): Task | null {
const idx = store.active.findIndex((t) => t.id === id);
if (idx < 0) return null;
const cur = store.active[idx];
const next: Task = { ...cur, ...patch, id: cur.id };
store.active[idx] = next;
return next;
}
/**
* task 를 done 으로 옮김. active 에서 빼고 done 에 push.
* 못 찾으면 null. 이미 done 이었으면 active 에는 없으므로 동일.
*/
export function completeTask(store: TaskStore, id: string, completedAt?: string): Task | null {
const idx = store.active.findIndex((t) => t.id === id);
if (idx < 0) return null;
const [t] = store.active.splice(idx, 1);
const closed: Task = { ...t, status: 'done', completedAt: completedAt ?? new Date().toISOString().slice(0, 16) };
store.done.push(closed);
return closed;
}
/** 프롬프트 주입용 — active 만 짧은 마크다운으로. due 가 가까운 순. */
export function summarizeActiveTasks(store: TaskStore, max: number = 12): string {
if (store.active.length === 0) return '';
const sorted = [...store.active].sort((a, b) => {
// due 있는 task 우선 (asc). 없는 건 뒤로.
if (a.due && !b.due) return -1;
if (!a.due && b.due) return 1;
return (a.due || '').localeCompare(b.due || '');
});
const shown = sorted.slice(0, max);
const lines = shown.map((t) => {
const due = t.due ? ` · 마감 ${t.due}` : '';
const owner = t.owner ? ` · ${t.owner}` : '';
const status = t.status !== 'open' ? ` [${t.status}]` : '';
const notes = t.notes ? `${t.notes}` : '';
return `- ${t.id}${status} ${t.title}${owner}${due}${notes}`;
});
if (sorted.length > max) lines.push(`- _(... ${sorted.length - max} more)_`);
return lines.join('\n');
}