@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/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.0.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,190 @@ 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 Date.now()
|
|
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
|
+
/** Training-pair record format version. Bump on any field-shape change. */
|
|
250
|
+
export const TRAINPAIR_SCHEMA_VERSION = 'fa-trainpair-1';
|
|
251
|
+
|
|
252
|
+
/** Oversize guard cap over input+output combined (same posture as
|
|
253
|
+
* CHECKPOINT_MAX_RESULT_CHARS, sized for full stage prompts): an over-cap pair is
|
|
254
|
+
* TRUNCATED with a named marker + a hash of the full text — never silently dropped
|
|
255
|
+
* (a lost pair is a lost training sample), never unbounded (a 10MB line would make
|
|
256
|
+
* the JSONL unusable and the write-agent prompt explode). */
|
|
257
|
+
export const TRAINPAIR_MAX_IO_CHARS = 48_000;
|
|
258
|
+
|
|
259
|
+
export type TrainingPairFamily = 'claude' | 'codex';
|
|
260
|
+
|
|
261
|
+
/** The family a model spec/label/runner-name belongs to. Family ∈ {claude, codex} —
|
|
262
|
+
* the field the cross-model dataset rule stands on. Anything naming codex/gpt/openai
|
|
263
|
+
* is 'codex' (incl. 'codex-fallback' — codex ACTUALLY produced that stage); everything
|
|
264
|
+
* else (opus/sonnet/fable/haiku, role agentTypes, 'claude-fallback') is 'claude'. */
|
|
265
|
+
export function trainingPairFamily(spec: unknown): TrainingPairFamily {
|
|
266
|
+
return /codex|gpt|openai/i.test(String(spec ?? '')) ? 'codex' : 'claude';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface TrainingPairEvaluation {
|
|
270
|
+
/** The QE grade for this pair, or null when the stage honestly has none (router). */
|
|
271
|
+
grade: string | null;
|
|
272
|
+
/** Who graded it (runner + model label), or null when ungraded. */
|
|
273
|
+
gradedBy: string | null;
|
|
274
|
+
/** Lesson texts/ids recalled into THIS stage's context (Step-0 recall). */
|
|
275
|
+
lessonsInjected: string[];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface TrainingPairProvenance {
|
|
279
|
+
/** The model label that produced the stage output. */
|
|
280
|
+
model: string;
|
|
281
|
+
/** The model FAMILY — load-bearing for the cross-model dataset rule. */
|
|
282
|
+
family: TrainingPairFamily;
|
|
283
|
+
/** The stage role: 'router' | 'design:*' | 'planner' | 'coder' | 'reviewer' | 'fleet-qe'. */
|
|
284
|
+
role: string;
|
|
285
|
+
tokens: number | null;
|
|
286
|
+
minutes: number | null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export interface TrainingPairTruncation {
|
|
290
|
+
/** Original (pre-truncation) char counts + full-text hashes — what was cut is NAMED. */
|
|
291
|
+
inputChars: number;
|
|
292
|
+
outputChars: number;
|
|
293
|
+
inputHash: string;
|
|
294
|
+
outputHash: string;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** One SFT-ready record: prompt → completion → evaluation, one JSON object per line. */
|
|
298
|
+
export interface TrainingPair {
|
|
299
|
+
schema: string;
|
|
300
|
+
slug: string;
|
|
301
|
+
stage: string;
|
|
302
|
+
ts: number | string | null;
|
|
303
|
+
input: string;
|
|
304
|
+
output: string;
|
|
305
|
+
evaluation: TrainingPairEvaluation;
|
|
306
|
+
provenance: TrainingPairProvenance;
|
|
307
|
+
truncated: TrainingPairTruncation | null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Per-stage JSONL path, relative to the repo root. ONE file per stage. */
|
|
311
|
+
export function trainingPairPath(slug: string, stage: string): string {
|
|
312
|
+
return '.dz/fa-training/' + slug + '/' + stage + '.jsonl';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** README dropped once into the capture dir. The caveat is documented ON DISK because the
|
|
316
|
+
* directory is deliberately not gitignored (explicit owner decision, 2026-08). */
|
|
317
|
+
export const TRAINPAIR_PRIVACY_NOTE =
|
|
318
|
+
'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.';
|
|
319
|
+
|
|
320
|
+
/** Coerce a stage input/output to text: strings pass through; objects serialize to JSON;
|
|
321
|
+
* an unserializable value degrades to String(v) — buildTrainingPair NEVER throws (capture
|
|
322
|
+
* is non-blocking by contract). */
|
|
323
|
+
function coerceText(v: unknown): string {
|
|
324
|
+
if (typeof v === 'string') return v;
|
|
325
|
+
if (v === null || v === undefined) return '';
|
|
326
|
+
try {
|
|
327
|
+
const s = JSON.stringify(v);
|
|
328
|
+
return typeof s === 'string' ? s : String(v);
|
|
329
|
+
} catch {
|
|
330
|
+
return String(v);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Assemble one SFT-ready training pair. Deterministic (ts passed in). Applies the oversize
|
|
335
|
+
* guard: when input+output exceed TRAINPAIR_MAX_IO_CHARS combined, each over-budget side is
|
|
336
|
+
* truncated with a marker naming the cut char count + the fnv1a64 of its FULL text (the
|
|
337
|
+
* budget flows to the smaller side, so a small prompt next to a huge output stays verbatim).
|
|
338
|
+
* Evaluation honesty: an empty/whitespace grade normalizes to null — a stub never reads as
|
|
339
|
+
* a real evaluation. Provenance: an explicit valid family wins; otherwise it is DERIVED from
|
|
340
|
+
* the model spec via modelFamily (an invalid family never leaks into the dataset). */
|
|
341
|
+
export function buildTrainingPair(opts: {
|
|
342
|
+
slug: string;
|
|
343
|
+
stage: string;
|
|
344
|
+
ts: number | string | null;
|
|
345
|
+
input: unknown;
|
|
346
|
+
output: unknown;
|
|
347
|
+
evaluation?: Partial<TrainingPairEvaluation> | null;
|
|
348
|
+
provenance?: Partial<TrainingPairProvenance> | null;
|
|
349
|
+
}): TrainingPair {
|
|
350
|
+
let input = coerceText(opts.input);
|
|
351
|
+
let output = coerceText(opts.output);
|
|
352
|
+
let truncated: TrainingPairTruncation | null = null;
|
|
353
|
+
if (input.length + output.length > TRAINPAIR_MAX_IO_CHARS) {
|
|
354
|
+
truncated = { inputChars: input.length, outputChars: output.length, inputHash: fnv1a64(input), outputHash: fnv1a64(output) };
|
|
355
|
+
const half = Math.floor(TRAINPAIR_MAX_IO_CHARS / 2);
|
|
356
|
+
let inKeep = input.length;
|
|
357
|
+
let outKeep = output.length;
|
|
358
|
+
if (outKeep <= half) inKeep = TRAINPAIR_MAX_IO_CHARS - outKeep;
|
|
359
|
+
else if (inKeep <= half) outKeep = TRAINPAIR_MAX_IO_CHARS - inKeep;
|
|
360
|
+
else { inKeep = half; outKeep = TRAINPAIR_MAX_IO_CHARS - half; }
|
|
361
|
+
if (inKeep < input.length) input = input.slice(0, inKeep) + '\n…[TRUNCATED ' + (truncated.inputChars - inKeep) + ' chars — full-text fnv1a64=' + truncated.inputHash + ']';
|
|
362
|
+
if (outKeep < output.length) output = output.slice(0, outKeep) + '\n…[TRUNCATED ' + (truncated.outputChars - outKeep) + ' chars — full-text fnv1a64=' + truncated.outputHash + ']';
|
|
363
|
+
}
|
|
364
|
+
const ev = opts.evaluation || {};
|
|
365
|
+
const pv = opts.provenance || {};
|
|
366
|
+
return {
|
|
367
|
+
schema: TRAINPAIR_SCHEMA_VERSION,
|
|
368
|
+
slug: opts.slug,
|
|
369
|
+
stage: opts.stage,
|
|
370
|
+
ts: opts.ts === undefined ? null : opts.ts,
|
|
371
|
+
input,
|
|
372
|
+
output,
|
|
373
|
+
evaluation: {
|
|
374
|
+
grade: typeof ev.grade === 'string' && ev.grade.trim() !== '' ? ev.grade : null,
|
|
375
|
+
gradedBy: typeof ev.gradedBy === 'string' && ev.gradedBy !== '' ? ev.gradedBy : null,
|
|
376
|
+
lessonsInjected: Array.isArray(ev.lessonsInjected) ? ev.lessonsInjected.filter((s): s is string => typeof s === 'string' && s !== '') : [],
|
|
377
|
+
},
|
|
378
|
+
provenance: {
|
|
379
|
+
model: typeof pv.model === 'string' && pv.model !== '' ? pv.model : 'unknown',
|
|
380
|
+
family: pv.family === 'claude' || pv.family === 'codex' ? pv.family : trainingPairFamily(pv.model),
|
|
381
|
+
role: typeof pv.role === 'string' && pv.role !== '' ? pv.role : 'unknown',
|
|
382
|
+
tokens: typeof pv.tokens === 'number' && Number.isFinite(pv.tokens) ? pv.tokens : null,
|
|
383
|
+
minutes: typeof pv.minutes === 'number' && Number.isFinite(pv.minutes) ? pv.minutes : null,
|
|
384
|
+
},
|
|
385
|
+
truncated,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Serialize one training pair to a JSONL line. The oversize guard already bounds the pair,
|
|
390
|
+
* so this only fails on the impossible (all fields are plain data) — null on that, never a throw. */
|
|
391
|
+
export function serializeTrainingPair(pair: TrainingPair): string | null {
|
|
392
|
+
try {
|
|
393
|
+
const line = JSON.stringify(pair);
|
|
394
|
+
return typeof line === 'string' ? line : null;
|
|
395
|
+
} catch {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The one Bash command the write agent runs: mkdir the slug dir, drop the privacy README
|
|
401
|
+
* once (if-absent guard), then append ONE line (single-quote-escaped byte-faithfully, same
|
|
402
|
+
* idiom as checkpointAppendCmd). */
|
|
403
|
+
export function trainingPairAppendCmd(repoAbs: string, slug: string, stage: string, line: string): string {
|
|
404
|
+
const dirAbs = repoAbs + '/.dz/fa-training/' + slug;
|
|
405
|
+
const readmeAbs = repoAbs + '/.dz/fa-training/README.md';
|
|
406
|
+
const fileAbs = dirAbs + '/' + stage + '.jsonl';
|
|
407
|
+
return (
|
|
408
|
+
'mkdir -p ' + shellQuote(dirAbs) +
|
|
409
|
+
' && { [ -f ' + shellQuote(readmeAbs) + ' ] || printf \'%s\\n\' ' + shellQuote(TRAINPAIR_PRIVACY_NOTE) + ' > ' + shellQuote(readmeAbs) + '; }' +
|
|
410
|
+
" && printf '%s\\n' " + shellQuote(line) + ' >> ' + shellQuote(fileAbs)
|
|
411
|
+
);
|
|
412
|
+
}
|
|
@@ -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;
|
package/src/guard.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// counts, store size) that the CLI gathers. No filesystem here → deterministic + unit-testable without a repo.
|
|
13
13
|
|
|
14
14
|
import { type RuleTemplate, type TemplateParams, type ChangeSet, templateFires, validTemplateParams } from './guard-promotion.js';
|
|
15
|
+
import { STUB_MARKERS, STUB_PHRASES, checkNoStubs, type StubWaiver } from './no-stubs.js';
|
|
15
16
|
|
|
16
17
|
export type GuardSeverity = 'hard' | 'soft';
|
|
17
18
|
export type GuardOp = 'publish' | 'teach' | 'consolidate' | 'reindex';
|
|
@@ -46,6 +47,13 @@ export interface GuardResult {
|
|
|
46
47
|
readonly violations: readonly Violation[];
|
|
47
48
|
/** ids of the rules that ran for this op (so a report can show what was checked, not just what failed). */
|
|
48
49
|
readonly checked: readonly string[];
|
|
50
|
+
/**
|
|
51
|
+
* Informational notes (FN-7): things a rule wants ON THE RECORD that are NOT violations and never
|
|
52
|
+
* touch the verdict — e.g. "no-stubs: N changed scannable file(s) not scanned". A fail-open skip
|
|
53
|
+
* that leaves no trace is fail-SILENT, the worst kind by the gate's own cost-of-detection
|
|
54
|
+
* argument; a note is the cheap fix. Present only when non-empty.
|
|
55
|
+
*/
|
|
56
|
+
readonly notes?: readonly string[];
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
/** Facts the CLI injects; each rule reads only the fields it needs. Missing evidence ⇒ that rule is skipped. */
|
|
@@ -79,7 +87,37 @@ export interface GuardFacts {
|
|
|
79
87
|
readonly change?: {
|
|
80
88
|
readonly files: readonly string[];
|
|
81
89
|
readonly contents?: Readonly<Record<string, string>>;
|
|
90
|
+
/**
|
|
91
|
+
* FN-7: how many changed STUB-SCANNABLE files the gatherer did NOT read (deleted, non-regular,
|
|
92
|
+
* oversize, read error, or beyond the file cap). The no-stubs scan stays fail-open on each of
|
|
93
|
+
* them — but the skip must be ON THE RECORD (a GuardResult note), never silent.
|
|
94
|
+
*/
|
|
95
|
+
readonly stubSkipped?: number;
|
|
82
96
|
};
|
|
97
|
+
/**
|
|
98
|
+
* for no-stubs: config waivers from `.dz/guard.json` `stubWaivers: [{path, reason}]` — path-keyed,
|
|
99
|
+
* reason MANDATORY (the feature-adr-setup --guards shape). The scan itself reads `change.files` +
|
|
100
|
+
* `change.contents`, the SAME working-tree diff every other diff-aware rule uses.
|
|
101
|
+
*/
|
|
102
|
+
readonly stubWaivers?: readonly StubWaiver[];
|
|
103
|
+
/**
|
|
104
|
+
* for licence-hold: per package that DECLARES a licence hold (package.json `licenseHold` field —
|
|
105
|
+
* ADR-001 of feature hermes-claude-adaptation), the raw evidence the checker needs. The checker
|
|
106
|
+
* fires only for packs that are actually publishable (`privateFlag !== true`): while `private:true`
|
|
107
|
+
* the npm layer itself refuses, and blocking every unrelated publish for a parked pack would train
|
|
108
|
+
* the --no-guard habit. The moment `private` is dropped without the hold being satisfied, this rule
|
|
109
|
+
* HARD-blocks publish.
|
|
110
|
+
*/
|
|
111
|
+
readonly licenceHold?: readonly {
|
|
112
|
+
readonly name: string;
|
|
113
|
+
readonly privateFlag: boolean;
|
|
114
|
+
/** LICENSE file text; null ⇒ absent. */
|
|
115
|
+
readonly licenseText: string | null;
|
|
116
|
+
/** THIRD_PARTY_NOTICES(.md) text; null ⇒ absent. */
|
|
117
|
+
readonly noticesText: string | null;
|
|
118
|
+
/** package.json `license` field. */
|
|
119
|
+
readonly licenseField: string | null;
|
|
120
|
+
}[];
|
|
83
121
|
readonly lockfile?: {
|
|
84
122
|
readonly parsed: boolean;
|
|
85
123
|
readonly importers?: readonly {
|
|
@@ -191,8 +229,15 @@ export const DEFAULT_RULES: readonly GuardRule[] = [
|
|
|
191
229
|
{ id: 'readme-first', severity: 'soft', ops: ['publish'], description: 'a package with a staged version bump must update its own README.md in the same change (README-first)' },
|
|
192
230
|
{ id: 'lockfile-in-sync', severity: 'soft', ops: ['publish'], description: 'every workspace @dzhechkov/* dependency spec matches the specifier pnpm-lock.yaml records for that importer (a dep bump without a lockfile refresh breaks CI with ERR_PNPM_OUTDATED_LOCKFILE). SOFT-ONLY — a config cannot promote it to HARD' },
|
|
193
231
|
{ id: 'store-bloat-cap', severity: 'soft', ops: ['teach', 'consolidate'], description: 'the learned store is within its size cap' },
|
|
232
|
+
// Description ASSEMBLED from STUB_MARKERS so guard.ts itself stays clean under the scan it defines
|
|
233
|
+
// (structural self-exemption — tested in no-stubs.test.ts).
|
|
234
|
+
{ id: 'no-stubs', severity: 'soft', ops: ['publish'], description: `an unfinished-stub marker (${STUB_MARKERS.join('/')} / "${STUB_PHRASES.join('", "')}") left in a CHANGED file — any unwaived match means the change ships incomplete; waive per line with "no-stubs: <reason>" or per path in .dz/guard.json stubWaivers (reason MANDATORY)` },
|
|
235
|
+
{ id: 'licence-hold', severity: 'hard', ops: ['publish'], description: 'a pack that declares a licence hold (package.json.licenseHold — ADR-001 hermes-claude-adaptation) must not become publishable until the hold is satisfied: LICENSE present without the PENDING grant placeholder, a Grant-Confirmation URL, non-empty THIRD_PARTY_NOTICES, and a clean SPDX license field' },
|
|
194
236
|
];
|
|
195
237
|
|
|
238
|
+
/** The exact placeholder LICENSE marker the licence-hold rule looks for (shared with pack tests). */
|
|
239
|
+
export const LICENCE_HOLD_PENDING_MARKER = '<!-- PENDING:';
|
|
240
|
+
|
|
196
241
|
/**
|
|
197
242
|
* Secret patterns — high-signal, low-false-positive. Each is anchored to a real credential shape, so ordinary
|
|
198
243
|
* prose does not trip it. Extend deliberately (a broad `[A-Za-z0-9]{32}` would flag every hash).
|
|
@@ -317,6 +362,53 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
317
362
|
if (!s || !Number.isFinite(s.count) || !Number.isFinite(s.cap) || s.cap <= 0) return [];
|
|
318
363
|
return s.count <= s.cap ? [] : [{ rule: 'store-bloat-cap', severity: sev, detail: `learned store has ${s.count} patterns, over the cap of ${s.cap} — run dz consolidate --prune-noise` }];
|
|
319
364
|
},
|
|
365
|
+
'no-stubs': (f, sev) => {
|
|
366
|
+
// Backlog 0b403a0106103901 (Karpathy-Michaels rule XI), layer-1 on the cost-of-detection ladder:
|
|
367
|
+
// a stub marker left in a file of THIS change-set means the task shipped incomplete. SCOPE is the
|
|
368
|
+
// change-set on purpose (MEASURED: a whole-tree scan is ~78% ancient-marker noise and would be
|
|
369
|
+
// switched off within a day). SOFT: it warns — an unfinished stub is a fact worth a record, but a
|
|
370
|
+
// publish of an unrelated package must not be blocked by it. Fail-open on missing evidence: no
|
|
371
|
+
// `change` fact, or a changed file whose contents were not gathered, reports nothing.
|
|
372
|
+
const ch = f.change;
|
|
373
|
+
if (!ch || typeof ch !== 'object' || !Array.isArray(ch.files)) return [];
|
|
374
|
+
return checkNoStubs(ch.files, ch.contents, f.stubWaivers).map((s) => ({
|
|
375
|
+
rule: 'no-stubs',
|
|
376
|
+
severity: sev,
|
|
377
|
+
detail: s.kind === 'marker'
|
|
378
|
+
? `${s.path}:${s.line}: stub marker "${s.detail}" in a changed file — finish it, or waive the line with "no-stubs: <reason>" (or .dz/guard.json stubWaivers with a reason)`
|
|
379
|
+
: `${s.path}${s.line > 0 ? ':' + s.line : ''}: ${s.detail}`,
|
|
380
|
+
}));
|
|
381
|
+
},
|
|
382
|
+
'licence-hold': (f, sev) => {
|
|
383
|
+
// ADR-001 (hermes-claude-adaptation), machine-armed: a pack parked behind a licence hold carries
|
|
384
|
+
// `private:true` (the npm layer refuses it) + a `licenseHold` trigger field. THIS rule is the second
|
|
385
|
+
// belt — it fires the moment the pack becomes PUBLISHABLE (private dropped) while the hold is still
|
|
386
|
+
// unsatisfied, and refuses `dz publish` HARD. Clearing the hold means: real LICENSE (no PENDING
|
|
387
|
+
// placeholder, a Grant-Confirmation URL), non-empty THIRD_PARTY_NOTICES, and a clean SPDX id in
|
|
388
|
+
// package.json.license. The trigger field itself stays — a satisfied hold passes with it in place.
|
|
389
|
+
const out: Violation[] = [];
|
|
390
|
+
for (const p of f.licenceHold ?? []) {
|
|
391
|
+
if (!p || typeof p.name !== 'string') continue;
|
|
392
|
+
if (p.privateFlag === true) continue; // npm refuses it; other packs' publishes stay unblocked
|
|
393
|
+
const problems: string[] = [];
|
|
394
|
+
const lic = (p.licenseText ?? '').trim();
|
|
395
|
+
const notices = (p.noticesText ?? '').trim();
|
|
396
|
+
if (lic.length < 40) problems.push('LICENSE missing or empty');
|
|
397
|
+
else {
|
|
398
|
+
if (lic.includes(LICENCE_HOLD_PENDING_MARKER)) problems.push('LICENSE still carries the PENDING grant placeholder');
|
|
399
|
+
if (!/Grant-Confirmation:\s*https?:\/\/\S+/.test(lic)) problems.push('LICENSE has no "Grant-Confirmation: <url>" line');
|
|
400
|
+
}
|
|
401
|
+
if (notices.length < 40) problems.push('THIRD_PARTY_NOTICES missing or empty');
|
|
402
|
+
const field = (p.licenseField ?? '').trim();
|
|
403
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9.+-]*(\s+(AND|OR|WITH)\s+[A-Za-z0-9][A-Za-z0-9.+-]*)*$/.test(field) || /^UNLICENSED$/i.test(field)) {
|
|
404
|
+
problems.push(`package.json license "${field}" is not a clean SPDX id`);
|
|
405
|
+
}
|
|
406
|
+
if (problems.length > 0) {
|
|
407
|
+
out.push({ rule: 'licence-hold', severity: sev, detail: `${p.name}: publishable (private flag removed) but the ADR-001 licence hold is UNSATISFIED — ${problems.join('; ')}` });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return out;
|
|
411
|
+
},
|
|
320
412
|
};
|
|
321
413
|
|
|
322
414
|
/**
|
|
@@ -431,7 +523,17 @@ export function evaluateGuard(facts: GuardFacts, rules: readonly GuardRule[] = D
|
|
|
431
523
|
}
|
|
432
524
|
}
|
|
433
525
|
const verdict: GuardVerdict = violations.some((v) => v.severity === 'hard') ? 'block' : violations.length > 0 ? 'warn' : 'pass';
|
|
434
|
-
|
|
526
|
+
// FN-7 — the fail-open skips go ON THE RECORD. The no-stubs scan is fail-open by contract
|
|
527
|
+
// (missing contents ⇒ nothing reported), but a skip nobody can see is fail-SILENT. One aggregate
|
|
528
|
+
// note, computed AFTER the verdict so it can never block or warn: information, not a violation.
|
|
529
|
+
const notes: string[] = [];
|
|
530
|
+
if (checked.includes('no-stubs')) {
|
|
531
|
+
const skipped = facts.change?.stubSkipped;
|
|
532
|
+
if (typeof skipped === 'number' && Number.isFinite(skipped) && skipped > 0) {
|
|
533
|
+
notes.push(`no-stubs: ${skipped} changed scannable file(s) not scanned (deleted/oversize/unreadable/beyond the file cap) — the stub scan is fail-open, so this is a coverage gap on the record, not a violation`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return { op, verdict, violations, checked, ...(notes.length > 0 ? { notes } : {}) };
|
|
435
537
|
}
|
|
436
538
|
|
|
437
539
|
/** An append-only audit record. `ts` is injected by the caller (no wall-clock here → deterministic tests). */
|
|
@@ -440,6 +542,8 @@ export interface GuardAuditRecord {
|
|
|
440
542
|
readonly op: GuardOp;
|
|
441
543
|
readonly verdict: GuardVerdict;
|
|
442
544
|
readonly violations: readonly Violation[];
|
|
545
|
+
/** informational notes (FN-7 — e.g. the no-stubs skipped-files note); on the record, never a verdict input. */
|
|
546
|
+
readonly notes?: readonly string[];
|
|
443
547
|
/** set when the operator overrode a block with `--force <reason>` — the override is logged, never silent. */
|
|
444
548
|
readonly override?: { readonly forced: true; readonly reason: string };
|
|
445
549
|
}
|
|
@@ -451,6 +555,7 @@ export function auditRecord(result: GuardResult, ts: string, override?: { reason
|
|
|
451
555
|
op: result.op,
|
|
452
556
|
verdict: result.verdict,
|
|
453
557
|
violations: result.violations,
|
|
558
|
+
...(Array.isArray(result.notes) && result.notes.length > 0 ? { notes: result.notes } : {}),
|
|
454
559
|
...(override && typeof override.reason === 'string' ? { override: { forced: true, reason: override.reason } } : {}),
|
|
455
560
|
};
|
|
456
561
|
}
|