@dzhechkov/harness-core 0.8.38 → 0.8.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/.dz-manifest.json +281 -161
  2. package/README.md +145 -2
  3. package/dist/agentdb-index.d.ts +3 -3
  4. package/dist/agentdb-index.js +5 -5
  5. package/dist/agentdb-index.js.map +1 -1
  6. package/dist/amendment-trace.d.ts.map +1 -1
  7. package/dist/amendment-trace.js +4 -1
  8. package/dist/amendment-trace.js.map +1 -1
  9. package/dist/backlog.d.ts +2 -1
  10. package/dist/backlog.d.ts.map +1 -1
  11. package/dist/backlog.js +3 -2
  12. package/dist/backlog.js.map +1 -1
  13. package/dist/brain.d.ts.map +1 -1
  14. package/dist/brain.js +9 -2
  15. package/dist/brain.js.map +1 -1
  16. package/dist/bto-optimize.d.ts.map +1 -1
  17. package/dist/bto-optimize.js +9 -12
  18. package/dist/bto-optimize.js.map +1 -1
  19. package/dist/claim-check.d.ts.map +1 -1
  20. package/dist/claim-check.js +50 -14
  21. package/dist/claim-check.js.map +1 -1
  22. package/dist/cmd-usage.d.ts.map +1 -1
  23. package/dist/cmd-usage.js +48 -2
  24. package/dist/cmd-usage.js.map +1 -1
  25. package/dist/compounding.d.ts +66 -0
  26. package/dist/compounding.d.ts.map +1 -1
  27. package/dist/compounding.js +76 -9
  28. package/dist/compounding.js.map +1 -1
  29. package/dist/doctor-instrument.d.ts +65 -0
  30. package/dist/doctor-instrument.d.ts.map +1 -0
  31. package/dist/doctor-instrument.js +91 -0
  32. package/dist/doctor-instrument.js.map +1 -0
  33. package/dist/experiment-assign.d.ts +110 -0
  34. package/dist/experiment-assign.d.ts.map +1 -0
  35. package/dist/experiment-assign.js +229 -0
  36. package/dist/experiment-assign.js.map +1 -0
  37. package/dist/feature-adr-checkpoints.d.ts +7 -2
  38. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  39. package/dist/feature-adr-checkpoints.js +20 -5
  40. package/dist/feature-adr-checkpoints.js.map +1 -1
  41. package/dist/feature-adr-envelope.d.ts +12 -0
  42. package/dist/feature-adr-envelope.d.ts.map +1 -1
  43. package/dist/feature-adr-envelope.js +12 -1
  44. package/dist/feature-adr-envelope.js.map +1 -1
  45. package/dist/feature-adr-routing.d.ts +4 -0
  46. package/dist/feature-adr-routing.d.ts.map +1 -1
  47. package/dist/feature-adr-routing.js +4 -0
  48. package/dist/feature-adr-routing.js.map +1 -1
  49. package/dist/feature-tier.d.ts.map +1 -1
  50. package/dist/feature-tier.js +13 -1
  51. package/dist/feature-tier.js.map +1 -1
  52. package/dist/guard.d.ts +17 -0
  53. package/dist/guard.d.ts.map +1 -1
  54. package/dist/guard.js +15 -0
  55. package/dist/guard.js.map +1 -1
  56. package/dist/index.d.ts +14 -7
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +11 -5
  59. package/dist/index.js.map +1 -1
  60. package/dist/ledger-cost-fill.d.ts +58 -0
  61. package/dist/ledger-cost-fill.d.ts.map +1 -0
  62. package/dist/ledger-cost-fill.js +78 -0
  63. package/dist/ledger-cost-fill.js.map +1 -0
  64. package/dist/loop-blobs.generated.js +2 -2
  65. package/dist/loop-blobs.generated.js.map +1 -1
  66. package/dist/mutation-gate.d.ts +59 -0
  67. package/dist/mutation-gate.d.ts.map +1 -1
  68. package/dist/mutation-gate.js +65 -1
  69. package/dist/mutation-gate.js.map +1 -1
  70. package/dist/name-check.d.ts +20 -1
  71. package/dist/name-check.d.ts.map +1 -1
  72. package/dist/name-check.js +42 -1
  73. package/dist/name-check.js.map +1 -1
  74. package/dist/no-stubs.d.ts +10 -0
  75. package/dist/no-stubs.d.ts.map +1 -1
  76. package/dist/no-stubs.js +13 -9
  77. package/dist/no-stubs.js.map +1 -1
  78. package/dist/operations.d.ts +13 -0
  79. package/dist/operations.d.ts.map +1 -1
  80. package/dist/operations.js +88 -2
  81. package/dist/operations.js.map +1 -1
  82. package/dist/patterns.d.ts +24 -0
  83. package/dist/patterns.d.ts.map +1 -1
  84. package/dist/patterns.js +49 -9
  85. package/dist/patterns.js.map +1 -1
  86. package/dist/publish-source-scope.d.ts +32 -0
  87. package/dist/publish-source-scope.d.ts.map +1 -0
  88. package/dist/publish-source-scope.js +53 -0
  89. package/dist/publish-source-scope.js.map +1 -0
  90. package/dist/publish.d.ts +5 -0
  91. package/dist/publish.d.ts.map +1 -1
  92. package/dist/publish.js +32 -21
  93. package/dist/publish.js.map +1 -1
  94. package/dist/qe-bridge.d.ts +4 -1
  95. package/dist/qe-bridge.d.ts.map +1 -1
  96. package/dist/qe-bridge.js +21 -11
  97. package/dist/qe-bridge.js.map +1 -1
  98. package/dist/rake-analyzer.d.ts +10 -3
  99. package/dist/rake-analyzer.d.ts.map +1 -1
  100. package/dist/rake-analyzer.js +84 -22
  101. package/dist/rake-analyzer.js.map +1 -1
  102. package/dist/recap.d.ts.map +1 -1
  103. package/dist/recap.js +8 -4
  104. package/dist/recap.js.map +1 -1
  105. package/dist/registry.d.ts +58 -0
  106. package/dist/registry.d.ts.map +1 -1
  107. package/dist/registry.js +62 -5
  108. package/dist/registry.js.map +1 -1
  109. package/dist/release.d.ts +20 -1
  110. package/dist/release.d.ts.map +1 -1
  111. package/dist/release.js +48 -4
  112. package/dist/release.js.map +1 -1
  113. package/dist/reqe.d.ts.map +1 -1
  114. package/dist/reqe.js +11 -1
  115. package/dist/reqe.js.map +1 -1
  116. package/dist/round-exec.d.ts +10 -0
  117. package/dist/round-exec.d.ts.map +1 -1
  118. package/dist/round-exec.js +3 -2
  119. package/dist/round-exec.js.map +1 -1
  120. package/dist/round.d.ts +19 -0
  121. package/dist/round.d.ts.map +1 -1
  122. package/dist/round.js +1 -0
  123. package/dist/round.js.map +1 -1
  124. package/dist/run-records.d.ts +18 -4
  125. package/dist/run-records.d.ts.map +1 -1
  126. package/dist/run-records.js +61 -7
  127. package/dist/run-records.js.map +1 -1
  128. package/dist/score.d.ts.map +1 -1
  129. package/dist/score.js +8 -1
  130. package/dist/score.js.map +1 -1
  131. package/dist/sign.d.ts +23 -0
  132. package/dist/sign.d.ts.map +1 -1
  133. package/dist/sign.js +52 -0
  134. package/dist/sign.js.map +1 -1
  135. package/dist/skills-verify.d.ts +8 -5
  136. package/dist/skills-verify.d.ts.map +1 -1
  137. package/dist/skills-verify.js +59 -7
  138. package/dist/skills-verify.js.map +1 -1
  139. package/dist/store-guard-prune.d.ts +22 -0
  140. package/dist/store-guard-prune.d.ts.map +1 -0
  141. package/dist/store-guard-prune.js +54 -0
  142. package/dist/store-guard-prune.js.map +1 -0
  143. package/dist/sweep-failure-classify.d.ts +45 -0
  144. package/dist/sweep-failure-classify.d.ts.map +1 -0
  145. package/dist/sweep-failure-classify.js +90 -0
  146. package/dist/sweep-failure-classify.js.map +1 -0
  147. package/dist/workflow-run-dispatch.d.ts +14 -2
  148. package/dist/workflow-run-dispatch.d.ts.map +1 -1
  149. package/dist/workflow-run-dispatch.js +14 -2
  150. package/dist/workflow-run-dispatch.js.map +1 -1
  151. package/package.json +1 -1
  152. package/sbom.json +460 -160
  153. package/src/agentdb-index.ts +5 -5
  154. package/src/amendment-trace.ts +4 -1
  155. package/src/backlog.ts +3 -2
  156. package/src/brain.ts +8 -2
  157. package/src/bto-optimize.ts +10 -8
  158. package/src/claim-check.ts +48 -12
  159. package/src/cmd-usage.ts +52 -2
  160. package/src/compounding.ts +112 -9
  161. package/src/doctor-instrument.ts +153 -0
  162. package/src/experiment-assign.ts +276 -0
  163. package/src/feature-adr-checkpoints.ts +20 -5
  164. package/src/feature-adr-envelope.ts +24 -1
  165. package/src/feature-adr-routing.ts +4 -0
  166. package/src/feature-tier.ts +14 -1
  167. package/src/guard.ts +24 -0
  168. package/src/index.ts +22 -5
  169. package/src/loop-blobs.generated.ts +2 -2
  170. package/src/mutation-gate.ts +113 -1
  171. package/src/name-check.ts +43 -1
  172. package/src/no-stubs.ts +14 -5
  173. package/src/operations.ts +85 -3
  174. package/src/patterns.ts +49 -8
  175. package/src/publish-source-scope.ts +53 -0
  176. package/src/publish.ts +38 -16
  177. package/src/qe-bridge.ts +22 -11
  178. package/src/rake-analyzer.ts +75 -20
  179. package/src/rake-signatures.json +98 -0
  180. package/src/recap.ts +8 -4
  181. package/src/registry.ts +91 -1
  182. package/src/release.ts +54 -4
  183. package/src/reqe.ts +11 -1
  184. package/src/round-exec.ts +13 -2
  185. package/src/round.ts +20 -0
  186. package/src/run-records.ts +78 -10
  187. package/src/score.ts +8 -1
  188. package/src/sign.ts +53 -0
  189. package/src/skills-verify.ts +69 -9
  190. package/src/store-guard-prune.ts +81 -0
  191. package/src/sweep-failure-classify.ts +88 -0
  192. package/src/workflow-run-dispatch.ts +14 -2
