@dzhechkov/harness-core 0.3.150 → 0.4.2
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 +410 -62
- package/README.md +81 -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 +127 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +199 -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 +16 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -2
- 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 +1068 -0
- package/dist/loop-render.js.map +1 -0
- package/dist/loop-trace.d.ts +229 -0
- package/dist/loop-trace.d.ts.map +1 -0
- package/dist/loop-trace.js +614 -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.map +1 -1
- package/dist/recall-domain-boost.js +6 -0
- package/dist/recall-domain-boost.js.map +1 -1
- package/dist/statusline.d.ts +10 -2
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +122 -36
- package/dist/statusline.js.map +1 -1
- 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 +6 -4
- package/sbom.json +1073 -203
- 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 +291 -1
- package/src/feature-adr-routing.ts +4 -0
- package/src/guard.ts +106 -1
- package/src/index.ts +62 -2
- 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 +1126 -0
- package/src/loop-trace.ts +727 -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 +6 -0
- package/src/statusline.ts +117 -30
- package/src/store-lock.ts +258 -0
- package/src/workflows.ts +18 -117
package/src/challenge-panel.ts
CHANGED
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
25
25
|
import { join, resolve, sep } from 'node:path';
|
|
26
26
|
|
|
27
|
+
/** Blob version stamp read by scripts/gen-loop-blobs.mjs (feature loop-designer, ADR-004) — the
|
|
28
|
+
* ONLY loop-designer change to this canonical file; bump when any blob-exported semantic changes. */
|
|
29
|
+
export const CHALLENGE_PANEL_BLOB_VERSION = '1.0.0';
|
|
30
|
+
|
|
27
31
|
export type CId = 'C1' | 'C2' | 'C3' | 'C4' | 'C5' | 'C6' | 'C7' | 'C8';
|
|
28
32
|
export type ChallengeSeverity = 'P0' | 'P1' | 'P2';
|
|
29
33
|
const SEV_RANK: Record<ChallengeSeverity, number> = { P0: 3, P1: 2, P2: 1 };
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain hold-out for the portable export — DEFENCE IN DEPTH, not the guarantee.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS — AND WHY IT IS THE SECOND LINE, NOT THE FIRST.
|
|
5
|
+
* (This heading once read "the thing that actually protects anything", contradicting the
|
|
6
|
+
* first line of the same comment. ADR-004 moved the guarantee to a separate store; this
|
|
7
|
+
* file did not move with it.)
|
|
8
|
+
*
|
|
9
|
+
* `health-advisor` teaches lessons learned from a real person's investigations. The
|
|
10
|
+
* first design tried to keep patient data out of the store by INSPECTING THE TEXT —
|
|
11
|
+
* deciding, from prose, whether a lesson described a method or a person. Seven rounds
|
|
12
|
+
* of independent cross-model review graded that F and the finding count never
|
|
13
|
+
* converged, because the question is about meaning: every pattern answering it fails
|
|
14
|
+
* in both directions, and none of them can see the case that matters most (a rare
|
|
15
|
+
* combination identifies a person with no name and no digits in the sentence).
|
|
16
|
+
*
|
|
17
|
+
* So the guarantee moved to where a guarantee can live. The realistic way a learned
|
|
18
|
+
* store leaks is not "somebody read the disk" — it is that the store gets SHARED:
|
|
19
|
+
* exported to JSON, committed, carried to another machine. `dz recall --all --json` is
|
|
20
|
+
* that path by design; it is documented as the portable sharing form.
|
|
21
|
+
*
|
|
22
|
+
* Holding a domain out of that export is decided by a tag the writer set rather than by
|
|
23
|
+
* parsing prose, so it is language-independent and provable by a test. But it is NOT the
|
|
24
|
+
* isolation guarantee, and an earlier version of this comment claiming otherwise was
|
|
25
|
+
* wrong: filtering each command that emits lesson text is itself an enumeration, and
|
|
26
|
+
* review produced five more such commands (`guard promote --json`, `epoch-replay
|
|
27
|
+
* --emit`, `vector harmonize`, `consolidate --prune-quarantine`, the `recall --forget`
|
|
28
|
+
* preview) the moment four were closed. The guarantee is ADR-004 — health lessons are
|
|
29
|
+
* written to a SEPARATE store and never reach this one. What remains here is a second
|
|
30
|
+
* line for stray or legacy records, which is worth having and is not the promise.
|
|
31
|
+
*
|
|
32
|
+
* THE PROMISE, NARROWLY. This governs the EXPORT. It does not encrypt the local store,
|
|
33
|
+
* it does not stop a human from copying a file, and it does not make the lesson text
|
|
34
|
+
* safe to publish. It means: the one command whose job is to hand the store to someone
|
|
35
|
+
* else will not hand over this domain unless you say so out loud.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { PatternRecord } from './patterns.js';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Canonical key for hold-out comparison — deliberately NOT `normalizeDomain`.
|
|
42
|
+
*
|
|
43
|
+
* `normalizeDomain` serves the RANKING boost, where being slightly wrong costs
|
|
44
|
+
* ordering. Here being slightly wrong costs a leak, so the two must not share a
|
|
45
|
+
* definition: review found `Health - Research` normalising to `health---research` and a
|
|
46
|
+
* FULLWIDTH hyphen (U+FF0D) surviving as its own character — both then exported by the
|
|
47
|
+
* `else` branch. A hold-out that a spelling variant defeats is not a hold-out.
|
|
48
|
+
*
|
|
49
|
+
* So: NFKC-fold first, then lower case, then collapse EVERY run of non-alphanumeric
|
|
50
|
+
* characters to a single `-`. That is an allowlist over the key (letters and digits
|
|
51
|
+
* survive, nothing else does) rather than a list of separators to keep up to date.
|
|
52
|
+
*
|
|
53
|
+
* NFKC is not decoration. Without it `Health-Research` in fullwidth forms
|
|
54
|
+
* produced `health-research` — a different key, so the record exported. Compatibility
|
|
55
|
+
* normalisation folds fullwidth, ligature and other presentation variants onto the
|
|
56
|
+
* characters they stand for, which is exactly the equivalence a tag comparison needs.
|
|
57
|
+
*/
|
|
58
|
+
export function canonicalDomainKey(domain: string | null | undefined): string {
|
|
59
|
+
return String(domain ?? '')
|
|
60
|
+
.normalize('NFKC')
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
|
63
|
+
.replace(/^-+|-+$/g, '');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Domains held out of the portable export by default.
|
|
68
|
+
*
|
|
69
|
+
* `health-research` is written by the `goap-research-ed25519` skill when it runs inside
|
|
70
|
+
* `health-advisor`. It is listed here rather than configured because a default that has
|
|
71
|
+
* to be switched ON protects nobody: the person who would have configured it is the
|
|
72
|
+
* person who already understood the risk.
|
|
73
|
+
*/
|
|
74
|
+
export const DEFAULT_HELD_OUT_DOMAINS: readonly string[] = ['health-research'];
|
|
75
|
+
|
|
76
|
+
export interface HoldoutResult<T> {
|
|
77
|
+
/** What the export may hand over. */
|
|
78
|
+
readonly exported: readonly T[];
|
|
79
|
+
/** What was withheld — returned, not silently dropped, so the caller can COUNT it. */
|
|
80
|
+
readonly withheld: readonly T[];
|
|
81
|
+
/** Which held-out domains actually matched, for an honest message. */
|
|
82
|
+
readonly domains: readonly string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Split records into what may be exported and what is held back.
|
|
87
|
+
*
|
|
88
|
+
* Comparison goes through `canonicalDomainKey`, so `Health-Research`, `health_research`,
|
|
89
|
+
* `Health - Research` and a fullwidth-hyphen spelling are one domain — a tag that leaks
|
|
90
|
+
* through a spelling variant would be the same class of defect as the text guard this
|
|
91
|
+
* replaced.
|
|
92
|
+
*
|
|
93
|
+
* AN UNTAGGED RECORD IS EXPORTED, and that is a real limit of tag-based isolation rather
|
|
94
|
+
* than an oversight: with no domain there is nothing to compare, and withholding every
|
|
95
|
+
* untagged lesson would empty the export for the ordinary case. What closes it upstream
|
|
96
|
+
* is that the writer always tags — `learning_bridge.py` passes `--domain` on every call.
|
|
97
|
+
*/
|
|
98
|
+
export function applyExportHoldout<T extends { readonly domain?: string | null }>(
|
|
99
|
+
records: readonly T[],
|
|
100
|
+
heldOut: readonly string[] = DEFAULT_HELD_OUT_DOMAINS,
|
|
101
|
+
): HoldoutResult<T> {
|
|
102
|
+
const targets = new Set(heldOut.map(canonicalDomainKey).filter((d) => d !== ''));
|
|
103
|
+
if (targets.size === 0) return { exported: records, withheld: [], domains: [] };
|
|
104
|
+
const exported: T[] = [];
|
|
105
|
+
const withheld: T[] = [];
|
|
106
|
+
const matched = new Set<string>();
|
|
107
|
+
for (const record of records) {
|
|
108
|
+
const domain = canonicalDomainKey(record.domain);
|
|
109
|
+
if (domain !== '' && targets.has(domain)) {
|
|
110
|
+
withheld.push(record);
|
|
111
|
+
matched.add(domain);
|
|
112
|
+
} else {
|
|
113
|
+
exported.push(record);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { exported, withheld, domains: [...matched].sort() };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The line the export prints when it held something back.
|
|
121
|
+
*
|
|
122
|
+
* Empty when nothing was withheld, so the common case stays quiet. When something WAS
|
|
123
|
+
* withheld the count is stated: a silent hold-out would leave the reader believing they
|
|
124
|
+
* had exported the whole store, which is its own kind of lie — and it would make a
|
|
125
|
+
* broken hold-out indistinguishable from an empty domain.
|
|
126
|
+
*/
|
|
127
|
+
export function renderHoldoutNote(result: HoldoutResult<unknown>): string {
|
|
128
|
+
if (result.withheld.length === 0) return '';
|
|
129
|
+
const n = result.withheld.length;
|
|
130
|
+
const domains = result.domains.join(', ');
|
|
131
|
+
return ` ${n} lesson(s) in ${domains} were HELD BACK from this export — that domain carries medical research and does not travel by default. To include it deliberately: --include-domain ${domains}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Parse a comma-separated `--include-domain` value into the hold-out list that remains. */
|
|
135
|
+
export function heldOutAfterOptIn(
|
|
136
|
+
optIn: string | undefined,
|
|
137
|
+
heldOut: readonly string[] = DEFAULT_HELD_OUT_DOMAINS,
|
|
138
|
+
): readonly string[] {
|
|
139
|
+
if (optIn === undefined) return heldOut;
|
|
140
|
+
const requested = new Set(
|
|
141
|
+
optIn.split(',').map((d) => canonicalDomainKey(d)).filter((d) => d !== ''),
|
|
142
|
+
);
|
|
143
|
+
return heldOut.filter((d) => !requested.has(canonicalDomainKey(d)));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Convenience for callers holding full `PatternRecord`s. */
|
|
147
|
+
export type PatternHoldout = HoldoutResult<PatternRecord>;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The advice printed when a held-out domain is written into a SHARED store.
|
|
151
|
+
*
|
|
152
|
+
* NOT a refusal. Someone who wants their medical lessons in the shared store owns both
|
|
153
|
+
* directories and the `dz` binary, and stopping them would mean defending a user against
|
|
154
|
+
* themselves — which this design deliberately does not attempt (ADR-004, threat model).
|
|
155
|
+
* What it does instead is make sure the choice is INFORMED: say what follows from it,
|
|
156
|
+
* name the default, and show the one command that does it the other way.
|
|
157
|
+
*
|
|
158
|
+
* The distinction that decides whether to warn is the STORE, not the person: a project
|
|
159
|
+
* whose directory is the health brain is exactly where these lessons belong, so writing
|
|
160
|
+
* one there is silent. Anywhere else, the lesson is about to join lessons that travel.
|
|
161
|
+
*
|
|
162
|
+
* `projectRoot` MUST already be resolved by the caller. Deciding from the unresolved
|
|
163
|
+
* NAME meant `ln -s <a shared project> /tmp/.health-brain` silenced the advice while the
|
|
164
|
+
* write landed in the shared store — the advice went quiet in exactly the case it exists
|
|
165
|
+
* for. A name is a claim about a path; only a resolved path is the path.
|
|
166
|
+
*/
|
|
167
|
+
export function renderSharedStoreAdvice(domain: string | null | undefined, resolvedProjectRoot: string): string {
|
|
168
|
+
const key = canonicalDomainKey(domain);
|
|
169
|
+
if (key === '' || !DEFAULT_HELD_OUT_DOMAINS.map(canonicalDomainKey).includes(key)) return '';
|
|
170
|
+
if (/[\\/]\.health-brain\/?$/.test(resolvedProjectRoot)) return '';
|
|
171
|
+
return [
|
|
172
|
+
` ⚠ "${domain}" is a medical domain, and this is a SHARED store.`,
|
|
173
|
+
' Lessons here are read by every command that reads this store, and the portable',
|
|
174
|
+
' export (dz recall --all --json) is how a store leaves a machine. We recommend',
|
|
175
|
+
' against it by default: keep medical lessons in their own store, which also keeps',
|
|
176
|
+
' the prompts and transcripts of that work in one place you can inspect or delete.',
|
|
177
|
+
` The other way: dz teach "<lesson>" --domain ${domain} --project <dir>/.health-brain`,
|
|
178
|
+
' Nothing was blocked — this is your call, and it is now on the record.',
|
|
179
|
+
].join('\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface VectorExportDecision {
|
|
183
|
+
readonly allow: boolean;
|
|
184
|
+
/** Why it was refused — empty when allowed. */
|
|
185
|
+
readonly reason: string;
|
|
186
|
+
/** What the caller should pass to proceed deliberately. */
|
|
187
|
+
readonly optInHint: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Whether `dz vector export` may write a checkpoint.
|
|
192
|
+
*
|
|
193
|
+
* EXTRACTED SO IT CAN BE TESTED. The decision used to live inline in the CLI behind an
|
|
194
|
+
* earlier `return` (the RVF engine is opt-in and absent on most machines), so the branch
|
|
195
|
+
* that matters could not be exercised at all in a normal checkout — I could reason about
|
|
196
|
+
* it and not run it, which is the position this project treats as unverified.
|
|
197
|
+
*
|
|
198
|
+
* The rule: a `.rvf` checkpoint carries embeddings keyed by id and NO domain, so its
|
|
199
|
+
* contents cannot be classified from the file. This export is also all-or-nothing — the
|
|
200
|
+
* adapter copies the store whole. So when such a file exists the answer is REFUSE unless
|
|
201
|
+
* the caller names what travels; `recall --forget` deletes a lexical record and leaves
|
|
202
|
+
* its embedding behind, which is exactly why the lexical store cannot stand in for this.
|
|
203
|
+
*/
|
|
204
|
+
export function decideVectorExport(input: {
|
|
205
|
+
readonly rvfExists: boolean;
|
|
206
|
+
readonly heldOutLexicalCount: number;
|
|
207
|
+
readonly heldOutDomains: readonly string[];
|
|
208
|
+
readonly optedIn: string | undefined;
|
|
209
|
+
}): VectorExportDecision {
|
|
210
|
+
// "NAMED" means the HELD-OUT domain was named, not that some flag was passed. The
|
|
211
|
+
// first version accepted any non-empty value, so `--include-domain security` unlocked
|
|
212
|
+
// an unclassifiable checkpoint — the message said "name what travels" and the code
|
|
213
|
+
// asked only "did you type something". Worse, the test I wrote alongside it asserted
|
|
214
|
+
// the same weak condition, so it locked the defect in instead of catching it.
|
|
215
|
+
const optedKeys = new Set(
|
|
216
|
+
(input.optedIn ?? '').split(',').map((d) => canonicalDomainKey(d)).filter((d) => d !== ''),
|
|
217
|
+
);
|
|
218
|
+
const named = DEFAULT_HELD_OUT_DOMAINS.some((d) => optedKeys.has(canonicalDomainKey(d)));
|
|
219
|
+
const hint = (input.heldOutDomains.length > 0 ? input.heldOutDomains : DEFAULT_HELD_OUT_DOMAINS).join(',');
|
|
220
|
+
if (input.rvfExists && !named) {
|
|
221
|
+
return {
|
|
222
|
+
allow: false,
|
|
223
|
+
reason: 'a .rvf checkpoint stores embeddings keyed by id with no domain, so this gate cannot tell whether it holds held-out lessons — and it is exported whole',
|
|
224
|
+
optInHint: hint,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
if (input.heldOutLexicalCount > 0 && !named) {
|
|
228
|
+
return {
|
|
229
|
+
allow: false,
|
|
230
|
+
reason: `the store holds ${input.heldOutLexicalCount} lesson(s) in ${input.heldOutDomains.join(', ')}, and this export is all-or-nothing`,
|
|
231
|
+
optInHint: hint,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return { allow: true, reason: '', optInHint: hint };
|
|
235
|
+
}
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
* the wiring test asserts the mirror stays present.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
/** Blob version stamp read by scripts/gen-loop-blobs.mjs (feature loop-designer, ADR-004) — the
|
|
28
|
+
* ONLY loop-designer change to this canonical file; bump when any blob-exported semantic changes. */
|
|
29
|
+
export const BLOB_VERSION = '1.1.0';
|
|
30
|
+
|
|
27
31
|
/** Stages the workflow checkpoints, in pipeline order. Cheap side-channel agents (usage probes,
|
|
28
32
|
* fa-record, auto-cost selects) are never checkpointed; the opt-in Delivery gate re-runs by design
|
|
29
33
|
* (advisory verdicts should reflect the CURRENT tree). */
|
|
@@ -74,7 +78,7 @@ export function fnv1a(str: string): string {
|
|
|
74
78
|
* findable collisions (Codex QE #9 produced a real pair at `11a08b58`); two passes make the
|
|
75
79
|
* single-pair collision odds ~2^-64 — adequate for one slug's checkpoint file. */
|
|
76
80
|
export function fnv1a64(str: string): string {
|
|
77
|
-
return fnv1a(str) + fnv1a('
|
|
81
|
+
return fnv1a(str) + fnv1a('fa-ckpt-salt' + str);
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
/** The stage's input fingerprint: a JSON-tuple (delimiter-ambiguity class — never a separator join)
|
|
@@ -219,3 +223,289 @@ export function checkpointAppendCmd(fdirAbs: string, line: string): string {
|
|
|
219
223
|
const file = shellQuote(fdirAbs + '/.fa-state/checkpoints.jsonl');
|
|
220
224
|
return 'mkdir -p ' + dir + " && printf '%s\\n' " + shellQuote(line) + ' >> ' + file;
|
|
221
225
|
}
|
|
226
|
+
|
|
227
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
228
|
+
// Training-pair capture (backlog 70e0f083) — the PURE half.
|
|
229
|
+
//
|
|
230
|
+
// Goal: fully-local development on compact models in 4–6 months needs a dataset of
|
|
231
|
+
// STAGE INPUT (full context+prompt) → STAGE OUTPUT (artifact/result) → EVALUATION
|
|
232
|
+
// (QE grade + the lessons injected into context) records. Everything is already in
|
|
233
|
+
// hand at the moment each feature-adr stage completes — so capture is instrumented
|
|
234
|
+
// NOW (every un-captured run is a lost pair); the dataset itself is built later.
|
|
235
|
+
//
|
|
236
|
+
// Storage: ONE JSONL file per stage under .dz/fa-training/<slug>/<stage>.jsonl
|
|
237
|
+
// (owner decision 2026-08). Deliberately NOT gitignored (owner decision — pairs may
|
|
238
|
+
// contain target-repo code; the capture dir carries a README privacy note instead).
|
|
239
|
+
//
|
|
240
|
+
// Stage asymmetry (the reason provenance.family is load-bearing): the downstream
|
|
241
|
+
// dataset must honour the cross-model rule — QE pairs must come from a DIFFERENT
|
|
242
|
+
// family than the coder's pairs; router/plan distill easily, code is hardest.
|
|
243
|
+
//
|
|
244
|
+
// Pure and deterministic like the checkpoint half: ts is PASSED IN (never read from a clock
|
|
245
|
+
// — the workflow sandbox forbids Date, and tests must stay deterministic); the
|
|
246
|
+
// workflow mirrors these functions inline and fills ts shell-side via sed.
|
|
247
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
export type CaptureMode = 'capture' | 'backfill' | 'skip-disabled' | 'skip-empty';
|
|
250
|
+
|
|
251
|
+
/** Decide whether this completion is captured. A resumed stage is backfilled rather than
|
|
252
|
+
* skipped: its input (stage template + args) and checkpointed output are both in scope at the
|
|
253
|
+
* capture site, so the pair is deterministically reconstructible. trainingPairBackfillCmd's
|
|
254
|
+
* persistent atomic mark makes that write at-most-once, so concurrent invocations and later
|
|
255
|
+
* runIds cannot double-append the same pair. */
|
|
256
|
+
export function decideCaptureMode(opts: { enabled: boolean; resumed: boolean; recordCount: number }): CaptureMode {
|
|
257
|
+
if (!opts.enabled) return 'skip-disabled';
|
|
258
|
+
if (!Number.isInteger(opts.recordCount) || opts.recordCount <= 0) return 'skip-empty';
|
|
259
|
+
return opts.resumed ? 'backfill' : 'capture';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export type CaptureFailureReason = 'threw' | 'unserializable' | 'unverified' | 'backfill-unverified' | 'empty-output';
|
|
263
|
+
|
|
264
|
+
export interface CaptureFailureRecord {
|
|
265
|
+
stage: string;
|
|
266
|
+
mode: CaptureMode | null;
|
|
267
|
+
reason: CaptureFailureReason;
|
|
268
|
+
detail: string | null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Normalize capture failures for collection by the caller. This recorder must never throw:
|
|
272
|
+
* replacing the original capture failure with a reporting failure would hide the real cause. */
|
|
273
|
+
export function captureFailureRecord(stage: unknown, mode: unknown, reason: unknown, detail: unknown): CaptureFailureRecord {
|
|
274
|
+
const normalizedStage = typeof stage === 'string' && stage.trim() !== '' ? stage : 'unknown';
|
|
275
|
+
const normalizedMode: CaptureMode | null =
|
|
276
|
+
mode === 'capture' || mode === 'backfill' || mode === 'skip-disabled' || mode === 'skip-empty'
|
|
277
|
+
? mode
|
|
278
|
+
: null;
|
|
279
|
+
const normalizedReason: CaptureFailureReason =
|
|
280
|
+
reason === 'threw' || reason === 'unserializable' || reason === 'unverified' || reason === 'backfill-unverified' || reason === 'empty-output'
|
|
281
|
+
? reason
|
|
282
|
+
: 'threw';
|
|
283
|
+
let normalizedDetail: string | null = null;
|
|
284
|
+
if (detail !== null && detail !== undefined) {
|
|
285
|
+
try {
|
|
286
|
+
const text = String(detail);
|
|
287
|
+
if (text !== '') normalizedDetail = text.length > 500 ? text.slice(0, 500) + '…' : text;
|
|
288
|
+
} catch {
|
|
289
|
+
normalizedDetail = null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { stage: normalizedStage, mode: normalizedMode, reason: normalizedReason, detail: normalizedDetail };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Training-pair record format version. Bump on any field-shape change. */
|
|
296
|
+
export const TRAINPAIR_SCHEMA_VERSION = 'fa-trainpair-2';
|
|
297
|
+
|
|
298
|
+
/** Oversize guard cap over input+output combined (same posture as
|
|
299
|
+
* CHECKPOINT_MAX_RESULT_CHARS, sized for full stage prompts): an over-cap pair is
|
|
300
|
+
* TRUNCATED with a named marker + a hash of the full text — never silently dropped
|
|
301
|
+
* (a lost pair is a lost training sample), never unbounded (a 10MB line would make
|
|
302
|
+
* the JSONL unusable and the write-agent prompt explode). */
|
|
303
|
+
export const TRAINPAIR_MAX_IO_CHARS = 48_000;
|
|
304
|
+
|
|
305
|
+
export type TrainingPairFamily = 'claude' | 'codex';
|
|
306
|
+
|
|
307
|
+
/** The family a model spec/label/runner-name belongs to. Family ∈ {claude, codex} —
|
|
308
|
+
* the field the cross-model dataset rule stands on. Anything naming codex/gpt/openai
|
|
309
|
+
* is 'codex' (incl. 'codex-fallback' — codex ACTUALLY produced that stage); everything
|
|
310
|
+
* else (opus/sonnet/fable/haiku, role agentTypes, 'claude-fallback') is 'claude'. */
|
|
311
|
+
export function trainingPairFamily(spec: unknown): TrainingPairFamily {
|
|
312
|
+
return /codex|gpt|openai/i.test(String(spec ?? '')) ? 'codex' : 'claude';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface TrainingPairEvaluation {
|
|
316
|
+
/** The QE grade for this pair, or null when the stage honestly has none (router). */
|
|
317
|
+
grade: string | null;
|
|
318
|
+
/** Who graded it (runner + model label), or null when ungraded. */
|
|
319
|
+
gradedBy: string | null;
|
|
320
|
+
/** Lesson texts/ids recalled into THIS stage's context (Step-0 recall). */
|
|
321
|
+
lessonsInjected: string[];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export interface TrainingPairProvenance {
|
|
325
|
+
/** The model label that produced the stage output. */
|
|
326
|
+
model: string;
|
|
327
|
+
/** The model FAMILY — load-bearing for the cross-model dataset rule. */
|
|
328
|
+
family: TrainingPairFamily;
|
|
329
|
+
/** The stage role: 'router' | 'design:*' | 'planner' | 'coder' | 'reviewer' | 'fleet-qe'. */
|
|
330
|
+
role: string;
|
|
331
|
+
tokens: number | null;
|
|
332
|
+
minutes: number | null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export interface TrainingPairTruncation {
|
|
336
|
+
/** Original (pre-truncation) char counts + full-text hashes — what was cut is NAMED. */
|
|
337
|
+
inputChars: number;
|
|
338
|
+
outputChars: number;
|
|
339
|
+
inputHash: string;
|
|
340
|
+
outputHash: string;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** One SFT-ready record: prompt → completion → evaluation, one JSON object per line. */
|
|
344
|
+
export interface TrainingPair {
|
|
345
|
+
schema: string;
|
|
346
|
+
slug: string;
|
|
347
|
+
stage: string;
|
|
348
|
+
/** ts is the CAPTURE time. On a record with captureMode: 'backfill' that is the RECONSTRUCTION time, NOT the stage's observation time — the original stage's timing lives in that run's .fa-state checkpoint. */
|
|
349
|
+
ts: number | string | null;
|
|
350
|
+
input: string;
|
|
351
|
+
output: string;
|
|
352
|
+
evaluation: TrainingPairEvaluation;
|
|
353
|
+
provenance: TrainingPairProvenance;
|
|
354
|
+
truncated: TrainingPairTruncation | null;
|
|
355
|
+
captureMode: 'capture' | 'backfill';
|
|
356
|
+
resumed: boolean;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Per-stage JSONL path, relative to the repo root. ONE file per stage. */
|
|
360
|
+
export function trainingPairPath(slug: string, stage: string): string {
|
|
361
|
+
return '.dz/fa-training/' + slug + '/' + stage + '.jsonl';
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** README dropped once into the capture dir. The caveat is documented ON DISK because the
|
|
365
|
+
* directory is deliberately not gitignored (explicit owner decision, 2026-08). */
|
|
366
|
+
export const TRAINPAIR_PRIVACY_NOTE =
|
|
367
|
+
"feature-adr TRAINING PAIRS (backlog 70e0f083): per-stage SFT records - STAGE INPUT (full prompt/context) -> STAGE OUTPUT (artifact/result) -> EVALUATION (QE grade + injected lessons) with model+family provenance; one JSONL file per stage per slug. PRIVACY: pairs may contain TARGET-REPO CODE and full prompts. This directory is NOT gitignored yet by explicit owner decision - review contents before sharing or publishing anything that embeds it. ts is the CAPTURE time. On a record with captureMode: 'backfill' that is the RECONSTRUCTION time, NOT the stage's observation time — the original stage's timing lives in that run's .fa-state checkpoint.";
|
|
368
|
+
|
|
369
|
+
/** Coerce a stage input/output to text: strings pass through; objects serialize to JSON;
|
|
370
|
+
* an unserializable value degrades to String(v) — buildTrainingPair NEVER throws (capture
|
|
371
|
+
* is non-blocking by contract). */
|
|
372
|
+
function coerceText(v: unknown): string {
|
|
373
|
+
if (typeof v === 'string') return v;
|
|
374
|
+
if (v === null || v === undefined) return '';
|
|
375
|
+
try {
|
|
376
|
+
const s = JSON.stringify(v);
|
|
377
|
+
return typeof s === 'string' ? s : String(v);
|
|
378
|
+
} catch {
|
|
379
|
+
return String(v);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Assemble one SFT-ready training pair. Deterministic (ts passed in). Applies the oversize
|
|
384
|
+
* guard: when input+output exceed TRAINPAIR_MAX_IO_CHARS combined, each over-budget side is
|
|
385
|
+
* truncated with a marker naming the cut char count + the fnv1a64 of its FULL text (the
|
|
386
|
+
* budget flows to the smaller side, so a small prompt next to a huge output stays verbatim).
|
|
387
|
+
* Evaluation honesty: an empty/whitespace grade normalizes to null — a stub never reads as
|
|
388
|
+
* a real evaluation. Provenance: an explicit valid family wins; otherwise it is DERIVED from
|
|
389
|
+
* the model spec via modelFamily (an invalid family never leaks into the dataset). */
|
|
390
|
+
export function buildTrainingPair(opts: {
|
|
391
|
+
slug: string;
|
|
392
|
+
stage: string;
|
|
393
|
+
ts: number | string | null;
|
|
394
|
+
input: unknown;
|
|
395
|
+
output: unknown;
|
|
396
|
+
evaluation?: Partial<TrainingPairEvaluation> | null;
|
|
397
|
+
provenance?: Partial<TrainingPairProvenance> | null;
|
|
398
|
+
captureMode?: unknown;
|
|
399
|
+
resumed?: unknown;
|
|
400
|
+
}): TrainingPair {
|
|
401
|
+
let input = coerceText(opts.input);
|
|
402
|
+
let output = coerceText(opts.output);
|
|
403
|
+
let truncated: TrainingPairTruncation | null = null;
|
|
404
|
+
if (input.length + output.length > TRAINPAIR_MAX_IO_CHARS) {
|
|
405
|
+
truncated = { inputChars: input.length, outputChars: output.length, inputHash: fnv1a64(input), outputHash: fnv1a64(output) };
|
|
406
|
+
const half = Math.floor(TRAINPAIR_MAX_IO_CHARS / 2);
|
|
407
|
+
let inKeep = input.length;
|
|
408
|
+
let outKeep = output.length;
|
|
409
|
+
if (outKeep <= half) inKeep = TRAINPAIR_MAX_IO_CHARS - outKeep;
|
|
410
|
+
else if (inKeep <= half) outKeep = TRAINPAIR_MAX_IO_CHARS - inKeep;
|
|
411
|
+
else { inKeep = half; outKeep = TRAINPAIR_MAX_IO_CHARS - half; }
|
|
412
|
+
if (inKeep < input.length) input = input.slice(0, inKeep) + '\n…[TRUNCATED ' + (truncated.inputChars - inKeep) + ' chars — full-text fnv1a64=' + truncated.inputHash + ']';
|
|
413
|
+
if (outKeep < output.length) output = output.slice(0, outKeep) + '\n…[TRUNCATED ' + (truncated.outputChars - outKeep) + ' chars — full-text fnv1a64=' + truncated.outputHash + ']';
|
|
414
|
+
}
|
|
415
|
+
const ev = opts.evaluation || {};
|
|
416
|
+
const pv = opts.provenance || {};
|
|
417
|
+
return {
|
|
418
|
+
schema: TRAINPAIR_SCHEMA_VERSION,
|
|
419
|
+
slug: opts.slug,
|
|
420
|
+
stage: opts.stage,
|
|
421
|
+
ts: opts.ts === undefined ? null : opts.ts,
|
|
422
|
+
input,
|
|
423
|
+
output,
|
|
424
|
+
evaluation: {
|
|
425
|
+
grade: typeof ev.grade === 'string' && ev.grade.trim() !== '' ? ev.grade : null,
|
|
426
|
+
gradedBy: typeof ev.gradedBy === 'string' && ev.gradedBy !== '' ? ev.gradedBy : null,
|
|
427
|
+
lessonsInjected: Array.isArray(ev.lessonsInjected) ? ev.lessonsInjected.filter((s): s is string => typeof s === 'string' && s !== '') : [],
|
|
428
|
+
},
|
|
429
|
+
provenance: {
|
|
430
|
+
model: typeof pv.model === 'string' && pv.model !== '' ? pv.model : 'unknown',
|
|
431
|
+
family: pv.family === 'claude' || pv.family === 'codex' ? pv.family : trainingPairFamily(pv.model),
|
|
432
|
+
role: typeof pv.role === 'string' && pv.role !== '' ? pv.role : 'unknown',
|
|
433
|
+
tokens: typeof pv.tokens === 'number' && Number.isFinite(pv.tokens) ? pv.tokens : null,
|
|
434
|
+
minutes: typeof pv.minutes === 'number' && Number.isFinite(pv.minutes) ? pv.minutes : null,
|
|
435
|
+
},
|
|
436
|
+
truncated,
|
|
437
|
+
captureMode: opts.captureMode === 'backfill' ? 'backfill' : 'capture',
|
|
438
|
+
resumed: opts.resumed === true,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Serialize one training pair to a JSONL line. The oversize guard already bounds the pair,
|
|
443
|
+
* so this only fails on the impossible (all fields are plain data) — null on that, never a throw. */
|
|
444
|
+
export function serializeTrainingPair(pair: TrainingPair): string | null {
|
|
445
|
+
try {
|
|
446
|
+
const line = JSON.stringify(pair);
|
|
447
|
+
return typeof line === 'string' ? line : null;
|
|
448
|
+
} catch {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** The one Bash command the write agent runs: mkdir the slug dir, drop the privacy README
|
|
454
|
+
* once (if-absent guard), then append ONE line (single-quote-escaped byte-faithfully, same
|
|
455
|
+
* idiom as checkpointAppendCmd). */
|
|
456
|
+
export function trainingPairAppendCmd(repoAbs: string, slug: string, stage: string, line: string): string {
|
|
457
|
+
const dirAbs = repoAbs + '/.dz/fa-training/' + slug;
|
|
458
|
+
const readmeAbs = repoAbs + '/.dz/fa-training/README.md';
|
|
459
|
+
const fileAbs = dirAbs + '/' + stage + '.jsonl';
|
|
460
|
+
return (
|
|
461
|
+
'mkdir -p ' + shellQuote(dirAbs) +
|
|
462
|
+
' && { [ -f ' + shellQuote(readmeAbs) + ' ] || printf \'%s\\n\' ' + shellQuote(TRAINPAIR_PRIVACY_NOTE) + ' > ' + shellQuote(readmeAbs) + '; }' +
|
|
463
|
+
" && printf '%s\\n' " + shellQuote(line) + ' >> ' + shellQuote(fileAbs)
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Readback sentinels for the caller to distinguish an at-most-once write from an existing pair. */
|
|
468
|
+
export const TP_BACKFILL_OK = 'TP-BACKFILL-OK';
|
|
469
|
+
export const TP_BACKFILL_SKIP = 'TP-BACKFILL-SKIP';
|
|
470
|
+
export const TP_BACKFILL_DUP = 'TP-BACKFILL-DUP';
|
|
471
|
+
|
|
472
|
+
/** Build the deterministic resume-backfill command. The persistent mkdir mark is the atomic
|
|
473
|
+
* at-most-once primitive; the inner file-absence guard also protects pair files created before
|
|
474
|
+
* marks existed. The mark is RELEASED when — and only when — the append fails, so a failed backfill
|
|
475
|
+
* stays retryable. The `[ -f ]` path keeps the mark because the pair genuinely exists.
|
|
476
|
+
* KNOWN RESIDUAL: a process killed (SIGKILL, sandbox timeout) between the `mkdir` claim and the end
|
|
477
|
+
* of the append still leaves a poisoned mark. That window is strictly narrower than "any append
|
|
478
|
+
* failure" and is the same externally-killed class this feature already names for the ledger row.
|
|
479
|
+
* `TP_BACKFILL_SKIP` means "the per-stage pair file already existed"; `TP_BACKFILL_DUP` means
|
|
480
|
+
* "another run already owns this content". The two strings are deliberately NON-PREFIXING because
|
|
481
|
+
* the two producers parse the readback differently — the generated loop compares `===` after
|
|
482
|
+
* `trim`, while the `feature-adr.js` twin tests an UNANCHORED regex; a prefixed name would be `DUP`
|
|
483
|
+
* to one parser and `SKIP` to the other from the same bytes. The default `markKey` is per-CONTENT
|
|
484
|
+
* only; a caller whose line embeds a per-run identifier must pass a run-independent `markKey`.
|
|
485
|
+
* Marks are deliberately never pruned. */
|
|
486
|
+
export function trainingPairBackfillCmd(repoAbs: string, slug: string, stage: string, lines: readonly string[], markKey?: string): string | null {
|
|
487
|
+
if (typeof repoAbs !== 'string' || repoAbs === '') return null;
|
|
488
|
+
if (typeof slug !== 'string' || slug === '') return null;
|
|
489
|
+
if (typeof stage !== 'string' || stage === '') return null;
|
|
490
|
+
if (!Array.isArray(lines) || lines.length === 0 || !lines.every(line => typeof line === 'string' && line !== '')) return null;
|
|
491
|
+
|
|
492
|
+
const dirAbs = repoAbs + '/.dz/fa-training/' + slug;
|
|
493
|
+
const readmeAbs = repoAbs + '/.dz/fa-training/README.md';
|
|
494
|
+
const fileAbs = dirAbs + '/' + stage + '.jsonl';
|
|
495
|
+
const markDir = repoAbs + '/.dz/fa-training/.backfill-marks';
|
|
496
|
+
const markStage = stage.replace(/\.\./g, '_').replace(/\//g, '_');
|
|
497
|
+
const resolvedMarkKey = markKey === undefined ? fnv1a64(stage + '\0' + lines.join('\n')) : markKey;
|
|
498
|
+
const markPath = markDir + '/' + markStage + '-' + resolvedMarkKey;
|
|
499
|
+
const appends = lines
|
|
500
|
+
.map(line => "printf '%s\\n' " + shellQuote(line) + ' >> ' + shellQuote(fileAbs))
|
|
501
|
+
.join(' && ');
|
|
502
|
+
return (
|
|
503
|
+
'mkdir -p ' + shellQuote(dirAbs) +
|
|
504
|
+
' && { [ -f ' + shellQuote(readmeAbs) + ' ] || printf \'%s\\n\' ' + shellQuote(TRAINPAIR_PRIVACY_NOTE) + ' > ' + shellQuote(readmeAbs) + '; }' +
|
|
505
|
+
' && mkdir -p ' + shellQuote(markDir) +
|
|
506
|
+
' && if mkdir ' + shellQuote(markPath) + ' 2>/dev/null; then ' +
|
|
507
|
+
'if [ -f ' + shellQuote(fileAbs) + ' ]; then echo ' + shellQuote(TP_BACKFILL_SKIP) +
|
|
508
|
+
'; else { ' + appends + ' && echo ' + shellQuote(TP_BACKFILL_OK) + '; } || { rmdir ' + shellQuote(markPath) + ' 2>/dev/null; false; }; fi' +
|
|
509
|
+
'; else echo ' + shellQuote(TP_BACKFILL_DUP) + '; fi'
|
|
510
|
+
);
|
|
511
|
+
}
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
* @packageDocumentation
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
+
/** Blob version stamp read by scripts/gen-loop-blobs.mjs (feature loop-designer, ADR-004) — the
|
|
31
|
+
* ONLY loop-designer change to this canonical file; bump when any blob-exported semantic changes. */
|
|
32
|
+
export const BLOB_VERSION = '1.0.0';
|
|
33
|
+
|
|
30
34
|
/** A resolved `agent()` opts fragment: either a Claude `{model}` or a codex spec. */
|
|
31
35
|
export interface StageOpts {
|
|
32
36
|
readonly model?: string;
|