@dzhechkov/harness-cli 0.6.0 → 0.6.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.
@@ -39,7 +39,7 @@
39
39
  * standing test (`test/core-import-floor.test.ts`, F1) fails if the two drift apart,
40
40
  * because a guard that says 0.4.7 while npm may install 0.4.2 is worse than no guard.
41
41
  */
42
- export declare const MIN_CORE = "0.6.0";
42
+ export declare const MIN_CORE = "0.6.1";
43
43
  /** The npm name of the guarded package — one literal, used by every leg below. */
44
44
  export declare const CORE_PACKAGE_NAME = "@dzhechkov/harness-core";
45
45
  /**
@@ -42,7 +42,7 @@ import { fileURLToPath } from 'node:url';
42
42
  * standing test (`test/core-import-floor.test.ts`, F1) fails if the two drift apart,
43
43
  * because a guard that says 0.4.7 while npm may install 0.4.2 is worse than no guard.
44
44
  */
45
- export const MIN_CORE = '0.6.0';
45
+ export const MIN_CORE = '0.6.1';
46
46
  /** The npm name of the guarded package — one literal, used by every leg below. */
47
47
  export const CORE_PACKAGE_NAME = '@dzhechkov/harness-core';
48
48
  /**
package/keys/README.md CHANGED
@@ -1,8 +1,47 @@
1
1
  # Trust root
2
2
 
3
- `dz.pub` — the pinned Ed25519 public key that `dz doctor` / `dz upgrade` use to verify **other**
4
- `skills-*` packs. It is not here yet: no project key has been generated (see task #36). Until it is,
5
- every pack reports `no-trust-root`, and nothing fails.
3
+ `dz.pub` — the pinned Ed25519 **public** key that `dz doctor`, `dz drift-check` and `dz upgrade` fall
4
+ back to when verifying installed skill packs against their `.dz-manifest.json`.
6
5
 
7
- This key verifies other packs, never `harness-cli` itself. A compromised verifier is outside the threat
8
- model: you have already executed its code. See `features/verify-apply-leg/03_adr/001-keyring-in-the-verifier.md`.
6
+ **It is here now.** Until 2026-08-21 this directory shipped without `dz.pub` it contained only this
7
+ README, which claimed no project key had been generated. The key had in fact existed at the repo root
8
+ since 2026-07-19. Measured with one binary run from two places on the same day:
9
+
10
+ ```
11
+ $ dz doctor # inside the harness repo, which has its own keys/dz.pub
12
+ signatures: 25 verified, 0 unsigned, 1 TAMPERED, 0 unverifiable; trust root: repo (keys/dz.pub)
13
+ $ dz doctor --project /tmp/consumer
14
+ signatures: 0 verified, 0 unsigned, 0 TAMPERED, 26 unverifiable; trust root: none
15
+ ```
16
+
17
+ So **every installation that relied on the packaged key** — that is, one passing no `--pubkey` and
18
+ holding no project-local `keys/dz.pub` — had a verifier that verified nothing, and said so in a
19
+ summary line that scrolls past.
20
+
21
+ ## Resolution order
22
+
23
+ An explicit `--pubkey` wins; then `keys/dz.pub` inside the project being checked; then this packaged
24
+ key. A `--pubkey` that lives INSIDE the pack being verified is refused — an artifact must never supply
25
+ the key that verifies it.
26
+
27
+ ## What a signature proves, and what it does not
28
+
29
+ It proves that the files **listed in that pack's `.dz-manifest.json`** still hash to the values
30
+ recorded when it was signed. It says nothing about any file the manifest does not list, nothing about
31
+ whether the skill is any good, and nothing about whether the signer deserves trust: pinning this key
32
+ decides which key is ACCEPTED, which is a different question from whether its holder is trustworthy.
33
+
34
+ ## Diagnosing a `TAMPERED` verdict
35
+
36
+ `TAMPERED` means the bytes on disk disagree with the signed manifest. Before assuming modification,
37
+ check which trust root was actually used — the summary line names it, and an explicit or project-local
38
+ key takes precedence over this one, so a verdict may be reported against a key you did not intend.
39
+
40
+ Re-signing is a **publisher-side** operation, not a consumer repair: it is correct only when you own
41
+ the pack, have reviewed what changed, and are re-issuing it. The usual innocent cause is an edit made
42
+ after signing, and its remedy is procedural — sign LAST, immediately before packing. A consumer facing
43
+ `TAMPERED` should report it, not silence it.
44
+
45
+ **Never** place a private key in this directory. The signing key lives outside the repository
46
+ (`~/.dz/keys/dz.key`, mode 0600), `dz sign` refuses to write it anywhere inside the tree, and a test
47
+ asserts no private-key PEM header appears anywhere in the published tarball.
package/keys/dz.pub ADDED
@@ -0,0 +1,3 @@
1
+ -----BEGIN PUBLIC KEY-----
2
+ MCowBQYDK2VwAyEAENTvPc1jcIhqkXHVbrqkcvRpJ56GtOFZByXxwzXEZhM=
3
+ -----END PUBLIC KEY-----
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhechkov/harness-cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "The dz CLI — install AI skills for Claude Code, Codex, OpenCode, Hermes, OpenClaude, GitHub Copilot. 67 commands, 14 presets, 10 platform targets.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  "sbom.json"
42
42
  ],
43
43
  "dependencies": {
44
- "@dzhechkov/harness-core": "^0.6.0",
44
+ "@dzhechkov/harness-core": "^0.6.1",
45
45
  "@dzhechkov/harness-presets": "^0.5.0",
46
46
  "@dzhechkov/scout": "^0.8.0",
47
47
  "@dzhechkov/skills-devops": "^0.3.0",
package/sbom.json CHANGED
@@ -15,7 +15,7 @@
15
15
  "hashes": [
16
16
  {
17
17
  "alg": "SHA-256",
18
- "content": "fb63e4a05e205a1a2ec6806b61898a4aa5ea19148e4bb0e553e2c9b19e382a5f"
18
+ "content": "b99db6be2d9e6d46f0a9d3ff0877c445efac7b9e90d5fe1db9f5bfa2cb6dc107"
19
19
  }
20
20
  ]
21
21
  },
@@ -25,7 +25,7 @@
25
25
  "hashes": [
26
26
  {
27
27
  "alg": "SHA-256",
28
- "content": "e4d839c8ae59d86f2150bb632e0f8794688ccfa117974eb5f2605676912e7176"
28
+ "content": "696aae63a95e8f8726eecc1aa0e68f20d7980e99bb4008b22f2e1058ca67c6c0"
29
29
  }
30
30
  ]
31
31
  },
@@ -105,7 +105,7 @@
105
105
  "hashes": [
106
106
  {
107
107
  "alg": "SHA-256",
108
- "content": "bc71c42fc8804e71454efde34beee1e4aed2d074ee029edf08fbee3e76cc369a"
108
+ "content": "8d15372b27f7d79f54df09671b01d5944e8f3f3d87075ba4ece7f0131fc1103e"
109
109
  }
110
110
  ]
111
111
  },
@@ -115,7 +115,7 @@
115
115
  "hashes": [
116
116
  {
117
117
  "alg": "SHA-256",
118
- "content": "b269e0a6c84ff4069655ee9cd8d264c3be16a12f7de2b849e8d9065d0b6c44b3"
118
+ "content": "be3648deb68dca8a48cc323f34f54c77c05f0ddf0d86982ea9d6f2cc8e073349"
119
119
  }
120
120
  ]
121
121
  },
@@ -125,7 +125,7 @@
125
125
  "hashes": [
126
126
  {
127
127
  "alg": "SHA-256",
128
- "content": "8ba4b1bc4d3cc767a686f54a63e38d247fe399ccdbc959951bc788cee52f1eef"
128
+ "content": "47bcecfe2c9f1daeb73056b3a2c1e10e6b77c465c1bb0d6169690f2276a9cc77"
129
129
  }
130
130
  ]
131
131
  },
@@ -135,7 +135,7 @@
135
135
  "hashes": [
136
136
  {
137
137
  "alg": "SHA-256",
138
- "content": "a8a7f048200d1cde46e1c4b9a175734624f86b432e45db13ff69343ef41adee3"
138
+ "content": "b735a6c18bfbe5adf17bbcfe6e2eeae71477b877e958c36e0a5e73e59f100d6a"
139
139
  }
140
140
  ]
141
141
  },
@@ -155,7 +155,7 @@
155
155
  "hashes": [
156
156
  {
157
157
  "alg": "SHA-256",
158
- "content": "817a16aafa21f9d592e1b74fbc7b393208aa6cbe7a1ce44059b3d57e4897ca48"
158
+ "content": "6d052e0a7352e9824bc1d4a319d03b42320f6551210f7dfd360afbdca4e556f0"
159
159
  }
160
160
  ]
161
161
  },
@@ -215,7 +215,17 @@
215
215
  "hashes": [
216
216
  {
217
217
  "alg": "SHA-256",
218
- "content": "204deec657a7f147bf3016441082c2f868f6f11051776f4edc13ec6b44745192"
218
+ "content": "142fc5d0d59003476b8e7367d6fc21fe40ce9e0bf70159318a05abdb10502b8a"
219
+ }
220
+ ]
221
+ },
222
+ {
223
+ "type": "file",
224
+ "name": "keys/dz.pub",
225
+ "hashes": [
226
+ {
227
+ "alg": "SHA-256",
228
+ "content": "353885160582f8e0cfb2a97ef9e51f77997f0120841f92a1ce50d202ebf5dd73"
219
229
  }
220
230
  ]
221
231
  },
@@ -225,7 +235,7 @@
225
235
  "hashes": [
226
236
  {
227
237
  "alg": "SHA-256",
228
- "content": "82ac146f7d849e1315dafc8ca4536e031d5c8104317da46ab8b52e543a187b17"
238
+ "content": "a43608842dab18914486c96c1989b294031f35118bb91c1e8bd0807041a10ddd"
229
239
  }
230
240
  ]
231
241
  },
@@ -245,7 +255,7 @@
245
255
  "hashes": [
246
256
  {
247
257
  "alg": "SHA-256",
248
- "content": "44be85b04467075bf671bc58572bf294f402b43abc76e23354724a74ea2a6c27"
258
+ "content": "d39fa1f437401419889965674889df3e5091d4ed76242e36f8e70f18683d4e4a"
249
259
  }
250
260
  ]
251
261
  },
@@ -255,7 +265,7 @@
255
265
  "hashes": [
256
266
  {
257
267
  "alg": "SHA-256",
258
- "content": "80b02871db06aa78df70cdc166e46a67fa9335da2f4956d6f937149b7ea69698"
268
+ "content": "5fef10b3d968d299eb7463157e8b164413872f7cc8bf5e9ca1c605e63569a9f7"
259
269
  }
260
270
  ]
261
271
  },
@@ -279,6 +289,16 @@
279
289
  }
280
290
  ]
281
291
  },
292
+ {
293
+ "type": "file",
294
+ "name": "test/amendment-check-cli.test.ts",
295
+ "hashes": [
296
+ {
297
+ "alg": "SHA-256",
298
+ "content": "3dd543fd3032f1fca5c063af98a7501b4cccb9d7e42f6c307074b67f2d55d231"
299
+ }
300
+ ]
301
+ },
282
302
  {
283
303
  "type": "file",
284
304
  "name": "test/cli.test.ts",
@@ -295,7 +315,7 @@
295
315
  "hashes": [
296
316
  {
297
317
  "alg": "SHA-256",
298
- "content": "a637ef30d058fcd95dc18ef6eca45d6ac06e0f2bf54341a0aae68b610180b44d"
318
+ "content": "d9677688116c0a35591cb7e53ab1502566e44336959ac0ea449f5e8a1c5e31e3"
299
319
  }
300
320
  ]
301
321
  },
@@ -349,6 +369,16 @@
349
369
  }
350
370
  ]
351
371
  },
372
+ {
373
+ "type": "file",
374
+ "name": "test/feature-adr-record-cli.test.ts",
375
+ "hashes": [
376
+ {
377
+ "alg": "SHA-256",
378
+ "content": "e1a2687a1ae2bc2d32cbcc852487d30f9451ce1ea4a132e188186dd4251c49d1"
379
+ }
380
+ ]
381
+ },
352
382
  {
353
383
  "type": "file",
354
384
  "name": "test/fixtures/discrimination-check/fake-runner.sh",
@@ -639,6 +669,16 @@
639
669
  }
640
670
  ]
641
671
  },
672
+ {
673
+ "type": "file",
674
+ "name": "test/packaged-trust-root.test.ts",
675
+ "hashes": [
676
+ {
677
+ "alg": "SHA-256",
678
+ "content": "46c0af5494ad717eb4d936489f4bde2199b40cd756ab6c8adb0cd1714a971304"
679
+ }
680
+ ]
681
+ },
642
682
  {
643
683
  "type": "file",
644
684
  "name": "test/parallel-worktree-smoke.test.ts",
package/src/cli.ts CHANGED
@@ -400,7 +400,19 @@ import {
400
400
  parseBundle,
401
401
  planImport,
402
402
  decideCheckpointWrite,
403
+ amendmentSection,
404
+ planSaysNoAmendments,
405
+ parseAmendments,
406
+ resolveAmendments,
407
+ decideAmendmentOutcome,
408
+ amendmentVerdictLine,
409
+ amendmentsMissingFromPlan,
410
+ AMENDMENT_VACUITY_NOTE,
411
+ decideRecordWrite,
412
+ decideReadBack,
413
+ recordVerdictLine,
403
414
  } from '@dzhechkov/harness-core';
415
+ import type { RecordKind, RecordDecision } from '@dzhechkov/harness-core';
404
416
  import type { MutationEntryResult, MutationObservation, MutationRegistryEntry } from '@dzhechkov/harness-core';
405
417
  import type { SkillApplyFailure, SkillLoadFailure } from '@dzhechkov/harness-core';
406
418
  import type { ReqeDebt } from '@dzhechkov/harness-core';
@@ -450,6 +462,8 @@ Usage:
450
462
  dz epoch-replay --judge <filled-work-order.json> [--out <file>] (blind judge prompts from the filled plans)
451
463
  dz epoch-replay --score <judgments.json> --work-order <file> [--slice <name>] [--json] (un-blind against the pre-registered assignment → SUPPORTED only when the two 95% Wilson CIs are DISJOINT, else FALSIFIED / INCONCLUSIVE)
452
464
  dz score --slug <feature> [--project <dir>] [--json] (process scorecard for ONE feature-adr run, from its artifacts: ADR confirmation, discrimination, cross-model QE grade, live verification, README-first, learning loop, amendments — descriptive-only, a low score exits 0)
465
+ dz amendment-check --slug <slug> | --feature-dir <dir> | --all [--json] (the deterministic Step-8 amendment gate: every AM-N row must resolve to a test found INSIDE the file the row names; the PLAN is authoritative when it carries rows, and an ideation amendment the plan drops is a failure. exit 0 pass/skip, 1 fail, 3 NOT-ESTABLISHED — a section that parsed ZERO rows is never a pass. --all is a CENSUS and always exits 0. Does NOT prove non-vacuity — that is dz discrimination-check)
466
+ dz feature-adr-record --kind ledger|training-pair --stage <s> [--slug <s>] [--row|--pair <json>] [--mark <n>] [--once] [--json] (the witnessed writer for the run-cost ledger and training pairs: the payload arrives as an ARGUMENT, never as shell; a malformed or wrong-kind payload is REFUSED before any write; the timestamp is stamped before serialising; the append is verified by re-reading the tail. exit 0 written|duplicate|skipped, 2 refused, 3 not-verified — a record failure is never blocking)
453
467
  dz feature-adr-checkpoint (--slug <feature> | --feature-dir <abs>) --stage <s> --input-hash <h> --result <json> [--artifact a,b] [--json] (record a pipeline stage ONLY after measuring its artifacts on disk; refuses a null result, an absent artifact, or a stage that declares none — the subagent runs a COMMAND instead of hand-writing durable state)
454
468
  dz reqe [--slug <feature> [--done --report <f>]] [--json] (the re-QE debt ledger: a usage-switched run whose Step-8 QE ran on the coder's OWN family records a debt; list debts, print the cross-family review brief, settle FAIL-CLOSED against a graded report — the settlement lands in 08_qe_report.md)
455
469
  dz qe-bridge --family claude --slug <feature> [--coder-family codex|claude] [--model <id>] [--files a,b] [--out <f>] [--timeout <s>] [--allow-same-family] [--json] (the REVERSE QE bridge: run an INDEPENDENT Claude reviewer over a feature's Step-8 artifacts from ANY host — a Codex session included, plain shell, no Claude agent plane needed — and land a PARSED signoff. The reviewer runs ISOLATED: an EMPTY temp cwd plus --safe-mode --strict-mcp-config --tools '' --no-session-persistence, so no CLAUDE.md/skills/plugins/hooks/MCP load, and the verdict is read from the --output-format json RESULT ENVELOPE — text a session customization printed onto the same stdout can never become a signoff. Probes the model before trusting it; sends SCOPED extracts with a loud 200k-char ceiling (never silent truncation); the grade must AGREE across three LAST-anchored channels (terminal marker line, fenced qe-bridge-signoff JSON, the report's own GRADE line) AND the marker must be the FINAL content — empty, gradeless, self-contradicting or miscounted output is one of 18 NAMED failures with an audit record under features/<slug>/.fa-state/qe-bridge/ (runId, resolved executable + binOverride, prompt sha256, channel offsets, requestedOut, reportWritten, retained raw stdout; 0600 files in a 0700 dir), never a clean review. A --coder-family that contradicts the recorded reqe debt is refused. Writes features/<slug>/08b_reqe_report.md, which dz reqe --done settles unchanged. DISCLOSURE: the extracts you scope are sent to the Claude runtime; the bridge cannot classify secrets. DZ_QE_BRIDGE_CLAUDE_BIN is a TEST SEAM, not a flag. exit 0 signoff parsed (ANY grade — it reports, it does not gate) / 1 named failure / 2 usage)
@@ -4791,7 +4805,7 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
4791
4805
  // Reject unknown flags/options so a typo (e.g. `--dry-rum`) can NEVER be
4792
4806
  // silently swallowed and flip the command into live-publish mode.
4793
4807
  const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance']);
4794
- const allowedOptions = new Set(['filter', 'claim-check', 'no-guard']);
4808
+ const allowedOptions = new Set(['filter', 'claim-check', 'no-guard', 'sign-key']);
4795
4809
  const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>, --no-guard "<reason>" (skip the guard pre-flight; logged)';
4796
4810
  for (const flag of flags) {
4797
4811
  if (!allowedFlags.has(flag)) {
@@ -4941,7 +4955,29 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
4941
4955
  }
4942
4956
  }
4943
4957
 
4944
- const report = publishPackages(cwd, { provenance, dryRun, filter, bumpOnly, claimGate: claimCheckOpt });
4958
+ // A signed pack must be RE-SIGNED after publish's own bump and README sync (feature
4959
+ // `sign-after-bump`): publish mutates the pack, so any earlier signature describes files that no
4960
+ // longer exist. Default to the same path `dz sign --init` writes, so the ordinary operator needs no
4961
+ // new flag; `--sign-key` overrides it.
4962
+ const signKey = (options.get('sign-key') ?? join(homedir(), '.dz', 'keys', 'dz.key')).trim();
4963
+ const report = publishPackages(cwd, {
4964
+ provenance,
4965
+ dryRun,
4966
+ filter,
4967
+ bumpOnly,
4968
+ claimGate: claimCheckOpt,
4969
+ signKey: signKey === '' ? undefined : resolve(cwd, signKey),
4970
+ reSign: (packDir: string, keyPath: string): void => {
4971
+ // The same three steps `dz sign` performs, including the SBOM — a manifest refreshed without
4972
+ // its SBOM would leave the two describing different trees.
4973
+ const files = packFiles(packDir);
4974
+ if (files.length === 0) throw new Error(`refusing to sign an empty pack: ${packDir}`);
4975
+ const manifest = buildManifest(packDir, basename(packDir), files);
4976
+ const signed = signManifest(manifest, readFileSync(keyPath, 'utf-8'));
4977
+ writeFileSync(join(packDir, MANIFEST_NAME), `${JSON.stringify(signed, null, 2)}\n`);
4978
+ writeFileSync(join(packDir, SBOM_NAME), `${JSON.stringify(buildSbom(manifest), null, 2)}\n`);
4979
+ },
4980
+ });
4945
4981
 
4946
4982
  write(`\ndz publish${dryRun ? ' --dry-run' : ''}${bumpOnly ? ' --bump-only' : ''}${claimCheckOpt !== 'warn' ? ` --claim-check ${claimCheckOpt}` : ''}`);
4947
4983
  write(` Published: ${report.published} Skipped: ${report.skipped} Errors: ${report.errors}\n`);
@@ -8958,6 +8994,214 @@ function scanOneReqeRoot(
8958
8994
  * It measures `--artifact` paths on disk itself. A caller cannot assert presence; it can only name
8959
8995
  * what must be there.
8960
8996
  */
