@warpgogol/forge 5.1.3 → 5.2.3

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.
Files changed (116) hide show
  1. package/AGENTS.md +3 -1
  2. package/bin/cli.ts +4 -0
  3. package/dist/bin/cli.js +4 -0
  4. package/dist/bin/cli.js.map +1 -1
  5. package/dist/os/adr/adr.module.js +1 -1
  6. package/dist/os/adr/adr.module.js.map +1 -1
  7. package/dist/os/compass/compass.module.js +2 -2
  8. package/dist/os/compass/compass.module.js.map +1 -1
  9. package/dist/os/compass/handlers/compass-audit-handler.d.ts +3 -0
  10. package/dist/os/compass/handlers/compass-audit-handler.d.ts.map +1 -1
  11. package/dist/os/compass/handlers/compass-audit-handler.js +87 -8
  12. package/dist/os/compass/handlers/compass-audit-handler.js.map +1 -1
  13. package/dist/os/core/core.module.js +1 -1
  14. package/dist/os/core/core.module.js.map +1 -1
  15. package/dist/os/mission/handlers/archive.d.ts.map +1 -1
  16. package/dist/os/mission/handlers/archive.js +22 -6
  17. package/dist/os/mission/handlers/archive.js.map +1 -1
  18. package/dist/os/program/lease.js +1 -1
  19. package/dist/os/program/lease.js.map +1 -1
  20. package/dist/os/queue/handlers/queue-validate.d.ts +4 -0
  21. package/dist/os/queue/handlers/queue-validate.d.ts.map +1 -0
  22. package/dist/os/queue/handlers/queue-validate.js +62 -0
  23. package/dist/os/queue/handlers/queue-validate.js.map +1 -0
  24. package/dist/os/queue/index.d.ts +5 -0
  25. package/dist/os/queue/index.d.ts.map +1 -0
  26. package/dist/os/queue/index.js +18 -0
  27. package/dist/os/queue/index.js.map +1 -0
  28. package/dist/os/queue/manifest.d.ts +14 -0
  29. package/dist/os/queue/manifest.d.ts.map +1 -0
  30. package/dist/os/queue/manifest.js +110 -0
  31. package/dist/os/queue/manifest.js.map +1 -0
  32. package/dist/os/queue/queue.module.d.ts +3 -0
  33. package/dist/os/queue/queue.module.d.ts.map +1 -0
  34. package/dist/os/queue/queue.module.js +56 -0
  35. package/dist/os/queue/queue.module.js.map +1 -0
  36. package/dist/os/queue/types.d.ts +25 -0
  37. package/dist/os/queue/types.d.ts.map +1 -0
  38. package/dist/os/queue/types.js +28 -0
  39. package/dist/os/queue/types.js.map +1 -0
  40. package/dist/os/rfc/handlers/list-create.d.ts.map +1 -1
  41. package/dist/os/rfc/handlers/list-create.js +79 -32
  42. package/dist/os/rfc/handlers/list-create.js.map +1 -1
  43. package/dist/os/rfc/handlers/pipeline-status.d.ts +2 -7
  44. package/dist/os/rfc/handlers/pipeline-status.d.ts.map +1 -1
  45. package/dist/os/rfc/handlers/pipeline-status.js +15 -57
  46. package/dist/os/rfc/handlers/pipeline-status.js.map +1 -1
  47. package/dist/os/rfc/rfc.module.js +1 -1
  48. package/dist/os/rfc/rfc.module.js.map +1 -1
  49. package/dist/os/spec/spec.module.d.ts.map +1 -1
  50. package/dist/os/spec/spec.module.js +2 -0
  51. package/dist/os/spec/spec.module.js.map +1 -1
  52. package/dist/src/knowledge/index.d.ts +1 -0
  53. package/dist/src/knowledge/index.d.ts.map +1 -1
  54. package/dist/src/knowledge/index.js +1 -0
  55. package/dist/src/knowledge/index.js.map +1 -1
  56. package/dist/src/knowledge/sync.d.ts +24 -0
  57. package/dist/src/knowledge/sync.d.ts.map +1 -0
  58. package/dist/src/knowledge/sync.js +127 -0
  59. package/dist/src/knowledge/sync.js.map +1 -0
  60. package/dist/src/onboarding/doctor.d.ts.map +1 -1
  61. package/dist/src/onboarding/doctor.js +7 -6
  62. package/dist/src/onboarding/doctor.js.map +1 -1
  63. package/dist/src/onboarding/init.d.ts.map +1 -1
  64. package/dist/src/onboarding/init.js +15 -8
  65. package/dist/src/onboarding/init.js.map +1 -1
  66. package/dist/src/onboarding/upgrade.d.ts.map +1 -1
  67. package/dist/src/onboarding/upgrade.js +42 -13
  68. package/dist/src/onboarding/upgrade.js.map +1 -1
  69. package/dist/src/pipeline-status.d.ts +62 -0
  70. package/dist/src/pipeline-status.d.ts.map +1 -0
  71. package/dist/src/pipeline-status.js +220 -0
  72. package/dist/src/pipeline-status.js.map +1 -0
  73. package/dist/src/types.d.ts +1 -2
  74. package/dist/src/types.d.ts.map +1 -1
  75. package/dist/src/types.js +1 -0
  76. package/dist/src/types.js.map +1 -1
  77. package/dist/src/utils/fs-atomic.js +1 -1
  78. package/dist/src/utils/fs-atomic.js.map +1 -1
  79. package/os/adr/adr.module.ts +1 -1
  80. package/os/compass/compass.module.ts +2 -2
  81. package/os/compass/handlers/compass-audit-handler.ts +116 -8
  82. package/os/compass/handlers/tests/compass-audit-plan.test.ts +98 -0
  83. package/os/compass/handlers/tests/compass-audit-record.test.ts +105 -0
  84. package/os/compass/handlers/tests/compass-audit-validate.test.ts +133 -0
  85. package/os/compass/handlers/tests/compass-ledger-scope.test.ts +124 -0
  86. package/os/core/core.module.ts +1 -1
  87. package/os/mission/handlers/archive.test.ts +49 -0
  88. package/os/mission/handlers/archive.ts +21 -6
  89. package/os/program/lease.ts +1 -1
  90. package/os/queue/handlers/queue-validate.ts +83 -0
  91. package/os/queue/index.ts +19 -0
  92. package/os/queue/manifest.ts +183 -0
  93. package/os/queue/queue-validate.test.ts +177 -0
  94. package/os/queue/queue.module.ts +59 -0
  95. package/os/queue/types.ts +49 -0
  96. package/os/rfc/handlers/list-create.ts +83 -35
  97. package/os/rfc/handlers/pipeline-status.ts +24 -67
  98. package/os/rfc/handlers/validate-rules.test.ts +50 -0
  99. package/os/rfc/rfc-create-concurrent.test.ts +118 -0
  100. package/os/rfc/rfc-create-hint.test.ts +90 -0
  101. package/os/rfc/rfc-read-only-no-side-effects.test.ts +83 -0
  102. package/os/rfc/rfc.module.ts +1 -1
  103. package/os/spec/spec.module.ts +2 -0
  104. package/package.json +17 -1
  105. package/skills/fo/fo-idea-i-just-want-to-see-the-result/SKILL.md +46 -1
  106. package/src/knowledge/__tests__/sync.test.ts +227 -0
  107. package/src/knowledge/index.ts +7 -0
  108. package/src/knowledge/sync.ts +161 -0
  109. package/src/onboarding/doctor.ts +7 -6
  110. package/src/onboarding/init.ts +15 -8
  111. package/src/onboarding/upgrade.ts +47 -16
  112. package/src/pipeline-status.test.ts +192 -0
  113. package/src/pipeline-status.ts +319 -0
  114. package/src/tests/upgrade.test.ts +53 -0
  115. package/src/types.ts +2 -2
  116. package/src/utils/fs-atomic.ts +1 -1
