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/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
+ }