@dzhechkov/harness-core 0.8.10 → 0.8.11
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 +120 -60
- package/dist/codex-invoke.d.ts +73 -0
- package/dist/codex-invoke.d.ts.map +1 -0
- package/dist/codex-invoke.js +80 -0
- package/dist/codex-invoke.js.map +1 -0
- package/dist/discrimination-gate.d.ts +63 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +113 -16
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/event-chain.d.ts +30 -0
- package/dist/event-chain.d.ts.map +1 -1
- package/dist/event-chain.js +24 -0
- package/dist/event-chain.js.map +1 -1
- package/dist/guard.d.ts +8 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +37 -0
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -4
- package/dist/index.js.map +1 -1
- package/dist/mutation-gate.d.ts +39 -36
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +111 -5
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +8 -5
- package/dist/operations.js.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +27 -5
- package/dist/plugin.js.map +1 -1
- package/dist/recommend.d.ts +4 -5
- package/dist/recommend.d.ts.map +1 -1
- package/dist/recommend.js +110 -45
- package/dist/recommend.js.map +1 -1
- package/dist/registry.d.ts +32 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +165 -9
- package/dist/registry.js.map +1 -1
- package/dist/run-records.d.ts +3 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +18 -0
- package/dist/run-records.js.map +1 -1
- package/dist/score.d.ts +95 -0
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +274 -2
- package/dist/score.js.map +1 -1
- package/dist/skill-selection.d.ts +72 -0
- package/dist/skill-selection.d.ts.map +1 -0
- package/dist/skill-selection.js +76 -0
- package/dist/skill-selection.js.map +1 -0
- package/dist/stem.d.ts +12 -0
- package/dist/stem.d.ts.map +1 -0
- package/dist/stem.js +89 -0
- package/dist/stem.js.map +1 -0
- package/dist/telemetry-vocabulary.d.ts +7 -0
- package/dist/telemetry-vocabulary.d.ts.map +1 -1
- package/dist/telemetry-vocabulary.js +29 -0
- package/dist/telemetry-vocabulary.js.map +1 -1
- package/package.json +8 -8
- package/sbom.json +209 -59
- package/src/codex-invoke.ts +138 -0
- package/src/discrimination-gate.ts +183 -19
- package/src/event-chain.ts +41 -0
- package/src/guard.ts +40 -0
- package/src/index.ts +10 -4
- package/src/mutation-gate.ts +165 -5
- package/src/operations.ts +8 -5
- package/src/plugin.ts +27 -5
- package/src/recommend.ts +116 -46
- package/src/registry.ts +144 -11
- package/src/run-records.ts +23 -0
- package/src/score.ts +361 -3
- package/src/skill-selection.ts +111 -0
- package/src/stem.ts +87 -0
- package/src/telemetry-vocabulary.ts +36 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one supported way to invoke Codex from a Claude Code session.
|
|
3
|
+
*
|
|
4
|
+
* Three failure modes were measured on 2026-08-31, each violating knowledge we had already
|
|
5
|
+
* written down and could not enforce: the fire-and-forget wrapper returning a dispatch stub for a
|
|
6
|
+
* stage whose deliverable is its return value; a prompt passed as a shell ARGUMENT whose backticks
|
|
7
|
+
* the shell read as command substitution, leaving codex to read an empty stdin and hang for 26
|
|
8
|
+
* minutes; and an unscoped run spending its whole budget exploring the tree and returning no
|
|
9
|
+
* verdict at all.
|
|
10
|
+
*
|
|
11
|
+
* The cure is structural, not advisory: this module has no parameter that accepts a prompt STRING
|
|
12
|
+
* (so a shell can never mangle it), a timeout is always present, and the outcome set is CLOSED —
|
|
13
|
+
* either the model's text, or one of five named refusals. There is no third state, which is the
|
|
14
|
+
* property the tests pin.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Every way this can fail, enumerated. A refusal outside this set is a bug, not a new case. */
|
|
18
|
+
export type CodexRefusal =
|
|
19
|
+
| 'timeout'
|
|
20
|
+
| 'no-output'
|
|
21
|
+
| 'model-unavailable'
|
|
22
|
+
| 'tool-error'
|
|
23
|
+
| 'bad-usage';
|
|
24
|
+
|
|
25
|
+
export interface CodexOk {
|
|
26
|
+
readonly ok: true;
|
|
27
|
+
readonly text: string;
|
|
28
|
+
readonly model: string;
|
|
29
|
+
readonly elapsedMs: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CodexRefused {
|
|
33
|
+
readonly ok: false;
|
|
34
|
+
readonly refusal: CodexRefusal;
|
|
35
|
+
/** Human-readable, names WHAT was observed — never a guess at the cause. */
|
|
36
|
+
readonly detail: string;
|
|
37
|
+
readonly model?: string;
|
|
38
|
+
readonly elapsedMs?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type CodexOutcome = CodexOk | CodexRefused;
|
|
42
|
+
|
|
43
|
+
export const CODEX_DEFAULT_TIMEOUT_MS = 600_000;
|
|
44
|
+
export const CODEX_PROBE_TIMEOUT_MS = 90_000;
|
|
45
|
+
|
|
46
|
+
/** Model ids this account has been seen to answer on. A name here is spellable, never available. */
|
|
47
|
+
export const CODEX_KNOWN_MODELS = ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5'] as const;
|
|
48
|
+
|
|
49
|
+
export interface CodexRunInput {
|
|
50
|
+
/** Absolute path to a file holding the prompt. There is deliberately no string variant. */
|
|
51
|
+
readonly promptFile: string;
|
|
52
|
+
readonly model: string;
|
|
53
|
+
readonly timeoutMs?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Files the model may read. REQUIRED for review-shaped work: an unscoped run was measured at
|
|
56
|
+
* 280s / exit 124 / 416KB of exploration and no verdict, while the same question scoped to two
|
|
57
|
+
* named files answered in 41s. Empty means "no scope declared" and is allowed only for a
|
|
58
|
+
* self-contained question that needs no repository access.
|
|
59
|
+
*/
|
|
60
|
+
readonly scope?: readonly string[];
|
|
61
|
+
readonly effort?: 'low' | 'medium' | 'high' | 'xhigh';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What a runner must provide. Kept tiny so tests can substitute it without spawning anything. */
|
|
65
|
+
export interface CodexRunner {
|
|
66
|
+
(argv: readonly string[], timeoutMs: number): {
|
|
67
|
+
readonly status: number | null;
|
|
68
|
+
readonly stdout: string;
|
|
69
|
+
readonly stderr: string;
|
|
70
|
+
readonly timedOut: boolean;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Build the argv. Exported so a test can assert the prompt never rides on the command line. */
|
|
75
|
+
export function codexArgv(input: CodexRunInput): string[] {
|
|
76
|
+
const argv = ['exec', '-m', input.model, '--skip-git-repo-check'];
|
|
77
|
+
if (input.effort !== undefined) argv.push('-c', `model_reasoning_effort=${input.effort}`);
|
|
78
|
+
return argv;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Turn a raw runner result into the closed outcome set. */
|
|
82
|
+
export function classifyCodexResult(
|
|
83
|
+
raw: { status: number | null; stdout: string; stderr: string; timedOut: boolean },
|
|
84
|
+
model: string,
|
|
85
|
+
elapsedMs: number,
|
|
86
|
+
): CodexOutcome {
|
|
87
|
+
if (raw.timedOut) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
refusal: 'timeout',
|
|
91
|
+
detail: `no answer within the deadline (${elapsedMs}ms); narrow the scope rather than raising the ceiling — an unscoped run spends the budget exploring`,
|
|
92
|
+
model,
|
|
93
|
+
elapsedMs,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (raw.status !== 0) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
refusal: 'tool-error',
|
|
100
|
+
detail: `codex exited ${raw.status === null ? 'by signal' : String(raw.status)}: ${raw.stderr.trim().slice(0, 300) || '(no stderr)'}`,
|
|
101
|
+
model,
|
|
102
|
+
elapsedMs,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const text = raw.stdout.trim();
|
|
106
|
+
if (text === '') {
|
|
107
|
+
// Silence is the failure this module exists to make impossible. A clean exit with nothing
|
|
108
|
+
// written is what a mangled prompt looks like from the outside, and it must never read as ok.
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
refusal: 'no-output',
|
|
112
|
+
detail: 'codex exited 0 but wrote nothing — an empty answer is a refusal, never a clean result',
|
|
113
|
+
model,
|
|
114
|
+
elapsedMs,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return { ok: true, text, model, elapsedMs };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Compose the prompt file's content: the task, plus the scope fence when one is declared. */
|
|
121
|
+
export function codexPromptBody(task: string, scope?: readonly string[]): string {
|
|
122
|
+
const trimmed = String(task ?? '').trim();
|
|
123
|
+
if (scope === undefined || scope.length === 0) return trimmed;
|
|
124
|
+
const list = scope.map((p) => `- ${p}`).join('\n');
|
|
125
|
+
return [
|
|
126
|
+
trimmed,
|
|
127
|
+
'',
|
|
128
|
+
'SCOPE — read ONLY these files and do not open others. This bound is what makes an answer',
|
|
129
|
+
'possible at all: an unscoped run was measured spending its entire budget exploring the tree',
|
|
130
|
+
'and returning no verdict.',
|
|
131
|
+
list,
|
|
132
|
+
].join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** True when the outcome may be consumed as an answer. Exists so callers cannot forget the check. */
|
|
136
|
+
export function codexAnswered(outcome: CodexOutcome): outcome is CodexOk {
|
|
137
|
+
return outcome.ok === true;
|
|
138
|
+
}
|
|
@@ -137,12 +137,103 @@ export interface ClassifyResultRow {
|
|
|
137
137
|
readonly tipEvidence?: ExecutionEvidence;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/** A CLOSED runner selection derived from the target package, never a command copied from package.json. */
|
|
141
|
+
export type RunnerSelection =
|
|
142
|
+
| {
|
|
143
|
+
readonly kind: 'vitest';
|
|
144
|
+
readonly command: 'npx vitest run';
|
|
145
|
+
readonly runnerName: 'vitest';
|
|
146
|
+
readonly how: 'scripts.test' | 'dev-dependency';
|
|
147
|
+
}
|
|
148
|
+
| {
|
|
149
|
+
readonly kind: 'node-test';
|
|
150
|
+
readonly command: 'node --test';
|
|
151
|
+
readonly runnerName: 'node --test';
|
|
152
|
+
readonly how: 'scripts.test';
|
|
153
|
+
}
|
|
154
|
+
| {
|
|
155
|
+
readonly kind: 'unsupported';
|
|
156
|
+
readonly runnerName: string;
|
|
157
|
+
readonly scriptsTest: string | null;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export type PlannedRunnerSelection =
|
|
161
|
+
| RunnerSelection
|
|
162
|
+
| {
|
|
163
|
+
readonly kind: 'explicit';
|
|
164
|
+
readonly command: string;
|
|
165
|
+
readonly runnerName: string;
|
|
166
|
+
readonly how: 'explicit-flag';
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export interface BaseRefResolution {
|
|
170
|
+
readonly requestedRef: string;
|
|
171
|
+
readonly resolvedRef: string;
|
|
172
|
+
readonly how: 'explicit-ref' | 'merge-base';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Select one of the two runner families this instrument can measure honestly.
|
|
177
|
+
*
|
|
178
|
+
* The package script is used only for classification. Its flags and shell text are never spliced
|
|
179
|
+
* into a command: a wrapper or a third runner family is an explicit unsupported result. Vitest in
|
|
180
|
+
* devDependencies is the sole tie-break when scripts.test is absent; it still maps to the fixed
|
|
181
|
+
* command template below.
|
|
182
|
+
*/
|
|
183
|
+
export function selectRunner(scriptsTest: string | null, devDeps: readonly string[]): RunnerSelection {
|
|
184
|
+
const script = typeof scriptsTest === 'string' && scriptsTest.trim() ? scriptsTest.trim() : null;
|
|
185
|
+
const tokens = script?.split(/\s+/) ?? [];
|
|
186
|
+
const hasShellControl = script !== null && /[\0`$;&|<>()\n\r]/.test(script);
|
|
187
|
+
|
|
188
|
+
if (!hasShellControl) {
|
|
189
|
+
const vitestOffset =
|
|
190
|
+
tokens[0] === 'vitest'
|
|
191
|
+
? 0
|
|
192
|
+
: tokens[0] === 'npx' && tokens[1] === 'vitest'
|
|
193
|
+
? 1
|
|
194
|
+
: tokens[0] === 'pnpm' && tokens[1] === 'exec' && tokens[2] === 'vitest'
|
|
195
|
+
? 2
|
|
196
|
+
: -1;
|
|
197
|
+
if (vitestOffset >= 0) {
|
|
198
|
+
return { kind: 'vitest', command: 'npx vitest run', runnerName: 'vitest', how: 'scripts.test' };
|
|
199
|
+
}
|
|
200
|
+
if (tokens[0] === 'node' && tokens[1] === '--test') {
|
|
201
|
+
return { kind: 'node-test', command: 'node --test', runnerName: 'node --test', how: 'scripts.test' };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const deps = Array.isArray(devDeps) ? devDeps : [];
|
|
206
|
+
if (script === null && deps.some((dep) => dep === 'vitest')) {
|
|
207
|
+
return { kind: 'vitest', command: 'npx vitest run', runnerName: 'vitest', how: 'dev-dependency' };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { kind: 'unsupported', runnerName: tokens[0] ?? 'none', scriptsTest: script };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Resolve an audited pre-feature ref supplied by the executor. HEAD never wins over a merge-base. */
|
|
214
|
+
export function resolveDiscriminationBaseRef(requestedRef: string, mergeBaseRef?: string): BaseRefResolution {
|
|
215
|
+
const requested = typeof requestedRef === 'string' ? requestedRef.trim() : '';
|
|
216
|
+
const mergeBase = typeof mergeBaseRef === 'string' ? mergeBaseRef.trim() : '';
|
|
217
|
+
if (requested === 'HEAD' && mergeBase) {
|
|
218
|
+
return { requestedRef: requested, resolvedRef: mergeBase, how: 'merge-base' };
|
|
219
|
+
}
|
|
220
|
+
return { requestedRef: requested, resolvedRef: requested, how: 'explicit-ref' };
|
|
221
|
+
}
|
|
222
|
+
|
|
140
223
|
export interface DiscriminationPlanInput {
|
|
141
224
|
/** the git ref of pre-feature HEAD — the "base" the property test must fail against. */
|
|
142
225
|
readonly baseRef: string;
|
|
226
|
+
/** merge-base already measured by the executor; mandatory to displace a sweeping HEAD. */
|
|
227
|
+
readonly mergeBaseRef?: string;
|
|
143
228
|
/** property test(s) mapped from the ADR Confirmation. Empty ⇒ CANNOT_ISOLATE. */
|
|
144
229
|
readonly propertyTests: readonly PropertyTestRef[];
|
|
145
|
-
/**
|
|
230
|
+
/** repo-relative directory owning the TARGET package.json; `.` when the repository root owns it. */
|
|
231
|
+
readonly packageDir?: string;
|
|
232
|
+
/** TARGET package.json scripts.test. It is classified, never executed verbatim. */
|
|
233
|
+
readonly packageTestScript?: string | null;
|
|
234
|
+
/** TARGET package devDependency names, used only for the documented vitest tie-break. */
|
|
235
|
+
readonly packageDevDependencies?: readonly string[];
|
|
236
|
+
/** explicit safe escape hatch. Absence derives from packageTestScript; unsafe input refuses. */
|
|
146
237
|
readonly runner?: string;
|
|
147
238
|
}
|
|
148
239
|
|
|
@@ -151,19 +242,33 @@ export interface DiscriminationPlan {
|
|
|
151
242
|
readonly runnable: boolean;
|
|
152
243
|
/** why not runnable, when `runnable` is false. */
|
|
153
244
|
readonly reason?: string;
|
|
245
|
+
/** Plan-time state. REFUSE is non-passing; PENDING says execution evidence is still required. */
|
|
246
|
+
readonly verdict: 'PENDING' | 'REFUSE';
|
|
247
|
+
/** A plan alone has measured nothing; in particular every refusal is false. */
|
|
248
|
+
readonly measurementValid: false;
|
|
249
|
+
readonly primaryAction: PrimaryAction;
|
|
154
250
|
/** the sanitized base ref actually used. */
|
|
155
251
|
readonly baseRef: string;
|
|
252
|
+
readonly baseRefResolution: BaseRefResolution;
|
|
253
|
+
readonly runnerSelection: PlannedRunnerSelection;
|
|
254
|
+
readonly packageDir: string;
|
|
156
255
|
/** the accepted, sanitized targets. */
|
|
157
256
|
readonly targets: readonly PropertyTestRef[];
|
|
158
257
|
/** refs rejected by sanitation, with the reason — surfaced so a rejection is never silent. */
|
|
159
258
|
readonly rejected: readonly { readonly file: string; readonly reason: string }[];
|
|
160
259
|
/**
|
|
161
|
-
* Ordered shell steps the caller runs: add
|
|
162
|
-
*
|
|
260
|
+
* Ordered shell steps the caller runs: add the complete detached revision at baseRef, run the selected
|
|
261
|
+
* package-scoped command over positional targets, then remove the worktree. No lone-file tree is valid.
|
|
163
262
|
* `{{WORKTREE}}` is a placeholder the caller substitutes with a fresh temp dir path it owns — the engine
|
|
164
263
|
* never invents a filesystem path. Commands use only sanitized tokens.
|
|
165
264
|
*/
|
|
166
265
|
readonly commands: readonly string[];
|
|
266
|
+
/** The detached worktree itself supplies the complete revision; no lone-file copy is an isolation tree. */
|
|
267
|
+
readonly isolation: {
|
|
268
|
+
readonly materialization: 'full-revision-tree';
|
|
269
|
+
readonly revision: string;
|
|
270
|
+
readonly overlays: readonly string[];
|
|
271
|
+
};
|
|
167
272
|
}
|
|
168
273
|
|
|
169
274
|
export interface ClassifyInput {
|
|
@@ -228,7 +333,6 @@ const SAFE_REF = /^[A-Za-z0-9_][A-Za-z0-9_./~^@{}-]{0,199}$/;
|
|
|
228
333
|
* relative file, and anything exotic is safer rejected (and surfaced) than quoted-and-hoped.
|
|
229
334
|
*/
|
|
230
335
|
const UNSAFE_PATH = /(^\/)|(^[A-Za-z]:)|(^~)|(^-)|(\/-)|(\.\.(\/|\\|$))|[\0`$;&|<>*?"'\n\r\t \\]/;
|
|
231
|
-
const DEFAULT_RUNNER = 'npx vitest run';
|
|
232
336
|
/** a runner must be a plain command with flags — no shell metacharacters that could chain a second command. */
|
|
233
337
|
const UNSAFE_RUNNER = /[\0`$;&|<>()\n\r]/;
|
|
234
338
|
|
|
@@ -244,15 +348,42 @@ function sanitizeName(name: string): string | null {
|
|
|
244
348
|
* ordered worktree commands. Returns `runnable:false` with a reason when there is nothing safe to run.
|
|
245
349
|
*/
|
|
246
350
|
export function planDiscriminationCheck(input: DiscriminationPlanInput): DiscriminationPlan {
|
|
247
|
-
const
|
|
351
|
+
const requestedBaseRef = typeof input.baseRef === 'string' ? input.baseRef.trim() : '';
|
|
352
|
+
const baseRefResolution = resolveDiscriminationBaseRef(requestedBaseRef, input.mergeBaseRef);
|
|
353
|
+
const baseRef = baseRefResolution.resolvedRef;
|
|
354
|
+
const packageDirRaw = typeof input.packageDir === 'string' ? input.packageDir.trim().replace(/\/$/, '') : '.';
|
|
355
|
+
const packageDir = packageDirRaw || '.';
|
|
248
356
|
const rejected: { file: string; reason: string }[] = [];
|
|
357
|
+
const explicitRunner = typeof input.runner === 'string' && input.runner.trim() ? input.runner.trim() : null;
|
|
358
|
+
const runnerName = explicitRunner?.split(/\s+/)[0] ?? 'none';
|
|
359
|
+
const runnerSelection: PlannedRunnerSelection = explicitRunner
|
|
360
|
+
? { kind: 'explicit', command: explicitRunner, runnerName, how: 'explicit-flag' }
|
|
361
|
+
: selectRunner(input.packageTestScript ?? null, input.packageDevDependencies ?? []);
|
|
362
|
+
const isolation = { materialization: 'full-revision-tree', revision: baseRef, overlays: [] } as const;
|
|
363
|
+
|
|
364
|
+
const refuse = (reason: string, action: PrimaryAction = 'fix-runner-invocation'): DiscriminationPlan => ({
|
|
365
|
+
runnable: false,
|
|
366
|
+
reason,
|
|
367
|
+
verdict: 'REFUSE',
|
|
368
|
+
measurementValid: false,
|
|
369
|
+
primaryAction: action,
|
|
370
|
+
baseRef,
|
|
371
|
+
baseRefResolution,
|
|
372
|
+
runnerSelection,
|
|
373
|
+
packageDir,
|
|
374
|
+
targets: [],
|
|
375
|
+
rejected,
|
|
376
|
+
commands: [],
|
|
377
|
+
isolation,
|
|
378
|
+
});
|
|
249
379
|
|
|
250
|
-
if (!SAFE_REF.test(baseRef)) {
|
|
251
|
-
return
|
|
380
|
+
if (!SAFE_REF.test(requestedBaseRef) || !SAFE_REF.test(baseRef)) {
|
|
381
|
+
return refuse('unsafe-or-missing-base-ref');
|
|
252
382
|
}
|
|
253
383
|
|
|
254
|
-
|
|
255
|
-
|
|
384
|
+
if (packageDir !== '.' && UNSAFE_PATH.test(packageDir)) {
|
|
385
|
+
return refuse('unsafe-package-dir');
|
|
386
|
+
}
|
|
256
387
|
|
|
257
388
|
const targets: PropertyTestRef[] = [];
|
|
258
389
|
const seen = new Set<string>();
|
|
@@ -260,6 +391,10 @@ export function planDiscriminationCheck(input: DiscriminationPlanInput): Discrim
|
|
|
260
391
|
const file = t && typeof t.file === 'string' ? t.file.trim() : '';
|
|
261
392
|
if (!file) { rejected.push({ file: String(t?.file ?? ''), reason: 'empty-path' }); continue; }
|
|
262
393
|
if (UNSAFE_PATH.test(file)) { rejected.push({ file, reason: 'unsafe-path' }); continue; }
|
|
394
|
+
if (packageDir !== '.' && !file.startsWith(`${packageDir}/`)) {
|
|
395
|
+
rejected.push({ file, reason: 'outside-target-package' });
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
263
398
|
const name = t && typeof t.name === 'string' ? sanitizeName(t.name) : null;
|
|
264
399
|
if (t && typeof t.name === 'string' && name === null) { rejected.push({ file, reason: 'unsafe-test-name' }); continue; }
|
|
265
400
|
const key = `${file}|${name ?? ''}`;
|
|
@@ -269,22 +404,51 @@ export function planDiscriminationCheck(input: DiscriminationPlanInput): Discrim
|
|
|
269
404
|
}
|
|
270
405
|
|
|
271
406
|
if (targets.length === 0) {
|
|
272
|
-
|
|
407
|
+
const plan = refuse('no-isolable-test', 'map-a-test');
|
|
408
|
+
return { ...plan, targets };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (explicitRunner !== null && UNSAFE_RUNNER.test(explicitRunner)) {
|
|
412
|
+
const plan = refuse(`unsafe-runner:${runnerName}`);
|
|
413
|
+
return { ...plan, targets };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (runnerSelection.kind === 'unsupported') {
|
|
417
|
+
const plan = refuse(`unsupported-runner:${runnerSelection.runnerName}`);
|
|
418
|
+
return { ...plan, targets };
|
|
273
419
|
}
|
|
274
420
|
|
|
275
421
|
// `{{WORKTREE}}` is substituted by the caller with a temp dir IT owns; the engine never invents a path.
|
|
276
|
-
//
|
|
277
|
-
//
|
|
422
|
+
// `git worktree add` materialises the complete base revision. No lone property-test copy is emitted:
|
|
423
|
+
// a copied test without its sibling source/config tree is not an isolated revision and cannot measure.
|
|
278
424
|
const commands: string[] = [`git worktree add --detach {{WORKTREE}} ${baseRef}`];
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
425
|
+
const relativeToPackage = (file: string): string => (packageDir === '.' ? file : file.slice(packageDir.length + 1));
|
|
426
|
+
const fileArgs = [...new Set(targets.map((t) => relativeToPackage(t.file)))].map((f) => `'${f}'`).join(' ');
|
|
427
|
+
const testNames = targets.filter((t) => t.name).map((t) => t.name as string);
|
|
428
|
+
const nameFilters =
|
|
429
|
+
runnerSelection.kind === 'node-test'
|
|
430
|
+
? testNames.map((name) => `--test-name-pattern '${name}'`).join(' ')
|
|
431
|
+
: testNames.map((name) => `-t '${name}'`).join(' ');
|
|
432
|
+
const runner = runnerSelection.command;
|
|
433
|
+
const runnerArgs = `${runner}${nameFilters ? ` ${nameFilters}` : ''} ${fileArgs}`;
|
|
434
|
+
const worktreePackageDir = packageDir === '.' ? '{{WORKTREE}}' : `{{WORKTREE}}/${packageDir}`;
|
|
435
|
+
commands.push(`( cd "${worktreePackageDir}" && ${runnerArgs} )`);
|
|
285
436
|
commands.push(`git worktree remove --force {{WORKTREE}}`);
|
|
286
437
|
|
|
287
|
-
return {
|
|
438
|
+
return {
|
|
439
|
+
runnable: true,
|
|
440
|
+
verdict: 'PENDING',
|
|
441
|
+
measurementValid: false,
|
|
442
|
+
primaryAction: 'none',
|
|
443
|
+
baseRef,
|
|
444
|
+
baseRefResolution,
|
|
445
|
+
runnerSelection,
|
|
446
|
+
packageDir,
|
|
447
|
+
targets,
|
|
448
|
+
rejected,
|
|
449
|
+
commands,
|
|
450
|
+
isolation,
|
|
451
|
+
};
|
|
288
452
|
}
|
|
289
453
|
|
|
290
454
|
|
package/src/event-chain.ts
CHANGED
|
@@ -224,6 +224,47 @@ export function readTailInfo(tailText: string, opts: { readonly partial?: boolea
|
|
|
224
224
|
/** An empty log — what an appender assumes when the file is absent. */
|
|
225
225
|
export const EMPTY_LOG_TAIL: LogTail = { lastLine: undefined, endsWithNewline: true, unreadable: false };
|
|
226
226
|
|
|
227
|
+
/**
|
|
228
|
+
* ONE journal whose records are hash-chained, and the decision that rests on it.
|
|
229
|
+
*
|
|
230
|
+
* `decides` is not documentation garnish: it is the reason integrity matters HERE and not
|
|
231
|
+
* everywhere. A chain costs nothing to read and something to maintain, so a journal earns one by
|
|
232
|
+
* being the basis of a verdict — where a lost or duplicated record is a WRONG ANSWER WITH NO
|
|
233
|
+
* SYMPTOM. A journal nobody decides on does not need a chain, and saying so keeps the registry from
|
|
234
|
+
* growing into a list of every file we happen to append to.
|
|
235
|
+
*/
|
|
236
|
+
export interface ChainedJournal {
|
|
237
|
+
/** Path relative to the project root. */
|
|
238
|
+
readonly rel: string;
|
|
239
|
+
/** The verdict that would silently go wrong if a record were lost or duplicated. */
|
|
240
|
+
readonly decides: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* THE registry of chained journals — the single list every verification surface reads.
|
|
245
|
+
*
|
|
246
|
+
* Why this exists (backlog `bc4ee35c`, W0-chain): the chain machinery was built, and then each
|
|
247
|
+
* consumer grew its OWN private list of which files carry a chain — `dz doctor` had a two-element
|
|
248
|
+
* array inline, the score aggregate checked its own file, and nothing checked the rest. Three
|
|
249
|
+
* surfaces, three lists, and no way to ask "are all the chained journals intact?" So a journal
|
|
250
|
+
* could be given a chain and STILL be checked by nobody: the mechanism present, the coverage
|
|
251
|
+
* absent, and no red anywhere to say so.
|
|
252
|
+
*
|
|
253
|
+
* MEASURED 2026-09-01: of eight append-only journals under `.dz/`, exactly two carry a chain
|
|
254
|
+
* (probe: `tail -1 <file>` for a `seq` field). Adding the third must be one line HERE, not one line
|
|
255
|
+
* in each surface — which is the whole point of a registry, and what its test pins.
|
|
256
|
+
*/
|
|
257
|
+
export const CHAINED_JOURNALS: readonly ChainedJournal[] = [
|
|
258
|
+
{
|
|
259
|
+
rel: '.dz/recall-usage.jsonl',
|
|
260
|
+
decides: 'dz compounding — whether a taught lesson is actually paying off',
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
rel: '.dz/guard-audit.jsonl',
|
|
264
|
+
decides: 'dz guard promote — whether a lesson has won twice and may become a rule',
|
|
265
|
+
},
|
|
266
|
+
];
|
|
267
|
+
|
|
227
268
|
/**
|
|
228
269
|
* The exact text to append for a run of records: chained, newline-terminated, and preceded by a
|
|
229
270
|
* newline when the file ends mid-line. THE one place that knows how to extend one of these logs —
|
package/src/guard.ts
CHANGED
|
@@ -77,6 +77,11 @@ export interface GuardFacts {
|
|
|
77
77
|
readonly packages?: readonly { readonly name: string; readonly deps: Readonly<Record<string, string>> }[];
|
|
78
78
|
/** for no-skill-drift: the names that byte-drift between copies (from sweepSkillDrift). */
|
|
79
79
|
readonly drift?: readonly string[];
|
|
80
|
+
/**
|
|
81
|
+
* for codex-wrapper-for-value-stage: workflow scripts to scan, as (path, text). Absent ⇒ the
|
|
82
|
+
* rule reports nothing: a guard with no evidence must stay silent rather than invent a verdict.
|
|
83
|
+
*/
|
|
84
|
+
readonly workflowScripts?: readonly { readonly path: string; readonly text: string }[];
|
|
80
85
|
/** for no-secrets: labelled blobs to scan (lesson text, staged files). */
|
|
81
86
|
readonly secretTargets?: readonly { readonly label: string; readonly text: string }[];
|
|
82
87
|
/** for readme-consistency: labelled (a,b) count pairs that must be equal. */
|
|
@@ -306,6 +311,7 @@ export const DEFAULT_RULES: readonly GuardRule[] = [
|
|
|
306
311
|
{ id: 'feature-artifact-diff-ratio', severity: 'soft', ops: ['publish'], description: 'observe feature artifact bytes against attributable unified-diff bytes, explicitly a proxy; advisory only' },
|
|
307
312
|
{ id: 'feature-tier-artifact-set', severity: 'soft', ops: ['publish'], description: 'observe artifacts due for the recorded feature tier, active steps, consumers, and lifecycle; advisory only' },
|
|
308
313
|
{ id: 'agents-md-policy-sync', severity: 'soft', ops: ['publish'], description: 'proves the AGENTS.md copy is in SYNC with its source — not that the runtime read or obeyed it; heal drift with dz agents-sync' },
|
|
314
|
+
{ id: 'codex-wrapper-for-value-stage', severity: 'hard', ops: ['publish'], description: 'a workflow stage routed to the fire-and-forget codex wrapper must not have its return value consumed — the wrapper answers with a dispatch stub, never with the model' },
|
|
309
315
|
{ 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' },
|
|
310
316
|
{ id: 'store-bloat-cap', severity: 'soft', ops: ['teach', 'consolidate'], description: 'the learned store is within its size cap' },
|
|
311
317
|
// Description ASSEMBLED from STUB_MARKERS so guard.ts itself stays clean under the scan it defines
|
|
@@ -510,6 +516,40 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
510
516
|
detail: `${drifted.length} AGENTS.md policy section(s) are out of sync: ${drifted.slice(0, 8).join(', ')}${drifted.length > 8 ? '…' : ''} — heal with: dz agents-sync`,
|
|
511
517
|
}];
|
|
512
518
|
},
|
|
519
|
+
/**
|
|
520
|
+
* The fire-and-forget wrapper returns a DISPATCH STUB, so a stage whose deliverable is its
|
|
521
|
+
* return value gets a receipt instead of an answer. MEASURED 2026-08-31: eight stages of one
|
|
522
|
+
* research swarm each returned "Codex Task started in the background as task-…", downstream
|
|
523
|
+
* agents built on those stubs, and no artifact was produced. The misuse is visible in the
|
|
524
|
+
* program text — the stage's result is assigned to a name that a later prompt interpolates —
|
|
525
|
+
* so it belongs on layer 1 rather than in a rule nobody re-reads.
|
|
526
|
+
*/
|
|
527
|
+
'codex-wrapper-for-value-stage': (f, sev) => {
|
|
528
|
+
const scripts = f.workflowScripts;
|
|
529
|
+
if (scripts === undefined || scripts.length === 0) return [];
|
|
530
|
+
const out: Violation[] = [];
|
|
531
|
+
for (const s of scripts) {
|
|
532
|
+
const text = String(s.text ?? '');
|
|
533
|
+
// Find `const <name> = await agent(… codex:codex-rescue …)` and ask whether <name> is later
|
|
534
|
+
// interpolated into another prompt. Assignment alone is not the defect: a stage may keep its
|
|
535
|
+
// handle for logging. Consumption in a prompt is what proves the VALUE was the deliverable.
|
|
536
|
+
const re = /(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*await\s+agent\(([\s\S]{0,4000}?)\)\s*(?:\n|;)/g;
|
|
537
|
+
for (const m of text.matchAll(re)) {
|
|
538
|
+
const name = String(m[1]);
|
|
539
|
+
const call = String(m[2]);
|
|
540
|
+
if (!/codex:codex-rescue/.test(call)) continue;
|
|
541
|
+
const consumed = new RegExp('\\$\\{\\s*(?:String\\()?' + name.replace(/[.*+?^{}()|[\]\\]/g, '\\$&') + '\\b');
|
|
542
|
+
if (consumed.test(text)) {
|
|
543
|
+
out.push({
|
|
544
|
+
rule: 'codex-wrapper-for-value-stage',
|
|
545
|
+
severity: sev,
|
|
546
|
+
detail: `${s.path}: stage "${name}" is routed to codex:codex-rescue AND its result is interpolated into another prompt — the wrapper returns a dispatch stub, so that prompt would receive a receipt, not an answer. Invoke codex synchronously (dz codex / codex exec) for a stage whose deliverable is its return value.`,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return out;
|
|
552
|
+
},
|
|
513
553
|
'lockfile-in-sync': (f, _sev) => {
|
|
514
554
|
// The 2026-07-28 CI break, mechanized: an overnight dep bump edited package.json and left
|
|
515
555
|
// pnpm-lock.yaml stale, so `pnpm install --frozen-lockfile` died with ERR_PNPM_OUTDATED_LOCKFILE.
|
package/src/index.ts
CHANGED
|
@@ -83,8 +83,8 @@ export type { SyncUpstreamReport, UpstreamCheckResult, SourcesManifest, SourcePa
|
|
|
83
83
|
export { sweepSkillDrift, syncCanonicalSkill } from './skill-drift.js';
|
|
84
84
|
export { SKILL_INSTALL_ROOTS, SKILL_INSTALL_ROOT_BY_TARGET, DEV_SKILL_ROOT, TARGET_ENRICHMENT_ASSETS } from './skill-install-roots.js';
|
|
85
85
|
export { stampCheckpointLine } from './checkpoint-stamp.js';
|
|
86
|
-
export { TELEMETRY_VOCAB_VERSION, TELEMETRY_FIELDS, PROVISIONAL_TELEMETRY_FIELDS, LOCAL_FIELD_ALIASES, telemetryFieldFor } from './telemetry-vocabulary.js';
|
|
87
|
-
export type { TelemetryField, FieldSource } from './telemetry-vocabulary.js';
|
|
86
|
+
export { TELEMETRY_VOCAB_VERSION, TELEMETRY_FIELDS, PROVISIONAL_TELEMETRY_FIELDS, LOCAL_FIELD_ALIASES, RUN_OUTCOMES, telemetryFieldFor, runOutcomeOf } from './telemetry-vocabulary.js';
|
|
87
|
+
export type { TelemetryField, FieldSource, RunOutcome } from './telemetry-vocabulary.js';
|
|
88
88
|
export { planLedgerBackfill, LEDGER_FILL_SOURCE, AMBIGUOUS, resolveLedgerRunId } from './ledger-backfill.js';
|
|
89
89
|
// project-skills root resolution (field report doc-25b): the ONE builder behind both the Step-0
|
|
90
90
|
// probe and the PS_GUIDANCE paragraph, so the two can never look at different roots again.
|
|
@@ -92,7 +92,8 @@ export { projectSkillsOneRoot, projectSkillsProbeCommand } from './project-skill
|
|
|
92
92
|
export type { LedgerBackfillPlan, LedgerBackfillRow, RunCostFacts } from './ledger-backfill.js';
|
|
93
93
|
export type { SweepResult, DriftedSkill, SyncResult, SyncCanonicalOptions } from './skill-drift.js';
|
|
94
94
|
export { benchmarkSkill, benchmarkSkills, compareSkills } from './benchmark.js';
|
|
95
|
-
export { buildRegistry, searchRegistry, filterByCategory, skillPackBaseDirs, discoverSkillPackDirs, discoverVerifiablePackDirs } from './registry.js';
|
|
95
|
+
export { buildRegistry, searchRegistry, filterByCategory, skillPackBaseDirs, discoverSkillPackDirs, discoverSkillCarryingDirs, discoverVerifiablePackDirs } from './registry.js';
|
|
96
|
+
export { tokenize, stemToken, stems } from './stem.js';
|
|
96
97
|
// Package skill-layout resolution (feature dz-install-npx-init) — the ONE seam that knows where an
|
|
97
98
|
// npm package keeps its skills (flat / templates/.claude/skills / skills). `cmdInstall` calls it;
|
|
98
99
|
// `dz init`/`dz registry` are the filed follow-up consumers.
|
|
@@ -577,6 +578,7 @@ export {
|
|
|
577
578
|
DEFAULT_REWRITE_ATTEMPTS,
|
|
578
579
|
liveSegmentStart,
|
|
579
580
|
classifyChainDefects,
|
|
581
|
+
CHAINED_JOURNALS,
|
|
580
582
|
} from './event-chain.js';
|
|
581
583
|
export type {
|
|
582
584
|
ChainFields,
|
|
@@ -593,6 +595,7 @@ export type {
|
|
|
593
595
|
VerifyEventChainOptions,
|
|
594
596
|
ChainDefectAge,
|
|
595
597
|
ChainDefectAges,
|
|
598
|
+
ChainedJournal,
|
|
596
599
|
} from './event-chain.js';
|
|
597
600
|
export { decideProvenance, environmentCanMintProvenance, publishArgv, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, findUnpublishedWorkspaceFloors, orderByDependencies, syncReadmeVersion, isChangelogEntryLine, changelogRegion } from './publish.js';
|
|
598
601
|
export { fetchAllDownloads } from './downloads.js';
|
|
@@ -961,7 +964,8 @@ export {
|
|
|
961
964
|
} from './epoch-replay.js';
|
|
962
965
|
|
|
963
966
|
// Run-process scorecard (feature dz-score, Reading C) — scores the DISCIPLINE of a feature-adr run
|
|
964
|
-
// from its artifacts. Descriptive-only,
|
|
967
|
+
// from its artifacts and folds immutable receipts into a chained aggregate. Descriptive-only,
|
|
968
|
+
// permanently: neither the single-run score nor the aggregate gates.
|
|
965
969
|
export * from './score.js';
|
|
966
970
|
export * from './recap.js';
|
|
967
971
|
export * from './provenance.js';
|
|
@@ -1037,3 +1041,5 @@ export type {
|
|
|
1037
1041
|
Register, Domain, OperatorProfile, ProfileReadResult, ProfileSyncResult,
|
|
1038
1042
|
ProfileDriftVerdict, ProfileDriftResult,
|
|
1039
1043
|
} from './profile.js';
|
|
1044
|
+
export * from './codex-invoke.js';
|
|
1045
|
+
export * from './skill-selection.js';
|