@dzhechkov/harness-core 0.7.2 → 0.7.4

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 (62) hide show
  1. package/.dz-manifest.json +121 -41
  2. package/README.md +1 -1
  3. package/dist/cli-flag-notice.d.ts +50 -0
  4. package/dist/cli-flag-notice.d.ts.map +1 -0
  5. package/dist/cli-flag-notice.js +106 -0
  6. package/dist/cli-flag-notice.js.map +1 -0
  7. package/dist/event-chain.d.ts +50 -0
  8. package/dist/event-chain.d.ts.map +1 -1
  9. package/dist/event-chain.js +31 -0
  10. package/dist/event-chain.js.map +1 -1
  11. package/dist/index.d.ts +9 -5
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +7 -3
  14. package/dist/index.js.map +1 -1
  15. package/dist/name-check.d.ts +98 -0
  16. package/dist/name-check.d.ts.map +1 -0
  17. package/dist/name-check.js +333 -0
  18. package/dist/name-check.js.map +1 -0
  19. package/dist/operations.d.ts.map +1 -1
  20. package/dist/operations.js +33 -5
  21. package/dist/operations.js.map +1 -1
  22. package/dist/provenance.d.ts +100 -92
  23. package/dist/provenance.d.ts.map +1 -1
  24. package/dist/provenance.js +122 -122
  25. package/dist/provenance.js.map +1 -1
  26. package/dist/recall-domain-boost.d.ts +10 -3
  27. package/dist/recall-domain-boost.d.ts.map +1 -1
  28. package/dist/recall-domain-boost.js +7 -0
  29. package/dist/recall-domain-boost.js.map +1 -1
  30. package/dist/recall-hook-policy.d.ts +15 -0
  31. package/dist/recall-hook-policy.d.ts.map +1 -1
  32. package/dist/recall-hook-policy.js +59 -0
  33. package/dist/recall-hook-policy.js.map +1 -1
  34. package/dist/recap.d.ts +146 -0
  35. package/dist/recap.d.ts.map +1 -0
  36. package/dist/recap.js +346 -0
  37. package/dist/recap.js.map +1 -0
  38. package/dist/retro.d.ts +131 -0
  39. package/dist/retro.d.ts.map +1 -0
  40. package/dist/retro.js +207 -0
  41. package/dist/retro.js.map +1 -0
  42. package/dist/score.d.ts +21 -0
  43. package/dist/score.d.ts.map +1 -1
  44. package/dist/score.js +44 -3
  45. package/dist/score.js.map +1 -1
  46. package/dist/vector-tier.d.ts +54 -0
  47. package/dist/vector-tier.d.ts.map +1 -1
  48. package/dist/vector-tier.js +69 -8
  49. package/dist/vector-tier.js.map +1 -1
  50. package/package.json +8 -8
  51. package/sbom.json +240 -40
  52. package/src/cli-flag-notice.ts +114 -0
  53. package/src/event-chain.ts +64 -0
  54. package/src/index.ts +13 -0
  55. package/src/name-check.ts +331 -0
  56. package/src/operations.ts +34 -6
  57. package/src/provenance.ts +217 -0
  58. package/src/recall-domain-boost.ts +10 -3
  59. package/src/recall-hook-policy.ts +60 -0
  60. package/src/recap.ts +462 -0
  61. package/src/score.ts +53 -3
  62. package/src/vector-tier.ts +109 -10
