@dzhechkov/harness-core 0.3.149 → 0.4.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.
Files changed (107) hide show
  1. package/.dz-manifest.json +399 -55
  2. package/README.md +80 -3
  3. package/dist/agentdb-index.d.ts.map +1 -1
  4. package/dist/agentdb-index.js +10 -2
  5. package/dist/agentdb-index.js.map +1 -1
  6. package/dist/backlog-embed.d.ts +94 -0
  7. package/dist/backlog-embed.d.ts.map +1 -0
  8. package/dist/backlog-embed.js +138 -0
  9. package/dist/backlog-embed.js.map +1 -0
  10. package/dist/backlog.d.ts +180 -7
  11. package/dist/backlog.d.ts.map +1 -1
  12. package/dist/backlog.js +429 -26
  13. package/dist/backlog.js.map +1 -1
  14. package/dist/challenge-panel.d.ts +3 -0
  15. package/dist/challenge-panel.d.ts.map +1 -1
  16. package/dist/challenge-panel.js +3 -0
  17. package/dist/challenge-panel.js.map +1 -1
  18. package/dist/export-holdout.d.ts +149 -0
  19. package/dist/export-holdout.d.ts.map +1 -0
  20. package/dist/export-holdout.js +198 -0
  21. package/dist/export-holdout.js.map +1 -0
  22. package/dist/feature-adr-checkpoints.d.ts +82 -0
  23. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  24. package/dist/feature-adr-checkpoints.js +138 -1
  25. package/dist/feature-adr-checkpoints.js.map +1 -1
  26. package/dist/feature-adr-routing.d.ts +3 -0
  27. package/dist/feature-adr-routing.d.ts.map +1 -1
  28. package/dist/feature-adr-routing.js +3 -0
  29. package/dist/feature-adr-routing.js.map +1 -1
  30. package/dist/guard.d.ts +42 -0
  31. package/dist/guard.d.ts.map +1 -1
  32. package/dist/guard.js +73 -1
  33. package/dist/guard.js.map +1 -1
  34. package/dist/index.d.ts +17 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +28 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/loop-blobs.generated.d.ts +33 -0
  39. package/dist/loop-blobs.generated.d.ts.map +1 -0
  40. package/dist/loop-blobs.generated.js +101 -0
  41. package/dist/loop-blobs.generated.js.map +1 -0
  42. package/dist/loop-lint.d.ts +63 -0
  43. package/dist/loop-lint.d.ts.map +1 -0
  44. package/dist/loop-lint.js +606 -0
  45. package/dist/loop-lint.js.map +1 -0
  46. package/dist/loop-plan.d.ts +416 -0
  47. package/dist/loop-plan.d.ts.map +1 -0
  48. package/dist/loop-plan.js +1151 -0
  49. package/dist/loop-plan.js.map +1 -0
  50. package/dist/loop-render.d.ts +104 -0
  51. package/dist/loop-render.d.ts.map +1 -0
  52. package/dist/loop-render.js +989 -0
  53. package/dist/loop-render.js.map +1 -0
  54. package/dist/loop-trace.d.ts +204 -0
  55. package/dist/loop-trace.d.ts.map +1 -0
  56. package/dist/loop-trace.js +550 -0
  57. package/dist/loop-trace.js.map +1 -0
  58. package/dist/mutation-gate.d.ts +247 -0
  59. package/dist/mutation-gate.d.ts.map +1 -0
  60. package/dist/mutation-gate.js +535 -0
  61. package/dist/mutation-gate.js.map +1 -0
  62. package/dist/no-stubs.d.ts +53 -0
  63. package/dist/no-stubs.d.ts.map +1 -0
  64. package/dist/no-stubs.js +190 -0
  65. package/dist/no-stubs.js.map +1 -0
  66. package/dist/package-skill-layouts.d.ts +67 -0
  67. package/dist/package-skill-layouts.d.ts.map +1 -0
  68. package/dist/package-skill-layouts.js +81 -0
  69. package/dist/package-skill-layouts.js.map +1 -0
  70. package/dist/patterns.d.ts.map +1 -1
  71. package/dist/patterns.js +156 -75
  72. package/dist/patterns.js.map +1 -1
  73. package/dist/recall-domain-boost.d.ts +96 -0
  74. package/dist/recall-domain-boost.d.ts.map +1 -0
  75. package/dist/recall-domain-boost.js +158 -0
  76. package/dist/recall-domain-boost.js.map +1 -0
  77. package/dist/store-lock.d.ts +108 -0
  78. package/dist/store-lock.d.ts.map +1 -0
  79. package/dist/store-lock.js +231 -0
  80. package/dist/store-lock.js.map +1 -0
  81. package/dist/workflows.d.ts +16 -22
  82. package/dist/workflows.d.ts.map +1 -1
  83. package/dist/workflows.js +17 -98
  84. package/dist/workflows.js.map +1 -1
  85. package/package.json +7 -5
  86. package/sbom.json +1062 -202
  87. package/src/agentdb-index.ts +10 -1
  88. package/src/backlog-embed.ts +156 -0
  89. package/src/backlog.ts +536 -28
  90. package/src/challenge-panel.ts +4 -0
  91. package/src/export-holdout.ts +235 -0
  92. package/src/feature-adr-checkpoints.ts +192 -1
  93. package/src/feature-adr-routing.ts +4 -0
  94. package/src/guard.ts +106 -1
  95. package/src/index.ts +72 -1
  96. package/src/loop-blobs.generated.ts +114 -0
  97. package/src/loop-lint.ts +643 -0
  98. package/src/loop-plan.ts +1419 -0
  99. package/src/loop-render.ts +1050 -0
  100. package/src/loop-trace.ts +650 -0
  101. package/src/mutation-gate.ts +701 -0
  102. package/src/no-stubs.ts +204 -0
  103. package/src/package-skill-layouts.ts +107 -0
  104. package/src/patterns.ts +135 -60
  105. package/src/recall-domain-boost.ts +178 -0
  106. package/src/store-lock.ts +258 -0
  107. package/src/workflows.ts +18 -117