package/src/round.ts CHANGED
@@ -41,9 +41,28 @@ export interface RoundExecState {
41
41
  }
42
42
 
43
43
  /** Additive row shape accepted by the existing run-cost ledger readers. */
44
+ /**
45
+ * The row this version WRITES. It is deliberately not the shape the FILE holds: the ledger is
46
+ * append-only, so rows written before a field existed do not carry it. Read a stored line as
47
+ * `unknown` and put it through {@link validateClosedRoundLedgerRow} or your own guard — typing a
48
+ * historical line with this interface and dereferencing a later field as a guaranteed string is the
49
+ * defect this note exists to prevent (named by cross-family review, Codex gpt-5.6-sol, 2026-09-21).
50
+ */
44
51
  export interface RoundLedgerRow {
45
52
  readonly slug: string;
46
53
  readonly stage: 'round';
54
+ /**
55
+ * This round's identity as a FIELD. It is also the first token of `note`, and that prefix is what
56
+ * the confirmation read greps for — but a key that lives inside prose can only be joined by
57
+ * parsing prose. MEASURED on the COMMITTED `.dz/feature-adr/run-cost-ledger.jsonl` at `488f596c`
58
+ * (backlog c60cc857, whose own numbers had gone stale): of 436 rows, 99 are `round`, and the
59
+ * round's own identity sat in a FIELD 0 times out of 99 while sitting inside `note` prose 99 times
60
+ * out of 99. Other id fields exist on those rows — 28 carry `taskId`, 76 carry `stateId` — but
61
+ * they identify the TASK and the STATE FILE, not the round's ledger identity; 22 carry none of
62
+ * `runId`/`taskId`/`stateId` (those three exactly — `runnerId`, the host, is on all 99).
63
+ * Reproducer and pinning commit: package README, section `roundId`.
64
+ */
65
+ readonly roundId: string;
47
66
  readonly tier: null;
48
67
  readonly coder: string | null;
49
68
  readonly reviewer: string | null;
@@ -552,6 +571,7 @@ export function closeRound(input: {
552
571
  const row: RoundLedgerRow = {
553
572
  slug: input.state.slug,
554
573
  stage: 'round',
574
+ roundId: marker,
555
575
  tier: null,
556
576
  coder: nonEmpty(input.coder) ? input.coder.trim() : null,
557
577
  reviewer,
@@ -141,6 +141,17 @@ function priceLookup(modelId: string, table: Readonly<Record<string, LedgerPrice
141
141
  return best;
142
142
  }
143
143
 
144
+ // instrument-round-b fix-round-1 (Codex r1 HIGH finding 3, ADR-001 D4 amended): `--no-strict` was a
145
+ // BLANKET opt-out — since the one demonstrated automatic writer (the pipeline) always passed it, the
146
+ // new strict-by-default was operationally empty for every real caller. Replaced by a NAMED, SCOPED
147
+ // allowance: the caller states exactly which fields it expects to be incomplete
148
+ // (`allowIncomplete`) and WHY, from a closed set of reason codes — an incomplete row is written only
149
+ // when its actual incompleteness is a SUBSET of what was named AND the reason is recognized.
150
+ // Growing the incompleteness beyond what was declared (e.g. `envelope` going missing tomorrow) is
151
+ // refused, by construction, without anyone having to remember to tighten a flag.
152
+ export const INCOMPLETE_REASON_CODES = ['sandbox-metrics-unavailable', 'manual-entry'] as const;
153
+ export type IncompleteReasonCode = typeof INCOMPLETE_REASON_CODES[number];
154
+
144
155
  export type RecordKind = 'ledger' | 'training-pair';
145
156
 
146
157
  export type RecordVerdict =
@@ -290,10 +301,22 @@ export function decideRecordWrite(input: {
290
301
  /** measurement-integrity FR-5/FR-6: rollout-log + price enrichment for a ledger row. Absent ⇒ zero
291
302
  * behavior change (NFR-1). */
292
303
  enrich?: LedgerEnrichInput;
293
- /** experiment-instrument FR-2/A3 (ADR-001): when `true`, an AUTO ledger row that would be written
294
- * `complete:false` is refused instead (exit 2, before any write) — a круг-B default candidate, opt-
295
- * in today so nothing that already writes incomplete auto rows starts failing underfoot (NFR-1). */
296
- strict?: boolean;
304
+ /**
305
+ * instrument-round-b FR-4/A5 (ADR-001 D4), fix-round-1 (Codex r1 HIGH finding 3): an AUTO ledger
306
+ * row that would be written `complete:false` is refused (exit 2, before any write) UNLESS the
307
+ * actual incompleteness is fully covered by this NAMED, SCOPED allowance — the CLI's
308
+ * `--allow-incomplete <fields>`. A field this row is incomplete in that is NOT in this set still
309
+ * refuses, naming exactly the uncovered field(s). `--no-strict` (a blanket opt-out) is gone —
310
+ * see {@link INCOMPLETE_REASON_CODES}.
311
+ */
312
+ allowIncomplete?: readonly string[];
313
+ /**
314
+ * instrument-round-b fix-round-1 (Codex r1 HIGH finding 3): WHY the row is legitimately
315
+ * incomplete — the CLI's `--incomplete-reason <code>`, required alongside `allowIncomplete` and
316
+ * validated against the closed {@link INCOMPLETE_REASON_CODES} set. An unrecognized or absent
317
+ * reason refuses the write even when every incomplete field IS named in `allowIncomplete`.
318
+ */
319
+ incompleteReason?: string | null;
297
320
  }): RecordDecision {
298
321
  const { kind, payloadRaw, stage } = input;
299
322
  if (kind !== 'ledger' && kind !== 'training-pair') {
@@ -354,7 +377,20 @@ export function decideRecordWrite(input: {
354
377
  // carry `auto:true` too (not just gate on it transiently), so every downstream reader of the
355
378
  // PERSISTED line keeps seeing the same signal `shapeMismatch` already gated on above. A no-op when
356
379
  // the payload already said `auto:true` (shapeMismatch already refused any OTHER value).
357
- if (kind === 'ledger' && input.auto === true) stamped['auto'] = true;
380
+ // Lead delta after Codex r2 (N1 HIGH): `auto:true` used to be indistinguishable between "the
381
+ // CLI-level trusted marker was present" and "a hand-built payload said so", so a manual row could
382
+ // read as an automatic one. STRIPPING the claim was tried and rejected: the minutes-delta contract
383
+ // (auto + runId) legitimately rides payload-declared auto rows, and 28 existing callers write
384
+ // them. So the claim SURVIVES and its PROVENANCE is recorded instead — `autoSource` is the field a
385
+ // reader filters on when it needs trusted automatic rows only.
386
+ if (kind === 'ledger') {
387
+ if (input.auto === true) {
388
+ stamped['auto'] = true;
389
+ stamped['autoSource'] = 'cli-flag';
390
+ } else if (stamped['auto'] === true) {
391
+ stamped['autoSource'] = 'payload-claim';
392
+ }
393
+ }
358
394
  const isGap = (v: unknown): boolean => v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
359
395
  if (input.timestamp != null && input.timestamp !== '') {
360
396
  // An EMPTY STRING is a gap, not a value. Stamping only over null/undefined let
@@ -598,11 +634,43 @@ export function decideRecordWrite(input: {
598
634
  ? [...new Set([...existingReasons, ...incompleteReasons])]
599
635
  : incompleteReasons;
600
636
  const complete = mergedReasons.length === 0 && !existingWasIncomplete;
601
- // A3: under `--strict`, incompleteness is a REFUSAL — before any write, the target untouched —
602
- // rather than a loudly-marked write. Without `--strict` (the default today; круг-B may flip it),
603
- // the row is still written, just honestly marked `complete:false` with its reasons.
604
- if (input.strict === true && !complete) {
605
- return refuse(`auto ledger row is incomplete (${mergedReasons.join(', ') || 'previously marked incomplete'}) — refused under --strict before any write`);
637
+ // A3/A5 (круг B, ADR-001 D4), fix-round-1 (Codex r1 HIGH finding 3, finding 9): incompleteness
638
+ // is a REFUSAL by DEFAULT, before any write — but the escape hatch is now a NAMED, SCOPED
639
+ // allowance, not a blanket flag: the row writes only when EVERY name in `mergedReasons` is
640
+ // covered by `input.allowIncomplete` AND `input.incompleteReason` is one of the closed
641
+ // {@link INCOMPLETE_REASON_CODES}. This is the load-bearing condition finding 9 asked for a
642
+ // real discriminating mutation against — an omitted allowance (the ordinary caller who never
643
+ // heard of this option) refuses exactly like круг B's original default; a caller naming the
644
+ // wrong reason, or a wider incompleteness than it declared, ALSO refuses.
645
+ // Lead delta after Codex r2 (N3 MEDIUM): the closed-list check used to live INSIDE the
646
+ // incomplete branch, so a COMPLETE row could carry `--incomplete-reason bogus` and be written
647
+ // with an unrecognized code sitting in its arguments. A supplied reason is validated whenever it
648
+ // is supplied, complete or not.
649
+ const suppliedReason = input.incompleteReason ?? null;
650
+ if (suppliedReason !== null && !(INCOMPLETE_REASON_CODES as readonly string[]).includes(suppliedReason)) {
651
+ return refuse(`--incomplete-reason ${JSON.stringify(suppliedReason)} is not a recognized reason code — expected one of ${INCOMPLETE_REASON_CODES.join(', ')}`);
652
+ }
653
+ if (!complete) {
654
+ const allowedFields = new Set(input.allowIncomplete ?? []);
655
+ const reason = suppliedReason;
656
+ const reasonKnown = reason !== null && (INCOMPLETE_REASON_CODES as readonly string[]).includes(reason);
657
+ // Lead delta after Codex r2 (N2 HIGH): a payload marked `complete:false` with NO concrete
658
+ // reasons cannot be covered by any allowance — the subset relation has nothing to check, so
659
+ // `uncovered` came out empty and the row slipped through. An unexplained incompleteness is a
660
+ // refusal: name the fields, or do not claim incompleteness.
661
+ if (mergedReasons.length === 0) {
662
+ return refuse('auto ledger row is marked `complete:false` but names no incompleteReasons — an allowance cannot cover an unnamed gap; list the incomplete field(s) or drop the claim');
663
+ }
664
+ const uncovered = mergedReasons.filter((r) => !allowedFields.has(r));
665
+ if (uncovered.length > 0 || !reasonKnown) {
666
+ if (reason === null && allowedFields.size === 0) {
667
+ return refuse(`auto ledger row is incomplete (${mergedReasons.join(', ') || 'previously marked incomplete'}) — refused by default; pass --allow-incomplete <fields> and --incomplete-reason <code> to permit a scoped incomplete write`);
668
+ }
669
+ if (!reasonKnown) {
670
+ return refuse(`--incomplete-reason ${JSON.stringify(reason)} is not a recognized reason code — expected one of ${INCOMPLETE_REASON_CODES.join(', ')}`);
671
+ }
672
+ return refuse(`auto ledger row is incomplete in field(s) not covered by --allow-incomplete: ${uncovered.join(', ')} (incomplete: ${mergedReasons.join(', ') || 'previously marked incomplete'})`);
673
+ }
606
674
  }
607
675
  stamped['complete'] = complete;
608
676
  if (!complete) stamped['incompleteReasons'] = mergedReasons.length > 0 ? mergedReasons : existingReasons;
package/src/score.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  * PURE: the CLI reads the artifact files; this module only classifies.
21
21
  */
22
22
 
23
+ import { maskMarkdown } from './markdown-masker.js';
23
24
  import { amendmentIdsIn } from './amendment-trace.js';
24
25
  import { QE_SEVERITIES, findInvalidQeVerdictLines, parseQeFindings, readQeVerdictLines, type QeFindingsRefusedRow, type QeFindingsSummary } from './qe-findings.js';
25
26
 
@@ -103,7 +104,13 @@ export function observabilityAnswer(architectureMarkdown: string | undefined | n
103
104
  // Fenced blocks are stripped FIRST: a `## Observability` inside an example fence is not a section
104
105
  // of this document, and accepting one is a false pass anybody could write by accident
105
106
  // (cross-family review, finding 1).
106
- const text = String(architectureMarkdown ?? '').replace(/\r\n/g, '\n').replace(/^ {0,3}(```|~~~)[\s\S]*?^ {0,3}\1[^\n]*$/gm, '');
107
+ // Block scoping is DELEGATED to the canonical masker, not a local paired-fence regex. The regex
108
+ // it replaces required a MATCHING CLOSE, so an UNCLOSED fence stripped nothing at all and a
109
+ // `## Observability` quoted inside the example counted as a real section — MEASURED 2026-09-20,
110
+ // exactly the false pass the comment above warns about. `unclosed: 'hide'` is the CommonMark
111
+ // reading (an unclosed fence runs to end of document) and is the safe direction for a gate:
112
+ // a section that may be code must not be accepted as an answer.
113
+ const text = String(maskMarkdown(String(architectureMarkdown ?? '').replace(/\r\n/g, '\n'), { unclosed: 'hide' }));
107
114
  // CommonMark: up to three leading spaces, then #s, then REQUIRED whitespace. `##Observability` is
108
115
  // not a heading and must not pass; an indented one is, and must not be missed (finding 2).
109
116
  const headingRe = new RegExp('^ {0,3}#{1,6}[ \t]+' + OBSERVABILITY_SECTION + '\\b.*$', 'gim');
package/src/sign.ts CHANGED
@@ -769,3 +769,56 @@ export function decideVerifyPolicy(verdict: PackVerdict, requireSigning: boolean
769
769
  ? { action: 'fail', reason: 'pack is unsigned and --require-signing was passed' }
770
770
  : { action: 'report', reason: 'pack is unsigned — not verified' };
771
771
  }
772
+
773
+ /**
774
+ * Файлы, которые npm НИКОГДА не кладёт в тарбол, а рабочее дерево несёт почти всегда.
775
+ * Список намеренно короткий: каждая позиция — не эвристика вкуса, а правило упаковщика или
776
+ * общепринятый файл разработки, отсутствующий в опубликованном артефакте.
777
+ */
778
+ const DEV_TREE_MARKERS: readonly string[] = [
779
+ 'node_modules', // npm исключает ВСЕГДА, безусловно
780
+ '.git',
781
+ 'tsconfig.json',
782
+ 'tsconfig.tsbuildinfo',
783
+ 'vitest.config.ts',
784
+ 'vitest.config.mts',
785
+ 'vitest.config.js',
786
+ 'CHANGELOG.md',
787
+ 'test',
788
+ 'tests',
789
+ ];
790
+
791
+ /**
792
+ * Объяснение к ПРОВАЛИВШЕЙСЯ проверке подписи, когда каталог похож на рабочее дерево.
793
+ *
794
+ * ЗАЧЕМ ЭТО СУЩЕСТВУЕТ. Манифест описывает ОПУБЛИКОВАННЫЙ ТАРБОЛ, а не рабочее дерево: при
795
+ * публикации package.json переписывается (`workspace:*` разворачивается, версия бампается), а
796
+ * файлы разработки отфильтровываются. Поэтому сверка неотфильтрованного дерева расходится
797
+ * ПО ПОСТРОЕНИЮ, и вывод выглядит как «подпись сломана» или «дерево подменили».
798
+ *
799
+ * ИЗМЕРЕНО 2026-09-01: обход 55 подписанных пакетов в рабочем дереве дал 54 отказа из 55, и за
800
+ * один день на это независимо попались ТРОЕ — ведущий агент и два роя; один внёс это в отчёт
801
+ * как системный дефект подписей, другой предложил завести задачу, третий чуть не поднял тревогу
802
+ * о взломе доверия. Инструмент, трижды за день обманувший опытного пользователя, дефектен сам.
803
+ *
804
+ * ПОЧЕМУ ЭТО ОБЪЯСНЕНИЕ, А НЕ КЛАССИФИКАЦИЯ. Соседний путь (обход установленных пакетов) считает
805
+ * исходным деревом всё, что лежит не под `node_modules`. Здесь так нельзя: законно распакованный
806
+ * тарбол в /tmp тоже лежит не под `node_modules`, и его объявили бы деревом. Поэтому функция
807
+ * НИЧЕГО НЕ УТВЕРЖДАЕТ о природе каталога — она НАЗЫВАЕТ НАЙДЕННЫЕ УЛИКИ и оставляет вывод
808
+ * человеку. Нет улик — нет и объяснения: молчание здесь честнее догадки.
809
+ *
810
+ * @param entries имена верхнего уровня в проверяемом каталоге
811
+ * @returns текст пояснения или null, если улик рабочего дерева не нашлось
812
+ */
813
+ export function explainPackVerificationFailure(entries: readonly string[]): string | null {
814
+ const found = DEV_TREE_MARKERS.filter((marker) => entries.includes(marker));
815
+ if (found.length === 0) return null;
816
+ return [
817
+ '',
818
+ 'Прежде чем читать это как подделку: манифест описывает ОПУБЛИКОВАННЫЙ ТАРБОЛ, а не рабочее дерево.',
819
+ `Этот каталог несёт файлы разработки, которых в тарболе нет: ${found.join(', ')}.`,
820
+ 'При публикации package.json переписывается, а файлы разработки отфильтровываются, поэтому',
821
+ 'сверка неотфильтрованного дерева расходится ПО ПОСТРОЕНИЮ — это не улика подделки.',
822
+ 'Проверять надо артефакт: npm pack <имя>@<версия> → распаковать → dz verify-pack --pack <распакованное>.',
823
+ ].join('\n');
824
+ }
@@ -6,8 +6,8 @@
6
6
  * the PROPERTY (registration). This module makes the property observable.
7
7
  *
8
8
  * Two layers:
9
- * L1 `scanSkillsLayout` — instant, no Claude session: which names CAN register, plus the three
10
- * layout shapes that produced the 1.2.0 defect.
9
+ * L1 `scanSkillsLayout` — instant, no Claude session: names whose on-disk form passes the
10
+ * static checks, plus known issue shapes.
11
11
  * L2 `parseInitFacts` — the authoritative listing, parsed from the `system/init` event of
12
12
  * + `classifyRegistration` `claude -p --output-format stream-json --verbose`. No model prose.
13
13
  *
@@ -19,13 +19,15 @@ import { basename, isAbsolute, join, resolve } from 'node:path';
19
19
 
20
20
  // ── Layer 1: static layout scan ─────────────────────────────────────
21
21
 
22
- /** The three non-registrable shapes, all observed in the health-advisor 1.2.0 defect. */
22
+ /** The seven static issue shapes this scanner reports. */
23
23
  export type SkillIssueKind =
24
24
  | 'no-skill-md' // a skill dir with no SKILL.md at depth 1 → never registers
25
25
  | 'buried-skill-md' // a SKILL.md at depth >= 2 → the loader does not scan that deep
26
26
  | 'plugin-manifest-trap' // .claude-plugin/plugin.json under .claude/skills → does NOT auto-register
27
27
  | 'wildcard-allowed-tools' // `allowed-tools: *` → every tool granted; a FINDING (see below)
28
- | 'empty-allowed-tools'; // `allowed-tools:` with no value → looks restrictive, restricts nothing
28
+ | 'empty-allowed-tools' // `allowed-tools:` with no value → looks restrictive, restricts nothing
29
+ | 'missing-frontmatter-fence' // measured absent from the registry without an opening `---`
30
+ | 'missing-description'; // measured absent from the registry without a non-empty description
29
31
 
30
32
  export interface SkillLayoutFinding {
31
33
  readonly dir: string;
@@ -38,7 +40,10 @@ export interface StaticScan {
38
40
  readonly projectDir: string;
39
41
  readonly skillsRoot: string;
40
42
  readonly exists: boolean;
41
- /** Names that CAN register: a dir with SKILL.md exactly one level deep. */
43
+ /**
44
+ * Bare skill names whose on-disk form passes static layout and measured frontmatter checks.
45
+ * Actual registration is established only by the Layer-2 session listing.
46
+ */
42
47
  readonly registrable: readonly string[];
43
48
  /** Load-blocking problems: these make the verdict FAIL. */
44
49
  readonly findings: readonly SkillLayoutFinding[];
@@ -228,6 +233,39 @@ export function parseAllowedTools(markdown: string): AllowedToolsState {
228
233
  return { state: 'listed', values, wildcard: values.includes('*') };
229
234
  }
230
235
 
236
+ type SkillFrontmatterIssue = 'missing-frontmatter-fence' | 'missing-description';
237
+
238
+ /**
239
+ * Read the registration-relevant frontmatter fields, line by line and only before its closing fence.
240
+ * Scanning the whole file would let `description:` in skill documentation suppress a real finding,
241
+ * the same false-positive/false-negative boundary guarded by `parseAllowedTools` above.
242
+ *
243
+ * The two conditions are measured as absence from the registry, not refusal of invocation. Each
244
+ * measurement used a fresh session, so it also does not establish appearance in the same response
245
+ * without a restart.
246
+ */
247
+ function findSkillFrontmatterIssue(markdown: string): SkillFrontmatterIssue | null {
248
+ const lines = markdown.split(/\r?\n/);
249
+ // NO explicit BOM strip, and that is deliberate: `String.prototype.trim()` already removes U+FEFF
250
+ // (it is <ZWNBSP>, part of the WhiteSpace production), so a BOM-prefixed fence compares equal to
251
+ // a bare one. MEASURED 2026-09-19: adding a strip left the mutant "don't strip" 70/70 green —
252
+ // dead code. The tolerance is pinned by a test instead, so replacing `trim()` with anything
253
+ // stricter goes red rather than silently rejecting every BOM-prefixed skill.
254
+ if (lines[0]?.trim() !== '---') return 'missing-frontmatter-fence';
255
+
256
+ let description: string | null = null;
257
+ for (let i = 1; i < lines.length; i++) {
258
+ const line = lines[i] ?? '';
259
+ if (line.trim() === '---') break;
260
+ const match = /^description:(.*)$/.exec(line);
261
+ if (match) {
262
+ description = match[1] ?? '';
263
+ break;
264
+ }
265
+ }
266
+ return description === null || description.trim() === '' ? 'missing-description' : null;
267
+ }
268
+
231
269
  /**
232
270
  * Privilege findings for one skill directory.
233
271
  *
@@ -426,16 +464,38 @@ export function scanSkillsLayout(projectDir: string): StaticScan {
426
464
  const isPluginContainer = hasPluginManifest(dir);
427
465
  const bucket = isPluginContainer ? advisories : findings;
428
466
 
467
+ // Read under a guard, exactly like scanSkillPrivileges: an unreadable SKILL.md is another
468
+ // check's business, and this scanner's contract is to REPORT findings, never to throw out of
469
+ // the whole sweep over one bad file.
470
+ let frontmatterIssue: SkillFrontmatterIssue | null = null;
471
+ if (registers) {
472
+ try {
473
+ frontmatterIssue = findSkillFrontmatterIssue(readFileSync(join(dir, 'SKILL.md'), 'utf8'));
474
+ } catch {
475
+ frontmatterIssue = null;
476
+ }
477
+ }
478
+ if (frontmatterIssue) {
479
+ findings.push({
480
+ dir: name,
481
+ kind: frontmatterIssue,
482
+ detail: frontmatterIssue === 'missing-frontmatter-fence'
483
+ ? 'SKILL.md has no opening frontmatter fence — measured absent from the registry without it'
484
+ : 'SKILL.md has no non-empty frontmatter description — measured absent from the registry without one',
485
+ });
486
+ }
487
+
429
488
  // Права проверяются у КАЖДОГО навыка с читаемым SKILL.md, включая одно-навыковый плагин: щедрая
430
489
  // выдача не становится безопаснее оттого, что навык лежит в контейнере.
431
490
  if (registers) scanSkillPrivileges(dir, name, findings, advisories);
432
491
 
433
- if (registers && !isPluginContainer) {
434
- registrable.push(name);
435
- } else if (registers && isPluginContainer) {
492
+ if (registers) {
493
+ if (!isPluginContainer && !frontmatterIssue) {
494
+ registrable.push(name);
495
+ }
436
496
  // A single-skill plugin: it has a depth-1 SKILL.md AND a manifest, so it registers NAMESPACED.
437
497
  // Expecting the bare directory name here was a false FAIL (QE5 #2); the container check below
438
- // accepts either form.
498
+ // accepts either form. A frontmatter finding likewise keeps the bare name out of registrable.
439
499
  } else {
440
500
  // A dir with no markdown was never meant to be a skill — advisory, not a failure (QE5 #7).
441
501
  const intended = looksLikeSkillDir(dir);
@@ -0,0 +1,81 @@
1
+ import { sep } from 'node:path';
2
+
3
+ export type StoreGuardPruneBucket = 'stale-temp' | 'live' | 'gone-outside-tmp' | 'unreadable';
4
+
5
+ export interface StoreGuardPruneEntry {
6
+ readonly file: string;
7
+ readonly project: string | null;
8
+ readonly bytes: number;
9
+ readonly bucket: StoreGuardPruneBucket;
10
+ }
11
+
12
+ export interface StoreGuardPrunePlan {
13
+ readonly entries: readonly StoreGuardPruneEntry[];
14
+ readonly counts: Readonly<Record<StoreGuardPruneBucket, number>>;
15
+ readonly reclaimableBytes: number;
16
+ }
17
+
18
+ export interface StoreGuardPruneDeps {
19
+ readonly exists: (path: string) => boolean;
20
+ readonly tmpDirs: readonly string[];
21
+ }
22
+
23
+ /**
24
+ * A temp-root candidate that authorizes deletion must be a REAL directory path, never the filesystem
25
+ * root or an empty string. MEASURED 2026-09-20 (cross-family review, P1): `TMPDIR=/` makes
26
+ * `os.tmpdir()` return the single character `"/"` (node strips a trailing slash only when the path is
27
+ * longer than one character), and `isUnder` then matches EVERY absolute path — so every
28
+ * `gone-outside-tmp` mark, which FR-3 exists to protect, would be classified `stale-temp` and deleted.
29
+ * A degenerate candidate authorizes nothing and is dropped here, in the pure half, where it is testable.
30
+ */
31
+ function isUsableTmpDir(tmpDir: string): boolean {
32
+ const trimmed = tmpDir.trim();
33
+ return trimmed.length > 1 && trimmed !== sep && trimmed !== '/';
34
+ }
35
+
36
+ function isUnder(project: string, tmpDir: string): boolean {
37
+ const prefix = tmpDir.endsWith(sep) ? tmpDir : `${tmpDir}${sep}`;
38
+ return project === tmpDir || project.startsWith(prefix);
39
+ }
40
+
41
+ export function planStoreGuardPrune(
42
+ input: readonly { readonly file: string; readonly bytes: number; readonly text: string }[],
43
+ deps: StoreGuardPruneDeps,
44
+ ): StoreGuardPrunePlan {
45
+ const counts: Record<StoreGuardPruneBucket, number> = {
46
+ 'stale-temp': 0,
47
+ live: 0,
48
+ 'gone-outside-tmp': 0,
49
+ unreadable: 0,
50
+ };
51
+ let reclaimableBytes = 0;
52
+
53
+ const entries = input.map(({ file, bytes, text }): StoreGuardPruneEntry => {
54
+ let value: unknown;
55
+ try {
56
+ value = JSON.parse(text);
57
+ } catch {
58
+ counts.unreadable += 1;
59
+ return { file, project: null, bytes, bucket: 'unreadable' };
60
+ }
61
+
62
+ if (typeof value !== 'object' || value === null
63
+ || typeof (value as { project?: unknown }).project !== 'string'
64
+ || (value as { project: string }).project.length === 0) {
65
+ counts.unreadable += 1;
66
+ return { file, project: null, bytes, bucket: 'unreadable' };
67
+ }
68
+
69
+ const project = (value as { project: string }).project;
70
+ const bucket: StoreGuardPruneBucket = deps.exists(project)
71
+ ? 'live'
72
+ : deps.tmpDirs.filter(isUsableTmpDir).some((tmpDir) => isUnder(project, tmpDir))
73
+ ? 'stale-temp'
74
+ : 'gone-outside-tmp';
75
+ counts[bucket] += 1;
76
+ if (bucket === 'stale-temp') reclaimableBytes += bytes;
77
+ return { file, project, bytes, bucket };
78
+ });
79
+
80
+ return { entries, counts, reclaimableBytes };
81
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Различитель красных обхода: ЛОГИЧЕСКИЙ отказ против отказа ПОД НАГРУЗКОЙ.
3
+ *
4
+ * ЗАЧЕМ. Запись 9e48a975 установила класс: набор harness-core детерминирован В ОДИНОЧКУ и
5
+ * краснеет ТОЛЬКО под параллельной нагрузкой (пять последовательных прогонов — ноль красных;
6
+ * те же тесты под фоновым вторым набором — красные поодиночке и в разных сочетаниях).
7
+ * Подпись класса: тест (а) читает собранный `dist`, (б) порождает процессы, (в) несёт явный
8
+ * таймаут, близкий к измеренному времени.
9
+ *
10
+ * ИЗМЕРЕНО 2026-09-19, новый экземпляр того же класса: `scout` прошёл в одном обходе и упал в
11
+ * следующем через 17 минут — `deep-analyzer.test.ts`, `Test timed out in 5000ms`, 192 из 193
12
+ * зелёные; тот же файл в одиночку 5 из 5. Единственное различие между прогонами — загрузка
13
+ * машины (подкачка 2342 → 2671 МБ).
14
+ *
15
+ * Класс диагностирован, но МЕХАНИЗМА не имел: каждый экземпляр стоил ручного разбора. Этот
16
+ * модуль — чистая половина механизма: он извлекает из журнала обхода ИМЕНА упавших файлов и
17
+ * классифицирует результат ПЕРЕПРОГОНА В ОДИНОЧКУ. Запуск живёт в скрипте, здесь его нет.
18
+ *
19
+ * ТРЁХЗНАЧНОСТЬ ОБЯЗАТЕЛЬНА. Перепрогон, который не удалось выполнить, — это НЕ «прошёл»;
20
+ * прибор, который не смог посмотреть, не имеет права на вердикт.
21
+ */
22
+
23
+ /** Вердикт перепрогона одного файла в одиночку. */
24
+ export type RerunVerdict = 'does-not-reproduce-alone' | 'reproduces-alone' | 'inconclusive';
25
+
26
+ /**
27
+ * Имена упавших тестовых файлов из журнала набора. Понимает ОБА формата, которые даёт обход:
28
+ * vitest ` FAIL test/x.test.ts > describe > it`
29
+ * node:test ` location: '/abs/.../test/x.test.mjs:95:1'`
30
+ *
31
+ * Пути приводятся к виду, относительному каталогу пакета, потому что перепрогон запускается
32
+ * из него. Порядок сохраняется, дубликаты убираются: один файл перепрогоняется один раз.
33
+ */
34
+ export function parseFailedTestFiles(logText: string, packageDir?: string): string[] {
35
+ const out: string[] = [];
36
+ const seen = new Set<string>();
37
+ const add = (raw: string): void => {
38
+ let p = raw.trim();
39
+ if (packageDir !== undefined && p.startsWith(packageDir)) {
40
+ p = p.slice(packageDir.length).replace(/^\/+/, '');
41
+ }
42
+ // Абсолютный путь к ЧУЖОМУ пакету перепрогонять нельзя: он не относителен нашему каталогу.
43
+ if (p.startsWith('/')) return;
44
+ if (p === '' || seen.has(p)) return;
45
+ seen.add(p);
46
+ out.push(p);
47
+ };
48
+ for (const line of logText.split('\n')) {
49
+ const vitest = /^\s*FAIL\s+(?:\|[^|]*\|\s*)?(\S+\.(?:test|spec)\.[cm]?[jt]s)\b/.exec(line);
50
+ if (vitest?.[1] !== undefined) { add(vitest[1]); continue; }
51
+ const node = /location:\s*'([^']+\.(?:test|spec)\.[cm]?[jt]s):\d+/.exec(line);
52
+ if (node?.[1] !== undefined) { add(node[1]); }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * Вердикт по исходу перепрогона.
59
+ *
60
+ * @param sweepFailed упал ли файл в обходе (иначе классифицировать нечего)
61
+ * @param rerun результат одиночного прогона, или null если запустить не удалось
62
+ */
63
+ export function classifyRerun(
64
+ sweepFailed: boolean,
65
+ rerun: { readonly exitCode: number; readonly ranAnyTest: boolean } | null,
66
+ ): RerunVerdict {
67
+ if (!sweepFailed) return 'inconclusive';
68
+ if (rerun === null) return 'inconclusive';
69
+ // «Ни одного теста не запустилось» — это не зелёный прогон, а несостоявшаяся проба: так
70
+ // выглядит и опечатка в пути, и набор, который не грузится. Читать это как «прошёл» значит
71
+ // объявить файл нагрузочно-зависимым на основании того, что его никто не проверил.
72
+ if (!rerun.ranAnyTest) return 'inconclusive';
73
+ return rerun.exitCode === 0 ? 'does-not-reproduce-alone' : 'reproduces-alone';
74
+ }
75
+
76
+ /** Человеческая расшифровка вердикта — одна строка, называющая, что делать дальше. */
77
+ export function describeVerdict(verdict: RerunVerdict): string {
78
+ if (verdict === 'does-not-reproduce-alone') {
79
+ // НЕ «зависит от нагрузки»: у зелёного одиночного прогона есть ДВЕ причины, и различить их
80
+ // перепрогоном нельзя — нагрузка обхода ЛИБО починка, внесённая уже после него. Называть
81
+ // одну из двух значило бы выдать догадку за измерение.
82
+ return 'в одиночку ЗЕЛЁНЫЙ — либо отказ зависел от нагрузки обхода, либо починка легла после него; перепрогон эти две причины не различает';
83
+ }
84
+ if (verdict === 'reproduces-alone') {
85
+ return 'в одиночку ТОЖЕ красный — настоящий дефект, нагрузка ни при чём';
86
+ }
87
+ return 'НЕ УСТАНОВЛЕНО — перепрогон не состоялся; это не «прошёл»';
88
+ }
@@ -11,6 +11,7 @@
11
11
  * a convention whose reason is lost is the next thing somebody "cleans up".
12
12
  */
13
13
 
14
+ import { CODEX_EXEC_PROMPT_CEILING_CHARS } from './feature-adr-routing.js';
14
15
  import type { Deliverable } from './loop-plan.js';
15
16
  import { claudeProbeArgs, claudeReviewArgs, extractClaudeResult, interpretClaudeProbe, type BridgeFamily } from './qe-bridge.js';
16
17
  import type { WfRunReason } from './workflow-run.js';
@@ -149,13 +150,24 @@ export const CODEX_SCOPING_PREFIX =
149
150
  'Answer directly from this prompt text alone; no commands, no files, no tools.';
150
151
 
151
152
  /**
152
- * The REAL prompt ceiling for `codex exec`.
153
+ * The REAL prompt ceiling for `codex exec` — RE-EXPORTED, never re-declared.
153
154
  *
154
155
  * The folk value 1200 is refuted history: it came from an era when the prompt travelled through a
155
156
  * fire-and-forget wrapper. Over-ceiling ⇒ a LOUD `prompt-over-ceiling`, never truncation — a
156
157
  * truncated prompt produces a confident answer to a question nobody asked.
158
+ *
159
+ * ONE NUMBER, ONE DEFINITION. Until 2026-09-20 this module declared its own `= 24_000` beside the
160
+ * one in `feature-adr-routing.ts`, and NOTHING compared them: no test imported both, so the two
161
+ * could diverge in silence and `codexExecPlan` would route to Claude at a different threshold than
162
+ * `makeCodexExecDispatcher` rejects at. Measured: `feature-adr-codex-dispatch.test.ts` asserts only
163
+ * `> 4000`, and the mirror guard in `codex-scoped-review.test.ts` ties routing.ts to the four
164
+ * workflow copies but knows nothing about this file.
165
+ *
166
+ * The definition lives in `feature-adr-routing.ts` and not here because THAT module is lifted
167
+ * verbatim into the workflow sandbox by `scripts/gen-loop-blobs.mjs` and therefore may not import
168
+ * anything. The dependency can only point this way.
157
169
  */
158
- export const CODEX_EXEC_PROMPT_CEILING_CHARS = 24_000;
170
+ export { CODEX_EXEC_PROMPT_CEILING_CHARS };
159
171
 
160
172
  /**
161
173
  * argv for one codex dispatch. The prompt travels as ONE argv element (no shell, no quoting), and