@@ -0,0 +1,114 @@
1
+ /**
2
+ * An unrecognised `--flag` must not pass in silence.
3
+ *
4
+ * MEASURED 2026-08-24: `dz recall "x" --breif --limit 2` printed the full ordinary output and exited
5
+ * 0. Someone who typed `--breif` for `--brief` reads that as "the mode worked" — and every `dz`
6
+ * command behaves the same way, because the argv parser accepts any `--name` it is handed.
7
+ *
8
+ * WHY THIS WARNS RATHER THAN REFUSES, and the measurement behind it. Two ways to build a per-command
9
+ * allowlist were tried and BOTH are unsafe:
10
+ *
11
+ * - from the help text: 53 of the 220 flag names the CLI actually reads appear nowhere in help, so
12
+ * refusing on a help-derived list would break 53 working invocations;
13
+ * - from static extraction over the dispatch table: it lost `--week` from `dz recap` (those flags
14
+ * are read through a loop over a constant, not a literal `flags.has('week')`) and picked up a
15
+ * neighbouring command's flags for `dz usage`. It both under- and over-covers.
16
+ *
17
+ * A refusal built on either would reject working commands, and breaking a correct invocation is a
18
+ * worse failure than the one being fixed. So the KNOWN set here is the union of every name the CLI
19
+ * reads and every name its help documents, and an unrecognised name is reported loudly while the
20
+ * command still does its work. That removes the SILENCE, which is the actual harm.
21
+ *
22
+ * HONEST LIMIT, and it is real: this catches a name no command anywhere knows. It does NOT catch a
23
+ * name that is valid for a different command — `dz recap --manifest` stays quiet. Closing that needs
24
+ * a hand-curated per-command list, which is filed with these measurements rather than guessed at.
25
+ */
26
+
27
+ /**
28
+ * Damerau-Levenshtein distance — Levenshtein plus ADJACENT TRANSPOSITION at cost 1.
29
+ *
30
+ * The transposition case is not a refinement, it is the common case: `--limti` for `--limit` is one
31
+ * swapped pair, which plain Levenshtein scores 2 and a length-scaled bound then rejected, so the
32
+ * most frequent kind of typo got no suggestion at all (measured on this very set 2026-08-24).
33
+ * Suggestion only — never a decision.
34
+ */
35
+ function editDistance(a: string, b: string): number {
36
+ const m = a.length;
37
+ const n = b.length;
38
+ if (m === 0) return n;
39
+ if (n === 0) return m;
40
+ const d: number[][] = Array.from({ length: m + 1 }, (_, i) => [i, ...Array<number>(n).fill(0)]);
41
+ for (let j = 0; j <= n; j++) (d[0] as number[])[j] = j;
42
+ for (let i = 1; i <= m; i++) {
43
+ for (let j = 1; j <= n; j++) {
44
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
45
+ let best = Math.min(
46
+ ((d[i] as number[])[j - 1] as number) + 1,
47
+ ((d[i - 1] as number[])[j] as number) + 1,
48
+ ((d[i - 1] as number[])[j - 1] as number) + cost,
49
+ );
50
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
51
+ best = Math.min(best, ((d[i - 2] as number[])[j - 2] as number) + 1);
52
+ }
53
+ (d[i] as number[])[j] = best;
54
+ }
55
+ }
56
+ return (d[m] as number[])[n] as number;
57
+ }
58
+
59
+ /**
60
+ * Every known name as close to `name` as the closest one is.
61
+ *
62
+ * TIES ARE NOT BROKEN. `--wek` sits one edit from both `--week` and `--weak`, and picking whichever
63
+ * came first in the list points the reader confidently at a coin flip. All of them are named, and
64
+ * the reader decides.
65
+ *
66
+ * The bound scales with length so a three-letter name cannot match everything: at most a third of
67
+ * the name may differ, and never more than two characters.
68
+ */
69
+ export function nearestKnownFlag(name: string, known: readonly string[]): string[] {
70
+ const limit = Math.min(2, Math.max(1, Math.floor(name.length / 3)));
71
+ let bestScore = limit + 1;
72
+ let best: string[] = [];
73
+ for (const candidate of known) {
74
+ const dist = editDistance(name, candidate);
75
+ if (dist < bestScore) {
76
+ bestScore = dist;
77
+ best = [candidate];
78
+ } else if (dist === bestScore) {
79
+ best.push(candidate);
80
+ }
81
+ }
82
+ return bestScore <= limit ? [...new Set(best)].sort() : [];
83
+ }
84
+
85
+ export interface UnknownFlagNotice {
86
+ readonly name: string;
87
+ /** Every equally-close known name. Empty when nothing is close enough to be worth naming. */
88
+ readonly suggestions: readonly string[];
89
+ readonly line: string;
90
+ }
91
+
92
+ /**
93
+ * One notice per unrecognised name, or an empty list when everything is known.
94
+ *
95
+ * `passed` is every `--name` the user typed, whether it took a value or not: a typo'd OPTION
96
+ * (`--limti 5`) is exactly as silent as a typo'd flag, and was equally unreported.
97
+ */
98
+ export function unknownFlagNotice(passed: readonly string[], known: readonly string[]): UnknownFlagNotice[] {
99
+ const set = new Set(known);
100
+ const out: UnknownFlagNotice[] = [];
101
+ for (const name of passed) {
102
+ if (name === '' || set.has(name)) continue;
103
+ const suggestions = nearestKnownFlag(name, known);
104
+ const hint = suggestions.length === 0
105
+ ? 'no dz command reads it.'
106
+ : `did you mean ${suggestions.map((s) => `--${s}`).join(' or ')}?`;
107
+ out.push({
108
+ name,
109
+ suggestions,
110
+ line: `dz: unknown option --${name} — ${hint} It was IGNORED, not applied`,
111
+ });
112
+ }
113
+ return out;
114
+ }
@@ -868,3 +868,67 @@ export function renderEventChainVerification(v: EventChainVerification, label: s
868
868
  if (kinds.length > 0) parts.push(kinds.join('/'));
869
869
  return `${parts.join(' · ')} — ${v.scope}`;
870
870
  }