@@ -13,12 +13,14 @@ mode (RFC-0556). Drives per-file semantic-truth auditing on a revision cadence (
13
13
  <item>RFC-0352: initial implementation of compass.audit.plan, compass.audit.record, compass.audit.baseline, compass.audit.validate.</item>
14
14
  <item>RFC-0556: moved from @warpgogol/site-kernel-checks to @warpgogol/forge for autonomous mode.</item>
15
15
  <item>RFC-1094: audit work orders now carry the KEY_DECISIONS block alongside MODULE_CONTRACT and CHANGE_SUMMARY.</item>
16
+ <item>RFC-1139: CLI hint accuracy and agent-safety hygiene — rfc.create hint, EC-14-PARTIAL, amend delegation, ledger scope, sync footer, mission.open remnants</item>
17
+ <item>RFC-1143: validate/plan/record apply filterLedgerEligiblePaths — ledger-ineligible authored paths (missions/, gitignored) are skipped with skippedIneligible diagnostics instead of failing COMPASS-AUDIT-01; record warns but still writes.</item>
16
18
  </CHANGE_SUMMARY>
17
19
  */
18
20
 
19
- import { readFile } from "node:fs/promises";
21
+ import { mkdir, readFile } from "node:fs/promises";
20
22
  import { resolve, relative } from "node:path";
21
- import { execFile } from "node:child_process";
23
+ import { execFile, execFileSync } from "node:child_process";
22
24
  import { promisify } from "node:util";
23
25
  import { createCompassInventoryEntries } from "./compass-inventory.ts";
24
26
  import { resolveCompassPolicy } from "../policy.ts";
@@ -37,6 +39,9 @@ import { parse as yamlParse, stringify as yamlStringify } from "yaml";
37
39
  const execFileAsync = promisify(execFile);
38
40
  const LEDGER_PATH = "docs/compass-audit-ledger.generated.yaml";
39
41
  const DEFAULT_THRESHOLD = 30;
42
+ // RFC-1143: cap the skippedPaths diagnostic list so huge ineligible sets do not
43
+ // flood the output; the pretty summary appends "and N more" when truncated.
44
+ const SKIPPED_PATHS_CAP = 20;
40
45
 
41
46
  function resolveRevisionThreshold(value: unknown, fallback: number): number {
42
47
  if (value === undefined) return fallback;
@@ -99,6 +104,7 @@ async function saveLedger(workspaceRoot: string, ledger: CompassAuditLedger): Pr
99
104
  const normalized = withLedgerAdvisory(ledger);
100
105
  normalized.entries.sort((a, b) => a.path.localeCompare(b.path));
101
106
  const abs = resolve(workspaceRoot, LEDGER_PATH);
107
+ await mkdir(resolve(abs, ".."), { recursive: true });
102
108
  const header = buildGeneratedHeader({
103
109
  ownerCommand: "compass.audit.record",
104
110
  filePath: LEDGER_PATH,
@@ -129,6 +135,54 @@ function getAuthoredEntries(entries: CompassInventoryEntry[]): CompassInventoryE
129
135
  );
130
136
  }
131
137
 
138
+ // RFC-1139: the tracked ledger must not accumulate entries for paths that
139
+ // can never be audited long-term — gitignored roots (missions/, dist/) and
140
+ // the missions/ tree specifically (workpieces move to archive on close).
141
+ // Batch-checks `git check-ignore --stdin` once for all candidate paths.
142
+ async function filterLedgerEligiblePaths(
143
+ workspaceRoot: string,
144
+ paths: string[],
145
+ ): Promise<Set<string>> {
146
+ const eligible = new Set<string>();
147
+ const candidates = paths.filter((p) => !p.startsWith("missions/"));
148
+ let ignored = new Set<string>();
149
+ if (candidates.length > 0) {
150
+ try {
151
+ const stdout = execFileSync("git", ["check-ignore", "--stdin"], {
152
+ cwd: workspaceRoot,
153
+ input: candidates.join("\n"),
154
+ encoding: "utf-8",
155
+ });
156
+ ignored = new Set(stdout.split("\n").filter((l: string) => l.length > 0));
157
+ } catch {
158
+ // check-ignore exits 1 when nothing is ignored — treat all as eligible
159
+ ignored = new Set();
160
+ }
161
+ }
162
+ for (const p of candidates) {
163
+ if (!ignored.has(p)) eligible.add(p);
164
+ }
165
+ return eligible;
166
+ }
167
+
168
+ // RFC-1143: partition authored entries into ledger-eligible and ineligible
169
+ // sets. Shared by plan/validate so the predicate lives in exactly one place.
170
+ async function partitionLedgerEligibleAuthored(
171
+ workspaceRoot: string,
172
+ authored: CompassInventoryEntry[],
173
+ ): Promise<{ eligible: CompassInventoryEntry[]; ineligible: CompassInventoryEntry[] }> {
174
+ const eligiblePaths = await filterLedgerEligiblePaths(
175
+ workspaceRoot,
176
+ authored.map((e) => e.path),
177
+ );
178
+ const eligible: CompassInventoryEntry[] = [];
179
+ const ineligible: CompassInventoryEntry[] = [];
180
+ for (const entry of authored) {
181
+ (eligiblePaths.has(entry.path) ? eligible : ineligible).push(entry);
182
+ }
183
+ return { eligible, ineligible };
184
+ }
185
+
132
186
  export async function runCompassAuditPlan(
133
187
  input: ForgeCommandInput,
134
188
  context: ForgeRuntimeContext,
@@ -137,6 +191,7 @@ export async function runCompassAuditPlan(
137
191
  threshold: number;
138
192
  dueCount: number;
139
193
  items: CompassAuditWorkOrderItem[];
194
+ skippedIneligible: number;
140
195
  }>
141
196
  > {
142
197
  const scanRoot = resolveCompassScanRoot(input, context);
@@ -153,6 +208,15 @@ export async function runCompassAuditPlan(
153
208
 
154
209
  const threshold = resolveRevisionThreshold(input.flags["threshold"], ledger.revisionThreshold);
155
210
 
211
+ // RFC-1143: drop ledger-ineligible authored paths (missions/, gitignored)
212
+ // before the per-entry revision loop — they can never be audited long-term
213
+ // and must not cost a git call each.
214
+ const { eligible: eligibleAuthored, ineligible } = await partitionLedgerEligibleAuthored(
215
+ context.workspaceRoot,
216
+ authored,
217
+ );
218
+ const skippedIneligible = ineligible.length;
219
+
156
220
  const ledgerMap = new Map<string, CompassAuditLedgerEntry>();
157
221
  for (const e of ledger.entries) {
158
222
  ledgerMap.set(e.path, e);
@@ -160,7 +224,7 @@ export async function runCompassAuditPlan(
160
224
 
161
225
  const items: CompassAuditWorkOrderItem[] = [];
162
226
 
163
- for (const entry of authored) {
227
+ for (const entry of eligibleAuthored) {
164
228
  const ledgerEntry = ledgerMap.get(entry.path);
165
229
  const auditedRevision = ledgerEntry?.auditedRevision ?? null;
166
230
 
@@ -192,10 +256,12 @@ export async function runCompassAuditPlan(
192
256
 
193
257
  items.sort((a, b) => a.path.localeCompare(b.path));
194
258
 
195
- context.logger.info(`[compass.audit.plan] threshold=${threshold}, due=${items.length}`);
259
+ context.logger.info(
260
+ `[compass.audit.plan] threshold=${threshold}, due=${items.length}, skippedIneligible=${skippedIneligible}`,
261
+ );
196
262
 
197
263
  return {
198
- data: { threshold, dueCount: items.length, items },
264
+ data: { threshold, dueCount: items.length, items, skippedIneligible },
199
265
  exitCode: 0,
200
266
  summary: `[compass.audit.plan] ${items.length} files due for audit (threshold=${threshold})`,
201
267
  nextSteps:
@@ -235,6 +301,16 @@ export async function runCompassAuditRecord(
235
301
 
236
302
  const filePath = relative(context.workspaceRoot, resolve(process.cwd(), rawFilePath));
237
303
 
304
+ // RFC-1143: warn when recording an audit for a ledger-ineligible path — the
305
+ // entry will be swept by the next baseline run (RFC-1139). Operator intent
306
+ // wins: the write still proceeds.
307
+ const recordEligible = await filterLedgerEligiblePaths(context.workspaceRoot, [filePath]);
308
+ if (!recordEligible.has(filePath)) {
309
+ context.logger.warn(
310
+ `[compass.audit.record] ${filePath}: path is ledger-ineligible (missions/ or gitignored) — this entry will be swept by the next compass.audit.baseline run`,
311
+ );
312
+ }
313
+
238
314
  const { revision, entityId, contentHash } = await getRevisionByPath(
239
315
  context.workspaceRoot,
240
316
  filePath,
@@ -302,11 +378,17 @@ export async function runCompassAuditBaseline(
302
378
  const authored = getAuthoredEntries(entries);
303
379
  const ledger = await loadLedger(context.workspaceRoot);
304
380
 
381
+ // RFC-1139: drop ineligible entries (missions/, gitignored) already in the
382
+ // ledger — self-healing cleanup — and never seed new ones.
383
+ const candidatePaths = [...authored.map((e) => e.path), ...ledger.entries.map((e) => e.path)];
384
+ const eligible = await filterLedgerEligiblePaths(context.workspaceRoot, candidatePaths);
385
+ ledger.entries = ledger.entries.filter((e) => eligible.has(e.path));
386
+
305
387
  const existingPaths = new Set(ledger.entries.map((e) => e.path));
306
388
  let seededCount = 0;
307
389
 
308
390
  for (const entry of authored) {
309
- if (existingPaths.has(entry.path)) {
391
+ if (!eligible.has(entry.path) || existingPaths.has(entry.path)) {
310
392
  continue;
311
393
  }
312
394
 
@@ -358,6 +440,8 @@ export async function runCompassAuditValidate(
358
440
  message: string;
359
441
  fix: string;
360
442
  }>;
443
+ skippedIneligible: number;
444
+ skippedPaths: string[];
361
445
  }>
362
446
  > {
363
447
  const strict = input.flags["strict"] === true;
@@ -373,6 +457,16 @@ export async function runCompassAuditValidate(
373
457
  const authored = getAuthoredEntries(entries);
374
458
  const ledger = await loadLedger(context.workspaceRoot);
375
459
 
460
+ // RFC-1143: drop ledger-ineligible authored paths (missions/, gitignored)
461
+ // before the per-entry revision loop — baseline is forbidden to seed them,
462
+ // so demanding entries is a guaranteed false-positive (COMPASS-AUDIT-01).
463
+ const { eligible: eligibleAuthored, ineligible } = await partitionLedgerEligibleAuthored(
464
+ context.workspaceRoot,
465
+ authored,
466
+ );
467
+ const skippedPaths = ineligible.map((e) => e.path).sort();
468
+ const skippedIneligible = skippedPaths.length;
469
+
376
470
  const ledgerMap = new Map<string, CompassAuditLedgerEntry>();
377
471
  for (const e of ledger.entries) {
378
472
  ledgerMap.set(e.path, e);
@@ -386,7 +480,7 @@ export async function runCompassAuditValidate(
386
480
  fix: string;
387
481
  }> = [];
388
482
 
389
- for (const entry of authored) {
483
+ for (const entry of eligibleAuthored) {
390
484
  const ledgerEntry = ledgerMap.get(entry.path);
391
485
  const auditedRevision = ledgerEntry?.auditedRevision ?? null;
392
486
 
@@ -415,8 +509,22 @@ export async function runCompassAuditValidate(
415
509
  const dueCount = diagnostics.length;
416
510
  const hasErrors = strict && dueCount > 0;
417
511
 
512
+ if (skippedIneligible > 0) {
513
+ const shown = skippedPaths.slice(0, SKIPPED_PATHS_CAP);
514
+ const more = skippedIneligible - shown.length;
515
+ context.logger.info(
516
+ `[compass.audit.validate] skipped ${skippedIneligible} ledger-ineligible path(s) (missions/, gitignored)${more > 0 ? ` and ${more} more` : ""}`,
517
+ );
518
+ }
519
+
418
520
  return {
419
- data: { strict, dueCount, diagnostics },
521
+ data: {
522
+ strict,
523
+ dueCount,
524
+ diagnostics,
525
+ skippedIneligible,
526
+ skippedPaths: skippedPaths.slice(0, SKIPPED_PATHS_CAP),
527
+ },
420
528
  exitCode: hasErrors ? 1 : 0,
421
529
  summary: dueCount > 0 ? undefined : `[compass.audit.validate] OK (0 files overdue)`,
422
530
  nextSteps:
@@ -0,0 +1,98 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ RFC-1143 AC-4: compass.audit.plan must not list ledger-ineligible paths
5
+ (missions/, gitignored) in its due work-order items and must report the
6
+ skippedIneligible count.
7
+ </purpose>
8
+ </MODULE_CONTRACT>
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
12
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { execFile } from "node:child_process";
16
+ import { promisify } from "node:util";
17
+ import { runCompassAuditPlan } from "../compass-audit-handler.ts";
18
+ import type { ForgeCommandInput, ForgeRuntimeContext } from "../../../../src/types.ts";
19
+
20
+ const execFileAsync = promisify(execFile);
21
+
22
+ const logger = {
23
+ section() {},
24
+ info() {},
25
+ warn() {},
26
+ error() {},
27
+ success() {},
28
+ getEvents() {
29
+ return [];
30
+ },
31
+ };
32
+
33
+ function makeContext(workspaceRoot: string): ForgeRuntimeContext {
34
+ return {
35
+ workspaceRoot,
36
+ site: undefined,
37
+ siteExplicit: false,
38
+ logger: logger as never,
39
+ dryRun: false,
40
+ outputFormat: "json",
41
+ io: {} as never,
42
+ actualState: undefined as never,
43
+ fileIntents: [],
44
+ } as unknown as ForgeRuntimeContext;
45
+ }
46
+
47
+ // missions/ is not in the default scanRoots — pass both roots so the fixture
48
+ // produces a mixed eligible/ineligible authored set.
49
+ const SCAN_ROOTS = ["missions", "packages"];
50
+
51
+ function makeInput(flags: Record<string, unknown> = {}): ForgeCommandInput {
52
+ return { argv: [], flags: { root: SCAN_ROOTS, ...flags } } as unknown as ForgeCommandInput;
53
+ }
54
+
55
+ const ELIGIBLE_FILE = "packages/foo/index.ts";
56
+
57
+ describe("compass.audit.plan — RFC-1143 ledger-ineligible skips", () => {
58
+ let root: string;
59
+
60
+ beforeEach(async () => {
61
+ root = await mkdtemp(join(tmpdir(), "compass-plan-"));
62
+ await mkdir(join(root, "packages", "foo"), { recursive: true });
63
+ await writeFile(join(root, "packages", "foo", "index.ts"), "export const x = 1;\n", "utf8");
64
+ await mkdir(join(root, "missions", "acme-m000001", "workpiece", "src"), {
65
+ recursive: true,
66
+ });
67
+ await writeFile(
68
+ join(root, "missions", "acme-m000001", "workpiece", "src", "local.ts"),
69
+ "export const y = 2;\n",
70
+ "utf8",
71
+ );
72
+ await writeFile(join(root, ".gitignore"), "missions/\n", "utf8");
73
+ await execFileAsync("git", ["init"], { cwd: root });
74
+ await execFileAsync("git", ["config", "user.email", "test@test.com"], { cwd: root });
75
+ await execFileAsync("git", ["config", "user.name", "Test"], { cwd: root });
76
+ await execFileAsync("git", ["add", "."], { cwd: root });
77
+ await execFileAsync("git", ["commit", "-m", "init"], { cwd: root });
78
+ });
79
+
80
+ afterEach(async () => {
81
+ await rm(root, { recursive: true, force: true });
82
+ });
83
+
84
+ it("AC-4: due items exclude ledger-ineligible paths and report skippedIneligible", async () => {
85
+ const result = await runCompassAuditPlan(makeInput(), makeContext(root));
86
+ expect(result.exitCode).toBe(0);
87
+ const data = result.data as {
88
+ items: Array<{ path: string }>;
89
+ skippedIneligible: number;
90
+ };
91
+ expect(
92
+ data.items.some((i) => i.path.startsWith("missions/")),
93
+ "plan must not emit work orders for ledger-ineligible paths — check the eligibility partition in runCompassAuditPlan",
94
+ ).toBe(false);
95
+ expect(data.items.some((i) => i.path === ELIGIBLE_FILE)).toBe(true);
96
+ expect(data.skippedIneligible).toBeGreaterThan(0);
97
+ });
98
+ });
@@ -0,0 +1,105 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ RFC-1143 AC-5: compass.audit.record on a ledger-ineligible path (missions/,
5
+ gitignored) still writes the entry (operator intent wins) but emits a
6
+ warning that the next baseline run will sweep it.
7
+ </purpose>
8
+ </MODULE_CONTRACT>
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
12
+ import { mkdtemp, mkdir, writeFile, rm, readFile } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { execFile } from "node:child_process";
16
+ import { promisify } from "node:util";
17
+ import { parse as yamlParse } from "yaml";
18
+ import { runCompassAuditRecord } from "../compass-audit-handler.ts";
19
+ import type { ForgeCommandInput, ForgeRuntimeContext } from "../../../../src/types.ts";
20
+
21
+ const execFileAsync = promisify(execFile);
22
+
23
+ const warnings: string[] = [];
24
+ const logger = {
25
+ section() {},
26
+ info() {},
27
+ warn(msg: string) {
28
+ warnings.push(msg);
29
+ },
30
+ error() {},
31
+ success() {},
32
+ getEvents() {
33
+ return [];
34
+ },
35
+ };
36
+
37
+ function makeContext(workspaceRoot: string): ForgeRuntimeContext {
38
+ return {
39
+ workspaceRoot,
40
+ site: undefined,
41
+ siteExplicit: false,
42
+ logger: logger as never,
43
+ dryRun: false,
44
+ outputFormat: "json",
45
+ io: {} as never,
46
+ actualState: undefined as never,
47
+ fileIntents: [],
48
+ } as unknown as ForgeRuntimeContext;
49
+ }
50
+
51
+ function makeInput(flags: Record<string, unknown> = {}): ForgeCommandInput {
52
+ return { argv: [], flags } as unknown as ForgeCommandInput;
53
+ }
54
+
55
+ const MISSION_FILE = "missions/acme-m000001/workpiece/src/local.ts";
56
+
57
+ describe("compass.audit.record — RFC-1143 ineligible-path warning", () => {
58
+ let root: string;
59
+
60
+ beforeEach(async () => {
61
+ warnings.length = 0;
62
+ root = await mkdtemp(join(tmpdir(), "compass-record-"));
63
+ await mkdir(join(root, "missions", "acme-m000001", "workpiece", "src"), {
64
+ recursive: true,
65
+ });
66
+ await writeFile(
67
+ join(root, "missions", "acme-m000001", "workpiece", "src", "local.ts"),
68
+ "export const y = 2;\n",
69
+ "utf8",
70
+ );
71
+ await writeFile(join(root, ".gitignore"), "missions/\n", "utf8");
72
+ await execFileAsync("git", ["init"], { cwd: root });
73
+ await execFileAsync("git", ["config", "user.email", "test@test.com"], { cwd: root });
74
+ await execFileAsync("git", ["config", "user.name", "Test"], { cwd: root });
75
+ await execFileAsync("git", ["add", "."], { cwd: root });
76
+ await execFileAsync("git", ["commit", "-m", "init"], { cwd: root });
77
+ });
78
+
79
+ afterEach(async () => {
80
+ await rm(root, { recursive: true, force: true });
81
+ });
82
+
83
+ it("AC-5: record on ineligible path writes the entry and warns about the sweep", async () => {
84
+ const absTarget = join(root, MISSION_FILE);
85
+ const result = await runCompassAuditRecord(
86
+ makeInput({ file: absTarget, verdict: "pass", agent: "agent:test" }),
87
+ makeContext(root),
88
+ );
89
+ expect(result.exitCode).toBe(0);
90
+
91
+ // Entry was written despite ineligibility (operator intent wins)
92
+ const ledgerRaw = await readFile(
93
+ join(root, "docs", "compass-audit-ledger.generated.yaml"),
94
+ "utf8",
95
+ );
96
+ const ledger = yamlParse(ledgerRaw) as { entries: Array<{ path: string }> };
97
+ expect(ledger.entries.some((e) => e.path === MISSION_FILE)).toBe(true);
98
+
99
+ // Warning about the next baseline sweep was emitted
100
+ expect(
101
+ warnings.some((w) => w.includes("ledger-ineligible") && w.includes("baseline")),
102
+ "record must warn that an ineligible entry will be swept by the next baseline run",
103
+ ).toBe(true);
104
+ });
105
+ });
@@ -0,0 +1,133 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ RFC-1143 AC-1..AC-3: compass.audit.validate must skip ledger-ineligible
5
+ authored paths (missions/, gitignored) instead of emitting COMPASS-AUDIT-01,
6
+ report skippedIneligible/skippedPaths diagnostics, and still flag eligible
7
+ authored files that have no ledger entry.
8
+ </purpose>
9
+ </MODULE_CONTRACT>
10
+ */
11
+
12
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
13
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { execFile } from "node:child_process";
17
+ import { promisify } from "node:util";
18
+ import { runCompassAuditValidate } from "../compass-audit-handler.ts";
19
+ import type { ForgeCommandInput, ForgeRuntimeContext } from "../../../../src/types.ts";
20
+
21
+ const execFileAsync = promisify(execFile);
22
+
23
+ const logger = {
24
+ section() {},
25
+ info() {},
26
+ warn() {},
27
+ error() {},
28
+ success() {},
29
+ getEvents() {
30
+ return [];
31
+ },
32
+ };
33
+
34
+ function makeContext(workspaceRoot: string): ForgeRuntimeContext {
35
+ return {
36
+ workspaceRoot,
37
+ site: undefined,
38
+ siteExplicit: false,
39
+ logger: logger as never,
40
+ dryRun: false,
41
+ outputFormat: "json",
42
+ io: {} as never,
43
+ actualState: undefined as never,
44
+ fileIntents: [],
45
+ } as unknown as ForgeRuntimeContext;
46
+ }
47
+
48
+ // missions/ is not in the default scanRoots — the real-world failure mode is a
49
+ // scoped scan (--root / site workpiece) that surfaces missions/ paths. Passing
50
+ // both roots reproduces the mixed eligible/ineligible authored set.
51
+ const SCAN_ROOTS = ["missions", "packages"];
52
+
53
+ function makeInput(flags: Record<string, unknown> = {}): ForgeCommandInput {
54
+ return { argv: [], flags: { root: SCAN_ROOTS, ...flags } } as unknown as ForgeCommandInput;
55
+ }
56
+
57
+ const MISSION_FILE = "missions/acme-m000001/workpiece/src/local.ts";
58
+ const ELIGIBLE_FILE = "packages/foo/index.ts";
59
+
60
+ describe("compass.audit.validate — RFC-1143 ledger-ineligible skips", () => {
61
+ let root: string;
62
+
63
+ beforeEach(async () => {
64
+ root = await mkdtemp(join(tmpdir(), "compass-validate-"));
65
+ // Authored source file (plain .ts, no generated marker → authored entry)
66
+ await mkdir(join(root, "packages", "foo"), { recursive: true });
67
+ await writeFile(join(root, "packages", "foo", "index.ts"), "export const x = 1;\n", "utf8");
68
+ // Mission workpiece file — authored but ledger-ineligible
69
+ await mkdir(join(root, "missions", "acme-m000001", "workpiece", "src"), {
70
+ recursive: true,
71
+ });
72
+ await writeFile(
73
+ join(root, "missions", "acme-m000001", "workpiece", "src", "local.ts"),
74
+ "export const y = 2;\n",
75
+ "utf8",
76
+ );
77
+ await writeFile(join(root, ".gitignore"), "missions/\n", "utf8");
78
+ // Git repo for getRevisionByPath + check-ignore
79
+ await execFileAsync("git", ["init"], { cwd: root });
80
+ await execFileAsync("git", ["config", "user.email", "test@test.com"], { cwd: root });
81
+ await execFileAsync("git", ["config", "user.name", "Test"], { cwd: root });
82
+ await execFileAsync("git", ["add", "."], { cwd: root });
83
+ await execFileAsync("git", ["commit", "-m", "init"], { cwd: root });
84
+ });
85
+
86
+ afterEach(async () => {
87
+ await rm(root, { recursive: true, force: true });
88
+ });
89
+
90
+ it("AC-1: no COMPASS-AUDIT-01 diagnostic for missions/ paths", async () => {
91
+ const result = await runCompassAuditValidate(makeInput(), makeContext(root));
92
+ const diagnostics = (result.data as { diagnostics: Array<{ file: string; ruleId: string }> })
93
+ .diagnostics;
94
+ expect(
95
+ diagnostics.some((d) => d.file.startsWith("missions/")),
96
+ "validate must not flag ledger-ineligible missions/ paths — check the eligibility partition in runCompassAuditValidate",
97
+ ).toBe(false);
98
+ });
99
+
100
+ it("AC-2: result reports skippedIneligible > 0 and skippedPaths", async () => {
101
+ const result = await runCompassAuditValidate(makeInput(), makeContext(root));
102
+ const data = result.data as { skippedIneligible: number; skippedPaths: string[] };
103
+ expect(data.skippedIneligible).toBeGreaterThan(0);
104
+ expect(data.skippedPaths).toContain(MISSION_FILE);
105
+ });
106
+
107
+ it("AC-3: eligible authored file without ledger entry still emits COMPASS-AUDIT-01", async () => {
108
+ const result = await runCompassAuditValidate(makeInput(), makeContext(root));
109
+ const diagnostics = (result.data as { diagnostics: Array<{ file: string; ruleId: string }> })
110
+ .diagnostics;
111
+ expect(
112
+ diagnostics.some((d) => d.file === ELIGIBLE_FILE && d.ruleId === "COMPASS-AUDIT-01"),
113
+ "eligible authored files must still be audited — check that only ineligible paths were partitioned out",
114
+ ).toBe(true);
115
+ });
116
+
117
+ it("AC-1 (strict): exits 0 when only ineligible paths would be due", async () => {
118
+ // Seed a ledger entry for the eligible file at its current revision (1
119
+ // commit → revision 1) so only the missions/ file would be due.
120
+ await mkdir(join(root, "docs"), { recursive: true });
121
+ await writeFile(
122
+ join(root, "docs", "compass-audit-ledger.generated.yaml"),
123
+ `entries:\n - path: ${ELIGIBLE_FILE}\n entityId: ""\n auditedRevision: 1\n auditedHash: ""\n auditedAt: "2026-01-01T00:00:00Z"\n verdict: pass\n agent: human:test\n`,
124
+ "utf8",
125
+ );
126
+
127
+ const result = await runCompassAuditValidate(makeInput({ strict: true }), makeContext(root));
128
+ expect(result.exitCode).toBe(0);
129
+ const data = result.data as { dueCount: number; skippedIneligible: number };
130
+ expect(data.dueCount).toBe(0);
131
+ expect(data.skippedIneligible).toBeGreaterThan(0);
132
+ });
133
+ });