8997
+ /**
8998
+ * `dz amendment-check` — the deterministic half of the Step-8 amendment gate (ADR-001).
8999
+ *
9000
+ * The gate used to be prompt text asking the QE agent to confirm every `AM-N` row names a real test.
9001
+ * That is layer 4 on the cost-of-detection ladder, and `features/qe-scoped-review` shipped with five
9002
+ * dangling ids and a plan recording `## Amendments: None`. This command owns I/O and the exit code;
9003
+ * `harness-core/src/amendment-trace.ts` owns the grammar and the rules.
9004
+ */
9005
+ /**
9006
+ * `dz feature-adr-record` — the witnessed writer for the run-cost ledger and training pairs
9007
+ * (ADR-001 … ADR-003). The subagent stops being a COURIER handed a shell pipeline and becomes a
9008
+ * CALLER handed arguments: this command owns the paths, the refusal, the append, the READ-BACK and
9009
+ * the exit code. A courier can neither refuse nor verify, which is how four workflow runs finished
9010
+ * with no cost row at all.
9011
+ */
9012
+ function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
9013
+ const json = flags.has('json');
9014
+ const kind = (options.get('kind') ?? '').trim() as RecordKind;
9015
+ const stage = (options.get('stage') ?? '').trim();
9016
+ const slug = (options.get('slug') ?? '').trim();
9017
+ const payloadRaw = options.get('row') ?? options.get('pair') ?? '';
9018
+
9019
+ const emit = (d: RecordDecision, extra: Record<string, unknown> = {}): number => {
9020
+ if (json) {
9021
+ write(JSON.stringify({ ok: d.exit === 0, kind, stage, verdict: d.verdict, exit: d.exit, reason: d.reason, blocking: d.blocking, ...extra }));
9022
+ } else {
9023
+ write(recordVerdictLine(kind === 'ledger' || kind === 'training-pair' ? kind : 'ledger', stage, d));
9024
+ }
9025
+ return d.exit;
9026
+ };
9027
+
9028
+ if (kind !== 'ledger' && kind !== 'training-pair') {
9029
+ write('dz feature-adr-record: --kind must be ledger or training-pair');
9030
+ return 2;
9031
+ }
9032
+ if (payloadRaw === '') {
9033
+ write('dz feature-adr-record: --row (ledger) or --pair (training-pair) is required');
9034
+ return 2;
9035
+ }
9036
+
9037
+ const repo = (options.get('project') ?? cwd).trim() || cwd;
9038
+ const target = kind === 'ledger'
9039
+ ? join(repo, '.dz', 'feature-adr', 'run-cost-ledger.jsonl')
9040
+ : join(repo, '.dz', 'fa-training', slug === '' ? 'unknown' : slug, `${stage.replace(/[^\w.-]/g, '_')}.jsonl`);
9041
+ const markDir = join(repo, '.dz', 'fa-training', '.backfill-marks');
9042
+ const markName = (options.get('mark') ?? '').trim();
9043
+ const markPath = markName === '' ? null : join(markDir, markName.replace(/[^\w.-]/g, '_'));
9044
+
9045
+ const decision = decideRecordWrite({
9046
+ kind,
9047
+ payloadRaw,
9048
+ stage,
9049
+ stageProducedResult: flags.has('no-result') ? false : true,
9050
+ markExists: markPath !== null && existsSync(markPath),
9051
+ targetExists: existsSync(target),
9052
+ targetHasPair: flags.has('once') && existsSync(target),
9053
+ timestamp: new Date().toISOString(),
9054
+ });
9055
+ if (decision.line === null) return emit(decision);
9056
+
9057
+ // MEASURE, never assume: an unwritable target is a LOUD refusal, because a swallowed mkdir failure
9058
+ // is exactly how a write becomes a silent no-op (acid case A4).
9059
+ try {
9060
+ mkdirSync(dirname(target), { recursive: true });
9061
+ if (markPath !== null) {
9062
+ mkdirSync(markDir, { recursive: true });
9063
+ mkdirSync(markPath);
9064
+ }
9065
+ } catch (err) {
9066
+ const code = (err as NodeJS.ErrnoException).code;
9067
+ if (markPath !== null && code === 'EEXIST') {
9068
+ // A stale mark (target absent) was already decided as writable above; re-taking it is a no-op,
9069
+ // not a duplicate. Only a mark WITH its target means another run got here first.
9070
+ if (decision.staleMark !== true) {
9071
+ return emit({ verdict: 'duplicate', exit: 0, reason: 'a mark for this record already exists — another run captured it first', blocking: false, line: null });
9072
+ }
9073
+ }
9074
+ return emit({ verdict: 'refused', exit: 2, reason: `the target could not be prepared: ${(err as Error).message}`, blocking: false, line: null });
9075
+ }
9076
+
9077
+ try {
9078
+ appendFileSync(target, `${decision.line}\n`, 'utf-8');
9079
+ } catch (err) {
9080
+ return emit({ verdict: 'refused', exit: 2, reason: `the append failed: ${(err as Error).message}`, blocking: false, line: null });
9081
+ }
9082
+
9083
+ // ADR-002: the write is verified against the disk, never inferred from the absence of an error.
9084
+ let lastLine: string | null = null;
9085
+ try {
9086
+ const body = readFileSync(target, 'utf-8');
9087
+ const lines = body.split('\n').filter((l) => l !== '');
9088
+ lastLine = lines.length > 0 ? (lines[lines.length - 1] as string) : null;
9089
+ } catch {
9090
+ lastLine = null;
9091
+ }
9092
+ return emit(decideReadBack(decision.line, lastLine), { target });
9093
+ }
9094
+
9095
+ function cmdAmendmentCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
9096
+ const json = flags.has('json');
9097
+ const readOr = (abs: string): string | null => {
9098
+ try {
9099
+ return readFileSync(abs, 'utf-8');
9100
+ } catch {
9101
+ return null;
9102
+ }
9103
+ };
9104
+
9105
+ const checkOne = (featureDir: string): { decision: ReturnType<typeof decideAmendmentOutcome>; resolutions: ReturnType<typeof resolveAmendments>; slug: string } => {
9106
+ const slug = basename(featureDir);
9107
+ const ideation = readOr(join(featureDir, '03.5_ideation_report.md'));
9108
+ const plan = readOr(join(featureDir, '06_implementation_plan.md'));
9109
+ if (ideation === null) {
9110
+ return {
9111
+ slug,
9112
+ resolutions: [],
9113
+ decision: decideAmendmentOutcome({ sectionPresent: false, rows: [], resolutions: [], planSaysNone: false }),
9114
+ };
9115
+ }
9116
+ const sectionPresent = amendmentSection(ideation) !== null;
9117
+ const ideationRows = parseAmendments(ideation);
9118
+ const planRows = plan === null ? [] : parseAmendments(plan);
9119
+ // The PLAN is authoritative when it carries rows: Step 6 owes "carry AM-N into the plan
9120
+ // verbatim", and the ideation report is the historical record — rewriting its rows to match
9121
+ // tests named later would close the trail by falsifying it. Coverage keeps that honest below.
9122
+ const rows = planRows.length > 0 ? planRows : ideationRows;
9123
+ const missingFromPlan = planRows.length > 0 ? amendmentsMissingFromPlan(ideationRows, planRows) : [];
9124
+ // Paths in an amendment row are repo-relative, so they resolve against the repo root — not
9125
+ // against the feature directory, and not against wherever the caller happened to stand.
9126
+ const resolutions = resolveAmendments(rows, { readFile: (rel) => readOr(resolve(cwd, rel)) });
9127
+ const decision = decideAmendmentOutcome({
9128
+ sectionPresent,
9129
+ rows,
9130
+ resolutions,
9131
+ planSaysNone: plan !== null && planSaysNoAmendments(plan),
9132
+ missingFromPlan,
9133
+ });
9134
+ return { slug, decision, resolutions };
9135
+ };
9136
+
9137
+ // --all is a CENSUS, not a gate (ADR-003): a gate that is red on arrival over 21 historical
9138
+ // features gets disabled, and then the mechanism is gone along with the debt it was to surface.
9139
+ if (flags.has('all')) {
9140
+ const featuresDir = join(cwd, 'features');
9141
+ let slugs: string[] = [];
9142
+ try {
9143
+ slugs = readdirSync(featuresDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort();
9144
+ } catch {
9145
+ if (json) write(JSON.stringify({ ok: true, mode: 'all', features: [], note: 'no features/ directory' }));
9146
+ else write('amendment traceability census: no features/ directory');
9147
+ return 0;
9148
+ }
9149
+ const rowsOut: Record<string, unknown>[] = [];
9150
+ for (const slug of slugs) {
9151
+ try {
9152
+ const r = checkOne(join(featuresDir, slug));
9153
+ rowsOut.push({ slug, outcome: r.decision.outcome, counts: r.decision.counts, reasons: r.decision.reasons.length });
9154
+ } catch (err) {
9155
+ // AM-6: one unreadable feature becomes its own row. Dropping it silently would make the
9156
+ // census read like coverage it does not have.
9157
+ rowsOut.push({ slug, outcome: 'read-error', error: (err as Error).message });
9158
+ }
9159
+ }
9160
+ if (json) {
9161
+ write(JSON.stringify({ ok: true, mode: 'all', note: AMENDMENT_VACUITY_NOTE, features: rowsOut }));
9162
+ } else {
9163
+ const tally = new Map<string, number>();
9164
+ for (const r of rowsOut) tally.set(String(r['outcome']), (tally.get(String(r['outcome'])) ?? 0) + 1);
9165
+ for (const r of rowsOut) {
9166
+ if (r['outcome'] !== 'pass' && r['outcome'] !== 'skip') write(` [${String(r['outcome'])}] ${String(r['slug'])}`);
9167
+ }
9168
+ write(`amendment traceability census over ${rowsOut.length} feature(s): ${[...tally].map(([k, v]) => `${v} ${k}`).join(', ')}`);
9169
+ write(AMENDMENT_VACUITY_NOTE);
9170
+ }
9171
+ return 0;
9172
+ }
9173
+
9174
+ const explicitDir = (options.get('feature-dir') ?? '').trim();
9175
+ const slug = (options.get('slug') ?? '').trim();
9176
+ if (explicitDir === '' && slug === '') {
9177
+ write('dz amendment-check: one of --slug / --feature-dir is required (or --all for a census)');
9178
+ return 2;
9179
+ }
9180
+ const featureDir = explicitDir !== '' ? resolve(cwd, explicitDir) : join(cwd, 'features', slug);
9181
+ const { decision, resolutions } = checkOne(featureDir);
9182
+ if (json) {
9183
+ write(JSON.stringify({
9184
+ ok: decision.outcome === 'pass' || decision.outcome === 'skip',
9185
+ slug: basename(featureDir),
9186
+ outcome: decision.outcome,
9187
+ exit: decision.exit,
9188
+ counts: decision.counts,
9189
+ reasons: decision.reasons,
9190
+ rows: resolutions,
9191
+ note: AMENDMENT_VACUITY_NOTE,
9192
+ }));
9193
+ } else {
9194
+ for (const r of resolutions) {
9195
+ if (r.verdict !== 'resolved') write(` [${r.verdict}] ${r.id}${r.testId === null ? '' : ` \`${r.testId}\``} — ${r.detail}`);
9196
+ }
9197
+ write(AMENDMENT_VACUITY_NOTE);
9198
+ // The verdict is the LAST line, in the K2 gate's own shape, so a caller that reads the tail of
9199
+ // the output reads a verdict rather than a finding.
9200
+ write(amendmentVerdictLine(decision));
9201
+ }
9202
+ return decision.exit;
9203
+ }
9204
+
8961
9205
  function cmdFeatureAdrCheckpoint(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
8962
9206
  const json = flags.has('json');
8963
9207
  const emit = (payload: Record<string, unknown>, human: string, code: number): number => {
@@ -11775,6 +12019,10 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
11775
12019
  return cmdEpochReplay(options, flags, cwd, write);
11776
12020
  case 'score':
11777
12021
  return cmdScore(options, flags, cwd, write);
12022
+ case 'feature-adr-record':
12023
+ return cmdFeatureAdrRecord(options, flags, cwd, write);
12024
+ case 'amendment-check':
12025
+ return cmdAmendmentCheck(options, flags, cwd, write);
11778
12026
  case 'feature-adr-checkpoint':
11779
12027
  return cmdFeatureAdrCheckpoint(options, flags, cwd, write);
11780
12028
  case 'reqe':
@@ -44,7 +44,7 @@ import { fileURLToPath } from 'node:url';
44
44
  * standing test (`test/core-import-floor.test.ts`, F1) fails if the two drift apart,
45
45
  * because a guard that says 0.4.7 while npm may install 0.4.2 is worse than no guard.
46
46
  */
47
- export const MIN_CORE = '0.6.0';
47
+ export const MIN_CORE = '0.6.1';
48
48
 
49
49
  /** The npm name of the guarded package — one literal, used by every leg below. */
50
50
  export const CORE_PACKAGE_NAME = '@dzhechkov/harness-core';