871
+
872
+ /**
873
+ * How old a defect is relative to the log's CURRENT unbroken run.
874
+ *
875
+ * `historical` — the break happened, and an unbroken segment has run since. It cannot be un-happened
876
+ * and it does not make today's records suspect. `live` — the break is inside the segment we are
877
+ * still appending to, so records after it are the ones a verdict would rest on.
878
+ */
879
+ /**
880
+ * Where a defect sits relative to the log's current unbroken run.
881
+ *
882
+ * Deliberately NOT called "historical". A break followed by ONE record is not a healed log, and a
883
+ * name that implies healing would let a caller skip the count — the reviewer's exact objection
884
+ * (codex `gpt-5.6-sol`, 2026-08-24: a defect at line 2 of 3 was filed as historical because line 3
885
+ * existed). These names state a position; the COUNT states how much evidence stands behind it, and
886
+ * the count is what a caller must show.
887
+ */
888
+ export type ChainDefectAge = 'before-run' | 'in-run';
889
+
890
+ /**
891
+ * The line where the log's current unbroken run begins: one past the last defect, or 1 when there
892
+ * are none.
893
+ *
894
+ * Why this exists: `dz doctor` reported "learning verdicts computed from this log are unsafe" for
895
+ * both `.dz` event logs, flatly, for four weeks. MEASURED 2026-08-24 — every defect in both files
896
+ * precedes an unbroken run of 998 of 1138 rows in one and 88 of 426 in the other. The verdict was
897
+ * true of the file and false of those runs, and a permanent red about something nobody can change is
898
+ * a red that stops being read.
899
+ */
900
+ export function liveSegmentStart(verification: EventChainVerification): number {
901
+ let last = 0;
902
+ for (const d of verification.defects) if (d.line > last) last = d.line;
903
+ return last + 1;
904
+ }
905
+
906
+ export interface ChainDefectAges {
907
+ /** Defects that an unbroken run has followed. How MUCH of a run is `runRecords`, never implied. */
908
+ readonly beforeRun: readonly EventChainDefect[];
909
+ /** Defects with nothing sound after them — the strong verdict is earned here. */
910
+ readonly inRun: readonly EventChainDefect[];
911
+ /** First line of the current unbroken run. */
912
+ readonly runFrom: number;
913
+ /**
914
+ * Records in that run. THE NUMBER IS THE EVIDENCE: 998 sound records after a break say something a
915
+ * caller may relax on; 1 says almost nothing, and a caller that prints "sound" without printing
916
+ * this number is overclaiming on its behalf.
917
+ */
918
+ readonly runRecords: number;
919
+ }
920
+
921
+ /**
922
+ * Split defects by whether an unbroken run followed them, and say how long that run is.
923
+ *
924
+ * `runFrom` is computed from the defects themselves, so a defect can never classify itself: the run
925
+ * only begins after the LAST of them. A log whose newest defect is its final record therefore has an
926
+ * empty `beforeRun` and a `runRecords` of zero.
927
+ */
928
+ export function classifyChainDefects(verification: EventChainVerification, totalRecords: number): ChainDefectAges {
929
+ const runFrom = liveSegmentStart(verification);
930
+ const runRecords = Math.max(0, totalRecords - runFrom + 1);
931
+ const beforeRun = runRecords > 0 ? verification.defects : [];
932
+ const inRun = runRecords > 0 ? [] : verification.defects;
933
+ return { beforeRun, inRun, runFrom, runRecords };
934
+ }
package/src/index.ts CHANGED
@@ -127,6 +127,8 @@ export {
127
127
  readVectorEngineMode,
128
128
  readHarmonizeThreshold,
129
129
  vectorMirrorEnabled,
130
+ mirrorWriterReason,
131
+ mirrorWriterExplanation,
130
132
  resolveVectorEngine,
131
133
  mirrorEntriesToVector,
132
134
  mirrorPatternsToVector,
@@ -155,6 +157,7 @@ export type {
155
157
  RankedPattern,
156
158
  VectorServiceOptions,
157
159
  VectorTierStatus,
160
+ MirrorWriterState,
158
161
  HarmonizeItem,
159
162
  HarmonizeCluster,
160
163
  HarmonizeReport,
@@ -311,6 +314,8 @@ export {
311
314
  DEFAULT_RECALL_HOOK_BUDGET_CHARS,
312
315
  MIN_PROMPT_CHARS,
313
316
  MIN_CONTENT_TOKENS,
317
+ closenessLine,
318
+ anyAboveFloor,
314
319
  } from './recall-hook-policy.js';
315
320
  export type { QueryLang, RecallFloors, HookCandidate, HookSelection } from './recall-hook-policy.js';
316
321
  export {
@@ -437,6 +442,8 @@ export {
437
442
  rewriteSnapshotUnchanged,
438
443
  guardedRewrite,
439
444
  DEFAULT_REWRITE_ATTEMPTS,
445
+ liveSegmentStart,
446
+ classifyChainDefects,
440
447
  } from './event-chain.js';
441
448
  export type {
442
449
  ChainFields,
@@ -451,6 +458,8 @@ export type {
451
458
  EventChainDefectKind,
452
459
  EventChainVerification,
453
460
  VerifyEventChainOptions,
461
+ ChainDefectAge,
462
+ ChainDefectAges,
454
463
  } from './event-chain.js';
455
464
  export { decideProvenance, environmentCanMintProvenance, publishArgv, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, orderByDependencies, syncReadmeVersion } from './publish.js';
456
465
  export { fetchAllDownloads } from './downloads.js';
@@ -805,6 +814,10 @@ export {
805
814
  // Run-process scorecard (feature dz-score, Reading C) — scores the DISCIPLINE of a feature-adr run
806
815
  // from its artifacts. Descriptive-only, permanently: it never gates.
807
816
  export * from './score.js';
817
+ export * from './recap.js';
818
+ export * from './provenance.js';
819
+ export * from './name-check.js';
820
+ export * from './cli-flag-notice.js';
808
821
 
809
822
  // Mutation gate (feature ha-mutation-gate) — deliberately break each NAMED protection in a scratch
810
823
  // copy, run the suite, REQUIRE red. Proves a test DISCRIMINATES, not merely that it is green.
@@ -0,0 +1,331 @@
1
+ /**
2
+ * `dz name-check` — is this name free, before a line of code is written?
3
+ *
4
+ * WHY THIS EXISTS, stated plainly: twice in one day a name collision broke the build outright.
5
+ * `dz retro` was already a command (the per-session process retro), and its star re-export clash
6
+ * stopped the CLI from importing at all; `decideProvenance` was already an export (npm provenance),
7
+ * and the build went red mid-feature. MEASURED 2026-08-24: `case 'retro':` is in the dispatcher, and
8
+ * both `buildRetro` and `decideProvenance` are in the core's 1020-name public surface — so BOTH were
9
+ * answerable before any code, and nobody asked.
10
+ *
11
+ * The owner's question was "what guarantees you will check?". An agent's intention is layer 4 on this
12
+ * project's cost-of-detection ladder: it works while remembered and is silent when it lapses. A
13
+ * command is the guarantee; a promise is not.
14
+ *
15
+ * PURE: no filesystem here. The scan runs in the CLI and arrives as facts — see ADR-001 for why
16
+ * those facts come from SOURCE and never from `dist`.
17
+ */
18
+
19
+ /** What kind of name was asked about. */
20
+ export type NameKind = 'command' | 'module' | 'export';
21
+
22
+ export interface NameQuery {
23
+ readonly kind: NameKind;
24
+ readonly name: string;
25
+ }
26
+
27
+ export interface NameFacts {
28
+ /** Command names found in the dispatcher and in the help block. */
29
+ readonly commands: ReadonlySet<string>;
30
+ /** Module basenames found as `src/<basename>.ts` in any workspace package. */
31
+ readonly modules: ReadonlyMap<string, string>;
32
+ /** Exported identifiers found by scanning SOURCE, mapped to the file that declares them. */
33
+ readonly exports: ReadonlyMap<string, string>;
34
+ /**
35
+ * True when the scan itself could not be performed (no workspace found, unreadable tree).
36
+ * A scan that did not run must never report "free" — that is the whole failure this command
37
+ * exists to prevent, one level up.
38
+ */
39
+ readonly scanFailed?: boolean;
40
+ /**
41
+ * What the sweep actually saw. Reported to the operator, because "one empty .ts file in a
42
+ * lookalike directory" and "the real workspace" both used to satisfy a bare did-it-open-a-file
43
+ * test, and the second question a reviewer asked was exactly that (2026-08-24).
44
+ */
45
+ readonly scanned?: { readonly packages: number; readonly files: number; readonly exports: number; readonly commands: number };
46
+ }
47
+
48
+ export type NameVerdict = 'free' | 'taken';
49
+
50
+ export interface NameResolution {
51
+ readonly kind: NameKind;
52
+ readonly name: string;
53
+ readonly verdict: NameVerdict;
54
+ /** Where the collision lives, when there is one. Empty for a free name. */
55
+ readonly where: string;
56
+ }
57
+
58
+ export type NameOutcome = 'free' | 'taken' | 'not-established';
59
+
60
+ export interface NameDecision {
61
+ readonly outcome: NameOutcome;
62
+ /** 0 every name free · 1 at least one taken · 2 nothing asked or the scan did not run. */
63
+ readonly exit: 0 | 1 | 2;
64
+ readonly results: readonly NameResolution[];
65
+ readonly reason: string;
66
+ }
67
+
68
+ /** Is a proposed name already spoken for? */
69
+ export function classifyName(query: NameQuery, facts: NameFacts): NameResolution {
70
+ const name = query.name.trim();
71
+ const at = (verdict: NameVerdict, where: string): NameResolution => ({ kind: query.kind, name, verdict, where });
72
+ if (query.kind === 'command') {
73
+ return facts.commands.has(name) ? at('taken', 'already dispatched as a dz command') : at('free', '');
74
+ }
75
+ if (query.kind === 'module') {
76
+ const file = facts.modules.get(name);
77
+ return file === undefined ? at('free', '') : at('taken', file);
78
+ }
79
+ const file = facts.exports.get(name);
80
+ return file === undefined ? at('free', '') : at('taken', file);
81
+ }
82
+
83
+ /**
84
+ * The whole verdict.
85
+ *
86
+ * Two ways to be not-established, and neither returns zero: nothing was asked, or the scan did not
87
+ * run. "I checked nothing" and "nothing is taken" are different answers, and a gate that conflates
88
+ * them is green exactly when it is blind — the defect measured on `dz sync` (0/0, exit 0) and on the
89
+ * source scanner that printed `github: 0` for a 401.
90
+ */
91
+ export function decideNameCheck(queries: readonly NameQuery[], facts: NameFacts): NameDecision {
92
+ if (facts.scanFailed === true) {
93
+ return { outcome: 'not-established', exit: 2, results: [], reason: 'the workspace could not be scanned, so no name was checked — this is not a clean bill' };
94
+ }
95
+ const asked = queries.filter((q) => q.name.trim() !== '');
96
+ if (asked.length === 0) {
97
+ return { outcome: 'not-established', exit: 2, results: [], reason: 'no name was asked about — pass --command, --module or --export' };
98
+ }
99
+ // ESTABLISHMENT IS PER KIND. A question about an export cannot be answered by a sweep that found
100
+ // no exports at all; a question about a command cannot be answered without having seen a CLI. A
101
+ // sweep of a lookalike tree satisfies neither, and the honest verdict there is "not established",
102
+ // not "free".
103
+ const unanswerable = asked.filter((q) => {
104
+ if (q.kind === 'command') return facts.commands.size === 0;
105
+ if (q.kind === 'module') return facts.modules.size === 0;
106
+ return facts.exports.size === 0;
107
+ });
108
+ if (unanswerable.length > 0) {
109
+ const kinds = [...new Set(unanswerable.map((q) => q.kind))].join(', ');
110
+ return {
111
+ outcome: 'not-established',
112
+ exit: 2,
113
+ results: [],
114
+ reason: `the sweep found nothing of kind: ${kinds} — a tree with no ${kinds} cannot answer a question about one, and reporting "free" from it would be a clean bill from an empty room`,
115
+ };
116
+ }
117
+ const results = asked.map((q) => classifyName(q, facts));
118
+ const taken = results.filter((r) => r.verdict === 'taken');
119
+ if (taken.length > 0) {
120
+ return {
121
+ outcome: 'taken',
122
+ exit: 1,
123
+ results,
124
+ reason: `${taken.length} of ${results.length} name(s) already spoken for — rename before writing, not after the build goes red`,
125
+ };
126
+ }
127
+ return { outcome: 'free', exit: 0, results, reason: `all ${results.length} name(s) are free` };
128
+ }
129
+
130
+ export function renderNameCheck(decision: NameDecision, scanned?: NameFacts['scanned']): string[] {
131
+ const out: string[] = [];
132
+ if (scanned !== undefined) {
133
+ // Printed always, pass or fail: the operator must be able to see that the sweep looked at a real
134
+ // workspace and not at a directory that merely has the right shape.
135
+ out.push(` swept ${scanned.packages} package(s), ${scanned.files} source file(s) — ${scanned.exports} export(s), ${scanned.commands} command(s)`);
136
+ }
137
+ for (const r of decision.results) {
138
+ out.push(r.verdict === 'taken'
139
+ ? ` [taken] ${r.kind} ${r.name} — ${r.where}`
140
+ : ` [free] ${r.kind} ${r.name}`);
141
+ }
142
+ const label = decision.outcome === 'free' ? 'FREE' : decision.outcome === 'taken' ? 'TAKEN' : 'NOT ESTABLISHED';
143
+ out.push(`dz name-check: ${label} — ${decision.reason}`);
144
+ if (decision.outcome === 'free') {
145
+ // Said on the passing path, because that is where the limit gets forgotten: the scan reads
146
+ // declarations, so a re-export under a different name (`export { a as b }`) is invisible to it.
147
+ out.push(' note: this reads declarations in source. A re-export under a different name is not visible here — the build still owns that case.');
148
+ }
149
+ return out;
150
+ }
151
+
152
+ /**
153
+ * Exported identifiers declared in one TypeScript source file.
154
+ *
155
+ * Deliberately a scanner over DECLARATIONS, not a loader of the built package (ADR-001): a stale
156
+ * `dist` answers "free" about a name the source already took, and answers it confidently. MEASURED
157
+ * 2026-08-22 in this repo — half an hour of live runs against a previous build while `tsc` was red.
158
+ */
159
+ /**
160
+ * Source with comments blanked out, quotes respected.
161
+ *
162
+ * Trivia may sit between ANY two tokens: an `export` followed by a block comment and then `class`
163
+ * was reported FREE, because the declaration pattern expects the keyword to be adjacent
164
+ * (cross-family review round 4, codex gpt-5.6-sol, 2026-08-24). Rather than widen the pattern for
165
+ * one shape of trivia, the trivia is removed first — which fixes the whole class at once.
166
+ *
167
+ * Newlines are PRESERVED so line-anchored patterns keep their anchors.
168
+ */
169
+ export function stripComments(source: string): string {
170
+ let out = '';
171
+ let i = 0;
172
+ let quote: string | null = null;
173
+ // The previous significant character decides whether a `/` opens a REGEX or divides. Without that
174
+ // distinction the regex literal `/[/*]/` reads as a block-comment opener and everything after it
175
+ // is blanked — so a later `export const Taken = 2;` vanished and was reported FREE (cross-family
176
+ // review round 5, codex gpt-5.6-sol, 2026-08-24).
177
+ let prev = '';
178
+ const REGEX_MAY_FOLLOW = new Set(['', '(', ',', '=', ':', '[', '!', '&', '|', '?', '{', '}', ';', '+', '-', '*', '%', '~', '^', '<', '>', '\n']);
179
+ const KEYWORD_BEFORE_REGEX = /\b(return|typeof|case|in|of|new|delete|void|instanceof|do|else|yield|await)\s*$/;
180
+ while (i < source.length) {
181
+ const c = source[i] as string;
182
+ const next = source[i + 1];
183
+ if (quote !== null) {
184
+ out += c;
185
+ if (c === '\\') { out += next ?? ''; i += 2; continue; }
186
+ if (c === quote) quote = null;
187
+ i++;
188
+ continue;
189
+ }
190
+ if (c === '"' || c === "'" || c === '`') { quote = c; out += c; i++; prev = c; continue; }
191
+ // Comment forms are checked FIRST because they are unambiguous: a regex literal can begin
192
+ // with neither `/` nor `*`. Putting the regex check first made a block comment at the start
193
+ // of a line look like a literal and survive the strip — a regression caught by its own test.
194
+ if (c === '/' && next === '/') {
195
+ while (i < source.length && source[i] !== '\n') { out += ' '; i++; }
196
+ prev = '\n';
197
+ continue;
198
+ }
199
+ if (c === '/' && next === '*') {
200
+ const close = source.indexOf('*/', i + 2);
201
+ if (close === -1) {
202
+ // An UNTERMINATED block comment is not a comment — it is a misread. Blanking to EOF turned
203
+ // every heuristic slip into a whole-file loss: `if (true) /[/*]/.test('*')` was read as an
204
+ // opener after `)`, and every export below it vanished and was reported FREE (cross-family
205
+ // review round 6, codex gpt-5.6-sol, 2026-08-24). Real source with an unclosed comment does
206
+ // not compile, so treating the text as text is strictly the safer reading: the worst case
207
+ // becomes a false TAKEN, which is conservative, instead of a false FREE, which is a lie.
208
+ out += c;
209
+ prev = c;
210
+ i++;
211
+ continue;
212
+ }
213
+ const stop = close + 2;
214
+ for (let k = i; k < stop; k++) out += source[k] === '\n' ? '\n' : ' ';
215
+ i = stop;
216
+ prev = ' ';
217
+ continue;
218
+ }
219
+ if (c === '/' && (REGEX_MAY_FOLLOW.has(prev) || KEYWORD_BEFORE_REGEX.test(out))) {
220
+ // A regex literal: copy it verbatim to its unescaped closing slash. A `/` inside a character
221
+ // class does not close it.
222
+ let j = i + 1;
223
+ let inClass = false;
224
+ let closed = false;
225
+ while (j < source.length) {
226
+ const d = source[j] as string;
227
+ if (d === '\\') { j += 2; continue; }
228
+ if (d === '\n') break; // an unterminated literal is not one
229
+ if (d === '[') inClass = true;
230
+ else if (d === ']') inClass = false;
231
+ else if (d === '/' && !inClass) { closed = true; j++; break; }
232
+ j++;
233
+ }
234
+ if (closed) { out += source.slice(i, j); prev = '/'; i = j; continue; }
235
+ // Not a regex after all — fall through to the comment checks below.
236
+ }
237
+ out += c;
238
+ if (!/\s/.test(c) || c === '\n') prev = c;
239
+ i++;
240
+ }
241
+ return out;
242
+ }
243
+
244
+ export function exportedNamesIn(rawSource: string): string[] {
245
+ const source = stripComments(rawSource);
246
+ const names = new Set<string>();
247
+ // Non-binding declarations export exactly one name. The KEYWORD SET is the correctness of this
248
+ // line just as much as the modifier set was: `export namespace Taken {}` was reported FREE because
249
+ // `namespace` was missing (cross-family review round 3, codex gpt-5.6-sol, 2026-08-24). `module` is
250
+ // the legacy spelling of the same thing and is admitted with it.
251
+ const decl = /^\s*export\s+(?:(?:declare|abstract|async)\s+)*(?:function|class|interface|type|enum|namespace|module)\s+([A-Za-z_$][\w$]*)/gm;
252
+ for (let m = decl.exec(source); m !== null; m = decl.exec(source)) if (m[1] !== undefined) names.add(m[1]);
253
+
254
+ // `const`/`let`/`var` can declare MANY names in one statement, and only the first was captured:
255
+ // `export const Seen = 1, Taken = 2;` reported `Taken` FREE (cross-family review round 2, codex
256
+ // gpt-5.6-sol, 2026-08-24). Destructuring exports names too. So the declarator list is parsed.
257
+ const binding = /^\s*export\s+(?:declare\s+)*(?:const|let|var)\s+/gm;
258
+ for (let m = binding.exec(source); m !== null; m = binding.exec(source)) {
259
+ for (const n of declaredBindingNames(source.slice(m.index + m[0].length))) names.add(n);
260
+ }
261
+
262
+ // `export { a, b as c }` — the EXPORTED name is what a consumer collides with, so for an alias it
263
+ // is the right-hand side. A bare list contributes its own names.
264
+ const list = /^\s*export\s*\{([^}]*)\}/gm;
265
+ for (let m = list.exec(source); m !== null; m = list.exec(source)) {
266
+ for (const raw of (m[1] ?? '').split(',')) {
267
+ const part = raw.trim();
268
+ if (part === '' || part.startsWith('*')) continue;
269
+ const alias = /\bas\s+([A-Za-z_$][\w$]*)\s*$/.exec(part);
270
+ const bare = /^(?:type\s+)?([A-Za-z_$][\w$]*)$/.exec(part);
271
+ const picked = alias?.[1] ?? bare?.[1];
272
+ if (picked !== undefined && picked !== 'default') names.add(picked);
273
+ }
274
+ }
275
+ return [...names];
276
+ }
277
+
278
+ const BINDING_NOISE: ReadonlySet<string> = new Set(['readonly', 'as', 'const', 'await', 'typeof']);
279
+
280
+ /**
281
+ * Every name bound by one `const`/`let`/`var` statement, given the text just after the keyword.
282
+ *
283
+ * Walks to the statement end at depth zero, splits the declarator list on top-level commas, and for
284
+ * each declarator takes the identifiers before its first top-level `=` or `:` — so an initialiser
285
+ * and a type annotation contribute nothing, while `a, b`, `{ a, b }` and `[a, b]` all do.
286
+ */
287
+ function declaredBindingNames(after: string): string[] {
288
+ let depth = 0;
289
+ let end = after.length;
290
+ for (let i = 0; i < after.length; i++) {
291
+ const c = after[i] as string;
292
+ if (c === '(' || c === '[' || c === '{') depth++;
293
+ else if (c === ')' || c === ']' || c === '}') { if (depth === 0) { end = i; break; } depth--; }
294
+ else if (c === ';' && depth === 0) { end = i; break; }
295
+ }
296
+ const stmt = after.slice(0, end);
297
+ const parts: string[] = [];
298
+ let level = 0;
299
+ let start = 0;
300
+ for (let i = 0; i < stmt.length; i++) {
301
+ const c = stmt[i] as string;
302
+ if (c === '(' || c === '[' || c === '{') level++;
303
+ else if (c === ')' || c === ']' || c === '}') level--;
304
+ else if (c === ',' && level === 0) { parts.push(stmt.slice(start, i)); start = i + 1; }
305
+ }
306
+ parts.push(stmt.slice(start));
307
+
308
+ const out: string[] = [];
309
+ for (const part of parts) {
310
+ let head = part;
311
+ let lvl = 0;
312
+ for (let i = 0; i < part.length; i++) {
313
+ const c = part[i] as string;
314
+ if (c === '(' || c === '[' || c === '{') lvl++;
315
+ else if (c === ')' || c === ']' || c === '}') lvl--;
316
+ else if ((c === '=' || c === ':') && lvl === 0) { head = part.slice(0, i); break; }
317
+ }
318
+ for (const m of head.matchAll(/[A-Za-z_$][\w$]*/g)) {
319
+ if (!BINDING_NOISE.has(m[0])) out.push(m[0]);
320
+ }
321
+ }
322
+ return out;
323
+ }
324
+
325
+ /** Command names a CLI source dispatches. The help block is scanned separately by the caller. */
326
+ export function dispatchedCommandsIn(source: string): string[] {
327
+ const names = new Set<string>();
328
+ const re = /^\s*case\s+'([a-z][a-z0-9-]*)':/gm;
329
+ for (let m = re.exec(source); m !== null; m = re.exec(source)) if (m[1] !== undefined) names.add(m[1]);
330
+ return [...names];
331
+ }
package/src/operations.ts CHANGED
@@ -928,7 +928,14 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
928
928
  // 7. Skills directory health
929
929
  const skillsDir = join(root, '.claude', 'skills');
930
930
  if (existsSync(skillsDir)) {
931
- const skillDirs = readdirSync(skillsDir, { withFileTypes: true }).filter((e) => e.isDirectory());
931
+ // A DOT-prefixed directory is not a skill. `.claude/skills/.validation` holds the shared schemas
932
+ // and eval templates the whole tree references (30+ eval files name its path), and it has no
933
+ // SKILL.md because it is not invokable. Counting it made this check permanently red at 270/271 —
934
+ // and TWO earlier investigations reached that same conclusion and left the checker alone
935
+ // (features/autonomous-2026-07-27/health-sweep.md, features/audit-2026-06-12). A red that three
936
+ // people diagnose and nobody fixes is a red that has stopped being read.
937
+ const skillDirs = readdirSync(skillsDir, { withFileTypes: true })
938
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'));
932
939
  const withSkillMd = skillDirs.filter((e) => existsSync(join(skillsDir, e.name, 'SKILL.md')));
933
940
  checks.push({
934
941
  name: 'skills health',
@@ -1070,18 +1077,39 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1070
1077
  // Silent when a log is absent or has never been chained: an unchained file is legal (FR-5), not a
1071
1078
  // fault, and reporting it would train the reader to ignore this line.
1072
1079
  try {
1073
- const { verifyEventChainText, EVENT_CHAIN_SCOPE } = await import('./event-chain.js');
1080
+ const { verifyEventChainText, classifyChainDefects, EVENT_CHAIN_SCOPE } = await import('./event-chain.js');
1074
1081
  for (const rel of ['recall-usage.jsonl', 'guard-audit.jsonl']) {
1075
1082
  const p = join(root, '.dz', rel);
1076
1083
  if (!existsSync(p)) continue;
1077
- const v = verifyEventChainText(readFileSync(p, 'utf-8'));
1084
+ const text = readFileSync(p, 'utf-8');
1085
+ const v = verifyEventChainText(text);
1078
1086
  if (v.chained === 0 || v.ok) continue;
1087
+ const total = text.split('\n').filter((l) => l.trim() !== '').length;
1088
+ const age = classifyChainDefects(v, total);
1089
+ const named = `${v.defects.length} defect(s): ${v.defects.slice(0, 3).map((d) => `${d.kind}@L${d.line}`).join(', ')}`;
1090
+ // A break that an unbroken run has already outlived is not a reason to distrust today's
1091
+ // records. Reporting both alike made this line PERMANENTLY red for four weeks — MEASURED
1092
+ // 2026-08-24: every defect in both logs is historical, with 998 of 1138 rows in one and 88 of
1093
+ // 426 in the other forming an unbroken run after the last of them. The verdict was true of the
1094
+ // file and false of the present, and a red nobody can act on is a red nobody reads.
1095
+ if (age.inRun.length === 0 && age.runRecords > 0) {
1096
+ checks.push({
1097
+ name: `evidence chain (.dz/${rel})`,
1098
+ ok: true,
1099
+ // The COUNT carries the meaning, and is printed first for that reason: "1 record forms an
1100
+ // unbroken run" is true and says almost nothing, while 998 says a great deal. Naming the
1101
+ // position without the count would overclaim on the reader's behalf (cross-family review,
1102
+ // codex gpt-5.6-sol, 2026-08-24).
1103
+ detail:
1104
+ `${named} — all BEFORE the current run: the last ${age.runRecords} record(s), from L${age.runFrom}, are unbroken, ` +
1105
+ `so verdicts over those ${age.runRecords} are sound. The break itself cannot be un-happened. Scope: ${EVENT_CHAIN_SCOPE}`,
1106
+ });
1107
+ continue;
1108
+ }
1079
1109
  checks.push({
1080
1110
  name: `evidence chain (.dz/${rel})`,
1081
1111
  ok: false,
1082
- detail:
1083
- `${v.defects.length} defect(s): ${v.defects.slice(0, 3).map((d) => `${d.kind}@L${d.line}`).join(', ')}` +
1084
- ` — learning verdicts computed from this log are unsafe. Scope: ${EVENT_CHAIN_SCOPE}`,
1112
+ detail: `${named} — with NO sound records after them: learning verdicts computed from this log are unsafe. Scope: ${EVENT_CHAIN_SCOPE}`,
1085
1113
  });
1086
1114
  }
1087
1115
  } catch {