@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.
- package/.dz-manifest.json +399 -55
- package/README.md +80 -3
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +10 -2
- package/dist/agentdb-index.js.map +1 -1
- package/dist/backlog-embed.d.ts +94 -0
- package/dist/backlog-embed.d.ts.map +1 -0
- package/dist/backlog-embed.js +138 -0
- package/dist/backlog-embed.js.map +1 -0
- package/dist/backlog.d.ts +180 -7
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +429 -26
- package/dist/backlog.js.map +1 -1
- package/dist/challenge-panel.d.ts +3 -0
- package/dist/challenge-panel.d.ts.map +1 -1
- package/dist/challenge-panel.js +3 -0
- package/dist/challenge-panel.js.map +1 -1
- package/dist/export-holdout.d.ts +149 -0
- package/dist/export-holdout.d.ts.map +1 -0
- package/dist/export-holdout.js +198 -0
- package/dist/export-holdout.js.map +1 -0
- package/dist/feature-adr-checkpoints.d.ts +82 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +138 -1
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +3 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +3 -0
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +42 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +73 -1
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +17 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -1
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.d.ts +33 -0
- package/dist/loop-blobs.generated.d.ts.map +1 -0
- package/dist/loop-blobs.generated.js +101 -0
- package/dist/loop-blobs.generated.js.map +1 -0
- package/dist/loop-lint.d.ts +63 -0
- package/dist/loop-lint.d.ts.map +1 -0
- package/dist/loop-lint.js +606 -0
- package/dist/loop-lint.js.map +1 -0
- package/dist/loop-plan.d.ts +416 -0
- package/dist/loop-plan.d.ts.map +1 -0
- package/dist/loop-plan.js +1151 -0
- package/dist/loop-plan.js.map +1 -0
- package/dist/loop-render.d.ts +104 -0
- package/dist/loop-render.d.ts.map +1 -0
- package/dist/loop-render.js +989 -0
- package/dist/loop-render.js.map +1 -0
- package/dist/loop-trace.d.ts +204 -0
- package/dist/loop-trace.d.ts.map +1 -0
- package/dist/loop-trace.js +550 -0
- package/dist/loop-trace.js.map +1 -0
- package/dist/mutation-gate.d.ts +247 -0
- package/dist/mutation-gate.d.ts.map +1 -0
- package/dist/mutation-gate.js +535 -0
- package/dist/mutation-gate.js.map +1 -0
- package/dist/no-stubs.d.ts +53 -0
- package/dist/no-stubs.d.ts.map +1 -0
- package/dist/no-stubs.js +190 -0
- package/dist/no-stubs.js.map +1 -0
- package/dist/package-skill-layouts.d.ts +67 -0
- package/dist/package-skill-layouts.d.ts.map +1 -0
- package/dist/package-skill-layouts.js +81 -0
- package/dist/package-skill-layouts.js.map +1 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +156 -75
- package/dist/patterns.js.map +1 -1
- package/dist/recall-domain-boost.d.ts +96 -0
- package/dist/recall-domain-boost.d.ts.map +1 -0
- package/dist/recall-domain-boost.js +158 -0
- package/dist/recall-domain-boost.js.map +1 -0
- package/dist/store-lock.d.ts +108 -0
- package/dist/store-lock.d.ts.map +1 -0
- package/dist/store-lock.js +231 -0
- package/dist/store-lock.js.map +1 -0
- package/dist/workflows.d.ts +16 -22
- package/dist/workflows.d.ts.map +1 -1
- package/dist/workflows.js +17 -98
- package/dist/workflows.js.map +1 -1
- package/package.json +7 -5
- package/sbom.json +1062 -202
- package/src/agentdb-index.ts +10 -1
- package/src/backlog-embed.ts +156 -0
- package/src/backlog.ts +536 -28
- package/src/challenge-panel.ts +4 -0
- package/src/export-holdout.ts +235 -0
- package/src/feature-adr-checkpoints.ts +192 -1
- package/src/feature-adr-routing.ts +4 -0
- package/src/guard.ts +106 -1
- package/src/index.ts +72 -1
- package/src/loop-blobs.generated.ts +114 -0
- package/src/loop-lint.ts +643 -0
- package/src/loop-plan.ts +1419 -0
- package/src/loop-render.ts +1050 -0
- package/src/loop-trace.ts +650 -0
- package/src/mutation-gate.ts +701 -0
- package/src/no-stubs.ts +204 -0
- package/src/package-skill-layouts.ts +107 -0
- package/src/patterns.ts +135 -60
- package/src/recall-domain-boost.ts +178 -0
- package/src/store-lock.ts +258 -0
- package/src/workflows.ts +18 -117
package/src/no-stubs.ts
ADDED
|
@@ -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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
const
|
|
1418
|
-
|
|
1419
|
-
prev =
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
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) {
|