@taicho-ai/framework 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +48 -0
- package/src/coaching/patterns.ts +103 -0
- package/src/coaching/proposal.ts +29 -0
- package/src/coaching/retrieval.ts +27 -0
- package/src/coaching/supersede.ts +14 -0
- package/src/coaching/teach.ts +74 -0
- package/src/core/agent-status.ts +79 -0
- package/src/core/auth/constants.ts +53 -0
- package/src/core/auth/login.ts +110 -0
- package/src/core/auth/pkce.ts +18 -0
- package/src/core/auth/profile.ts +38 -0
- package/src/core/auth/refresh.ts +57 -0
- package/src/core/auth/status.ts +26 -0
- package/src/core/command-guard.ts +126 -0
- package/src/core/conversation-artifacts.ts +40 -0
- package/src/core/conversation-replay.ts +160 -0
- package/src/core/costs.ts +97 -0
- package/src/core/discovery.ts +22 -0
- package/src/core/draft.ts +6 -0
- package/src/core/e2e-model.ts +315 -0
- package/src/core/embed.ts +221 -0
- package/src/core/events.ts +133 -0
- package/src/core/firecrawl.ts +25 -0
- package/src/core/headless.ts +260 -0
- package/src/core/instrument.ts +88 -0
- package/src/core/logger.ts +143 -0
- package/src/core/mcp/adapter.ts +34 -0
- package/src/core/mcp/manager.ts +145 -0
- package/src/core/mcp/oauth.ts +119 -0
- package/src/core/memory.ts +13 -0
- package/src/core/mock-model.ts +70 -0
- package/src/core/model.ts +91 -0
- package/src/core/pricing.ts +27 -0
- package/src/core/providers/openai-codex.ts +67 -0
- package/src/core/registry.ts +67 -0
- package/src/core/run.ts +909 -0
- package/src/core/schedule-cli.ts +55 -0
- package/src/core/scheduler.ts +356 -0
- package/src/core/tasks.ts +108 -0
- package/src/core/team-cli.ts +118 -0
- package/src/core/team-routing.ts +58 -0
- package/src/core/tools.ts +1104 -0
- package/src/core/turn-audit.ts +100 -0
- package/src/core/verification.ts +102 -0
- package/src/core/workflow-run.ts +119 -0
- package/src/index.ts +8 -0
- package/src/knowledge/retrieval.ts +73 -0
- package/src/knowledge/sync.ts +28 -0
- package/src/skills/retrieval.ts +22 -0
- package/src/store/annotations.ts +107 -0
- package/src/store/artifacts.ts +338 -0
- package/src/store/config.ts +187 -0
- package/src/store/conversation.ts +107 -0
- package/src/store/db.ts +34 -0
- package/src/store/files.ts +51 -0
- package/src/store/knowledge.ts +201 -0
- package/src/store/mcp-store.ts +65 -0
- package/src/store/migrate.ts +278 -0
- package/src/store/plans.ts +260 -0
- package/src/store/policy.ts +66 -0
- package/src/store/prefs.ts +64 -0
- package/src/store/roster.ts +308 -0
- package/src/store/run-transcript.ts +103 -0
- package/src/store/schedules.ts +94 -0
- package/src/store/seed-skills.ts +75 -0
- package/src/store/skills.ts +89 -0
- package/src/store/sources.ts +58 -0
- package/src/store/spend-ledger.ts +114 -0
- package/src/store/task-state.ts +267 -0
- package/src/store/teams.ts +227 -0
- package/src/store/thread.ts +72 -0
- package/src/store/trace.ts +98 -0
- package/src/store/vectors.ts +26 -0
- package/src/store/workflows.ts +144 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/** Plan 18: the plan store.
|
|
2
|
+
*
|
|
3
|
+
* On disk, mirroring artifacts/ and artifacts/<id>/annotations.jsonl:
|
|
4
|
+
*
|
|
5
|
+
* plans/<id>/v1.json immutable envelope — the item set (the intent)
|
|
6
|
+
* plans/<id>/v2.json a replan; parents: ["<id>@v1"]
|
|
7
|
+
* plans/<id>/events.jsonl append-only item transitions, across ALL versions
|
|
8
|
+
*
|
|
9
|
+
* Files are canon. The `plans` table is a rebuildable index of the FOLDED counters, so the panel and
|
|
10
|
+
* /plan never walk the event log to answer "how many are open". reindexPlans rebuilds it from the files.
|
|
11
|
+
*
|
|
12
|
+
* Crash recovery falls out of the shape: the event log is append-only, so a process that dies mid-run
|
|
13
|
+
* leaves a legible tail, and reconcilePlans appends `interrupted` for items whose bound run never
|
|
14
|
+
* settled — without touching the versioned intent. */
|
|
15
|
+
import { mkdirSync, writeFileSync, appendFileSync, readFileSync, existsSync, readdirSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import type { Database } from "bun:sqlite";
|
|
18
|
+
import { Plan, PlanEvent, PlanItem, PlanItemStatus, TERMINAL_ITEM_STATUS, planHandle, parsePlanHandle, type FoldedItem, type PlanState } from "@taicho-ai/contracts/plan";
|
|
19
|
+
import { paths } from "./files";
|
|
20
|
+
import { log } from "../core/logger";
|
|
21
|
+
|
|
22
|
+
const planDir = (ws: string, id: string) => join(paths.plansDir(ws), id);
|
|
23
|
+
const versionFile = (ws: string, id: string, v: number) => join(planDir(ws, id), `v${v}.json`);
|
|
24
|
+
const eventsFile = (ws: string, id: string) => join(planDir(ws, id), "events.jsonl");
|
|
25
|
+
|
|
26
|
+
/** Every version number on disk for a plan, ascending. */
|
|
27
|
+
function versions(ws: string, id: string): number[] {
|
|
28
|
+
const dir = planDir(ws, id);
|
|
29
|
+
if (!existsSync(dir)) return [];
|
|
30
|
+
return readdirSync(dir)
|
|
31
|
+
.map((f) => /^v(\d+)\.json$/.exec(f))
|
|
32
|
+
.filter((m): m is RegExpExecArray => !!m)
|
|
33
|
+
.map((m) => Number(m[1]))
|
|
34
|
+
.sort((a, b) => a - b);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function latestVersion(ws: string, id: string): number | null {
|
|
38
|
+
const vs = versions(ws, id);
|
|
39
|
+
return vs.length ? vs[vs.length - 1]! : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function readPlanVersion(ws: string, id: string, v: number): Plan | null {
|
|
43
|
+
const f = versionFile(ws, id, v);
|
|
44
|
+
if (!existsSync(f)) return null;
|
|
45
|
+
try { return Plan.parse(JSON.parse(readFileSync(f, "utf8"))); }
|
|
46
|
+
catch (e) { log.warn(`unparseable plan ${id}@v${v}`, e); return null; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Resolve `p_ship` (latest) or `p_ship@v2` (concrete). */
|
|
50
|
+
export function readPlan(ws: string, handle: string): Plan | null {
|
|
51
|
+
const { id, version } = parsePlanHandle(handle);
|
|
52
|
+
const v = version ?? latestVersion(ws, id);
|
|
53
|
+
return v ? readPlanVersion(ws, id, v) : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PlanDraft {
|
|
57
|
+
id?: string;
|
|
58
|
+
owner: string;
|
|
59
|
+
goal: string;
|
|
60
|
+
items: PlanItem[];
|
|
61
|
+
producer: string;
|
|
62
|
+
runId: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const sameShape = (a: PlanItem[], b: PlanItem[]): boolean =>
|
|
66
|
+
a.length === b.length &&
|
|
67
|
+
a.every((x, i) => x.id === b[i]!.id && x.text === b[i]!.text && (x.assignee ?? "") === (b[i]!.assignee ?? ""));
|
|
68
|
+
|
|
69
|
+
/** Mint v1, or vN+1 IF AND ONLY IF the item set differs from the latest version.
|
|
70
|
+
*
|
|
71
|
+
* The dedup is the guard against version churn from a model that re-states its plan every iteration:
|
|
72
|
+
* an identical write returns the existing handle and mints nothing. Version history should answer
|
|
73
|
+
* "when did this agent change its mind", not "how many times did it repeat itself".
|
|
74
|
+
*
|
|
75
|
+
* Exclusive-create (`flag: "wx"`), retrying the version number on EEXIST — the same race-free idiom as
|
|
76
|
+
* saveArtifact and reserveRunId. Two concurrent replans cannot collide on a version number. */
|
|
77
|
+
export function writePlan(ws: string, draft: PlanDraft): { plan: Plan; minted: boolean } {
|
|
78
|
+
const id = draft.id ?? `p_${draft.owner}`;
|
|
79
|
+
const items = draft.items.map((i) => PlanItem.parse(i));
|
|
80
|
+
const dir = planDir(ws, id);
|
|
81
|
+
mkdirSync(dir, { recursive: true });
|
|
82
|
+
|
|
83
|
+
const latest = latestVersion(ws, id);
|
|
84
|
+
if (latest) {
|
|
85
|
+
const prev = readPlanVersion(ws, id, latest);
|
|
86
|
+
if (prev && sameShape(prev.items, items)) return { plan: prev, minted: false };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (let v = (latest ?? 0) + 1; v < (latest ?? 0) + 50; v++) {
|
|
90
|
+
const plan = Plan.parse({
|
|
91
|
+
id, version: v, owner: draft.owner, goal: draft.goal, items,
|
|
92
|
+
parents: latest ? [planHandle({ id, version: latest })] : [],
|
|
93
|
+
producer: draft.producer, runId: draft.runId, created: new Date().toISOString(),
|
|
94
|
+
});
|
|
95
|
+
try {
|
|
96
|
+
writeFileSync(versionFile(ws, id, v), JSON.stringify(plan, null, 2), { flag: "wx" });
|
|
97
|
+
return { plan, minted: true };
|
|
98
|
+
} catch (e) {
|
|
99
|
+
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
|
|
100
|
+
// lost the race for this version number — try the next
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
throw new Error(`could not mint a new version of plan "${id}" (50 collisions)`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Append one transition. A free function, not a RunContext method, because the BACKGROUND task settle
|
|
107
|
+
* path must write to it long after the run that dispatched it is gone. */
|
|
108
|
+
export function appendPlanEvent(ws: string, id: string, event: Omit<PlanEvent, "ts"> & { ts?: string }): PlanEvent {
|
|
109
|
+
const e = PlanEvent.parse({ ...event, ts: event.ts ?? new Date().toISOString() });
|
|
110
|
+
mkdirSync(planDir(ws, id), { recursive: true });
|
|
111
|
+
appendFileSync(eventsFile(ws, id), JSON.stringify(e) + "\n");
|
|
112
|
+
return e;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function readPlanEvents(ws: string, id: string): PlanEvent[] {
|
|
116
|
+
const f = eventsFile(ws, id);
|
|
117
|
+
if (!existsSync(f)) return [];
|
|
118
|
+
const out: PlanEvent[] = [];
|
|
119
|
+
for (const line of readFileSync(f, "utf8").split("\n")) {
|
|
120
|
+
if (!line.trim()) continue;
|
|
121
|
+
try { out.push(PlanEvent.parse(JSON.parse(line))); }
|
|
122
|
+
catch (e) { log.warn(`skipping malformed plan event in ${id}`, e); }
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Current state = fold(events) over the latest version's items. The last event per item wins.
|
|
128
|
+
*
|
|
129
|
+
* Events naming an item that no longer exists (removed by a replan) are ignored — the version is the
|
|
130
|
+
* authority on WHICH items exist, the log is the authority on what happened to them. */
|
|
131
|
+
export function foldPlan(ws: string, handle: string): PlanState | null {
|
|
132
|
+
const plan = readPlan(ws, handle);
|
|
133
|
+
if (!plan) return null;
|
|
134
|
+
const byItem = new Map<string, PlanEvent>();
|
|
135
|
+
// Later lines overwrite earlier. A `rejected` attempt is recorded but must NOT win the fold — that
|
|
136
|
+
// would hand the model exactly the lie the engine-owns rule refuses it.
|
|
137
|
+
for (const e of readPlanEvents(ws, plan.id)) if (!e.rejected) byItem.set(e.item, e);
|
|
138
|
+
|
|
139
|
+
const items: FoldedItem[] = plan.items.map((i) => {
|
|
140
|
+
const e = byItem.get(i.id);
|
|
141
|
+
return e
|
|
142
|
+
? { ...i, status: e.status, boundRunId: e.boundRunId, note: e.note, updated: e.ts }
|
|
143
|
+
: { ...i, status: "pending" as PlanItemStatus };
|
|
144
|
+
});
|
|
145
|
+
const done = items.filter((i) => i.status === "done").length;
|
|
146
|
+
const failed = items.filter((i) => i.status === "failed" || i.status === "blocked" || i.status === "interrupted").length;
|
|
147
|
+
const open = items.filter((i) => !TERMINAL_ITEM_STATUS.has(i.status)).length;
|
|
148
|
+
return { plan, handle: planHandle(plan), items, counts: { total: items.length, done, open, failed } };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The item an item is BOUND to — used by the background-task settle path, which knows only a taskId. */
|
|
152
|
+
export function findPlanItemByBoundRun(ws: string, boundRunId: string): { planId: string; item: string } | null {
|
|
153
|
+
for (const id of listPlanIds(ws)) {
|
|
154
|
+
for (const e of readPlanEvents(ws, id)) if (e.boundRunId === boundRunId) return { planId: id, item: e.item };
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Settle whatever plan item a background task was bound to. Called from the REPL's off-turn settle
|
|
160
|
+
* path, which knows only a taskId — the run that dispatched it is long gone, which is exactly why
|
|
161
|
+
* appendPlanEvent is a free function and not a RunContext method. No-op when nothing is bound. */
|
|
162
|
+
export function settlePlanItemForTask(
|
|
163
|
+
ws: string,
|
|
164
|
+
db: Database,
|
|
165
|
+
taskId: string,
|
|
166
|
+
status: PlanItemStatus,
|
|
167
|
+
note?: string,
|
|
168
|
+
): { planId: string; item: string } | null {
|
|
169
|
+
const found = findPlanItemByBoundRun(ws, taskId);
|
|
170
|
+
if (!found) return null;
|
|
171
|
+
appendPlanEvent(ws, found.planId, { item: found.item, status, by: "engine", runId: taskId, boundRunId: taskId, note });
|
|
172
|
+
const after = foldPlan(ws, found.planId);
|
|
173
|
+
if (after) indexPlan(db, after);
|
|
174
|
+
return found;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function listPlanIds(ws: string): string[] {
|
|
178
|
+
const dir = paths.plansDir(ws);
|
|
179
|
+
if (!existsSync(dir)) return [];
|
|
180
|
+
return readdirSync(dir)
|
|
181
|
+
.filter((id) => existsSync(join(dir, id, "v1.json")))
|
|
182
|
+
.sort();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---- SQLite index (rebuildable; the files are canon) ----------------------------------------------
|
|
186
|
+
|
|
187
|
+
export function indexPlan(db: Database, s: PlanState): void {
|
|
188
|
+
db.query(
|
|
189
|
+
`INSERT INTO plans (id, version, owner, goal, total, done, open, failed, root_run_id, created, updated)
|
|
190
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
191
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
192
|
+
version=excluded.version, owner=excluded.owner, goal=excluded.goal, total=excluded.total,
|
|
193
|
+
done=excluded.done, open=excluded.open, failed=excluded.failed, updated=excluded.updated`,
|
|
194
|
+
).run(
|
|
195
|
+
s.plan.id, s.plan.version, s.plan.owner, s.plan.goal,
|
|
196
|
+
s.counts.total, s.counts.done, s.counts.open, s.counts.failed,
|
|
197
|
+
s.plan.runId, s.plan.created, new Date().toISOString(),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface PlanRow { id: string; version: number; owner: string; goal: string; total: number; done: number; open: number; failed: number; updated: string }
|
|
202
|
+
|
|
203
|
+
/** The agent's LIVE plan: the most recently updated one it owns. */
|
|
204
|
+
export function currentPlanId(db: Database, owner: string): string | null {
|
|
205
|
+
const r = db.query<{ id: string }, [string]>("SELECT id FROM plans WHERE owner = ? ORDER BY updated DESC LIMIT 1").get(owner);
|
|
206
|
+
return r?.id ?? null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function listPlanRows(db: Database, owner?: string): PlanRow[] {
|
|
210
|
+
return owner
|
|
211
|
+
? db.query<PlanRow, [string]>("SELECT id, version, owner, goal, total, done, open, failed, updated FROM plans WHERE owner = ? ORDER BY updated DESC").all(owner)
|
|
212
|
+
: db.query<PlanRow, []>("SELECT id, version, owner, goal, total, done, open, failed, updated FROM plans ORDER BY updated DESC").all();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function reindexPlans(ws: string, db: Database): void {
|
|
216
|
+
db.exec("DELETE FROM plans");
|
|
217
|
+
for (const id of listPlanIds(ws)) {
|
|
218
|
+
const s = foldPlan(ws, id);
|
|
219
|
+
if (s) indexPlan(db, s);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Boot. An item left `in_progress` means the process died while its bound run was in flight — exactly
|
|
224
|
+
* what reconcileTasks does for a task. Append `interrupted` (never rewrite history) and report it.
|
|
225
|
+
* The versioned INTENT is untouched: the item is still what the agent meant to do. */
|
|
226
|
+
export function reconcilePlans(ws: string, db: Database): { planId: string; item: string }[] {
|
|
227
|
+
const interrupted: { planId: string; item: string }[] = [];
|
|
228
|
+
for (const id of listPlanIds(ws)) {
|
|
229
|
+
const s = foldPlan(ws, id);
|
|
230
|
+
if (!s) continue;
|
|
231
|
+
for (const item of s.items) {
|
|
232
|
+
if (item.status !== "in_progress") continue;
|
|
233
|
+
appendPlanEvent(ws, id, {
|
|
234
|
+
item: item.id, status: "interrupted", by: "engine", runId: "boot",
|
|
235
|
+
boundRunId: item.boundRunId, note: "the process exited while this item was in flight",
|
|
236
|
+
});
|
|
237
|
+
interrupted.push({ planId: id, item: item.id });
|
|
238
|
+
}
|
|
239
|
+
const after = foldPlan(ws, id);
|
|
240
|
+
if (after) indexPlan(db, after);
|
|
241
|
+
}
|
|
242
|
+
return interrupted;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Render the plan as the model sees it — the tail-slot block (core/plan-inject.ts wraps it). */
|
|
246
|
+
export function renderPlan(s: PlanState): string {
|
|
247
|
+
const glyph: Record<PlanItemStatus, string> = {
|
|
248
|
+
pending: "[ ]", in_progress: "[~]", done: "[x]", failed: "[!]",
|
|
249
|
+
blocked: "[!]", interrupted: "[?]", dropped: "[-]",
|
|
250
|
+
};
|
|
251
|
+
const lines = s.items.map((i) => {
|
|
252
|
+
const who = i.assignee ? ` @${i.assignee}` : "";
|
|
253
|
+
const why = i.note ? ` — ${i.note}` : "";
|
|
254
|
+
const owned = i.boundRunId ? " (engine-owned)" : "";
|
|
255
|
+
return `${glyph[i.status]} ${i.id}: ${i.text}${who}${owned}${why}`;
|
|
256
|
+
});
|
|
257
|
+
return `GOAL: ${s.plan.goal}\n${lines.join("\n")}\n(${s.counts.done}/${s.counts.total} done, ${s.counts.open} open)`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export { TERMINAL_ITEM_STATUS };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Coaching policy store: one file per note at agents/<id>/policies/<pol_id>.md
|
|
2
|
+
* (YAML frontmatter = the note minus `do`, body = the instruction). Mirrors roster.ts. */
|
|
3
|
+
import { YAML } from "bun";
|
|
4
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { PolicyNote } from "@taicho-ai/contracts/policy";
|
|
7
|
+
import { paths } from "./files";
|
|
8
|
+
import { log } from "../core/logger";
|
|
9
|
+
|
|
10
|
+
const FRONTMATTER = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/;
|
|
11
|
+
|
|
12
|
+
export function serializePolicy(n: PolicyNote): string {
|
|
13
|
+
const { do: doText, ...meta } = n;
|
|
14
|
+
return `---\n${YAML.stringify(meta, null, 2)}\n---\n${doText}\n`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parsePolicy(text: string): PolicyNote {
|
|
18
|
+
const m = FRONTMATTER.exec(text);
|
|
19
|
+
if (!m) throw new Error("policy file missing YAML frontmatter");
|
|
20
|
+
const meta = YAML.parse(m[1]) as Record<string, unknown>;
|
|
21
|
+
return PolicyNote.parse({ ...meta, do: m[2].trim() });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function writePolicy(ws: string, note: PolicyNote): void {
|
|
25
|
+
const dir = paths.policyDir(ws, note.agent);
|
|
26
|
+
mkdirSync(dir, { recursive: true });
|
|
27
|
+
writeFileSync(join(dir, `${note.id}.md`), serializePolicy(note));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function listPolicies(ws: string, agentId: string): PolicyNote[] {
|
|
31
|
+
const dir = paths.policyDir(ws, agentId);
|
|
32
|
+
if (!existsSync(dir)) return [];
|
|
33
|
+
const out: PolicyNote[] = [];
|
|
34
|
+
for (const f of readdirSync(dir)) {
|
|
35
|
+
if (!f.endsWith(".md")) continue;
|
|
36
|
+
try { out.push(parsePolicy(readFileSync(join(dir, f), "utf8"))); }
|
|
37
|
+
catch (e) { log.warn(`skipping policy ${f}`, e); }
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function readPolicy(ws: string, agentId: string, polId: string): PolicyNote | null {
|
|
43
|
+
const f = join(paths.policyDir(ws, agentId), `${polId}.md`);
|
|
44
|
+
if (!existsSync(f)) return null;
|
|
45
|
+
try { return parsePolicy(readFileSync(f, "utf8")); } catch { return null; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function deletePolicy(ws: string, agentId: string, polId: string): boolean {
|
|
49
|
+
const f = join(paths.policyDir(ws, agentId), `${polId}.md`);
|
|
50
|
+
if (!existsSync(f)) return false;
|
|
51
|
+
rmSync(f);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The captain's approval gate for a `proposed` note. Flips it to `approved` (the only status run.ts
|
|
56
|
+
* applies) and persists. Returns the updated note, or null if no such note for that agent. Idempotent:
|
|
57
|
+
* an already-approved note is returned unchanged. Addressed by (agentId, polId), like deletePolicy —
|
|
58
|
+
* a repeated-failure coaching proposal (coaching/patterns.ts) is inert until it passes through here. */
|
|
59
|
+
export function approvePolicy(ws: string, agentId: string, polId: string): PolicyNote | null {
|
|
60
|
+
const cur = readPolicy(ws, agentId, polId);
|
|
61
|
+
if (!cur) return null;
|
|
62
|
+
if (cur.status === "approved") return cur;
|
|
63
|
+
const updated = PolicyNote.parse({ ...cur, status: "approved" });
|
|
64
|
+
writePolicy(ws, updated);
|
|
65
|
+
return updated;
|
|
66
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Plan 10 Phase 4 — durable UI preferences, per workspace at <ws>/agents/.prefs.json (under the
|
|
2
|
+
* gitignored agents/ dir). Mirrors the mcp-store pattern: a tiny JSON file the REPL reads on boot
|
|
3
|
+
* and rewrites when the captain changes a setting. Today it holds only the live view mode
|
|
4
|
+
* (bar/panes/both); the shape is open so future prefs layer on without a migration. */
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
|
+
import { log } from "../core/logger";
|
|
9
|
+
|
|
10
|
+
/** The live-view surfaces: the status bar (summary), the split panes (per-agent detail), or both.
|
|
11
|
+
* Plan 13's consistent-block view is the DEFAULT render (no mode toggle needed). The live waterfall
|
|
12
|
+
* was retired with the /trace inspector (Plan 17) — trace visualization is OpenTelemetry's job now. */
|
|
13
|
+
export const VIEW_MODES = ["bar", "panes", "both"] as const;
|
|
14
|
+
export type ViewMode = (typeof VIEW_MODES)[number];
|
|
15
|
+
export const DEFAULT_VIEW_MODE: ViewMode = "both";
|
|
16
|
+
|
|
17
|
+
export function isViewMode(s: string): s is ViewMode {
|
|
18
|
+
return (VIEW_MODES as readonly string[]).includes(s);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Plan 18: whether the pinned plan panel renders. `.passthrough()` means a new key needs no migration.
|
|
22
|
+
const PrefsSchema = z.object({ viewMode: z.enum(VIEW_MODES).optional(), planPanel: z.boolean().optional() }).passthrough();
|
|
23
|
+
export type Prefs = z.infer<typeof PrefsSchema>;
|
|
24
|
+
|
|
25
|
+
function prefsPath(ws: string): string { return join(ws, "agents", ".prefs.json"); }
|
|
26
|
+
|
|
27
|
+
export function readPrefs(ws: string): Prefs {
|
|
28
|
+
const f = prefsPath(ws);
|
|
29
|
+
if (!existsSync(f)) return {};
|
|
30
|
+
let raw: unknown;
|
|
31
|
+
try { raw = JSON.parse(readFileSync(f, "utf8")); } catch { return {}; }
|
|
32
|
+
const parsed = PrefsSchema.safeParse(raw);
|
|
33
|
+
if (!parsed.success) { log.warn("ignoring malformed .prefs.json"); return {}; }
|
|
34
|
+
return parsed.data;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function write(ws: string, prefs: Prefs): void {
|
|
38
|
+
const f = prefsPath(ws);
|
|
39
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
40
|
+
writeFileSync(f, JSON.stringify(prefs, null, 2));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The persisted view mode, defaulting to `both` (bar + panes) when unset or unreadable. */
|
|
44
|
+
export function getViewMode(ws: string): ViewMode {
|
|
45
|
+
return readPrefs(ws).viewMode ?? DEFAULT_VIEW_MODE;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Persist the chosen view mode (leaving any other prefs intact). */
|
|
49
|
+
export function setViewMode(ws: string, mode: ViewMode): void {
|
|
50
|
+
const prefs = readPrefs(ws);
|
|
51
|
+
prefs.viewMode = mode;
|
|
52
|
+
write(ws, prefs);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Plan 18: is the pinned plan panel shown? Default on — a plan you cannot see is a plan you cannot steer. */
|
|
56
|
+
export function getPlanPanel(ws: string): boolean {
|
|
57
|
+
return readPrefs(ws).planPanel ?? true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function setPlanPanel(ws: string, on: boolean): void {
|
|
61
|
+
const prefs = readPrefs(ws);
|
|
62
|
+
prefs.planPanel = on;
|
|
63
|
+
write(ws, prefs);
|
|
64
|
+
}
|