facult 1.1.0 → 1.2.1
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 +312 -26
- package/package.json +1 -1
- package/src/agents.ts +26 -1
- package/src/ai-state.ts +27 -2
- package/src/ai.ts +1763 -0
- package/src/audit/update-index.ts +1 -0
- package/src/autosync.ts +96 -27
- package/src/builtin.ts +61 -0
- package/src/cli-context.ts +198 -0
- package/src/enable-disable.ts +1 -0
- package/src/global-docs.ts +50 -6
- package/src/graph-query.ts +175 -0
- package/src/graph.ts +119 -0
- package/src/index-builder.ts +1099 -41
- package/src/index.ts +445 -23
- package/src/manage.ts +1904 -187
- package/src/paths.ts +137 -5
- package/src/query.ts +135 -4
- package/src/remote.ts +140 -9
- package/src/trust-list.ts +1 -0
- package/src/trust.ts +1 -0
package/src/ai.ts
ADDED
|
@@ -0,0 +1,1763 @@
|
|
|
1
|
+
import { appendFile, mkdir, readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { ensureAiGraphPath } from "./ai-state";
|
|
4
|
+
import { parseCliContextArgs, resolveCliContextRoot } from "./cli-context";
|
|
5
|
+
import type { AssetScope, GraphNodeKind } from "./graph";
|
|
6
|
+
import { loadGraph, resolveGraphNode } from "./graph-query";
|
|
7
|
+
import {
|
|
8
|
+
facultAiDraftDir,
|
|
9
|
+
facultAiJournalPath,
|
|
10
|
+
facultAiProposalDir,
|
|
11
|
+
facultAiWritebackQueuePath,
|
|
12
|
+
facultRootDir,
|
|
13
|
+
projectRootFromAiRoot,
|
|
14
|
+
projectSlugFromAiRoot,
|
|
15
|
+
} from "./paths";
|
|
16
|
+
|
|
17
|
+
const NEWLINE_RE = /\r?\n/;
|
|
18
|
+
const TRAILING_NEWLINE_RE = /\n$/;
|
|
19
|
+
const NUMERIC_SUFFIX_RE = /(\d+)$/;
|
|
20
|
+
const SLUG_SPLIT_RE = /[/_-]+/;
|
|
21
|
+
const SKILL_MD_SUFFIX_RE = /\/SKILL\.md$/;
|
|
22
|
+
const MARKDOWN_SUFFIX_RE = /\.md$/;
|
|
23
|
+
const SKILL_SUFFIX_RE = /SKILL$/;
|
|
24
|
+
|
|
25
|
+
export type WritebackStatus =
|
|
26
|
+
| "suggested"
|
|
27
|
+
| "recorded"
|
|
28
|
+
| "grouped"
|
|
29
|
+
| "promoted"
|
|
30
|
+
| "resolved"
|
|
31
|
+
| "dismissed"
|
|
32
|
+
| "superseded";
|
|
33
|
+
export type ProposalStatus =
|
|
34
|
+
| "proposed"
|
|
35
|
+
| "drafted"
|
|
36
|
+
| "in_review"
|
|
37
|
+
| "accepted"
|
|
38
|
+
| "rejected"
|
|
39
|
+
| "applied"
|
|
40
|
+
| "failed"
|
|
41
|
+
| "superseded";
|
|
42
|
+
export type ConfidenceLevel = "low" | "medium" | "high";
|
|
43
|
+
export type ProposalKind =
|
|
44
|
+
| "update_asset"
|
|
45
|
+
| "create_asset"
|
|
46
|
+
| "create_instruction"
|
|
47
|
+
| "update_instruction"
|
|
48
|
+
| "create_agent"
|
|
49
|
+
| "update_agent"
|
|
50
|
+
| "extract_snippet"
|
|
51
|
+
| "add_skill"
|
|
52
|
+
| "promote_asset";
|
|
53
|
+
|
|
54
|
+
export interface WritebackEvidence {
|
|
55
|
+
type: string;
|
|
56
|
+
ref: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface AiJournalEvent {
|
|
60
|
+
id: string;
|
|
61
|
+
ts: string;
|
|
62
|
+
kind: string;
|
|
63
|
+
source: string;
|
|
64
|
+
scope: AssetScope;
|
|
65
|
+
projectSlug?: string;
|
|
66
|
+
projectRoot?: string;
|
|
67
|
+
summary: string;
|
|
68
|
+
refs?: string[];
|
|
69
|
+
evidence?: WritebackEvidence[];
|
|
70
|
+
tags?: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface AiWritebackRecord {
|
|
74
|
+
id: string;
|
|
75
|
+
ts: string;
|
|
76
|
+
updatedAt?: string;
|
|
77
|
+
scope: AssetScope;
|
|
78
|
+
projectSlug?: string;
|
|
79
|
+
projectRoot?: string;
|
|
80
|
+
kind: string;
|
|
81
|
+
summary: string;
|
|
82
|
+
evidence: WritebackEvidence[];
|
|
83
|
+
confidence: ConfidenceLevel;
|
|
84
|
+
source: string;
|
|
85
|
+
assetRef?: string;
|
|
86
|
+
assetId?: string;
|
|
87
|
+
assetType?: string;
|
|
88
|
+
suggestedDestination?: string;
|
|
89
|
+
domain?: string;
|
|
90
|
+
tags: string[];
|
|
91
|
+
status: WritebackStatus;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ProposalReviewHistoryEntry {
|
|
95
|
+
ts: string;
|
|
96
|
+
action: string;
|
|
97
|
+
actor: string;
|
|
98
|
+
note?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ProposalReviewRecord {
|
|
102
|
+
status?: "in_review" | "accepted" | "rejected" | "superseded";
|
|
103
|
+
reviewer?: string;
|
|
104
|
+
reviewedAt?: string;
|
|
105
|
+
rejectionReason?: string;
|
|
106
|
+
supersededBy?: string;
|
|
107
|
+
history: ProposalReviewHistoryEntry[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ProposalApplyResult {
|
|
111
|
+
status: "applied" | "failed";
|
|
112
|
+
appliedAt: string;
|
|
113
|
+
appliedBy: string;
|
|
114
|
+
changedFiles: string[];
|
|
115
|
+
draftRefs: string[];
|
|
116
|
+
message?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ProposalDraftHistoryEntry {
|
|
120
|
+
ts: string;
|
|
121
|
+
action: "generated" | "revised";
|
|
122
|
+
actor: string;
|
|
123
|
+
draftRefs: string[];
|
|
124
|
+
note?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface AiProposalRecord {
|
|
128
|
+
id: string;
|
|
129
|
+
ts: string;
|
|
130
|
+
status: ProposalStatus;
|
|
131
|
+
scope: AssetScope;
|
|
132
|
+
projectSlug?: string;
|
|
133
|
+
projectRoot?: string;
|
|
134
|
+
kind: ProposalKind;
|
|
135
|
+
targets: string[];
|
|
136
|
+
sourceWritebacks: string[];
|
|
137
|
+
summary: string;
|
|
138
|
+
rationale: string;
|
|
139
|
+
confidence: ConfidenceLevel;
|
|
140
|
+
reviewRequired: boolean;
|
|
141
|
+
policyClass: string;
|
|
142
|
+
draftRefs: string[];
|
|
143
|
+
sourceProposals?: string[];
|
|
144
|
+
draftHistory?: ProposalDraftHistoryEntry[];
|
|
145
|
+
review?: ProposalReviewRecord;
|
|
146
|
+
applyResult?: ProposalApplyResult;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface AiWritebackGroup {
|
|
150
|
+
by: "asset" | "kind" | "domain";
|
|
151
|
+
key: string;
|
|
152
|
+
count: number;
|
|
153
|
+
writebackIds: string[];
|
|
154
|
+
assetRefs: string[];
|
|
155
|
+
kinds: string[];
|
|
156
|
+
domains: string[];
|
|
157
|
+
tags: string[];
|
|
158
|
+
summary: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface ScopeContext {
|
|
162
|
+
scope: AssetScope;
|
|
163
|
+
projectSlug?: string;
|
|
164
|
+
projectRoot?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface AddWritebackArgs {
|
|
168
|
+
homeDir?: string;
|
|
169
|
+
rootDir: string;
|
|
170
|
+
kind: string;
|
|
171
|
+
summary: string;
|
|
172
|
+
asset?: string;
|
|
173
|
+
evidence?: WritebackEvidence[];
|
|
174
|
+
confidence?: ConfidenceLevel;
|
|
175
|
+
source?: string;
|
|
176
|
+
suggestedDestination?: string;
|
|
177
|
+
domain?: string;
|
|
178
|
+
tags?: string[];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function nowIso(): string {
|
|
182
|
+
return new Date().toISOString();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
186
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function fileExists(pathValue: string): Promise<boolean> {
|
|
190
|
+
try {
|
|
191
|
+
await Bun.file(pathValue).stat();
|
|
192
|
+
return true;
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function ensureParentDir(pathValue: string) {
|
|
199
|
+
await mkdir(dirname(pathValue), { recursive: true });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function appendJsonLine(pathValue: string, value: unknown) {
|
|
203
|
+
await ensureParentDir(pathValue);
|
|
204
|
+
await appendFile(pathValue, `${JSON.stringify(value)}\n`, "utf8");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function readJsonLines<T>(pathValue: string): Promise<T[]> {
|
|
208
|
+
if (!(await fileExists(pathValue))) {
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
const text = await readFile(pathValue, "utf8");
|
|
212
|
+
return text
|
|
213
|
+
.split(NEWLINE_RE)
|
|
214
|
+
.map((line) => line.trim())
|
|
215
|
+
.filter(Boolean)
|
|
216
|
+
.map((line) => JSON.parse(line) as T);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function supportedDraftTarget(pathValue: string): boolean {
|
|
220
|
+
return pathValue.toLowerCase().endsWith(".md");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function uniqueStrings(values: string[]): string[] {
|
|
224
|
+
return [...new Set(values.filter(Boolean))];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function slugToTitle(value: string): string {
|
|
228
|
+
return value
|
|
229
|
+
.split(SLUG_SPLIT_RE)
|
|
230
|
+
.filter(Boolean)
|
|
231
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
|
232
|
+
.join(" ");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function canonicalRefToPath(args: {
|
|
236
|
+
ref: string;
|
|
237
|
+
homeDir: string;
|
|
238
|
+
rootDir: string;
|
|
239
|
+
}): string | null {
|
|
240
|
+
if (args.ref.startsWith("@ai/")) {
|
|
241
|
+
return join(facultRootDir(args.homeDir), args.ref.slice("@ai/".length));
|
|
242
|
+
}
|
|
243
|
+
if (args.ref.startsWith("@project/")) {
|
|
244
|
+
return join(args.rootDir, args.ref.slice("@project/".length));
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function numericSuffix(id: string): number {
|
|
250
|
+
const match = NUMERIC_SUFFIX_RE.exec(id);
|
|
251
|
+
return match ? Number.parseInt(match[1] ?? "0", 10) : 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function nextId(prefix: string, ids: string[]): string {
|
|
255
|
+
const next = ids.reduce((max, id) => Math.max(max, numericSuffix(id)), 0) + 1;
|
|
256
|
+
return `${prefix}-${String(next).padStart(5, "0")}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function resolveScopeContext(rootDir: string, homeDir: string): ScopeContext {
|
|
260
|
+
const projectRoot = projectRootFromAiRoot(rootDir, homeDir);
|
|
261
|
+
const projectSlug = projectSlugFromAiRoot(rootDir, homeDir);
|
|
262
|
+
if (projectRoot && projectSlug) {
|
|
263
|
+
return {
|
|
264
|
+
scope: "project",
|
|
265
|
+
projectRoot,
|
|
266
|
+
projectSlug,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return { scope: "global" };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function latestWritebackMap(args: {
|
|
273
|
+
homeDir: string;
|
|
274
|
+
rootDir: string;
|
|
275
|
+
}): Promise<Map<string, AiWritebackRecord>> {
|
|
276
|
+
const entries = await readJsonLines<AiWritebackRecord>(
|
|
277
|
+
facultAiWritebackQueuePath(args.homeDir, args.rootDir)
|
|
278
|
+
);
|
|
279
|
+
const latest = new Map<string, AiWritebackRecord>();
|
|
280
|
+
for (const entry of entries) {
|
|
281
|
+
latest.set(entry.id, entry);
|
|
282
|
+
}
|
|
283
|
+
return latest;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function appendEvent(
|
|
287
|
+
homeDir: string,
|
|
288
|
+
rootDir: string,
|
|
289
|
+
event: AiJournalEvent
|
|
290
|
+
): Promise<void> {
|
|
291
|
+
const pathValue = facultAiJournalPath(homeDir, rootDir);
|
|
292
|
+
const existing = await readJsonLines<AiJournalEvent>(pathValue);
|
|
293
|
+
const next = {
|
|
294
|
+
...event,
|
|
295
|
+
id: nextId(
|
|
296
|
+
"EVT",
|
|
297
|
+
existing.map((entry) => entry.id)
|
|
298
|
+
),
|
|
299
|
+
};
|
|
300
|
+
await appendJsonLine(pathValue, next);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function mapGraphNodeKind(kind: GraphNodeKind): string {
|
|
304
|
+
switch (kind) {
|
|
305
|
+
case "instruction":
|
|
306
|
+
case "snippet":
|
|
307
|
+
case "agent":
|
|
308
|
+
case "skill":
|
|
309
|
+
case "mcp":
|
|
310
|
+
case "doc":
|
|
311
|
+
case "rendered-target":
|
|
312
|
+
return kind;
|
|
313
|
+
default:
|
|
314
|
+
return "asset";
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function resolveAssetSelection(args: {
|
|
319
|
+
homeDir: string;
|
|
320
|
+
rootDir: string;
|
|
321
|
+
asset: string;
|
|
322
|
+
}): Promise<{
|
|
323
|
+
assetRef?: string;
|
|
324
|
+
assetId?: string;
|
|
325
|
+
assetType?: string;
|
|
326
|
+
}> {
|
|
327
|
+
await ensureAiGraphPath({
|
|
328
|
+
homeDir: args.homeDir,
|
|
329
|
+
rootDir: args.rootDir,
|
|
330
|
+
repair: true,
|
|
331
|
+
});
|
|
332
|
+
const graph = await loadGraph({
|
|
333
|
+
homeDir: args.homeDir,
|
|
334
|
+
rootDir: args.rootDir,
|
|
335
|
+
});
|
|
336
|
+
const node = resolveGraphNode(graph, args.asset);
|
|
337
|
+
if (!node) {
|
|
338
|
+
throw new Error(`Asset not found in graph: ${args.asset}`);
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
assetRef: node.canonicalRef ?? node.id,
|
|
342
|
+
assetId: node.id,
|
|
343
|
+
assetType: mapGraphNodeKind(node.kind),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export async function addWriteback(
|
|
348
|
+
args: AddWritebackArgs
|
|
349
|
+
): Promise<AiWritebackRecord> {
|
|
350
|
+
const homeDir = args.homeDir ?? process.env.HOME ?? "";
|
|
351
|
+
const scopeContext = resolveScopeContext(args.rootDir, homeDir);
|
|
352
|
+
const latest = await latestWritebackMap({
|
|
353
|
+
homeDir,
|
|
354
|
+
rootDir: args.rootDir,
|
|
355
|
+
});
|
|
356
|
+
const asset = args.asset
|
|
357
|
+
? await resolveAssetSelection({
|
|
358
|
+
homeDir,
|
|
359
|
+
rootDir: args.rootDir,
|
|
360
|
+
asset: args.asset,
|
|
361
|
+
})
|
|
362
|
+
: {};
|
|
363
|
+
const record: AiWritebackRecord = {
|
|
364
|
+
id: nextId("WB", [...latest.keys()]),
|
|
365
|
+
ts: nowIso(),
|
|
366
|
+
scope: scopeContext.scope,
|
|
367
|
+
projectSlug: scopeContext.projectSlug,
|
|
368
|
+
projectRoot: scopeContext.projectRoot,
|
|
369
|
+
kind: args.kind.trim(),
|
|
370
|
+
summary: args.summary.trim(),
|
|
371
|
+
evidence: args.evidence ?? [],
|
|
372
|
+
confidence: args.confidence ?? "medium",
|
|
373
|
+
source: args.source ?? "facult:manual",
|
|
374
|
+
assetRef: asset.assetRef,
|
|
375
|
+
assetId: asset.assetId,
|
|
376
|
+
assetType: asset.assetType,
|
|
377
|
+
suggestedDestination: args.suggestedDestination ?? asset.assetRef,
|
|
378
|
+
domain: args.domain,
|
|
379
|
+
tags: [
|
|
380
|
+
...new Set((args.tags ?? []).map((tag) => tag.trim()).filter(Boolean)),
|
|
381
|
+
],
|
|
382
|
+
status: "recorded",
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
await appendJsonLine(
|
|
386
|
+
facultAiWritebackQueuePath(homeDir, args.rootDir),
|
|
387
|
+
record
|
|
388
|
+
);
|
|
389
|
+
await appendEvent(homeDir, args.rootDir, {
|
|
390
|
+
id: nextId("EVT", []),
|
|
391
|
+
ts: record.ts,
|
|
392
|
+
kind: "writeback_recorded",
|
|
393
|
+
source: record.source,
|
|
394
|
+
scope: record.scope,
|
|
395
|
+
projectSlug: record.projectSlug,
|
|
396
|
+
projectRoot: record.projectRoot,
|
|
397
|
+
summary: record.summary,
|
|
398
|
+
refs: record.assetRef ? [record.assetRef] : undefined,
|
|
399
|
+
evidence: record.evidence,
|
|
400
|
+
tags: record.tags,
|
|
401
|
+
});
|
|
402
|
+
return record;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export async function listWritebacks(args?: {
|
|
406
|
+
homeDir?: string;
|
|
407
|
+
rootDir: string;
|
|
408
|
+
}): Promise<AiWritebackRecord[]> {
|
|
409
|
+
if (!args) {
|
|
410
|
+
throw new Error("listWritebacks requires a rootDir");
|
|
411
|
+
}
|
|
412
|
+
const homeDir = args?.homeDir ?? process.env.HOME ?? "";
|
|
413
|
+
const latest = await latestWritebackMap({
|
|
414
|
+
homeDir,
|
|
415
|
+
rootDir: args.rootDir,
|
|
416
|
+
});
|
|
417
|
+
return [...latest.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export async function showWriteback(
|
|
421
|
+
id: string,
|
|
422
|
+
args: { homeDir?: string; rootDir: string }
|
|
423
|
+
): Promise<AiWritebackRecord | null> {
|
|
424
|
+
const latest = await latestWritebackMap({
|
|
425
|
+
homeDir: args.homeDir ?? process.env.HOME ?? "",
|
|
426
|
+
rootDir: args.rootDir,
|
|
427
|
+
});
|
|
428
|
+
return latest.get(id) ?? null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function updateWritebackStatus(
|
|
432
|
+
id: string,
|
|
433
|
+
status: WritebackStatus,
|
|
434
|
+
args: { homeDir?: string; rootDir: string }
|
|
435
|
+
): Promise<AiWritebackRecord> {
|
|
436
|
+
const homeDir = args.homeDir ?? process.env.HOME ?? "";
|
|
437
|
+
const current = await showWriteback(id, { homeDir, rootDir: args.rootDir });
|
|
438
|
+
if (!current) {
|
|
439
|
+
throw new Error(`Writeback not found: ${id}`);
|
|
440
|
+
}
|
|
441
|
+
const next: AiWritebackRecord = {
|
|
442
|
+
...current,
|
|
443
|
+
status,
|
|
444
|
+
updatedAt: nowIso(),
|
|
445
|
+
};
|
|
446
|
+
const eventTs = next.updatedAt ?? next.ts;
|
|
447
|
+
await appendJsonLine(facultAiWritebackQueuePath(homeDir, args.rootDir), next);
|
|
448
|
+
await appendEvent(homeDir, args.rootDir, {
|
|
449
|
+
id: nextId("EVT", []),
|
|
450
|
+
ts: eventTs,
|
|
451
|
+
kind: "writeback_status_changed",
|
|
452
|
+
source: "facult:manual",
|
|
453
|
+
scope: next.scope,
|
|
454
|
+
projectSlug: next.projectSlug,
|
|
455
|
+
projectRoot: next.projectRoot,
|
|
456
|
+
summary: `${id} -> ${status}`,
|
|
457
|
+
refs: next.assetRef ? [next.assetRef] : undefined,
|
|
458
|
+
tags: next.tags,
|
|
459
|
+
});
|
|
460
|
+
return next;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function dismissWriteback(
|
|
464
|
+
id: string,
|
|
465
|
+
args: { homeDir?: string; rootDir: string }
|
|
466
|
+
): Promise<AiWritebackRecord> {
|
|
467
|
+
return updateWritebackStatus(id, "dismissed", args);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function promoteWriteback(
|
|
471
|
+
id: string,
|
|
472
|
+
args: { homeDir?: string; rootDir: string }
|
|
473
|
+
): Promise<AiWritebackRecord> {
|
|
474
|
+
return updateWritebackStatus(id, "promoted", args);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function summarizeGroup(
|
|
478
|
+
by: "asset" | "kind" | "domain",
|
|
479
|
+
key: string,
|
|
480
|
+
entries: AiWritebackRecord[]
|
|
481
|
+
): string {
|
|
482
|
+
if (by === "asset") {
|
|
483
|
+
return `${key} has ${entries.length} writeback${entries.length === 1 ? "" : "s"} across ${uniqueStrings(entries.map((entry) => entry.kind)).join(", ")}.`;
|
|
484
|
+
}
|
|
485
|
+
if (by === "domain") {
|
|
486
|
+
return `${key} appears in ${entries.length} writeback${entries.length === 1 ? "" : "s"} across ${uniqueStrings(entries.map((entry) => entry.kind)).join(", ")}.`;
|
|
487
|
+
}
|
|
488
|
+
return `${key} appears in ${entries.length} writeback${entries.length === 1 ? "" : "s"} across ${uniqueStrings(entries.map((entry) => entry.assetRef ?? "unscoped")).join(", ")}.`;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export async function groupWritebacks(args: {
|
|
492
|
+
homeDir?: string;
|
|
493
|
+
rootDir: string;
|
|
494
|
+
by: "asset" | "kind" | "domain";
|
|
495
|
+
}): Promise<AiWritebackGroup[]> {
|
|
496
|
+
const writebacks = await listWritebacks({
|
|
497
|
+
homeDir: args.homeDir,
|
|
498
|
+
rootDir: args.rootDir,
|
|
499
|
+
});
|
|
500
|
+
const groups = new Map<string, AiWritebackRecord[]>();
|
|
501
|
+
for (const entry of writebacks) {
|
|
502
|
+
if (entry.status === "dismissed" || entry.status === "superseded") {
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
const key =
|
|
506
|
+
args.by === "asset"
|
|
507
|
+
? (entry.assetRef ?? entry.suggestedDestination ?? "unassigned")
|
|
508
|
+
: args.by === "kind"
|
|
509
|
+
? entry.kind
|
|
510
|
+
: (entry.domain ?? "unassigned");
|
|
511
|
+
const next = groups.get(key) ?? [];
|
|
512
|
+
next.push(entry);
|
|
513
|
+
groups.set(key, next);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return [...groups.entries()]
|
|
517
|
+
.map(([key, entries]) => ({
|
|
518
|
+
by: args.by,
|
|
519
|
+
key,
|
|
520
|
+
count: entries.length,
|
|
521
|
+
writebackIds: entries.map((entry) => entry.id).sort(),
|
|
522
|
+
assetRefs: uniqueStrings(entries.map((entry) => entry.assetRef ?? "")),
|
|
523
|
+
kinds: uniqueStrings(entries.map((entry) => entry.kind)),
|
|
524
|
+
domains: uniqueStrings(entries.map((entry) => entry.domain ?? "")),
|
|
525
|
+
tags: uniqueStrings(entries.flatMap((entry) => entry.tags)),
|
|
526
|
+
summary: summarizeGroup(args.by, key, entries),
|
|
527
|
+
}))
|
|
528
|
+
.sort((a, b) => a.key.localeCompare(b.key));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export function summarizeWritebacks(args: {
|
|
532
|
+
homeDir?: string;
|
|
533
|
+
rootDir: string;
|
|
534
|
+
by: "asset" | "kind" | "domain";
|
|
535
|
+
}): Promise<AiWritebackGroup[]> {
|
|
536
|
+
return groupWritebacks(args);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function inferProposalKind(args: {
|
|
540
|
+
target: string;
|
|
541
|
+
targetPath: string | null;
|
|
542
|
+
targetKind?: GraphNodeKind | null;
|
|
543
|
+
}): ProposalKind {
|
|
544
|
+
if (args.target.includes("/skills/") || args.target.endsWith("/SKILL.md")) {
|
|
545
|
+
return "add_skill";
|
|
546
|
+
}
|
|
547
|
+
if (args.target.includes("/snippets/")) {
|
|
548
|
+
return "extract_snippet";
|
|
549
|
+
}
|
|
550
|
+
if (args.targetKind === "agent" || args.target.includes("/agents/")) {
|
|
551
|
+
return args.targetPath ? "update_agent" : "create_agent";
|
|
552
|
+
}
|
|
553
|
+
if (
|
|
554
|
+
args.targetKind === "instruction" ||
|
|
555
|
+
args.target.includes("/instructions/")
|
|
556
|
+
) {
|
|
557
|
+
return args.targetPath ? "update_instruction" : "create_instruction";
|
|
558
|
+
}
|
|
559
|
+
if (!args.targetPath) {
|
|
560
|
+
return "create_asset";
|
|
561
|
+
}
|
|
562
|
+
return "update_asset";
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function nextProposalId(
|
|
566
|
+
homeDir: string,
|
|
567
|
+
rootDir: string
|
|
568
|
+
): Promise<string> {
|
|
569
|
+
const dir = facultAiProposalDir(homeDir, rootDir);
|
|
570
|
+
const entries = await readdir(dir).catch(() => [] as string[]);
|
|
571
|
+
const ids = entries
|
|
572
|
+
.filter((entry) => entry.endsWith(".json"))
|
|
573
|
+
.map((entry) => basename(entry, ".json"));
|
|
574
|
+
return nextId("EV", ids);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function policyProfileForProposal(
|
|
578
|
+
scope: AssetScope,
|
|
579
|
+
kind: ProposalKind
|
|
580
|
+
): { policyClass: string; reviewRequired: boolean } {
|
|
581
|
+
if (scope === "global") {
|
|
582
|
+
return {
|
|
583
|
+
policyClass: "high-risk",
|
|
584
|
+
reviewRequired: true,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (
|
|
589
|
+
kind === "create_instruction" ||
|
|
590
|
+
kind === "create_asset" ||
|
|
591
|
+
kind === "extract_snippet" ||
|
|
592
|
+
kind === "add_skill"
|
|
593
|
+
) {
|
|
594
|
+
return {
|
|
595
|
+
policyClass: "low-risk",
|
|
596
|
+
reviewRequired: false,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (kind === "update_instruction" || kind === "update_asset") {
|
|
601
|
+
return {
|
|
602
|
+
policyClass: "medium-risk",
|
|
603
|
+
reviewRequired: true,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
policyClass: "high-risk",
|
|
609
|
+
reviewRequired: true,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function isStandaloneProposalKind(kind: ProposalKind): boolean {
|
|
614
|
+
return (
|
|
615
|
+
kind === "create_asset" ||
|
|
616
|
+
kind === "create_instruction" ||
|
|
617
|
+
kind === "create_agent" ||
|
|
618
|
+
kind === "extract_snippet" ||
|
|
619
|
+
kind === "add_skill" ||
|
|
620
|
+
kind === "promote_asset"
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function isAppendProposalKind(kind: ProposalKind): boolean {
|
|
625
|
+
return !isStandaloneProposalKind(kind);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function isApplySupportedProposalKind(kind: ProposalKind): boolean {
|
|
629
|
+
return isStandaloneProposalKind(kind) || isAppendProposalKind(kind);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function writeProposalFile(
|
|
633
|
+
homeDir: string,
|
|
634
|
+
rootDir: string,
|
|
635
|
+
proposal: AiProposalRecord
|
|
636
|
+
) {
|
|
637
|
+
const dir = facultAiProposalDir(homeDir, rootDir);
|
|
638
|
+
await mkdir(dir, { recursive: true });
|
|
639
|
+
await Bun.write(
|
|
640
|
+
join(dir, `${proposal.id}.json`),
|
|
641
|
+
`${JSON.stringify(proposal, null, 2)}\n`
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export async function proposeEvolution(args: {
|
|
646
|
+
homeDir?: string;
|
|
647
|
+
rootDir: string;
|
|
648
|
+
asset?: string;
|
|
649
|
+
}): Promise<AiProposalRecord[]> {
|
|
650
|
+
const homeDir = args.homeDir ?? process.env.HOME ?? "";
|
|
651
|
+
const writebacks = await listWritebacks({
|
|
652
|
+
homeDir,
|
|
653
|
+
rootDir: args.rootDir,
|
|
654
|
+
});
|
|
655
|
+
const scopeContext = resolveScopeContext(args.rootDir, homeDir);
|
|
656
|
+
const graph = await loadGraph({
|
|
657
|
+
homeDir,
|
|
658
|
+
rootDir: args.rootDir,
|
|
659
|
+
}).catch(() => null);
|
|
660
|
+
const filterAsset = args.asset
|
|
661
|
+
? await resolveAssetSelection({
|
|
662
|
+
homeDir,
|
|
663
|
+
rootDir: args.rootDir,
|
|
664
|
+
asset: args.asset,
|
|
665
|
+
})
|
|
666
|
+
: null;
|
|
667
|
+
|
|
668
|
+
const candidates = writebacks.filter((entry) => {
|
|
669
|
+
if (entry.status === "dismissed" || entry.status === "superseded") {
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
if (filterAsset) {
|
|
673
|
+
return (
|
|
674
|
+
entry.assetId === filterAsset.assetId ||
|
|
675
|
+
entry.assetRef === filterAsset.assetRef
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
return Boolean(entry.suggestedDestination ?? entry.assetRef);
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
const groups = new Map<string, AiWritebackRecord[]>();
|
|
682
|
+
for (const entry of candidates) {
|
|
683
|
+
const target = entry.suggestedDestination ?? entry.assetRef;
|
|
684
|
+
if (!target) {
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
const next = groups.get(target) ?? [];
|
|
688
|
+
next.push(entry);
|
|
689
|
+
groups.set(target, next);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const proposals: AiProposalRecord[] = [];
|
|
693
|
+
for (const [target, entries] of groups) {
|
|
694
|
+
if (entries.length === 0) {
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
const id = await nextProposalId(homeDir, args.rootDir);
|
|
698
|
+
const targetPath = canonicalRefToPath({
|
|
699
|
+
ref: target,
|
|
700
|
+
homeDir,
|
|
701
|
+
rootDir: args.rootDir,
|
|
702
|
+
});
|
|
703
|
+
const targetNode = graph && (resolveGraphNode(graph, target) ?? undefined);
|
|
704
|
+
const kind = inferProposalKind({
|
|
705
|
+
target,
|
|
706
|
+
targetKind: targetNode?.kind,
|
|
707
|
+
targetPath:
|
|
708
|
+
targetNode?.path ??
|
|
709
|
+
((targetPath && (await fileExists(targetPath)) && targetPath) || null),
|
|
710
|
+
});
|
|
711
|
+
const policy = policyProfileForProposal(scopeContext.scope, kind);
|
|
712
|
+
const proposal: AiProposalRecord = {
|
|
713
|
+
id,
|
|
714
|
+
ts: nowIso(),
|
|
715
|
+
status: "proposed",
|
|
716
|
+
scope: scopeContext.scope,
|
|
717
|
+
projectSlug: scopeContext.projectSlug,
|
|
718
|
+
projectRoot: scopeContext.projectRoot,
|
|
719
|
+
kind,
|
|
720
|
+
targets: [target],
|
|
721
|
+
sourceWritebacks: entries.map((entry) => entry.id),
|
|
722
|
+
summary: `Update ${target} based on ${entries.length} writeback${entries.length === 1 ? "" : "s"}.`,
|
|
723
|
+
rationale: `Generated from ${entries.length} writeback${entries.length === 1 ? "" : "s"}: ${entries
|
|
724
|
+
.map((entry) => entry.kind)
|
|
725
|
+
.join(", ")}.`,
|
|
726
|
+
confidence: entries.length > 1 ? "high" : "medium",
|
|
727
|
+
reviewRequired: policy.reviewRequired,
|
|
728
|
+
policyClass: policy.policyClass,
|
|
729
|
+
draftRefs: [],
|
|
730
|
+
};
|
|
731
|
+
await writeProposalFile(homeDir, args.rootDir, proposal);
|
|
732
|
+
await appendEvent(homeDir, args.rootDir, {
|
|
733
|
+
id: nextId("EVT", []),
|
|
734
|
+
ts: proposal.ts,
|
|
735
|
+
kind: "proposal_generated",
|
|
736
|
+
source: "facult:evolution",
|
|
737
|
+
scope: proposal.scope,
|
|
738
|
+
projectSlug: proposal.projectSlug,
|
|
739
|
+
projectRoot: proposal.projectRoot,
|
|
740
|
+
summary: proposal.summary,
|
|
741
|
+
refs: proposal.targets,
|
|
742
|
+
tags: [],
|
|
743
|
+
});
|
|
744
|
+
proposals.push(proposal);
|
|
745
|
+
for (const entry of entries) {
|
|
746
|
+
if (entry.status !== "promoted") {
|
|
747
|
+
await promoteWriteback(entry.id, { homeDir, rootDir: args.rootDir });
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return proposals.sort((a, b) => a.id.localeCompare(b.id));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
export async function listProposals(args?: {
|
|
756
|
+
homeDir?: string;
|
|
757
|
+
rootDir: string;
|
|
758
|
+
}): Promise<AiProposalRecord[]> {
|
|
759
|
+
if (!args) {
|
|
760
|
+
throw new Error("listProposals requires a rootDir");
|
|
761
|
+
}
|
|
762
|
+
const homeDir = args?.homeDir ?? process.env.HOME ?? "";
|
|
763
|
+
const dir = facultAiProposalDir(homeDir, args?.rootDir);
|
|
764
|
+
const entries = await readdir(dir).catch(() => [] as string[]);
|
|
765
|
+
const out: AiProposalRecord[] = [];
|
|
766
|
+
for (const entry of entries.sort()) {
|
|
767
|
+
if (!entry.endsWith(".json")) {
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
const raw = await readFile(join(dir, entry), "utf8");
|
|
771
|
+
const parsed = JSON.parse(raw) as AiProposalRecord;
|
|
772
|
+
out.push(parsed);
|
|
773
|
+
}
|
|
774
|
+
return out;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
export async function showProposal(
|
|
778
|
+
id: string,
|
|
779
|
+
args: { homeDir?: string; rootDir: string }
|
|
780
|
+
): Promise<AiProposalRecord | null> {
|
|
781
|
+
const homeDir = args.homeDir ?? process.env.HOME ?? "";
|
|
782
|
+
const pathValue = join(
|
|
783
|
+
facultAiProposalDir(homeDir, args.rootDir),
|
|
784
|
+
`${id}.json`
|
|
785
|
+
);
|
|
786
|
+
if (!(await fileExists(pathValue))) {
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
const raw = await readFile(pathValue, "utf8");
|
|
790
|
+
return JSON.parse(raw) as AiProposalRecord;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function promoteTargetRef(target: string, to: "global"): string {
|
|
794
|
+
if (to !== "global") {
|
|
795
|
+
throw new Error(`Unsupported promotion target: ${to}`);
|
|
796
|
+
}
|
|
797
|
+
if (target.startsWith("@project/")) {
|
|
798
|
+
return `@ai/${target.slice("@project/".length)}`;
|
|
799
|
+
}
|
|
800
|
+
if (target.startsWith("@ai/")) {
|
|
801
|
+
return target;
|
|
802
|
+
}
|
|
803
|
+
throw new Error(`Cannot promote non-canonical target: ${target}`);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async function saveProposal(
|
|
807
|
+
proposal: AiProposalRecord,
|
|
808
|
+
args: { homeDir?: string; rootDir: string }
|
|
809
|
+
): Promise<void> {
|
|
810
|
+
const homeDir = args.homeDir ?? process.env.HOME ?? "";
|
|
811
|
+
await writeProposalFile(homeDir, args.rootDir, proposal);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function proposalActor(): string {
|
|
815
|
+
return "facult:manual";
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function nextReviewHistory(
|
|
819
|
+
proposal: AiProposalRecord,
|
|
820
|
+
entry: ProposalReviewHistoryEntry
|
|
821
|
+
): ProposalReviewRecord {
|
|
822
|
+
const review = proposal.review ?? { history: [] };
|
|
823
|
+
return {
|
|
824
|
+
...review,
|
|
825
|
+
history: [...(review.history ?? []), entry],
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
async function updateProposal(
|
|
830
|
+
id: string,
|
|
831
|
+
args: { homeDir?: string; rootDir: string },
|
|
832
|
+
mutate: (proposal: AiProposalRecord) => AiProposalRecord
|
|
833
|
+
): Promise<AiProposalRecord> {
|
|
834
|
+
const current = await showProposal(id, args);
|
|
835
|
+
if (!current) {
|
|
836
|
+
throw new Error(`Proposal not found: ${id}`);
|
|
837
|
+
}
|
|
838
|
+
const next = mutate(current);
|
|
839
|
+
await saveProposal(next, args);
|
|
840
|
+
return next;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function draftRefForProposal(
|
|
844
|
+
homeDir: string,
|
|
845
|
+
rootDir: string,
|
|
846
|
+
id: string
|
|
847
|
+
): string {
|
|
848
|
+
return join(facultAiDraftDir(homeDir, rootDir), `${id}.md`);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function patchRefForProposal(
|
|
852
|
+
homeDir: string,
|
|
853
|
+
rootDir: string,
|
|
854
|
+
id: string
|
|
855
|
+
): string {
|
|
856
|
+
return join(facultAiDraftDir(homeDir, rootDir), `${id}.patch`);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function renderDraftBody(
|
|
860
|
+
proposal: AiProposalRecord,
|
|
861
|
+
writebacks: AiWritebackRecord[]
|
|
862
|
+
): string {
|
|
863
|
+
if (proposal.kind === "add_skill") {
|
|
864
|
+
const target = proposal.targets[0] ?? "";
|
|
865
|
+
const skillSlug =
|
|
866
|
+
target.split("/skills/")[1]?.replace(SKILL_MD_SUFFIX_RE, "") ??
|
|
867
|
+
"new-skill";
|
|
868
|
+
return [
|
|
869
|
+
"---",
|
|
870
|
+
`name: ${skillSlug}`,
|
|
871
|
+
`description: ${proposal.summary}`,
|
|
872
|
+
"---",
|
|
873
|
+
"",
|
|
874
|
+
`# ${slugToTitle(skillSlug)}`,
|
|
875
|
+
"",
|
|
876
|
+
"## Purpose",
|
|
877
|
+
proposal.rationale,
|
|
878
|
+
"",
|
|
879
|
+
"## When to Use",
|
|
880
|
+
...writebacks.map((entry) => `- ${entry.summary}`),
|
|
881
|
+
"",
|
|
882
|
+
].join("\n");
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (isStandaloneProposalKind(proposal.kind)) {
|
|
886
|
+
const target = proposal.targets[0] ?? "";
|
|
887
|
+
const leaf =
|
|
888
|
+
target
|
|
889
|
+
.split("/")
|
|
890
|
+
.pop()
|
|
891
|
+
?.replace(MARKDOWN_SUFFIX_RE, "")
|
|
892
|
+
.replace(SKILL_SUFFIX_RE, "") ?? proposal.id;
|
|
893
|
+
return [
|
|
894
|
+
`# ${slugToTitle(leaf)}`,
|
|
895
|
+
"",
|
|
896
|
+
proposal.summary,
|
|
897
|
+
"",
|
|
898
|
+
"## Rationale",
|
|
899
|
+
proposal.rationale,
|
|
900
|
+
"",
|
|
901
|
+
"## Supporting Writebacks",
|
|
902
|
+
...writebacks.map(
|
|
903
|
+
(entry) => `- ${entry.id} (${entry.kind}): ${entry.summary}`
|
|
904
|
+
),
|
|
905
|
+
"",
|
|
906
|
+
].join("\n");
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const additionLines = [
|
|
910
|
+
`## Facult Evolution Applied: ${proposal.id}`,
|
|
911
|
+
"",
|
|
912
|
+
`Summary: ${proposal.summary}`,
|
|
913
|
+
"",
|
|
914
|
+
"Supporting writebacks:",
|
|
915
|
+
...writebacks.map(
|
|
916
|
+
(entry) => `- ${entry.id} (${entry.kind}): ${entry.summary}`
|
|
917
|
+
),
|
|
918
|
+
];
|
|
919
|
+
|
|
920
|
+
return [
|
|
921
|
+
`# Generated Draft: ${proposal.id}`,
|
|
922
|
+
"",
|
|
923
|
+
`Target: ${proposal.targets.join(", ")}`,
|
|
924
|
+
`Kind: ${proposal.kind}`,
|
|
925
|
+
"",
|
|
926
|
+
"## Rationale",
|
|
927
|
+
proposal.rationale,
|
|
928
|
+
"",
|
|
929
|
+
"## Proposed Addition",
|
|
930
|
+
`<!-- facult:evolution:${proposal.id}:start -->`,
|
|
931
|
+
...additionLines,
|
|
932
|
+
`<!-- facult:evolution:${proposal.id}:end -->`,
|
|
933
|
+
"",
|
|
934
|
+
].join("\n");
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function renderAppliedContent(
|
|
938
|
+
proposal: AiProposalRecord,
|
|
939
|
+
writebacks: AiWritebackRecord[]
|
|
940
|
+
): string {
|
|
941
|
+
if (isStandaloneProposalKind(proposal.kind)) {
|
|
942
|
+
return renderDraftBody(proposal, writebacks).trimEnd();
|
|
943
|
+
}
|
|
944
|
+
return extractDraftAddition(
|
|
945
|
+
proposal.id,
|
|
946
|
+
renderDraftBody(proposal, writebacks)
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function renderPatchBody(args: {
|
|
951
|
+
targetPath: string;
|
|
952
|
+
currentText: string;
|
|
953
|
+
nextText: string;
|
|
954
|
+
}): string {
|
|
955
|
+
const oldLines = args.currentText
|
|
956
|
+
.replace(TRAILING_NEWLINE_RE, "")
|
|
957
|
+
.split("\n");
|
|
958
|
+
const newLines = args.nextText.replace(TRAILING_NEWLINE_RE, "").split("\n");
|
|
959
|
+
return [
|
|
960
|
+
`--- ${args.targetPath}`,
|
|
961
|
+
`+++ ${args.targetPath}`,
|
|
962
|
+
`@@ -1,${oldLines.length} +1,${newLines.length} @@`,
|
|
963
|
+
...oldLines.map((line) => `-${line}`),
|
|
964
|
+
...newLines.map((line) => `+${line}`),
|
|
965
|
+
"",
|
|
966
|
+
].join("\n");
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function extractDraftAddition(proposalId: string, input: string): string {
|
|
970
|
+
const startMarker = `<!-- facult:evolution:${proposalId}:start -->`;
|
|
971
|
+
const endMarker = `<!-- facult:evolution:${proposalId}:end -->`;
|
|
972
|
+
const start = input.indexOf(startMarker);
|
|
973
|
+
const end = input.indexOf(endMarker);
|
|
974
|
+
if (start < 0 || end < 0 || end <= start) {
|
|
975
|
+
throw new Error(`Draft for ${proposalId} is missing apply markers`);
|
|
976
|
+
}
|
|
977
|
+
return input.slice(start, end + endMarker.length).trim();
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async function resolveProposalTargetNode(
|
|
981
|
+
proposal: AiProposalRecord,
|
|
982
|
+
args: { homeDir?: string; rootDir: string }
|
|
983
|
+
) {
|
|
984
|
+
const homeDir = args.homeDir ?? process.env.HOME ?? "";
|
|
985
|
+
const target = proposal.targets[0];
|
|
986
|
+
if (!target) {
|
|
987
|
+
throw new Error(`Proposal ${proposal.id} has no targets`);
|
|
988
|
+
}
|
|
989
|
+
const graph = await loadGraph({
|
|
990
|
+
homeDir,
|
|
991
|
+
rootDir: args.rootDir,
|
|
992
|
+
}).catch(() => null);
|
|
993
|
+
const node = graph ? resolveGraphNode(graph, target) : null;
|
|
994
|
+
const fallbackPath = canonicalRefToPath({
|
|
995
|
+
ref: target,
|
|
996
|
+
homeDir,
|
|
997
|
+
rootDir: args.rootDir,
|
|
998
|
+
});
|
|
999
|
+
const pathValue = node?.path ?? fallbackPath;
|
|
1000
|
+
if (!pathValue) {
|
|
1001
|
+
throw new Error(`Could not resolve target path for ${target}`);
|
|
1002
|
+
}
|
|
1003
|
+
if (!supportedDraftTarget(pathValue)) {
|
|
1004
|
+
throw new Error(
|
|
1005
|
+
`Apply currently supports markdown targets only: ${pathValue}`
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
return {
|
|
1009
|
+
...node,
|
|
1010
|
+
path: pathValue,
|
|
1011
|
+
canonicalRef: node?.canonicalRef ?? target,
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
export async function draftProposal(
|
|
1016
|
+
id: string,
|
|
1017
|
+
args: { homeDir?: string; rootDir: string; append?: string }
|
|
1018
|
+
): Promise<AiProposalRecord> {
|
|
1019
|
+
const homeDir = args.homeDir ?? process.env.HOME ?? "";
|
|
1020
|
+
const current = await showProposal(id, { homeDir, rootDir: args.rootDir });
|
|
1021
|
+
if (!current) {
|
|
1022
|
+
throw new Error(`Proposal not found: ${id}`);
|
|
1023
|
+
}
|
|
1024
|
+
const writebacks = (
|
|
1025
|
+
await Promise.all(
|
|
1026
|
+
current.sourceWritebacks.map(async (writebackId) => {
|
|
1027
|
+
const entry = await showWriteback(writebackId, {
|
|
1028
|
+
homeDir,
|
|
1029
|
+
rootDir: args.rootDir,
|
|
1030
|
+
});
|
|
1031
|
+
return entry ?? null;
|
|
1032
|
+
})
|
|
1033
|
+
)
|
|
1034
|
+
).filter((entry): entry is AiWritebackRecord => Boolean(entry));
|
|
1035
|
+
const targetNode = await resolveProposalTargetNode(current, {
|
|
1036
|
+
homeDir,
|
|
1037
|
+
rootDir: args.rootDir,
|
|
1038
|
+
});
|
|
1039
|
+
const draftPath = draftRefForProposal(homeDir, args.rootDir, id);
|
|
1040
|
+
const patchPath = patchRefForProposal(homeDir, args.rootDir, id);
|
|
1041
|
+
await mkdir(dirname(draftPath), { recursive: true });
|
|
1042
|
+
const generatedBody = renderDraftBody(current, writebacks);
|
|
1043
|
+
const priorDraft =
|
|
1044
|
+
args.append && (await fileExists(draftPath))
|
|
1045
|
+
? await readFile(draftPath, "utf8")
|
|
1046
|
+
: null;
|
|
1047
|
+
const draftBody = args.append
|
|
1048
|
+
? `${(priorDraft ?? generatedBody).trimEnd()}\n\n## Draft Revision\n${args.append.trim()}\n`
|
|
1049
|
+
: generatedBody;
|
|
1050
|
+
await Bun.write(draftPath, `${draftBody}\n`);
|
|
1051
|
+
const currentText = (await fileExists(targetNode.path!))
|
|
1052
|
+
? await readFile(targetNode.path!, "utf8")
|
|
1053
|
+
: "";
|
|
1054
|
+
const appliedContent = isAppendProposalKind(current.kind)
|
|
1055
|
+
? extractDraftAddition(id, draftBody)
|
|
1056
|
+
: draftBody.trimEnd();
|
|
1057
|
+
const nextText = currentText.includes(appliedContent)
|
|
1058
|
+
? currentText
|
|
1059
|
+
: `${currentText.trimEnd()}\n\n${appliedContent}\n`;
|
|
1060
|
+
await Bun.write(
|
|
1061
|
+
patchPath,
|
|
1062
|
+
`${renderPatchBody({
|
|
1063
|
+
targetPath: targetNode.path!,
|
|
1064
|
+
currentText,
|
|
1065
|
+
nextText,
|
|
1066
|
+
})}\n`
|
|
1067
|
+
);
|
|
1068
|
+
|
|
1069
|
+
const actor = proposalActor();
|
|
1070
|
+
const next = await updateProposal(
|
|
1071
|
+
id,
|
|
1072
|
+
{ homeDir, rootDir: args.rootDir },
|
|
1073
|
+
(proposal) => ({
|
|
1074
|
+
...proposal,
|
|
1075
|
+
status: "drafted",
|
|
1076
|
+
draftRefs: uniqueStrings([draftPath, patchPath, ...proposal.draftRefs]),
|
|
1077
|
+
draftHistory: [
|
|
1078
|
+
...(proposal.draftHistory ?? []),
|
|
1079
|
+
{
|
|
1080
|
+
ts: nowIso(),
|
|
1081
|
+
action: args.append ? "revised" : "generated",
|
|
1082
|
+
actor,
|
|
1083
|
+
draftRefs: uniqueStrings([draftPath, patchPath]),
|
|
1084
|
+
note: args.append?.trim(),
|
|
1085
|
+
},
|
|
1086
|
+
],
|
|
1087
|
+
review: nextReviewHistory(proposal, {
|
|
1088
|
+
ts: nowIso(),
|
|
1089
|
+
action: args.append ? "draft_revised" : "drafted",
|
|
1090
|
+
actor,
|
|
1091
|
+
note: targetNode.canonicalRef ?? targetNode.path,
|
|
1092
|
+
}),
|
|
1093
|
+
})
|
|
1094
|
+
);
|
|
1095
|
+
await appendEvent(homeDir, args.rootDir, {
|
|
1096
|
+
id: "",
|
|
1097
|
+
ts: nowIso(),
|
|
1098
|
+
kind: "proposal_drafted",
|
|
1099
|
+
source: actor,
|
|
1100
|
+
scope: next.scope,
|
|
1101
|
+
projectSlug: next.projectSlug,
|
|
1102
|
+
projectRoot: next.projectRoot,
|
|
1103
|
+
summary: `Drafted ${next.id}`,
|
|
1104
|
+
refs: [...next.targets, ...next.draftRefs],
|
|
1105
|
+
tags: [],
|
|
1106
|
+
});
|
|
1107
|
+
return next;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
export function reviewProposal(
|
|
1111
|
+
id: string,
|
|
1112
|
+
args: { homeDir?: string; rootDir: string }
|
|
1113
|
+
): Promise<AiProposalRecord> {
|
|
1114
|
+
const homeDir = args.homeDir ?? process.env.HOME ?? "";
|
|
1115
|
+
const actor = proposalActor();
|
|
1116
|
+
return updateProposal(id, { homeDir, rootDir: args.rootDir }, (proposal) => {
|
|
1117
|
+
const reviewedAt = nowIso();
|
|
1118
|
+
return {
|
|
1119
|
+
...proposal,
|
|
1120
|
+
status: "in_review",
|
|
1121
|
+
review: {
|
|
1122
|
+
...nextReviewHistory(proposal, {
|
|
1123
|
+
ts: reviewedAt,
|
|
1124
|
+
action: "in_review",
|
|
1125
|
+
actor,
|
|
1126
|
+
}),
|
|
1127
|
+
status: "in_review",
|
|
1128
|
+
reviewer: actor,
|
|
1129
|
+
reviewedAt,
|
|
1130
|
+
},
|
|
1131
|
+
};
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
export function acceptProposal(
|
|
1136
|
+
id: string,
|
|
1137
|
+
args: { homeDir?: string; rootDir: string }
|
|
1138
|
+
): Promise<AiProposalRecord> {
|
|
1139
|
+
const homeDir = args.homeDir ?? process.env.HOME ?? "";
|
|
1140
|
+
const actor = proposalActor();
|
|
1141
|
+
return updateProposal(id, { homeDir, rootDir: args.rootDir }, (proposal) => {
|
|
1142
|
+
const reviewedAt = nowIso();
|
|
1143
|
+
return {
|
|
1144
|
+
...proposal,
|
|
1145
|
+
status: "accepted",
|
|
1146
|
+
review: {
|
|
1147
|
+
...nextReviewHistory(proposal, {
|
|
1148
|
+
ts: reviewedAt,
|
|
1149
|
+
action: "accepted",
|
|
1150
|
+
actor,
|
|
1151
|
+
}),
|
|
1152
|
+
status: "accepted",
|
|
1153
|
+
reviewer: actor,
|
|
1154
|
+
reviewedAt,
|
|
1155
|
+
rejectionReason: undefined,
|
|
1156
|
+
},
|
|
1157
|
+
};
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
export function rejectProposal(
|
|
1162
|
+
id: string,
|
|
1163
|
+
args: { homeDir?: string; rootDir: string; reason: string }
|
|
1164
|
+
): Promise<AiProposalRecord> {
|
|
1165
|
+
const homeDir = args.homeDir ?? process.env.HOME ?? "";
|
|
1166
|
+
const actor = proposalActor();
|
|
1167
|
+
return updateProposal(id, { homeDir, rootDir: args.rootDir }, (proposal) => {
|
|
1168
|
+
const reviewedAt = nowIso();
|
|
1169
|
+
return {
|
|
1170
|
+
...proposal,
|
|
1171
|
+
status: "rejected",
|
|
1172
|
+
review: {
|
|
1173
|
+
...nextReviewHistory(proposal, {
|
|
1174
|
+
ts: reviewedAt,
|
|
1175
|
+
action: "rejected",
|
|
1176
|
+
actor,
|
|
1177
|
+
note: args.reason,
|
|
1178
|
+
}),
|
|
1179
|
+
status: "rejected",
|
|
1180
|
+
reviewer: actor,
|
|
1181
|
+
reviewedAt,
|
|
1182
|
+
rejectionReason: args.reason,
|
|
1183
|
+
},
|
|
1184
|
+
};
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
export function supersedeProposal(
|
|
1189
|
+
id: string,
|
|
1190
|
+
by: string,
|
|
1191
|
+
args: { homeDir?: string; rootDir: string }
|
|
1192
|
+
): Promise<AiProposalRecord> {
|
|
1193
|
+
const homeDir = args.homeDir ?? process.env.HOME ?? "";
|
|
1194
|
+
const actor = proposalActor();
|
|
1195
|
+
return updateProposal(id, { homeDir, rootDir: args.rootDir }, (proposal) => {
|
|
1196
|
+
const reviewedAt = nowIso();
|
|
1197
|
+
return {
|
|
1198
|
+
...proposal,
|
|
1199
|
+
status: "superseded",
|
|
1200
|
+
review: {
|
|
1201
|
+
...nextReviewHistory(proposal, {
|
|
1202
|
+
ts: reviewedAt,
|
|
1203
|
+
action: "superseded",
|
|
1204
|
+
actor,
|
|
1205
|
+
note: by,
|
|
1206
|
+
}),
|
|
1207
|
+
status: "superseded",
|
|
1208
|
+
reviewer: actor,
|
|
1209
|
+
reviewedAt,
|
|
1210
|
+
supersededBy: by,
|
|
1211
|
+
},
|
|
1212
|
+
};
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
export async function applyProposal(
|
|
1217
|
+
id: string,
|
|
1218
|
+
args: { homeDir?: string; rootDir: string }
|
|
1219
|
+
): Promise<AiProposalRecord> {
|
|
1220
|
+
const homeDir = args.homeDir ?? process.env.HOME ?? "";
|
|
1221
|
+
const current = await showProposal(id, { homeDir, rootDir: args.rootDir });
|
|
1222
|
+
if (!current) {
|
|
1223
|
+
throw new Error(`Proposal not found: ${id}`);
|
|
1224
|
+
}
|
|
1225
|
+
if (!isApplySupportedProposalKind(current.kind)) {
|
|
1226
|
+
throw new Error(`Unsupported proposal kind for apply: ${current.kind}`);
|
|
1227
|
+
}
|
|
1228
|
+
const requiresAcceptedReview = current.reviewRequired !== false;
|
|
1229
|
+
if (
|
|
1230
|
+
(requiresAcceptedReview && current.status !== "accepted") ||
|
|
1231
|
+
(!requiresAcceptedReview &&
|
|
1232
|
+
current.status !== "accepted" &&
|
|
1233
|
+
current.status !== "drafted")
|
|
1234
|
+
) {
|
|
1235
|
+
throw new Error(`Proposal must be accepted before apply: ${id}`);
|
|
1236
|
+
}
|
|
1237
|
+
if (current.draftRefs.length === 0) {
|
|
1238
|
+
throw new Error(
|
|
1239
|
+
`Proposal ${id} has no draft refs. Run "facult ai evolve draft ${id}" first.`
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const targetNode = await resolveProposalTargetNode(current, {
|
|
1244
|
+
homeDir,
|
|
1245
|
+
rootDir: args.rootDir,
|
|
1246
|
+
});
|
|
1247
|
+
const draftPath = current.draftRefs[0];
|
|
1248
|
+
if (!draftPath) {
|
|
1249
|
+
throw new Error(`Proposal ${id} has no primary draft ref`);
|
|
1250
|
+
}
|
|
1251
|
+
const draftText = await readFile(draftPath, "utf8");
|
|
1252
|
+
const existingTarget = (await fileExists(targetNode.path!))
|
|
1253
|
+
? await readFile(targetNode.path!, "utf8")
|
|
1254
|
+
: "";
|
|
1255
|
+
const nextText = isAppendProposalKind(current.kind)
|
|
1256
|
+
? (() => {
|
|
1257
|
+
const addition = extractDraftAddition(id, draftText);
|
|
1258
|
+
return existingTarget.includes(addition)
|
|
1259
|
+
? existingTarget
|
|
1260
|
+
: `${existingTarget.trimEnd()}\n\n${addition}\n`;
|
|
1261
|
+
})()
|
|
1262
|
+
: `${draftText.trimEnd()}\n`;
|
|
1263
|
+
await Bun.write(targetNode.path!, nextText);
|
|
1264
|
+
|
|
1265
|
+
for (const writebackId of current.sourceWritebacks) {
|
|
1266
|
+
const writeback = await showWriteback(writebackId, {
|
|
1267
|
+
homeDir,
|
|
1268
|
+
rootDir: args.rootDir,
|
|
1269
|
+
});
|
|
1270
|
+
if (!writeback) {
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
await updateWritebackStatus(writebackId, "resolved", {
|
|
1274
|
+
homeDir,
|
|
1275
|
+
rootDir: args.rootDir,
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
const actor = proposalActor();
|
|
1280
|
+
const appliedAt = nowIso();
|
|
1281
|
+
const next = await updateProposal(
|
|
1282
|
+
id,
|
|
1283
|
+
{ homeDir, rootDir: args.rootDir },
|
|
1284
|
+
(proposal) => ({
|
|
1285
|
+
...proposal,
|
|
1286
|
+
status: "applied",
|
|
1287
|
+
review: nextReviewHistory(proposal, {
|
|
1288
|
+
ts: appliedAt,
|
|
1289
|
+
action: "applied",
|
|
1290
|
+
actor,
|
|
1291
|
+
note: targetNode.path,
|
|
1292
|
+
}),
|
|
1293
|
+
applyResult: {
|
|
1294
|
+
status: "applied",
|
|
1295
|
+
appliedAt,
|
|
1296
|
+
appliedBy: actor,
|
|
1297
|
+
changedFiles: [targetNode.path!],
|
|
1298
|
+
draftRefs: proposal.draftRefs,
|
|
1299
|
+
message: `Applied ${proposal.id} to ${targetNode.path}`,
|
|
1300
|
+
},
|
|
1301
|
+
})
|
|
1302
|
+
);
|
|
1303
|
+
|
|
1304
|
+
await appendEvent(homeDir, args.rootDir, {
|
|
1305
|
+
id: "",
|
|
1306
|
+
ts: appliedAt,
|
|
1307
|
+
kind: "proposal_applied",
|
|
1308
|
+
source: actor,
|
|
1309
|
+
scope: next.scope,
|
|
1310
|
+
projectSlug: next.projectSlug,
|
|
1311
|
+
projectRoot: next.projectRoot,
|
|
1312
|
+
summary: `Applied ${next.id}`,
|
|
1313
|
+
refs: [targetNode.path!, ...next.targets],
|
|
1314
|
+
tags: [],
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
return next;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
export async function promoteProposal(
|
|
1321
|
+
id: string,
|
|
1322
|
+
args: {
|
|
1323
|
+
homeDir?: string;
|
|
1324
|
+
rootDir: string;
|
|
1325
|
+
to: "global";
|
|
1326
|
+
}
|
|
1327
|
+
): Promise<AiProposalRecord> {
|
|
1328
|
+
const homeDir = args.homeDir ?? process.env.HOME ?? "";
|
|
1329
|
+
const current = await showProposal(id, { homeDir, rootDir: args.rootDir });
|
|
1330
|
+
if (!current) {
|
|
1331
|
+
throw new Error(`Proposal not found: ${id}`);
|
|
1332
|
+
}
|
|
1333
|
+
if (current.scope !== "project") {
|
|
1334
|
+
throw new Error(`Only project-scoped proposals can be promoted: ${id}`);
|
|
1335
|
+
}
|
|
1336
|
+
const sourceWritebacks = (
|
|
1337
|
+
await Promise.all(
|
|
1338
|
+
current.sourceWritebacks.map(async (writebackId) => {
|
|
1339
|
+
return (
|
|
1340
|
+
(await showWriteback(writebackId, {
|
|
1341
|
+
homeDir,
|
|
1342
|
+
rootDir: args.rootDir,
|
|
1343
|
+
})) ?? null
|
|
1344
|
+
);
|
|
1345
|
+
})
|
|
1346
|
+
)
|
|
1347
|
+
).filter((entry): entry is AiWritebackRecord => Boolean(entry));
|
|
1348
|
+
const targetRoot =
|
|
1349
|
+
args.to === "global" ? facultRootDir(homeDir) : args.rootDir;
|
|
1350
|
+
const nextIdValue = await nextProposalId(homeDir, targetRoot);
|
|
1351
|
+
const promoted: AiProposalRecord = {
|
|
1352
|
+
...current,
|
|
1353
|
+
id: nextIdValue,
|
|
1354
|
+
ts: nowIso(),
|
|
1355
|
+
status: "proposed",
|
|
1356
|
+
scope: "global",
|
|
1357
|
+
projectSlug: undefined,
|
|
1358
|
+
projectRoot: undefined,
|
|
1359
|
+
kind: "promote_asset",
|
|
1360
|
+
targets: current.targets.map((target) => promoteTargetRef(target, args.to)),
|
|
1361
|
+
summary: sourceWritebacks[0]?.summary ?? current.summary,
|
|
1362
|
+
rationale: `Promoted from project proposal ${current.id} targeting ${current.targets.join(", ")}. ${current.rationale}`,
|
|
1363
|
+
policyClass: "high-risk",
|
|
1364
|
+
draftRefs: [],
|
|
1365
|
+
sourceProposals: uniqueStrings([
|
|
1366
|
+
...(current.sourceProposals ?? []),
|
|
1367
|
+
current.id,
|
|
1368
|
+
]),
|
|
1369
|
+
review: {
|
|
1370
|
+
history: [
|
|
1371
|
+
{
|
|
1372
|
+
ts: nowIso(),
|
|
1373
|
+
action: "promoted",
|
|
1374
|
+
actor: proposalActor(),
|
|
1375
|
+
note: `from ${current.scope} to ${args.to}`,
|
|
1376
|
+
},
|
|
1377
|
+
],
|
|
1378
|
+
},
|
|
1379
|
+
applyResult: undefined,
|
|
1380
|
+
};
|
|
1381
|
+
await writeProposalFile(homeDir, targetRoot, promoted);
|
|
1382
|
+
await appendEvent(homeDir, targetRoot, {
|
|
1383
|
+
id: "",
|
|
1384
|
+
ts: promoted.ts,
|
|
1385
|
+
kind: "proposal_promoted",
|
|
1386
|
+
source: proposalActor(),
|
|
1387
|
+
scope: promoted.scope,
|
|
1388
|
+
summary: `Promoted ${current.id} -> ${promoted.id}`,
|
|
1389
|
+
refs: [...promoted.targets, current.id],
|
|
1390
|
+
tags: [],
|
|
1391
|
+
});
|
|
1392
|
+
return promoted;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function aiHelp(): string {
|
|
1396
|
+
return `facult ai — writeback and evolution workflows
|
|
1397
|
+
|
|
1398
|
+
Usage:
|
|
1399
|
+
facult ai writeback <add|list|show|dismiss|promote> [args...]
|
|
1400
|
+
facult ai evolve <propose|list|show|draft|review|accept|reject|supersede|apply> [args...]
|
|
1401
|
+
`;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function writebackHelp(): string {
|
|
1405
|
+
return `facult ai writeback
|
|
1406
|
+
|
|
1407
|
+
Usage:
|
|
1408
|
+
facult ai writeback add --kind <kind> --summary <text> [--asset <selector>] [--tag <tag>] [--evidence <type:ref>]
|
|
1409
|
+
facult ai writeback list [--json]
|
|
1410
|
+
facult ai writeback show <id> [--json]
|
|
1411
|
+
facult ai writeback group --by <asset|kind|domain> [--json]
|
|
1412
|
+
facult ai writeback summarize [--by <asset|kind|domain>] [--json]
|
|
1413
|
+
facult ai writeback dismiss <id>
|
|
1414
|
+
facult ai writeback promote <id>
|
|
1415
|
+
`;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
function evolveHelp(): string {
|
|
1419
|
+
return `facult ai evolve
|
|
1420
|
+
|
|
1421
|
+
Usage:
|
|
1422
|
+
facult ai evolve propose [--asset <selector>] [--json]
|
|
1423
|
+
facult ai evolve list [--json]
|
|
1424
|
+
facult ai evolve show <id> [--json]
|
|
1425
|
+
facult ai evolve draft <id> [--append <text>]
|
|
1426
|
+
facult ai evolve review <id>
|
|
1427
|
+
facult ai evolve accept <id>
|
|
1428
|
+
facult ai evolve reject <id> --reason <text>
|
|
1429
|
+
facult ai evolve supersede <id> --by <proposal-id>
|
|
1430
|
+
facult ai evolve apply <id>
|
|
1431
|
+
facult ai evolve promote <id> --to global
|
|
1432
|
+
`;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function parseStringFlag(argv: string[], flag: string): string | undefined {
|
|
1436
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1437
|
+
const arg = argv[i];
|
|
1438
|
+
if (!arg) {
|
|
1439
|
+
continue;
|
|
1440
|
+
}
|
|
1441
|
+
if (arg === flag) {
|
|
1442
|
+
const value = argv[i + 1];
|
|
1443
|
+
if (!value) {
|
|
1444
|
+
throw new Error(`${flag} requires a value`);
|
|
1445
|
+
}
|
|
1446
|
+
return value;
|
|
1447
|
+
}
|
|
1448
|
+
if (arg.startsWith(`${flag}=`)) {
|
|
1449
|
+
const value = arg.slice(flag.length + 1);
|
|
1450
|
+
if (!value) {
|
|
1451
|
+
throw new Error(`${flag} requires a value`);
|
|
1452
|
+
}
|
|
1453
|
+
return value;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
return undefined;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function parseRepeatedFlag(argv: string[], flag: string): string[] {
|
|
1460
|
+
const values: string[] = [];
|
|
1461
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1462
|
+
const arg = argv[i];
|
|
1463
|
+
if (!arg) {
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
if (arg === flag) {
|
|
1467
|
+
const value = argv[i + 1];
|
|
1468
|
+
if (!value) {
|
|
1469
|
+
throw new Error(`${flag} requires a value`);
|
|
1470
|
+
}
|
|
1471
|
+
values.push(value);
|
|
1472
|
+
i += 1;
|
|
1473
|
+
continue;
|
|
1474
|
+
}
|
|
1475
|
+
if (arg.startsWith(`${flag}=`)) {
|
|
1476
|
+
const value = arg.slice(flag.length + 1);
|
|
1477
|
+
if (!value) {
|
|
1478
|
+
throw new Error(`${flag} requires a value`);
|
|
1479
|
+
}
|
|
1480
|
+
values.push(value);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
return values;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
function parseEvidence(argv: string[]): WritebackEvidence[] {
|
|
1487
|
+
return parseRepeatedFlag(argv, "--evidence").map((entry) => {
|
|
1488
|
+
const [type, ...rest] = entry.split(":");
|
|
1489
|
+
const ref = rest.join(":").trim();
|
|
1490
|
+
if (!(type?.trim() && ref)) {
|
|
1491
|
+
throw new Error(`Invalid evidence reference: ${entry}`);
|
|
1492
|
+
}
|
|
1493
|
+
return { type: type.trim(), ref };
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
async function writebackCommand(argv: string[]) {
|
|
1498
|
+
const [sub, ...rest] = argv;
|
|
1499
|
+
const parsed = parseCliContextArgs(rest);
|
|
1500
|
+
|
|
1501
|
+
if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
|
|
1502
|
+
console.log(writebackHelp());
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
const rootDir = resolveCliContextRoot({
|
|
1507
|
+
rootArg: parsed.rootArg,
|
|
1508
|
+
scope: parsed.scope,
|
|
1509
|
+
cwd: process.cwd(),
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
try {
|
|
1513
|
+
if (sub === "add") {
|
|
1514
|
+
const kind = parseStringFlag(parsed.argv, "--kind");
|
|
1515
|
+
const summary = parseStringFlag(parsed.argv, "--summary");
|
|
1516
|
+
if (!(kind && summary)) {
|
|
1517
|
+
throw new Error("writeback add requires --kind and --summary");
|
|
1518
|
+
}
|
|
1519
|
+
const record = await addWriteback({
|
|
1520
|
+
rootDir,
|
|
1521
|
+
kind,
|
|
1522
|
+
summary,
|
|
1523
|
+
asset: parseStringFlag(parsed.argv, "--asset"),
|
|
1524
|
+
confidence:
|
|
1525
|
+
(parseStringFlag(parsed.argv, "--confidence") as
|
|
1526
|
+
| ConfidenceLevel
|
|
1527
|
+
| undefined) ?? undefined,
|
|
1528
|
+
suggestedDestination: parseStringFlag(
|
|
1529
|
+
parsed.argv,
|
|
1530
|
+
"--suggested-destination"
|
|
1531
|
+
),
|
|
1532
|
+
tags: parseRepeatedFlag(parsed.argv, "--tag"),
|
|
1533
|
+
evidence: parseEvidence(parsed.argv),
|
|
1534
|
+
});
|
|
1535
|
+
console.log(`Recorded writeback ${record.id}`);
|
|
1536
|
+
console.log(JSON.stringify(record, null, 2));
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
if (sub === "list") {
|
|
1541
|
+
const rows = await listWritebacks({ rootDir });
|
|
1542
|
+
if (parsed.argv.includes("--json")) {
|
|
1543
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
for (const row of rows) {
|
|
1547
|
+
console.log(`${row.id}\t${row.kind}\t[${row.status}]\t${row.summary}`);
|
|
1548
|
+
}
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
if (sub === "group" || sub === "summarize") {
|
|
1553
|
+
const byValue = parseStringFlag(parsed.argv, "--by") ?? "asset";
|
|
1554
|
+
if (byValue !== "asset" && byValue !== "kind" && byValue !== "domain") {
|
|
1555
|
+
throw new Error(`Unsupported writeback grouping: ${byValue}`);
|
|
1556
|
+
}
|
|
1557
|
+
const rows =
|
|
1558
|
+
sub === "group"
|
|
1559
|
+
? await groupWritebacks({ rootDir, by: byValue })
|
|
1560
|
+
: await summarizeWritebacks({ rootDir, by: byValue });
|
|
1561
|
+
if (parsed.argv.includes("--json")) {
|
|
1562
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
for (const row of rows) {
|
|
1566
|
+
console.log(
|
|
1567
|
+
`${row.key}\tcount=${row.count}\t${sub === "group" ? row.writebackIds.join(",") : row.summary}`
|
|
1568
|
+
);
|
|
1569
|
+
}
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
if (sub === "show") {
|
|
1574
|
+
const id = parsed.argv.find((arg) => !arg.startsWith("-"));
|
|
1575
|
+
if (!id) {
|
|
1576
|
+
throw new Error("writeback show requires an id");
|
|
1577
|
+
}
|
|
1578
|
+
const row = await showWriteback(id, { rootDir });
|
|
1579
|
+
if (!row) {
|
|
1580
|
+
throw new Error(`Writeback not found: ${id}`);
|
|
1581
|
+
}
|
|
1582
|
+
console.log(JSON.stringify(row, null, 2));
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
if (sub === "dismiss" || sub === "promote") {
|
|
1587
|
+
const id = parsed.argv.find((arg) => !arg.startsWith("-"));
|
|
1588
|
+
if (!id) {
|
|
1589
|
+
throw new Error(`writeback ${sub} requires an id`);
|
|
1590
|
+
}
|
|
1591
|
+
const row =
|
|
1592
|
+
sub === "dismiss"
|
|
1593
|
+
? await dismissWriteback(id, { rootDir })
|
|
1594
|
+
: await promoteWriteback(id, { rootDir });
|
|
1595
|
+
console.log(`${sub === "dismiss" ? "Dismissed" : "Promoted"} ${row.id}`);
|
|
1596
|
+
console.log(JSON.stringify(row, null, 2));
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
throw new Error(`Unknown writeback command: ${sub}`);
|
|
1601
|
+
} catch (error) {
|
|
1602
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1603
|
+
process.exitCode = 1;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
async function evolveCommand(argv: string[]) {
|
|
1608
|
+
const [sub, ...rest] = argv;
|
|
1609
|
+
const parsed = parseCliContextArgs(rest);
|
|
1610
|
+
|
|
1611
|
+
if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
|
|
1612
|
+
console.log(evolveHelp());
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
const rootDir = resolveCliContextRoot({
|
|
1617
|
+
rootArg: parsed.rootArg,
|
|
1618
|
+
scope: parsed.scope,
|
|
1619
|
+
cwd: process.cwd(),
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
try {
|
|
1623
|
+
if (sub === "propose") {
|
|
1624
|
+
const proposals = await proposeEvolution({
|
|
1625
|
+
rootDir,
|
|
1626
|
+
asset: parseStringFlag(parsed.argv, "--asset"),
|
|
1627
|
+
});
|
|
1628
|
+
if (parsed.argv.includes("--json")) {
|
|
1629
|
+
console.log(JSON.stringify(proposals, null, 2));
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
for (const proposal of proposals) {
|
|
1633
|
+
console.log(
|
|
1634
|
+
`${proposal.id}\t${proposal.targets.join(", ")}\t${proposal.summary}`
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
if (sub === "list") {
|
|
1641
|
+
const rows = await listProposals({ rootDir });
|
|
1642
|
+
if (parsed.argv.includes("--json")) {
|
|
1643
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
for (const row of rows) {
|
|
1647
|
+
console.log(`${row.id}\t[${row.status}]\t${row.targets.join(", ")}`);
|
|
1648
|
+
}
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
if (sub === "show") {
|
|
1653
|
+
const id = parsed.argv.find((arg) => !arg.startsWith("-"));
|
|
1654
|
+
if (!id) {
|
|
1655
|
+
throw new Error("evolve show requires an id");
|
|
1656
|
+
}
|
|
1657
|
+
const row = await showProposal(id, { rootDir });
|
|
1658
|
+
if (!row) {
|
|
1659
|
+
throw new Error(`Proposal not found: ${id}`);
|
|
1660
|
+
}
|
|
1661
|
+
console.log(JSON.stringify(row, null, 2));
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
if (
|
|
1666
|
+
sub === "draft" ||
|
|
1667
|
+
sub === "review" ||
|
|
1668
|
+
sub === "accept" ||
|
|
1669
|
+
sub === "reject" ||
|
|
1670
|
+
sub === "supersede" ||
|
|
1671
|
+
sub === "apply" ||
|
|
1672
|
+
sub === "promote"
|
|
1673
|
+
) {
|
|
1674
|
+
const id = parsed.argv.find((arg) => !arg.startsWith("-"));
|
|
1675
|
+
if (!id) {
|
|
1676
|
+
throw new Error(`evolve ${sub} requires an id`);
|
|
1677
|
+
}
|
|
1678
|
+
const row =
|
|
1679
|
+
sub === "draft"
|
|
1680
|
+
? await draftProposal(id, {
|
|
1681
|
+
rootDir,
|
|
1682
|
+
append: parseStringFlag(parsed.argv, "--append"),
|
|
1683
|
+
})
|
|
1684
|
+
: sub === "review"
|
|
1685
|
+
? await reviewProposal(id, { rootDir })
|
|
1686
|
+
: sub === "accept"
|
|
1687
|
+
? await acceptProposal(id, { rootDir })
|
|
1688
|
+
: sub === "reject"
|
|
1689
|
+
? await rejectProposal(id, {
|
|
1690
|
+
rootDir,
|
|
1691
|
+
reason:
|
|
1692
|
+
parseStringFlag(parsed.argv, "--reason") ??
|
|
1693
|
+
(() => {
|
|
1694
|
+
throw new Error("evolve reject requires --reason");
|
|
1695
|
+
})(),
|
|
1696
|
+
})
|
|
1697
|
+
: sub === "supersede"
|
|
1698
|
+
? await supersedeProposal(
|
|
1699
|
+
id,
|
|
1700
|
+
parseStringFlag(parsed.argv, "--by") ??
|
|
1701
|
+
(() => {
|
|
1702
|
+
throw new Error("evolve supersede requires --by");
|
|
1703
|
+
})(),
|
|
1704
|
+
{ rootDir }
|
|
1705
|
+
)
|
|
1706
|
+
: sub === "promote"
|
|
1707
|
+
? await promoteProposal(id, {
|
|
1708
|
+
rootDir,
|
|
1709
|
+
to:
|
|
1710
|
+
(parseStringFlag(parsed.argv, "--to") as
|
|
1711
|
+
| "global"
|
|
1712
|
+
| undefined) ??
|
|
1713
|
+
(() => {
|
|
1714
|
+
throw new Error("evolve promote requires --to");
|
|
1715
|
+
})(),
|
|
1716
|
+
})
|
|
1717
|
+
: await applyProposal(id, { rootDir });
|
|
1718
|
+
const verb =
|
|
1719
|
+
sub === "draft"
|
|
1720
|
+
? "Drafted"
|
|
1721
|
+
: sub === "review"
|
|
1722
|
+
? "Reviewed"
|
|
1723
|
+
: sub === "accept"
|
|
1724
|
+
? "Accepted"
|
|
1725
|
+
: sub === "reject"
|
|
1726
|
+
? "Rejected"
|
|
1727
|
+
: sub === "supersede"
|
|
1728
|
+
? "Superseded"
|
|
1729
|
+
: sub === "promote"
|
|
1730
|
+
? "Promoted"
|
|
1731
|
+
: "Applied";
|
|
1732
|
+
console.log(`${verb} ${row.id}`);
|
|
1733
|
+
console.log(JSON.stringify(row, null, 2));
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
throw new Error(`Unknown evolve command: ${sub}`);
|
|
1738
|
+
} catch (error) {
|
|
1739
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1740
|
+
process.exitCode = 1;
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
export async function aiCommand(argv: string[]) {
|
|
1745
|
+
const [sub, ...rest] = argv;
|
|
1746
|
+
if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
|
|
1747
|
+
console.log(aiHelp());
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
if (sub === "writeback") {
|
|
1752
|
+
await writebackCommand(rest);
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
if (sub === "evolve") {
|
|
1757
|
+
await evolveCommand(rest);
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
console.error(`Unknown ai command: ${sub}`);
|
|
1762
|
+
process.exitCode = 1;
|
|
1763
|
+
}
|