@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.
- package/.dz-manifest.json +29 -13
- package/README.md +13 -3
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +243 -3
- package/dist/cli.js.map +1 -1
- package/dist/core-compat.d.ts +1 -1
- package/dist/core-compat.js +1 -1
- package/keys/README.md +44 -5
- package/keys/dz.pub +3 -0
- package/package.json +2 -2
- package/sbom.json +52 -12
- package/src/cli.ts +250 -2
- package/src/core-compat.ts +1 -1
package/dist/core-compat.d.ts
CHANGED
|
@@ -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.
|
|
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
|
/**
|
package/dist/core-compat.js
CHANGED
|
@@ -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.
|
|
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`
|
|
4
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dzhechkov/harness-cli",
|
|
3
|
-
"version": "0.6.
|
|
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.
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
-
|
|
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':
|
package/src/core-compat.ts
CHANGED
|
@@ -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.
|
|
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';
|