v2.2.15: Astra Office Refactor & Multi-Service Integration
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
Task,
|
||||
TaskStatus,
|
||||
TaskStore,
|
||||
readTaskStore,
|
||||
writeTaskStore,
|
||||
parseTaskStore,
|
||||
renderTaskStore,
|
||||
addTask,
|
||||
updateTask,
|
||||
completeTask,
|
||||
summarizeActiveTasks,
|
||||
} from './taskStore';
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user