cadet-agent 0.34.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,37 +1,37 @@
1
- {
2
- "name": "cadet-agent",
3
- "version": "0.34.0",
4
- "description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
5
- "type": "module",
6
- "bin": {
7
- "cadet-agent": "bin/cli.mjs"
8
- },
9
- "scripts": {
10
- "test": "node --test test/*.test.mjs",
11
- "lint": "lychee --offline --include-fragments \"**/*.md\"",
12
- "verify": "npm test && npm run lint"
13
- },
14
- "files": [
15
- "bin/",
16
- "src/"
17
- ],
18
- "keywords": [
19
- "cadet",
20
- "cadet-agent",
21
- "unity",
22
- "game-development",
23
- "ai-agent",
24
- "copilot",
25
- "cursor",
26
- "claude-code"
27
- ],
28
- "license": "CC-BY-4.0",
29
- "repository": {
30
- "type": "git",
31
- "url": "git+https://github.com/naishtech/cadet-agent.git"
32
- },
33
- "homepage": "https://github.com/naishtech/cadet-agent#readme",
34
- "engines": {
35
- "node": ">=18.0.0"
36
- }
37
- }
1
+ {
2
+ "name": "cadet-agent",
3
+ "version": "0.36.0",
4
+ "description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
5
+ "type": "module",
6
+ "bin": {
7
+ "cadet-agent": "bin/cli.mjs"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test test/*.test.mjs",
11
+ "lint": "lychee --offline --include-fragments \"**/*.md\"",
12
+ "verify": "npm test && npm run lint"
13
+ },
14
+ "files": [
15
+ "bin/",
16
+ "src/"
17
+ ],
18
+ "keywords": [
19
+ "cadet",
20
+ "cadet-agent",
21
+ "unity",
22
+ "game-development",
23
+ "ai-agent",
24
+ "copilot",
25
+ "cursor",
26
+ "claude-code"
27
+ ],
28
+ "license": "CC-BY-4.0",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/naishtech/cadet-agent.git"
32
+ },
33
+ "homepage": "https://github.com/naishtech/cadet-agent#readme",
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ }
37
+ }
package/src/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { dirname, join } from 'node:path';
3
+ import { dirname, join, resolve } from 'node:path';
4
4
  import { install, sync } from './install.mjs';