@@ -0,0 +1,204 @@
1
+ // `no-stubs` — a deterministic layer-1 scan for unfinished-stub markers left in CHANGED files
2
+ // (backlog 0b403a0106103901, Karpathy-Michaels rule XI). `dz claim-check` catches false CLAIMS in
3
+ // prose; an unfinished stub in code was, until this rule, caught only by a QE agent's judgment
4
+ // (layer 4 on the cost-of-detection ladder). A five-line grep is layer 1 — cheap, deterministic,
5
+ // silent-proof — so that is where this check lives.
6
+ //
7
+ // MEASURED before design (2026-08-10, this repo): a case-INSENSITIVE whole-tree scan over
8
+ // packages/@dzhechkov/*/{src,lib,skills} yields 32 hits and over {test,fixtures} another 25 — the
9
+ // clear majority ancient and legitimate (prose "placeholder", a `todo` keyword list, `xxx` in an
10
+ // example URL). Two design decisions fall straight out of that measurement:
11
+ // 1. SCOPE = the CHANGE-SET, not the tree. The gate catches what YOU left in THIS change; it does
12
+ // not relitigate 25 ancient fixture markers. The change-set is the same working-tree
13
+ // `git status --porcelain` diff the readme-first rule and the template-rule `change` fact
14
+ // already use — one notion of "changed", not a third.
15
+ // 2. CASE-SENSITIVE bare markers. Dropping case halves the hits (32 → 16) and EVERY line the
16
+ // relaxation would add was inspected and found to be prose, not a stub. The uppercase
17
+ // convention is the stub convention; lowercase is English.
18
+ //
19
+ // SELF-EXEMPTION IS STRUCTURAL, NOT A PATH SKIP: every marker below is assembled from fragments at
20
+ // module load, so this file — and any file that builds its patterns the same way — contains no
21
+ // literal marker for the scan to find. A test proves the scan of this very source yields nothing.
22
+ //
23
+ // PURE: no filesystem, no child_process. The caller (CLI / guard facts gatherer) hands in paths,
24
+ // contents, and config waivers; everything here is deterministic string work.
25
+ //
26
+ // ── KNOWN LIMITS (conscious trade-offs — the rule SAYS them rather than implying totality) ──────
27
+ // A guarantee the code asserts but does not hold is worse than an honest limit. Each item below is
28
+ // a DECISION, not an oversight; none is silently patched around.
29
+ //
30
+ // • WHOLE-LINE inline waiver (FN-2). The waiver token exempts the ENTIRE line it appears on
31
+ // (indexOf-based, no position check) — any line can be silenced by appending the token. The
32
+ // defence is AUDITABILITY, not prevention: the token is a fixed greppable string, so every
33
+ // silencing is one grep away. That is a layer-4 defence (reviewer judgment over grep output) by
34
+ // the cost-of-detection ladder, and it is named as such here on purpose.
35
+ // • REASON QUALITY is not judged (FN-6). A junk reason ('.', 'x') satisfies the reason
36
+ // requirement. The design stops FORGETTING a reason, not FAKING one — a deterministic layer
37
+ // cannot judge whether a reason is honest; that is review-plane work.
38
+ // • MARKDOWN FENCE MODEL is a single boolean toggle (FN-4). It cannot model CommonMark: a
39
+ // mismatched pair of tilde/backtick fence styles, an INDENTED fence, an unclosed fence running
40
+ // to EOF (everything after it reads as fenced ⇒ skipped), and a marker sitting on the fence
41
+ // info-string line itself are all mis-scoped. Good enough for the docs this repo writes; not a
42
+ // CommonMark parser and not claimed to be one.
43
+ // • GIT-QUOTED PATHS are not decoded (FN-3). A path `git status --porcelain` quotes (spaces,
44
+ // non-ASCII under core.quotePath=true — the default) arrives here with quote characters baked
45
+ // in, matches no real file, and is therefore NOT scanned. Fail-open by shape, and invisible per
46
+ // file; the aggregate skipped-files note is the only trace when contents were also not gathered.
47
+ // • EXTENSION ALLOWLIST is a TS-monorepo set (FN-8). Extensionless bin scripts, Dockerfile,
48
+ // Makefile, .txt/.html/.css/.vue/.svelte/.c/.cpp/.php/.toml/.sql/.ps1/.kt/.swift are NOT
49
+ // scanned. MED risk if this rule template ships into a polyglot repo — extend
50
+ // STUB_SCAN_EXTENSIONS there; the list is exported and testable for exactly that reason.
51
+ // • STAGED-BUT-NOT-WORKTREE content is not read (FN-5). The gatherer reads the WORKING TREE; a
52
+ // change staged in the index but reverted in the worktree scans as the worktree text. Design-
53
+ // consistent for the publish op, which packs the worktree — the index never ships.
54
+ // • CONFIG WAIVERS match the EXACT repo-relative path string. No normalization, no globs: a
55
+ // waiver for a path spelled differently than git spells it simply does not match, and the
56
+ // finding FIRES. Errs toward firing — the safe side for an exemption mechanism.
57
+
58
+ /**
59
+ * The bare stub markers, ASSEMBLED so this module never fires on itself. Matched case-SENSITIVELY
60
+ * (measured: case-insensitivity doubles hits and adds only prose) with hard word boundaries on both
61
+ * sides, so a marker embedded in a word (a codename, a longer identifier) does not fire.
62
+ */
63
+ export const STUB_MARKERS: readonly string[] = [
64
+ 'TO' + 'DO',
65
+ 'FIX' + 'ME',
66
+ 'HA' + 'CK',
67
+ 'XX' + 'X',
68
+ 'PLACE' + 'HOLDER',
69
+ ];
70
+
71
+ /**
72
+ * Stub PHRASES: matched case-insensitively (a phrase is English, not a convention), with letter
73
+ * boundaries. Assembled for the same self-exemption reason.
74
+ */
75
+ export const STUB_PHRASES: readonly string[] = ['imple' + 'ment later'];
76
+
77
+ // One regex per class, built once. `(?<![A-Za-z0-9_])` / `(?![A-Za-z0-9_])`: a marker glued to a
78
+ // word character on either side is part of an identifier/hash/codename, not a stub. XXXX therefore
79
+ // does NOT match the three-letter marker (its neighbours are word chars), and lowercase variants of
80
+ // the bare markers never match at all.
81
+ const BARE_RE = new RegExp('(?<![A-Za-z0-9_])(' + STUB_MARKERS.join('|') + ')(?![A-Za-z0-9_])');
82
+ const PHRASE_RE = new RegExp('(?<![A-Za-z])(' + STUB_PHRASES.join('|').replace(/ /g, '\\s+') + ')(?![A-Za-z])', 'i');
83
+
84
+ /**
85
+ * The inline waiver token: a line carrying `no-stubs: <reason>` is exempt — WITH a non-empty reason.
86
+ * A reasonless waiver is REFUSED LOUDLY (its own finding): an exemption you cannot explain is a
87
+ * silent allowlist, the recurring defect class this repo keeps re-learning. The reason-required
88
+ * shape follows the `dz feature-adr-setup --guards` waiver precedent (`waivers: [{path, reason}]`,
89
+ * "a waiver without a reason is itself a violation").
90
+ */
91
+ const WAIVER_TOKEN = 'no-stubs:';
92
+
93
+ /**
94
+ * File extensions the scan reads. EXPLICIT and testable — never a silent glob. Fixture/test paths
95
+ * are NOT auto-exempt: a marker you just added to a fixture is still a decision, and the waiver is
96
+ * where that decision gets its reason.
97
+ */
98
+ export const STUB_SCAN_EXTENSIONS: readonly string[] = [
99
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
100
+ '.py', '.go', '.rs', '.java', '.rb', '.sh', '.bash',
101
+ '.md', '.mdx', '.markdown',
102
+ '.yml', '.yaml', '.json',
103
+ ];
104
+
105
+ /** Is this path one the stub scan reads? (extension allowlist, case-insensitive on the extension) */
106
+ export function scannableStubPath(path: unknown): boolean {
107
+ if (typeof path !== 'string' || path.length === 0) return false;
108
+ const lower = path.toLowerCase();
109
+ return STUB_SCAN_EXTENSIONS.some((ext) => lower.endsWith(ext));
110
+ }
111
+
112
+ export interface StubFinding {
113
+ readonly path: string;
114
+ /** 1-based line number. */
115
+ readonly line: number;
116
+ readonly kind: 'marker' | 'reasonless-waiver';
117
+ /** which marker/phrase matched (for 'marker'), or the refusal text (for 'reasonless-waiver'). */
118
+ readonly detail: string;
119
+ }
120
+
121
+ /** A config waiver: path-keyed, reason MANDATORY. A reasonless entry is refused, never honoured. */
122
+ export interface StubWaiver {
123
+ readonly path?: string;
124
+ readonly reason?: string;
125
+ }
126
+
127
+ function isMarkdown(path: string): boolean {
128
+ const lower = path.toLowerCase();
129
+ return lower.endsWith('.md') || lower.endsWith('.mdx') || lower.endsWith('.markdown');
130
+ }
131
+
132
+ /** Mask inline `code spans` with spaces (length-preserving, so line/column geometry survives). */
133
+ function maskInlineCode(line: string): string {
134
+ return line.replace(/`[^`]*`/g, (m) => ' '.repeat(m.length));
135
+ }
136
+
137
+ /**
138
+ * Scan ONE file's text. Case-sensitive bare markers + case-insensitive phrases, word-bounded.
139
+ * Markdown gets PROSE scoping: fenced code blocks and inline backtick spans are QUOTES of a marker,
140
+ * not stubs (the claim-check backtick-literal convention) — a doc explaining this very gate scans
141
+ * clean, while a naked stub line in doc prose still fires. Code files are scanned in full: a marker
142
+ * a code file must legitimately carry (another gate's source, a fixture) takes an inline
143
+ * `no-stubs: <reason>` waiver — visible, reasoned, greppable.
144
+ */
145
+ export function scanStubs(path: string, text: unknown): StubFinding[] {
146
+ if (typeof text !== 'string' || text.length === 0) return [];
147
+ const md = isMarkdown(path);
148
+ const out: StubFinding[] = [];
149
+ let inFence = false;
150
+ const lines = text.split('\n');
151
+ for (let i = 0; i < lines.length; i++) {
152
+ const raw = lines[i]!;
153
+ if (md && /^\s*(```|~~~)/.test(raw)) { inFence = !inFence; continue; }
154
+ if (md && inFence) continue;
155
+ const line = md ? maskInlineCode(raw) : raw;
156
+
157
+ // Inline waiver first: with a reason ⇒ the line is exempt; without one ⇒ refuse loudly AND
158
+ // leave the marker finding in place (a refused waiver must not half-work).
159
+ const w = line.indexOf(WAIVER_TOKEN);
160
+ if (w >= 0) {
161
+ const reason = line.slice(w + WAIVER_TOKEN.length).replace(/(\*\/|-->|#>)\s*$/, '').trim();
162
+ if (reason.length > 0) continue;
163
+ out.push({ path, line: i + 1, kind: 'reasonless-waiver', detail: `inline waiver ("${WAIVER_TOKEN}") carries NO reason — refused; a waiver you cannot explain is a silent allowlist` });
164
+ }
165
+
166
+ const bare = BARE_RE.exec(line);
167
+ if (bare && bare[1] !== undefined) { out.push({ path, line: i + 1, kind: 'marker', detail: bare[1] }); continue; }
168
+ const phrase = PHRASE_RE.exec(line);
169
+ if (phrase && phrase[1] !== undefined) out.push({ path, line: i + 1, kind: 'marker', detail: phrase[1] });
170
+ }
171
+ return out;
172
+ }
173
+
174
+ /**
175
+ * The aggregate check the guard rule calls: scan the CHANGED files whose contents were gathered,
176
+ * then apply config waivers. Missing contents for a changed file ⇒ that file reports nothing
177
+ * (fail-open on missing evidence — the standing guard contract; a deleted file has no contents).
178
+ *
179
+ * Config-waiver semantics: an entry with a non-empty `path` AND a non-empty `reason` exempts that
180
+ * exact repo-relative path. An entry with a path but NO reason is REFUSED as its own finding and
181
+ * exempts nothing. An entry with no path at all is inert garbage and is ignored (there is nothing
182
+ * it could exempt, and inventing a finding for it would punish a stray comma).
183
+ */
184
+ export function checkNoStubs(
185
+ files: readonly string[],
186
+ contents: Readonly<Record<string, string>> | undefined,
187
+ waivers: readonly StubWaiver[] | undefined,
188
+ ): StubFinding[] {
189
+ const out: StubFinding[] = [];
190
+ const waived = new Set<string>();
191
+ for (const w of Array.isArray(waivers) ? waivers : []) {
192
+ if (!w || typeof w !== 'object' || typeof w.path !== 'string' || w.path.trim() === '') continue;
193
+ const reason = typeof w.reason === 'string' ? w.reason.trim() : '';
194
+ if (reason.length > 0) waived.add(w.path);
195
+ else out.push({ path: w.path, line: 0, kind: 'reasonless-waiver', detail: 'config waiver (stubWaivers) carries NO reason — refused; add a reason or remove the entry' });
196
+ }
197
+ for (const f of Array.isArray(files) ? files : []) {
198
+ if (!scannableStubPath(f) || waived.has(f)) continue;
199
+ const text = contents?.[f];
200
+ if (typeof text !== 'string') continue; // no evidence gathered — fail-open, never guessed
201
+ out.push(...scanStubs(f, text));
202
+ }
203
+ return out;
204
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Package skill-layout resolution — where an installed npm package keeps its skills.
3
+ *
4
+ * `dz install <pkg>` (and, later, `dz init`/`dz registry` — see the backlog note below)
5
+ * must find `SKILL.md` skill dirs inside an npm package. Packages in the wild ship three
6
+ * on-disk arrangements; before this module existed, `cmdInstall` hard-coded exactly one
7
+ * (`flat`), so every npx-init pack (`templates/.claude/skills/…`) and bare-`skills/`
8
+ * pack resolved to "no SKILL.md files found" despite carrying valid skills.
9
+ *
10
+ * DESIGN RULE — ordered allowlist, NEVER a recursive find. A recursive "find any
11
+ * SKILL.md" walk would resolve decoys that must stay invisible (both MEASURED in the
12
+ * feature's layout census, `features/dz-install-npx-init/05_architecture.md` §3):
13
+ *
14
+ * - `adapter-claude/test/fixtures/<id>/SKILL.md` — a test fixture, not a shipped skill;
15
+ * - `health-advisor/base/skills/base/<id>/SKILL.md` — a vendored mirror of another
16
+ * pack's skills (skills-analyst-manual); resolving it would double-install them.
17
+ *
18
+ * The allowlist is three fixed relative paths with no walk, so those decoys are
19
+ * structurally unreachable (they are not on the probe list) rather than filtered out.
20
+ * Any future recursive extension MUST re-introduce exclusion logic for both cases.
21
+ *
22
+ * @packageDocumentation
23
+ */
24
+
25
+ import { existsSync, readdirSync } from 'node:fs';
26
+ import { dirname, join } from 'node:path';
27
+
28
+ import { discoverSkillIds } from './skills.js';
29
+
30
+ /** A known on-disk arrangement of skills inside an npm package. */
31
+ export type PackageSkillLayout =
32
+ | 'flat' // <pkg>/<skill>/SKILL.md — classic skills-* pack
33
+ | 'npx-template' // <pkg>/templates/.claude/skills/<skill>/SKILL.md — npx-init pack
34
+ | 'skills-dir'; // <pkg>/skills/<skill>/SKILL.md — bare skills dir
35
+
36
+ /** One resolved skills root inside a package. */
37
+ export interface PackageSkillRoot {
38
+ readonly layout: PackageSkillLayout;
39
+ /** Absolute dir to hand to `runInit({ skillsDir })` — its children are skill dirs. */
40
+ readonly dir: string;
41
+ /** Skill ids discovered under `dir` (sorted, via {@link discoverSkillIds}). */
42
+ readonly ids: readonly string[];
43
+ /**
44
+ * True iff the matched root's parent carries sibling non-skill component dirs the
45
+ * resolver does NOT install (e.g. `templates/.claude/commands` next to
46
+ * `templates/.claude/skills` in an npx-init pack). `dz install` uses this to disclose,
47
+ * honestly, that `npx -y <pkg> init` installs a larger kit. Always `false` for the
48
+ * `flat` layout (the package root's other dirs — `bin/`, `src/` — are not components).
49
+ */
50
+ readonly hasCompanionAssets: boolean;
51
+ }
52
+
53
+ /**
54
+ * Ordered layout probe list. ORDER IS THE CONTRACT — first non-empty wins.
55
+ *
56
+ * `flat` is probed first so a classic pack can never be re-interpreted; `npx-template`
57
+ * before `skills-dir` matches the measured census (13 vs 2 packages). Extending support
58
+ * for a new layout is a data edit here, not new control flow.
59
+ */
60
+ export const PACKAGE_SKILL_LAYOUTS: readonly { readonly layout: PackageSkillLayout; readonly rel: string }[] = [
61
+ { layout: 'flat', rel: '.' },
62
+ { layout: 'npx-template', rel: 'templates/.claude/skills' },
63
+ { layout: 'skills-dir', rel: 'skills' },
64
+ ];
65
+
66
+ /** Does the matched skills root have sibling component dirs (commands/hooks/agents…)? */
67
+ function companionAssetsPresent(skillsRoot: string): boolean {
68
+ const parent = dirname(skillsRoot);
69
+ if (!existsSync(parent)) return false;
70
+ try {
71
+ return readdirSync(parent, { withFileTypes: true }).some(
72
+ (entry) => entry.isDirectory() && entry.name !== 'skills',
73
+ );
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Resolve the skills root of an installed npm package.
81
+ *
82
+ * Probes {@link PACKAGE_SKILL_LAYOUTS} in order; the FIRST layout with at least one
83
+ * `<root>/<id>/SKILL.md` wins, and exactly one root is returned (`[root]`). Returns `[]`
84
+ * when the package carries no skills in ANY known layout — the caller MUST treat `[]`
85
+ * as an error (see `cmdInstall`'s exit code). Single-root is deliberate (feature
86
+ * dz-install-npx-init, plan AM-4): no package today has skills in two allowlisted
87
+ * layouts simultaneously, so multi-root install semantics would be untested dead code.
88
+ * The array return type keeps the door open without committing to a merge policy.
89
+ *
90
+ * Pure, filesystem-read-only, synchronous, never throws on missing/unreadable dirs.
91
+ */
92
+ export function resolvePackageSkillRoots(pkgDir: string): readonly PackageSkillRoot[] {
93
+ for (const { layout, rel } of PACKAGE_SKILL_LAYOUTS) {
94
+ const dir = rel === '.' ? pkgDir : join(pkgDir, rel);
95
+ const ids = discoverSkillIds(dir);
96
+ if (ids.length === 0) continue;
97
+ return [
98
+ {
99
+ layout,
100
+ dir,
101
+ ids,
102
+ hasCompanionAssets: layout === 'npx-template' && companionAssetsPresent(dir),
103
+ },
104
+ ];
105
+ }
106
+ return [];
107
+ }
package/src/patterns.ts CHANGED
@@ -30,6 +30,7 @@ import { JsonFileBackend, SqliteBackend, harvestDreamPatterns, isNoiseInsight, t
30
30
  import { resolveAgentdbPath } from './agentdb-index.js';
31
31
  import type { VectorEntry } from './vector-tier.js';
32
32
  import { rankLessonsByDelta, type LessonHistory } from './safla-delta.js';
33
+ import { withStoreLock, withStoreLockSync, StoreLockTimeoutError, StoreLockCompromisedError } from './store-lock.js';
33
34
 
34
35
  /** A learned pattern as written by `dz teach` to `.dz/patterns.jsonl`. */
35
36
  export interface PatternRecord {
@@ -304,8 +305,35 @@ export function computePatternBoost(
304
305
 
305
306
  /* ------------------------------------------------------------------ */
306
307
  /* Tier-2: the unified @dzhechkov/memory store (ADR-005) */
308
+ /* */
309
+ /* WRITER INVENTORY (store-wide locking — review finding 5). */
310
+ /* Every function that WRITES the JSON store (.dz/memory/patterns.json)
311
+ /* holds the store lock for its whole read-modify-write: */
312
+ /* - recordPattern (JSON branch) withStoreLock */
313
+ /* - reinforcePattern withStoreLock */
314
+ /* - updateReinforcementState withStoreLock */
315
+ /* - promotePatterns withStoreLock */
316
+ /* - consolidateSessions (JSON branch) withStoreLock */
317
+ /* - pruneNoisePatterns (JSON tier, live runs) withStoreLockSync */
318
+ /* - removePatternsByIds (JSON tier) withStoreLockSync */
319
+ /* Deliberately OUT of scope (reasoning): */
320
+ /* - the SQLite tier — better-sqlite3 transactions serialize it; */
321
+ /* - snapshotStore — reads the atomically-renamed store file and */
322
+ /* writes a DIFFERENT file, so it cannot lose store updates; */
323
+ /* - the consolidate watermark + sessions.jsonl / patterns.jsonl */
324
+ /* appends — separate append-only/idempotent files (an older */
325
+ /* watermark merely causes an idempotent re-scan, dedup by */
326
+ /* deterministic ids); */
327
+ /* - dry-run prune paths — read-only by construction. */
307
328
  /* ------------------------------------------------------------------ */
308
329
 
330
+ /** Map a lock failure to the best-effort writers' `{ error }` channel (still loud —
331
+ * the caller SEES the failure and nothing was silently dropped), rethrow anything else. */
332
+ function lockErrorMessage(err: unknown): string {
333
+ if (err instanceof StoreLockTimeoutError || err instanceof StoreLockCompromisedError) return err.message;
334
+ throw err;
335
+ }
336
+
309
337
  /** Path of the JSON store (the deterministic fallback backend). */
310
338
  function storePath(projectRoot: string): string {
311
339
  return join(projectRoot, '.dz', 'memory', 'patterns.json');
@@ -508,6 +536,9 @@ export function loadStoreRecords(projectRoot: string): MemoryRecord[] {
508
536
  }
509
537
  }
510
538
 
539
+ /** Write one record through the backend cascade. CALLER HOLDS THE STORE LOCK — every
540
+ * caller (reinforce / updateReinforcementState / promote) wraps its whole read-modify-write
541
+ * in `withStoreLock`; taking the (non-reentrant) lock here as well would deadlock. */
511
542
  async function putStoreRecord(projectRoot: string, rec: MemoryRecord): Promise<{ ok: true } | { error: string }> {
512
543
  const { sqliteBackend } = readLearningConfig(projectRoot);
513
544
  if (sqliteBackend !== 'json') {
@@ -543,6 +574,16 @@ export interface ReinforcePatternResult {
543
574
  }
544
575
 
545
576
  export async function reinforcePattern(projectRoot: string, dzIdOrText: string, opts: { reward?: number; ts?: string; mergedFrom?: readonly string[]; exposure?: boolean } = {}): Promise<ReinforcePatternResult> {
577
+ // The WHOLE read-modify-write holds the store lock (finding 5): two concurrent
578
+ // reinforces would otherwise both read uses=N and both write back N+1.
579
+ try {
580
+ return await withStoreLock(projectRoot, async () => reinforcePatternLocked(projectRoot, dzIdOrText, opts));
581
+ } catch (err) {
582
+ return { ok: false, error: lockErrorMessage(err) };
583
+ }
584
+ }
585
+
586
+ async function reinforcePatternLocked(projectRoot: string, dzIdOrText: string, opts: { reward?: number; ts?: string; mergedFrom?: readonly string[]; exposure?: boolean }): Promise<ReinforcePatternResult> {
546
587
  const records = loadStoreRecords(projectRoot);
547
588
  const rec = records.find((r) => r.id === dzIdOrText || r.text === dzIdOrText);
548
589
  if (rec === undefined) return { ok: false, error: `no learned pattern matches ${JSON.stringify(dzIdOrText)}` };
@@ -579,15 +620,21 @@ export async function reinforcePattern(projectRoot: string, dzIdOrText: string,
579
620
  }
580
621
 
581
622
  export async function updateReinforcementState(projectRoot: string, dzId: string, state: ReinforcementState): Promise<ReinforcePatternResult> {
582
- const rec = loadStoreRecords(projectRoot).find((r) => r.id === dzId);
583
- if (rec === undefined) return { ok: false, error: `no learned pattern matches ${JSON.stringify(dzId)}` };
584
- const next: MemoryRecord = {
585
- ...rec,
586
- metadata: { ...(rec.metadata ?? {}), ...encodeReinforcementState(state) },
587
- };
588
- const put = await putStoreRecord(projectRoot, next);
589
- if ('error' in put) return { ok: false, dzId, error: put.error };
590
- return { ok: true, dzId, uses: state.uses };
623
+ try {
624
+ return await withStoreLock(projectRoot, async () => {
625
+ const rec = loadStoreRecords(projectRoot).find((r) => r.id === dzId);
626
+ if (rec === undefined) return { ok: false, error: `no learned pattern matches ${JSON.stringify(dzId)}` };
627
+ const next: MemoryRecord = {
628
+ ...rec,
629
+ metadata: { ...(rec.metadata ?? {}), ...encodeReinforcementState(state) },
630
+ };
631
+ const put = await putStoreRecord(projectRoot, next);
632
+ if ('error' in put) return { ok: false, dzId, error: put.error };
633
+ return { ok: true, dzId, uses: state.uses };
634
+ });
635
+ } catch (err) {
636
+ return { ok: false, dzId, error: lockErrorMessage(err) };
637
+ }
591
638
  }
592
639
 
593
640
  /** Result of a promotion (dz recall --promote). */
@@ -606,22 +653,28 @@ export interface PromoteResult {
606
653
  * promoting by exposure would be a self-fulfilling prophecy (ADR).
607
654
  */
608
655
  export async function promotePatterns(projectRoot: string, dzIds: readonly string[]): Promise<PromoteResult> {
609
- const records = loadStoreRecords(projectRoot);
610
- const promoted: string[] = [];
611
- const notFound: string[] = [];
612
- const notQuarantined: string[] = [];
613
- for (const id of dzIds) {
614
- const rec = records.find((r) => r.id === id);
615
- if (rec === undefined) { notFound.push(id); continue; }
616
- if (!readQuarantineState(rec).quarantined) { notQuarantined.push(id); continue; }
617
- const meta = { ...(rec.metadata ?? {}) };
618
- delete meta['qStatus'];
619
- delete meta['quarantinedAt'];
620
- const put = await putStoreRecord(projectRoot, { ...rec, metadata: meta });
621
- if ('error' in put) return { ok: false, promoted, notFound, notQuarantined, error: put.error };
622
- promoted.push(id);
656
+ try {
657
+ return await withStoreLock(projectRoot, async () => {
658
+ const records = loadStoreRecords(projectRoot);
659
+ const promoted: string[] = [];
660
+ const notFound: string[] = [];
661
+ const notQuarantined: string[] = [];
662
+ for (const id of dzIds) {
663
+ const rec = records.find((r) => r.id === id);
664
+ if (rec === undefined) { notFound.push(id); continue; }
665
+ if (!readQuarantineState(rec).quarantined) { notQuarantined.push(id); continue; }
666
+ const meta = { ...(rec.metadata ?? {}) };
667
+ delete meta['qStatus'];
668
+ delete meta['quarantinedAt'];
669
+ const put = await putStoreRecord(projectRoot, { ...rec, metadata: meta });
670
+ if ('error' in put) return { ok: false, promoted, notFound, notQuarantined, error: put.error };
671
+ promoted.push(id);
672
+ }
673
+ return { ok: true, promoted, notFound, notQuarantined };
674
+ });
675
+ } catch (err) {
676
+ return { ok: false, promoted: [], notFound: [], notQuarantined: [], error: lockErrorMessage(err) };
623
677
  }
624
- return { ok: true, promoted, notFound, notQuarantined };
625
678
  }
626
679
 
627
680
  /** One quarantined-and-stale record, surfaced for review (informational — FR-7). */
@@ -867,14 +920,20 @@ export async function recordPattern(projectRoot: string, p: PatternRecord, opts:
867
920
  sqlite.close();
868
921
  }
869
922
  }
870
- // JSON fallback
871
- const backend = await JsonFileBackend.open(storePath(projectRoot));
872
- for (const legacy of readJsonl(join(projectRoot, '.dz', 'patterns.jsonl'), isPatternRecord)) {
873
- await backend.put(patternToRecord(legacy));
874
- }
875
- await backend.put(rec);
876
- await backend.save();
877
- return backend.count();
923
+ // JSON fallback. The load-mutate-save below MUST hold the store lock: the backend's
924
+ // write is atomic (no torn file) but a teach is a read-modify-write, and two that
925
+ // overlap each write back their own copy of the record set the later rename wins and
926
+ // the earlier lesson is silently gone while both processes report success. SQLite does
927
+ // its own locking and never reaches here.
928
+ return withStoreLock(projectRoot, async () => {
929
+ const backend = await JsonFileBackend.open(storePath(projectRoot));
930
+ for (const legacy of readJsonl(join(projectRoot, '.dz', 'patterns.jsonl'), isPatternRecord)) {
931
+ await backend.put(patternToRecord(legacy));
932
+ }
933
+ await backend.put(rec);
934
+ await backend.save();
935
+ return backend.count();
936
+ });
878
937
  }
879
938
 
880
939
  /** Outcome of a {@link recallPatterns} ranked search (or a hybrid merge — see `vector-tier.ts`). */
@@ -1241,22 +1300,28 @@ export function pruneNoisePatterns(projectRoot: string, opts: { dryRun?: boolean
1241
1300
  }
1242
1301
  }
1243
1302
 
1244
- // Lexical store, JSON tier (may hold pre-migration copies of the same noise)
1245
- try {
1246
- if (existsSync(storePath(projectRoot))) {
1247
- const backend = JsonFileBackend.openSync(storePath(projectRoot));
1248
- let removed = 0;
1249
- for (const r of backend.allSync()) {
1250
- if (readQuarantineState(r).quarantined) continue; // AM-3: never coupled to noise-prune
1251
- if (isNoiseInsight(r.text)) {
1252
- if (!candidates.some((c) => c.id === r.id)) candidates.push({ id: r.id, text: r.text });
1253
- if (!dryRun) backend.removeSync(r.id);
1254
- removed += 1;
1255
- }
1303
+ // Lexical store, JSON tier (may hold pre-migration copies of the same noise).
1304
+ // A LIVE run is a read-modify-write of the whole store file, so it holds the store
1305
+ // lock (finding 5); a dry run reads the atomically-renamed file and is safe unlocked
1306
+ // (and must not error against a merely-busy store).
1307
+ const sweepJsonTier = (): void => {
1308
+ if (!existsSync(storePath(projectRoot))) return;
1309
+ const backend = JsonFileBackend.openSync(storePath(projectRoot));
1310
+ let removed = 0;
1311
+ for (const r of backend.allSync()) {
1312
+ if (readQuarantineState(r).quarantined) continue; // AM-3: never coupled to noise-prune
1313
+ if (isNoiseInsight(r.text)) {
1314
+ if (!candidates.some((c) => c.id === r.id)) candidates.push({ id: r.id, text: r.text });
1315
+ if (!dryRun) backend.removeSync(r.id);
1316
+ removed += 1;
1256
1317
  }
1257
- if (removed > 0 && !dryRun) void backend.save(); // physically synchronous (writeFileSync under the hood)
1258
- lexicalRemoved += removed;
1259
1318
  }
1319
+ if (removed > 0 && !dryRun) void backend.save(); // physically synchronous (writeFileSync under the hood)
1320
+ lexicalRemoved += removed;
1321
+ };
1322
+ try {
1323
+ if (dryRun) sweepJsonTier();
1324
+ else withStoreLockSync(projectRoot, sweepJsonTier);
1260
1325
  } catch (err) {
1261
1326
  errors.push(`json store: ${err instanceof Error ? err.message : String(err)}`);
1262
1327
  }
@@ -1315,9 +1380,13 @@ export function removePatternsByIds(projectRoot: string, ids: ReadonlySet<string
1315
1380
  }
1316
1381
  }
1317
1382
 
1318
- // Lexical store, JSON tier (may hold pre-migration copies of the same record)
1383
+ // Lexical store, JSON tier (may hold pre-migration copies of the same record).
1384
+ // Read-modify-write of the whole store file ⇒ holds the store lock (finding 5): an
1385
+ // unlocked remove overlapping a teach would write back a snapshot without the new
1386
+ // lesson. On lock failure NOTHING was written and the error says so.
1319
1387
  try {
1320
- if (existsSync(storePath(projectRoot))) {
1388
+ withStoreLockSync(projectRoot, () => {
1389
+ if (!existsSync(storePath(projectRoot))) return;
1321
1390
  const backend = JsonFileBackend.openSync(storePath(projectRoot));
1322
1391
  let localRemoved = 0;
1323
1392
  for (const r of backend.allSync()) {
@@ -1328,7 +1397,7 @@ export function removePatternsByIds(projectRoot: string, ids: ReadonlySet<string
1328
1397
  }
1329
1398
  if (localRemoved > 0) void backend.save(); // physically synchronous (writeFileSync under the hood)
1330
1399
  removed += localRemoved;
1331
- }
1400
+ });
1332
1401
  } catch (err) {
1333
1402
  errors.push(`json store: ${err instanceof Error ? err.message : String(err)}`);
1334
1403
  }
@@ -1409,17 +1478,23 @@ export async function consolidateSessions(
1409
1478
  sqlite.close();
1410
1479
  }
1411
1480
  } else {
1412
- const backend = await JsonFileBackend.open(storePath(projectRoot));
1413
- const before = await backend.count();
1414
- let prev = before;
1415
- for (const d of dreams) {
1416
- await backend.put(dreamToStoreRecord(d));
1417
- const now = await backend.count();
1418
- if (now > prev) newDreams.push(d);
1419
- prev = now;
1420
- }
1421
- await backend.save();
1422
- added = prev - before;
1481
+ // JSON fallback: the open→put→save below is a read-modify-write of the whole store
1482
+ // file, so it must hold the store lock (finding 5) — a consolidate overlapping a
1483
+ // teach would otherwise overwrite the teach's record and, because the watermark
1484
+ // advances afterwards, make the loss permanent.
1485
+ added = await withStoreLock(projectRoot, async () => {
1486
+ const backend = await JsonFileBackend.open(storePath(projectRoot));
1487
+ const before = await backend.count();
1488
+ let prev = before;
1489
+ for (const d of dreams) {
1490
+ await backend.put(dreamToStoreRecord(d));
1491
+ const now = await backend.count();
1492
+ if (now > prev) newDreams.push(d);
1493
+ prev = now;
1494
+ }
1495
+ await backend.save();
1496
+ return prev - before;
1497
+ });
1423
1498
  }
1424
1499
 
1425
1500
  if (maxTs !== undefined && maxTs !== since) {