5
5
  import {
6
6
  validateState, migrateStateFile, readState, writeState, evaluateTransition, applyTransition,
@@ -9,6 +9,7 @@ import {
9
9
  detectRepoRole, describeRepoRole, GATES, manualConfirmation,
10
10
  parseTestInventory, parseStoryCriteria, compareCoverage, describeCoverageGaps,
11
11
  createEvidence, newId, computeInputTreeHash, hashCriteria,
12
+ collectDeclaredTestNames, reconcileTestNames,
12
13
  } from './harness/index.mjs';
13
14
 
14
15
  const __filename = fileURLToPath(import.meta.url);
@@ -90,9 +91,17 @@ function parseArgs(argv) {
90
91
  case '--environment': opts.environment = argv[++i]; break;
91
92
  case '--scope': opts.scope = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
92
93
  case '--evidence-status': opts.evidenceStatus = argv[++i]; break;
93
- case '--files': opts.files = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
94
+ // Track that the flag was supplied even when its value is empty, so an
95
+ // empty `--files ""` is rejected rather than silently falling back to the
96
+ // working-tree scan (which could bind evidence to Cadet's own files).
97
+ case '--files': opts.filesGiven = true; opts.files = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
94
98
  case '--story': opts.story = argv[++i]; break;
95
99
  case '--report': opts.report = argv[++i]; break;
100
+ // AR-1: the revision a gate record attests, so a gate-related fix claim
101
+ // can be traced to the commit that contains it.
102
+ case '--commit': opts.commitGiven = true; opts.commit = argv[++i]; break;
103
+ case '--matrix': opts.matrix = argv[++i]; break;
104
+ case '--inventory': opts.inventory = argv[++i]; break;
96
105
  case '--write-coverage': opts.writeCoverage = true; break;
97
106
  case '--strict-orphans': opts.strictOrphans = true; break;
98
107
  case '--dry-run': opts.dryRun = true; break;
@@ -320,6 +329,9 @@ async function cmdHarness(opts) {
320
329
  // Freshness binding mirrors `harness verify`: never record a gate against an
321
330
  // unknown input tree unless the repository explicitly opted out.
322
331
  const allowEmpty = policy?.allowEmptyFreshness === true;
332
+ if (opts.filesGiven && (!opts.files || opts.files.length === 0)) {
333
+ fail(opts, '--files was given with no paths. Pass a comma-separated list of the files this gate covers (e.g. --files src/Foo.cs,test/FooTests.cs), or omit --files to auto-detect changed files.', () => 1, { ok: false, gate, code: 'empty-files' });
334
+ }
323
335
  let relevantFiles;
324
336
  if (opts.files && opts.files.length) {
325
337
  relevantFiles = opts.files.map((f) => f.replace(/\\/g, '/'));
@@ -355,6 +367,7 @@ async function cmdHarness(opts) {
355
367
  relevantFiles,
356
368
  rootDir: opts.targetDir,
357
369
  approvedBy: opts.approvedBy || 'user',
370
+ commit: opts.commit || null,
358
371
  at,
359
372
  });
360
373
 
@@ -459,6 +472,9 @@ async function cmdHarness(opts) {
459
472
  const allowEmpty = policy?.allowEmptyFreshness === true;
460
473
  let relevantFiles;
461
474
  let filesSource;
475
+ if (opts.filesGiven && (!opts.files || opts.files.length === 0)) {
476
+ fail(opts, '--files was given with no paths. Pass a comma-separated list of the files this gate covers (e.g. --files src/Foo.cs,test/FooTests.cs), or omit --files to auto-detect changed files.', () => 1, { ok: false, gate, code: 'empty-files' });
477
+ }
462
478
  if (opts.files && opts.files.length) {
463
479
  relevantFiles = opts.files.map((f) => f.replace(/\\/g, '/'));
464
480
  filesSource = 'explicit';
@@ -517,6 +533,7 @@ async function cmdHarness(opts) {
517
533
  phase: ledger.phase || 'implementation',
518
534
  relevantFiles,
519
535
  rootDir: opts.targetDir,
536
+ commit: opts.commit || null,
520
537
  policy,
521
538
  budgets: ledger.tracker,
522
539
  artifactDir: join(runsDir(opts.targetDir), 'artifacts'),
@@ -755,6 +772,95 @@ async function cmdHarness(opts) {
755
772
  return;
756
773
  }
757
774
 
775
+ // AR-5. Reconcile a TDD matrix's DELIVERED test-name claims against a compiled
776
+ // inventory. Read-only: it reports, and never writes state, so it can be run at
777
+ // authoring time (before anything has been implemented) as well as in a gate.
778
+ if (sub === 'matrix-check') {
779
+ if (!opts.matrix) fail(opts, 'harness matrix-check requires --matrix <path-to-matrix.md>');
780
+ const matrixPath = resolve(opts.targetDir, opts.matrix);
781
+ let matrixText;
782
+ try {
783
+ matrixText = readFileSync(matrixPath, 'utf-8');
784
+ } catch (err) {
785
+ fail(opts, `cannot read matrix ${opts.matrix}: ${err.message}`, () => 2);
786
+ }
787
+
788
+ // The inventory may come from a run report (strongest: it proves the test
789
+ // RAN) or from C# sources (a name inventory only). Prefer a report.
790
+ let inventory = null;
791
+ let inventorySource = null;
792
+ if (opts.report) {
793
+ const reportPath = resolve(opts.targetDir, opts.report);
794
+ let reportText;
795
+ try {
796
+ reportText = readFileSync(reportPath, 'utf-8');
797
+ } catch (err) {
798
+ fail(opts, `cannot read report ${opts.report}: ${err.message}`, () => 2);
799
+ }
800
+ const parsed = parseTestInventory(reportText);
801
+ if (parsed && parsed.format !== 'unknown' && parsed.names.length > 0) {
802
+ inventory = new Set(parsed.names);
803
+ inventorySource = `${opts.report} (${parsed.format})`;
804
+ }
805
+ }
806
+ if (!inventory && opts.inventory) {
807
+ const invPath = resolve(opts.targetDir, opts.inventory);
808
+ let raw;
809
+ try {
810
+ raw = readFileSync(invPath, 'utf-8');
811
+ } catch (err) {
812
+ fail(opts, `cannot read inventory ${opts.inventory}: ${err.message}`, () => 2);
813
+ }
814
+ inventory = new Set(String(raw).split(/\r?\n/).map((s) => s.trim()).filter(Boolean));
815
+ inventorySource = opts.inventory;
816
+ }
817
+
818
+ const collected = collectDeclaredTestNames(matrixText);
819
+ if (!inventory) {
820
+ // Without an inventory this cannot prove anything, so it must not report
821
+ // success. Returning the collected names is still useful at authoring time:
822
+ // it shows what the matrix claims, and the caller can spot a name they know
823
+ // they never wrote.
824
+ const detail = {
825
+ ok: false,
826
+ code: 'inventory-unavailable',
827
+ matrix: opts.matrix,
828
+ claims: collected.claims,
829
+ intents: collected.intents,
830
+ };
831
+ if (opts.format === 'json') emit(opts, '', detail);
832
+ else {
833
+ console.error('❌ No inventory supplied, so no claim can be checked. Pass --report <test-results> (preferred, proves the test ran) or --inventory <names.txt>.');
834
+ console.error(` The matrix declares ${collected.claims.length} delivered claim(s) across ${collected.intents.length} undelivered intention(s).`);
835
+ }
836
+ process.exit(1);
837
+ }
838
+
839
+ const result = reconcileTestNames(collected, inventory);
840
+ const ok = result.missingFromInventory.length === 0;
841
+ const detail = {
842
+ ok,
843
+ matrix: opts.matrix,
844
+ inventory: inventorySource,
845
+ checked: result.checked,
846
+ intents: collected.intents.length,
847
+ missingFromInventory: result.missingFromInventory,
848
+ unmatchedIntents: result.unmatchedIntents,
849
+ };
850
+ if (opts.format === 'json') emit(opts, '', detail);
851
+ else if (ok) {
852
+ console.log(`✅ Every delivered test-name claim exists in the inventory (${result.checked} checked from ${inventorySource}).`);
853
+ if (result.unmatchedIntents.length > 0) {
854
+ console.log(` ℹ️ ${result.unmatchedIntents.length} undelivered intention(s) now exist and the row may be stale — consider marking it DELIVERED.`);
855
+ }
856
+ } else {
857
+ console.error(`❌ ${result.missingFromInventory.length} delivered test-name claim(s) do not exist in the inventory (${inventorySource}):`);
858
+ for (const n of result.missingFromInventory) console.error(` "${n}" — declared as delivered but absent. Attach it to the criterion it proves, or correct the name.`);
859
+ }
860
+ if (!ok) process.exit(1);
861
+ return;
862
+ }
863
+
758
864
  if (sub === 'cleanup') {
759
865
  const { deleted, kept } = cleanupRuns(opts.targetDir, policy, {
760
866
  olderThanMs: Number.isFinite(opts.olderThanMs) ? opts.olderThanMs : null,
@@ -763,7 +869,7 @@ async function cmdHarness(opts) {
763
869
  return;
764
870
  }
765
871
 
766
- fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|verify-acs|report|cleanup|capabilities.`);
872
+ fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|verify-acs|matrix-check|report|cleanup|capabilities.`);
767
873
  }
768
874
 
769
875
  export async function run(argv) {
@@ -69,3 +69,7 @@ export {
69
69
  normalizeTestName, parseTestInventory, parseStoryCriteria, parseStoryCriteriaText,
70
70
  compareCoverage, describeCoverageGaps,
71
71
  } from './verify-acs.mjs';
72
+
73
+ export {
74
+ collectDeclaredTestNames, reconcileTestNames, inventoryFromCSharpSources,
75
+ } from './matrix-check.mjs';
@@ -0,0 +1,122 @@
1
+ // AR-5 — mechanical reconciliation of a TDD matrix's test-name claims against a
2
+ // compiled test inventory.
3
+ //
4
+ // THE DEFECT THIS EXISTS TO REMOVE. A TDD matrix row names the tests that prove
5
+ // an acceptance criterion. Those rows are authored during architecture, BEFORE
6
+ // implementation, so a name can be an intention that changes (or never happens)
7
+ // while nothing re-checks the row. In one real project this produced the SAME
8
+ // phantom-test-name defect three times, and all three were found late — by the
9
+ // validation gate, two stories after the claim was written. A name that does not
10
+ // exist reads as proof and is not.
11
+ //
12
+ // THE TWO DIRECTIONS, AND WHY THE DISTINCTION MATTERS.
13
+ //
14
+ // DELIVERED rows carry a claim: "these tests exist and prove this criterion".
15
+ // A name here that is absent from the inventory is a DEFECT.
16
+ // undelivered rows carry an intention for planned work. A name here that is
17
+ // absent is EXPECTED and must NOT be reported.
18
+ //
19
+ // Collapsing the two produces false failures, and a false failure is how a real
20
+ // check gets switched off. This module keeps them separate and makes the
21
+ // separation the caller's explicit choice.
22
+ //
23
+ // Deliberately dependency-free and side-effect-free: it returns findings and
24
+ // never throws on content, so it can run at authoring time as well as in a gate.
25
+
26
+ /** Marker that separates a row's claims from its explanatory prose. */
27
+ const DELIVERED_MARKER = 'DELIVERED';
28
+
29
+ /**
30
+ * A test name is `Identifier_LikeThis` — at least one underscore, both sides
31
+ * identifier-shaped. This deliberately rejects prose symbols that appear in
32
+ * backticks (type names such as `ViewExtent`, single letters such as `u`), which
33
+ * is the specific false-positive class that made an earlier checker unusable.
34
+ */
35
+ const TEST_NAME = /^[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+$/;
36
+
37
+ /** Extract every backticked token that looks like a test name. */
38
+ function backtickedTestNames(text) {
39
+ const out = [];
40
+ for (const m of String(text).matchAll(/`([^`]+)`/g)) {
41
+ const name = m[1].trim();
42
+ if (TEST_NAME.test(name)) out.push(name);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** Split a markdown table row into its cells (leading/trailing pipes dropped). */
48
+ function cellsOf(line) {
49
+ const trimmed = line.trim();
50
+ if (!trimmed.startsWith('|')) return null;
51
+ const parts = trimmed.split('|');
52
+ // A row looks like `| a | b | c |`; the split yields ['', ' a ', ' b ', ' c ', ''].
53
+ return parts.slice(1, -1).map((c) => c.trim());
54
+ }
55
+
56
+ /**
57
+ * Collect test names from a TDD-matrix-style markdown document.
58
+ *
59
+ * Only the "declared tests" cell — the SECOND column — is read, and within a
60
+ * DELIVERED row only the text BEFORE the marker. Both restrictions exist because
61
+ * a matrix row's later columns discuss the design in prose and legitimately name
62
+ * types, symbols and fractions in backticks; treating those as claims is exactly
63
+ * the false-failure class this module was written to avoid.
64
+ *
65
+ * @returns {{claims: string[], intents: string[]}} deduplicated, source-ordered
66
+ */
67
+ export function collectDeclaredTestNames(markdown) {
68
+ const claims = [];
69
+ const intents = [];
70
+ for (const line of String(markdown).split(/\r?\n/)) {
71
+ const cells = cellsOf(line);
72
+ if (!cells || cells.length < 2) continue;
73
+ const declaredCell = cells[1];
74
+ if (!declaredCell) continue;
75
+ // A separator row (`| --- | --- |`) has no test names and no marker.
76
+ const markerAt = declaredCell.indexOf(DELIVERED_MARKER);
77
+ const isDelivered = markerAt !== -1;
78
+ const scope = isDelivered ? declaredCell.slice(0, markerAt) : declaredCell;
79
+ const names = backtickedTestNames(scope);
80
+ (isDelivered ? claims : intents).push(...names);
81
+ }
82
+ return { claims: [...new Set(claims)], intents: [...new Set(intents)] };
83
+ }
84
+
85
+ /**
86
+ * Compare collected names against a compiled inventory.
87
+ *
88
+ * @param {{claims: string[], intents: string[]}} collected
89
+ * @param {Set<string>|string[]} inventory compiled test method names
90
+ * @returns {{missingFromInventory: string[], unmatchedIntents: string[], checked: number}}
91
+ */
92
+ export function reconcileTestNames(collected, inventory) {
93
+ const have = inventory instanceof Set ? inventory : new Set(inventory || []);
94
+ const claims = Array.isArray(collected?.claims) ? collected.claims : [];
95
+ const intents = Array.isArray(collected?.intents) ? collected.intents : [];
96
+ return {
97
+ // Only DELIVERED claims can be defects. An intention is allowed to be absent.
98
+ missingFromInventory: claims.filter((n) => !have.has(n)),
99
+ // Reported separately and informationally: an intention that HAS landed is
100
+ // not a defect, but it usually means the row is stale and should be marked
101
+ // DELIVERED. Surfaced so the drift is visible rather than silent.
102
+ unmatchedIntents: intents.filter((n) => have.has(n)),
103
+ checked: claims.length,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Extract public test-method names from C# test sources.
109
+ *
110
+ * Used to build an inventory when no test report is available — for example at
111
+ * authoring time, before anything has run. This is a NAME inventory, not a
112
+ * pass/fail one: it proves a name exists, never that the test passes. Callers
113
+ * that need the stronger claim must use a run report.
114
+ */
115
+ export function inventoryFromCSharpSources(sources) {
116
+ const names = new Set();
117
+ const pattern = /public\s+(?:async\s+)?(?:void|Task)\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/g;
118
+ for (const text of sources) {
119
+ for (const m of String(text).matchAll(pattern)) names.add(m[1]);
120
+ }
121
+ return names;
122
+ }
@@ -212,6 +212,47 @@ export function validateState(state, context = {}) {
212
212
  if (lt.to !== undefined && !PHASES.includes(lt.to)) errors.push({ path: 'lastTransition.to', message: `unknown phase "${lt.to}"` });
213
213
  }
214
214
  }
215
+
216
+ // AR-2. A story marked `done` must have SOME evidence record of its own.
217
+ //
218
+ // WHY THIS IS NEEDED. Every gate rule in this file is scoped to the ACTIVE
219
+ // work item, so `state validate` could report a document as fully valid while
220
+ // an already-completed story had no evidence whatsoever. In one real project
221
+ // eight `done` stories had zero records and validation said "valid, 0 errors,
222
+ // 0 warnings" — the gaps were invisible until they were looked for by hand.
223
+ //
224
+ // SCOPE, DELIBERATELY NARROW. This asserts COVERAGE, not gate completeness:
225
+ // it asks only "is there any evidence for this story at all?". Whether every
226
+ // required gate was satisfied for the right phase is already enforced at
227
+ // transition time, against the active work item, where the phase is known.
228
+ // Re-deciding that here would duplicate the transition matrix and risk the
229
+ // two disagreeing.
230
+ //
231
+ // It is an ERROR, not a warning, because a `done` story with no evidence is
232
+ // indistinguishable from a story that was never verified — which is the
233
+ // condition the framework exists to prevent. Projects that closed stories
234
+ // before the harness existed can resolve it with a scoped gate exception or
235
+ // by re-recording; silently tolerating it is what let the gap grow.
236
+ if (isPlainObject(state.epics) && Array.isArray(state.gateEvidence)) {
237
+ const evidenced = new Set(
238
+ state.gateEvidence
239
+ .map((e) => (isPlainObject(e) ? e.workItemId : null))
240
+ .filter((id) => typeof id === 'string' && id.length > 0),
241
+ );
242
+ for (const [epicId, epic] of Object.entries(state.epics)) {
243
+ if (!isPlainObject(epic) || !isPlainObject(epic.stories)) continue;
244
+ for (const [storyId, status] of Object.entries(epic.stories)) {
245
+ if (status !== 'done') continue;
246
+ if (evidenced.has(`${epicId}::${storyId}`)) continue;
247
+ errors.push({
248
+ path: `epics.${epicId}.stories.${storyId}`,
249
+ message: `story "${storyId}" is marked done but has no evidence record for its work item `
250
+ + `"${epicId}::${storyId}". A completed story must be backed by at least one evidence `
251
+ + 'record; otherwise it is indistinguishable from one that was never verified.',
252
+ });
253
+ }
254
+ }
255
+ }
215
256
  } else if (state.gateEvidence !== undefined) {
216
257
  warnings.push({ path: 'gateEvidence', message: 'gateEvidence on a v1 state is ignored until migration' });
217
258
  }
@@ -275,6 +316,15 @@ function validateEvidenceShape(ev, strict = null) {
275
316
  if (ev.result !== undefined && ev.result !== null && typeof ev.result !== 'string') {
276
317
  errors.push({ path: 'result', message: 'result must be a string or null' });
277
318
  }
319
+ // AR-1. `commit` is optional (a v2-shaped record omits it) but must be a real
320
+ // revision identifier when present — a branch or tag name would read as a
321
+ // citation while being uncheckable later, which is worse than none.
322
+ if (ev.commit !== undefined && ev.commit !== null && !/^[0-9a-fA-F]{4,40}$/.test(String(ev.commit))) {
323
+ errors.push({
324
+ path: 'commit',
325
+ message: 'commit must be a 4-40 character hex revision identifier, or null',
326
+ });
327
+ }
278
328
  if (ev.createdAt !== undefined && ev.createdAt !== null && Number.isNaN(Date.parse(ev.createdAt))) {
279
329
  errors.push({ path: 'createdAt', message: 'createdAt must be an ISO-8601 date-time' });
280
330
  }
@@ -552,6 +602,30 @@ export function migrateStateFile(statePath, { backup = true } = {}) {
552
602
 
553
603
  // ── Evidence ────────────────────────────────────────────────────────────────
554
604
 
605
+ /**
606
+ * AR-1. Normalize and validate a commit citation.
607
+ *
608
+ * Accepts a full SHA or an abbreviated one (git's default short form is 7, but
609
+ * 4–40 hex characters are all unambiguous enough to store). A symbolic name such
610
+ * as a branch or tag is REJECTED: those move, so a record naming one cannot be
611
+ * checked later, which defeats the purpose of citing a revision at all.
612
+ *
613
+ * Returns null for an absent value so a v2-shaped record is unchanged.
614
+ */
615
+ export function normalizeCommit(commit) {
616
+ if (commit === null || commit === undefined) return null;
617
+ const value = String(commit).trim();
618
+ if (value === '') return null;
619
+ if (!/^[0-9a-fA-F]{4,40}$/.test(value)) {
620
+ throw new StateError(
621
+ `commit must be a 4-40 character hex revision identifier, but was "${value}". `
622
+ + 'A branch or tag name is not accepted: it moves, so the citation could not be '
623
+ + 'checked later. Pass an abbreviated or full SHA.',
624
+ );
625
+ }
626
+ return value.toLowerCase();
627
+ }
628
+
555
629
  /** Build an evidence record. `id` defaults to a fresh UUIDv4. */
556
630
  export function createEvidence({
557
631
  evidenceId,
@@ -569,12 +643,19 @@ export function createEvidence({
569
643
  criteriaHash = null,
570
644
  relevantFiles = [],
571
645
  toolVersion = null,
646
+ commit = null,
572
647
  createdAt = new Date(),
573
648
  expiresAt = null,
574
649
  freshnessPolicy = null,
575
650
  source = 'automated',
576
651
  id,
577
652
  }) {
653
+ // AR-1. A gate record must be able to name the revision it attests, or a
654
+ // "gate-related fix claim" cannot be traced to the code it claims to cover.
655
+ // Validated rather than trusted: this value is persisted into state.json and
656
+ // read back by the Reviewer, so a malformed one would make a claim look
657
+ // verified while naming nothing.
658
+ const normalizedCommit = normalizeCommit(commit);
578
659
  return {
579
660
  evidenceId: evidenceId || id || undefined,
580
661
  workItemId,
@@ -591,6 +672,7 @@ export function createEvidence({
591
672
  criteriaHash: criteriaHash || hashCriteria([]),
592
673
  relevantFiles,
593
674
  toolVersion,
675
+ commit: normalizedCommit,
594
676
  createdAt: timestamp(createdAt),
595
677
  expiresAt: expiresAt ? timestamp(expiresAt) : null,
596
678
  freshnessPolicy,
@@ -1,149 +1,172 @@
1
- /**
2
- * Shared harness primitives: UUIDv4, SHA-256 over UTF-8 bytes, tree hashing,
3
- * deterministic JSON serialization, and UTC timestamps.
4
- *
5
- * Contract: docs/core/HarnessContract.md §2 (identifiers, hashes).
6
- */
7
-
8
- import { createHash, randomUUID } from 'node:crypto';
9
- import { readFileSync, existsSync } from 'node:fs';
10
- import { spawnSync } from 'node:child_process';
11
-
12
- /** UUIDv4 identifier. */
13
- export function newId() {
14
- return randomUUID();
15
- }
16
-
17
- export function isUuid(value) {
18
- return typeof value === 'string'
19
- && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value);
20
- }
21
-
22
- /** SHA-256 hex digest over UTF-8 bytes. */
23
- export function sha256(value) {
24
- return createHash('sha256').update(value, 'utf-8').digest('hex');
25
- }
26
-
27
- /** SHA-256 hex digest over raw bytes (Buffers are hashed as-is). */
28
- export function sha256Bytes(buf) {
29
- return createHash('sha256').update(buf).digest('hex');
30
- }
31
-
32
- /** Hash of a file's exact bytes, or null when the file is missing. */
33
- export function hashFile(path) {
34
- if (!existsSync(path)) return null;
35
- try {
36
- return sha256Bytes(readFileSync(path));
37
- } catch {
38
- return null;
39
- }
40
- }
41
-
42
- /**
43
- * Deterministic hash of a set of `(relativePath, fileHash)` pairs.
44
- * Pairs are sorted by path so the hash is order-independent and stable across
45
- * platforms. Files without a resolvable hash are recorded as `missing`.
46
- */
47
- export function hashTree(pairs) {
48
- const normalized = [...pairs]
49
- .map(({ path, hash }) => ({
50
- path: String(path).replace(/\\/g, '/'),
51
- hash: hash || 'missing',
52
- }))
53
- .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
54
- return sha256(JSON.stringify(normalized));
55
- }
56
-
57
- /**
58
- * Hash an acceptance-criteria document or list. `criteria` may be a string or an
59
- * array of strings; a stable serialization is used either way.
60
- */
61
- export function hashCriteria(criteria) {
62
- if (criteria === null || criteria === undefined) return sha256('[]');
63
- const arr = Array.isArray(criteria) ? criteria.map(String) : [String(criteria)];
64
- return sha256(JSON.stringify(arr));
65
- }
66
-
67
- /** ISO-8601 UTC timestamp for a Date or "now". */
68
- export function timestamp(at = new Date()) {
69
- return (at instanceof Date ? at : new Date(at)).toISOString();
70
- }
71
-
72
- /** Current time provider; injectable for deterministic tests. */
73
- export function nowMs() {
74
- return Date.now();
75
- }
76
-
77
- /**
78
- * Canonical JSON with sorted keys. Used for stable hashes and for comparing
79
- * evidence records without key-order noise.
80
- */
81
- export function canonicalJson(value) {
82
- return JSON.stringify(sortKeys(value));
83
- }
84
-
85
- function sortKeys(value) {
86
- if (Array.isArray(value)) return value.map(sortKeys);
87
- if (value && typeof value === 'object') {
88
- const out = {};
89
- for (const key of Object.keys(value).sort()) out[key] = sortKeys(value[key]);
90
- return out;
91
- }
92
- return value;
93
- }
94
-
95
- export { canonicalJson as stableStringify };
96
-
97
- /**
98
- * List the files changed in the working tree relative to HEAD, using git.
99
- * Returns forward-slash relative paths. Returns an empty array when git is
100
- * unavailable or the directory is not a repository — callers must not assume
101
- * freshness coverage in that case; use `gitChangedFiles` when the distinction
102
- * between "no changes" and "no git" matters.
103
- */
104
- export function changedFiles(cwd, { runner = defaultGitRunner } = {}) {
105
- return gitChangedFiles(cwd, { runner }).files;
106
- }
107
-
108
- /**
109
- * List changed files and report whether git was actually queryable.
110
- * Returns `{ available, files, reason }`. `available: false` means freshness
111
- * coverage could not be established and callers must fail safe.
112
- */
113
- export function gitChangedFiles(cwd, { runner = defaultGitRunner } = {}) {
114
- let res;
115
- try {
116
- res = runner('git', ['-C', cwd, 'status', '--porcelain', '--untracked-files=all']);
117
- } catch (err) {
118
- return { available: false, files: [], reason: `git invocation failed: ${err.message}` };
119
- }
120
- if (!res) {
121
- return { available: false, files: [], reason: 'git is not available' };
122
- }
123
- if (res.error || res.status === null) {
124
- return { available: false, files: [], reason: 'git is not installed or could not be executed' };
125
- }
126
- if (res.status !== 0) {
127
- // Not a repository, or git refused the query.
128
- return { available: false, files: [], reason: String(res.stderr || '').trim() || `git exited ${res.status}` };
129
- }
130
- const files = new Set();
131
- for (const line of String(res.stdout || '').split(/\r?\n/)) {
132
- if (!line.trim()) continue;
133
- // Porcelain v1: XY<space>path (rename: "old -> new").
134
- let path = line.slice(3).trim();
135
- if (path.includes(' -> ')) path = path.split(' -> ').pop().trim();
136
- path = path.replace(/^"|"$/g, '');
137
- if (path) files.add(path.replace(/\\/g, '/'));
138
- }
139
- return { available: true, files: [...files].sort(), reason: null };
140
- }
141
-
142
- function defaultGitRunner(cmd, args) {
143
- try {
144
- return spawnSync(cmd, args, { encoding: 'utf-8', windowsHide: true });
145
- } catch {
146
- return null;
147
- }
148
- }
149
-
1
+ /**
2
+ * Shared harness primitives: UUIDv4, SHA-256 over UTF-8 bytes, tree hashing,
3
+ * deterministic JSON serialization, and UTC timestamps.
4
+ *
5
+ * Contract: docs/core/HarnessContract.md §2 (identifiers, hashes).
6
+ */
7
+
8
+ import { createHash, randomUUID } from 'node:crypto';
9
+ import { readFileSync, existsSync } from 'node:fs';
10
+ import { spawnSync } from 'node:child_process';
11
+
12
+ /** UUIDv4 identifier. */
13
+ export function newId() {
14
+ return randomUUID();
15
+ }
16
+
17
+ export function isUuid(value) {
18
+ return typeof value === 'string'
19
+ && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value);
20
+ }
21
+
22
+ /** SHA-256 hex digest over UTF-8 bytes. */
23
+ export function sha256(value) {
24
+ return createHash('sha256').update(value, 'utf-8').digest('hex');
25
+ }
26
+
27
+ /** SHA-256 hex digest over raw bytes (Buffers are hashed as-is). */
28
+ export function sha256Bytes(buf) {
29
+ return createHash('sha256').update(buf).digest('hex');
30
+ }
31
+
32
+ /** Hash of a file's exact bytes, or null when the file is missing. */
33
+ export function hashFile(path) {
34
+ if (!existsSync(path)) return null;
35
+ try {
36
+ return sha256Bytes(readFileSync(path));
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Deterministic hash of a set of `(relativePath, fileHash)` pairs.
44
+ * Pairs are sorted by path so the hash is order-independent and stable across
45
+ * platforms. Files without a resolvable hash are recorded as `missing`.
46
+ */
47
+ export function hashTree(pairs) {
48
+ const normalized = [...pairs]
49
+ .map(({ path, hash }) => ({
50
+ path: String(path).replace(/\\/g, '/'),
51
+ hash: hash || 'missing',
52
+ }))
53
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
54
+ return sha256(JSON.stringify(normalized));
55
+ }
56
+
57
+ /**
58
+ * Hash an acceptance-criteria document or list. `criteria` may be a string or an
59
+ * array of strings; a stable serialization is used either way.
60
+ */
61
+ export function hashCriteria(criteria) {
62
+ if (criteria === null || criteria === undefined) return sha256('[]');
63
+ const arr = Array.isArray(criteria) ? criteria.map(String) : [String(criteria)];
64
+ return sha256(JSON.stringify(arr));
65
+ }
66
+
67
+ /** ISO-8601 UTC timestamp for a Date or "now". */
68
+ export function timestamp(at = new Date()) {
69
+ return (at instanceof Date ? at : new Date(at)).toISOString();
70
+ }
71
+
72
+ /** Current time provider; injectable for deterministic tests. */
73
+ export function nowMs() {
74
+ return Date.now();
75
+ }
76
+
77
+ /**
78
+ * Canonical JSON with sorted keys. Used for stable hashes and for comparing
79
+ * evidence records without key-order noise.
80
+ */
81
+ export function canonicalJson(value) {
82
+ return JSON.stringify(sortKeys(value));
83
+ }
84
+
85
+ function sortKeys(value) {
86
+ if (Array.isArray(value)) return value.map(sortKeys);
87
+ if (value && typeof value === 'object') {
88
+ const out = {};
89
+ for (const key of Object.keys(value).sort()) out[key] = sortKeys(value[key]);
90
+ return out;
91
+ }
92
+ return value;
93
+ }
94
+
95
+ export { canonicalJson as stableStringify };
96
+
97
+ /**
98
+ * List the files changed in the working tree relative to HEAD, using git.
99
+ * Returns forward-slash relative paths. Returns an empty array when git is
100
+ * unavailable or the directory is not a repository — callers must not assume
101
+ * freshness coverage in that case; use `gitChangedFiles` when the distinction
102
+ * between "no changes" and "no git" matters.
103
+ */
104
+ export function changedFiles(cwd, { runner = defaultGitRunner } = {}) {
105
+ return gitChangedFiles(cwd, { runner }).files;
106
+ }
107
+
108
+ /**
109
+ * Cadet's own bookkeeping — never a meaningful verification input.
110
+ *
111
+ * `state.json` is rewritten by the very command that records a gate, and
112
+ * `runs/*.json` gains a new ledger on every harness invocation. If either were
113
+ * auto-detected as a relevant file, the evidence hash would describe a file the
114
+ * recording itself mutates: the gate would be stale the moment it was written,
115
+ * and the resulting record would certify no story code. Excluded here, at the
116
+ * single scan used by both `harness verify` and `harness confirm`.
117
+ */
118
+ const CADET_MACHINERY = ['.cadet/state.json', '.cadet/runs/'];
119
+
120
+ /** True when a repository-relative path is Cadet's own bookkeeping. */
121
+ function isCadetMachinery(relPath) {
122
+ return CADET_MACHINERY.some((p) => (p.endsWith('/') ? relPath.startsWith(p) : relPath === p));
123
+ }
124
+
125
+ /**
126
+ * List changed files and report whether git was actually queryable.
127
+ * Returns `{ available, files, reason }`. `available: false` means freshness
128
+ * coverage could not be established and callers must fail safe.
129
+ *
130
+ * Cadet's own machinery (`.cadet/state.json`, `.cadet/runs/**`) is filtered out
131
+ * of `files`; see `CADET_MACHINERY`.
132
+ */
133
+ export function gitChangedFiles(cwd, { runner = defaultGitRunner } = {}) {
134
+ let res;
135
+ try {
136
+ res = runner('git', ['-C', cwd, 'status', '--porcelain', '--untracked-files=all']);
137
+ } catch (err) {
138
+ return { available: false, files: [], reason: `git invocation failed: ${err.message}` };
139
+ }
140
+ if (!res) {
141
+ return { available: false, files: [], reason: 'git is not available' };
142
+ }
143
+ if (res.error || res.status === null) {
144
+ return { available: false, files: [], reason: 'git is not installed or could not be executed' };
145
+ }
146
+ if (res.status !== 0) {
147
+ // Not a repository, or git refused the query.
148
+ return { available: false, files: [], reason: String(res.stderr || '').trim() || `git exited ${res.status}` };
149
+ }
150
+ const files = new Set();
151
+ for (const line of String(res.stdout || '').split(/\r?\n/)) {
152
+ if (!line.trim()) continue;
153
+ // Porcelain v1: XY<space>path (rename: "old -> new").
154
+ let path = line.slice(3).trim();
155
+ if (path.includes(' -> ')) path = path.split(' -> ').pop().trim();
156
+ path = path.replace(/^"|"$/g, '');
157
+ if (!path) continue;
158
+ const rel = path.replace(/\\/g, '/');
159
+ if (isCadetMachinery(rel)) continue;
160
+ files.add(rel);
161
+ }
162
+ return { available: true, files: [...files].sort(), reason: null };
163
+ }
164
+
165
+ function defaultGitRunner(cmd, args) {
166
+ try {
167
+ return spawnSync(cmd, args, { encoding: 'utf-8', windowsHide: true });
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
@@ -252,6 +252,7 @@ export async function runVerificationLoop({
252
252
  relevantFiles = [],
253
253
  criteria = [],
254
254
  rootDir = process.cwd(),
255
+ commit = null,
255
256
  policy,
256
257
  budgets,
257
258
  runCommandImpl = runCommand,
@@ -327,6 +328,7 @@ export async function runVerificationLoop({
327
328
  inputTreeHash,
328
329
  criteriaHash,
329
330
  relevantFiles,
331
+ commit,
330
332
  createdAt: startedAt,
331
333
  source: 'automated',
332
334
  });
@@ -461,7 +463,7 @@ function finalize({ status, attempts, tracker, inputTreeHash, criteriaHash, stop
461
463
  export function manualConfirmation({
462
464
  gate, workItemId, phase, projectPath, editorVersion, scope, acceptanceCriterionId = null,
463
465
  relevantFiles = [], criteria = [], rootDir = process.cwd(), approvedBy = 'user', at = new Date(),
464
- reason = null, expiresAt = null, environment = null, expiresInMs = null,
466
+ reason = null, expiresAt = null, environment = null, expiresInMs = null, commit = null,
465
467
  } = {}) {
466
468
  const inputTreeHash = computeInputTreeHash(rootDir, relevantFiles);
467
469
  // v3 quality fields. `scope` is declared both as the free-text `result` line
@@ -504,6 +506,7 @@ export function manualConfirmation({
504
506
  relevantFiles,
505
507
  createdAt: at,
506
508
  expiresAt: expiry,
509
+ commit,
507
510
  source: 'manual-confirmation',
508
511
  }),
509
512
  // Present only when supplied, so a v2-shaped record is unchanged when the