@dzhechkov/harness-core 0.8.34 → 0.8.36
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 +61 -61
- package/README.md +255 -12
- package/dist/agentdb-index.d.ts +22 -1
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +156 -6
- package/dist/agentdb-index.js.map +1 -1
- package/dist/apply-leg.d.ts +180 -2
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +781 -38
- package/dist/apply-leg.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +67 -5
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks.d.ts +13 -1
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +13 -1
- package/dist/codex-hooks.js.map +1 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- package/dist/index.js.map +1 -1
- package/dist/mutation-gate.d.ts +19 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +37 -1
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts +17 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +88 -9
- package/dist/operations.js.map +1 -1
- package/dist/publish.d.ts +59 -7
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +205 -32
- package/dist/publish.js.map +1 -1
- package/dist/release-line.d.ts +16 -0
- package/dist/release-line.d.ts.map +1 -1
- package/dist/release-line.js +31 -0
- package/dist/release-line.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +90 -14
- package/dist/setup.js.map +1 -1
- package/dist/skills.d.ts +87 -3
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +266 -15
- package/dist/skills.js.map +1 -1
- package/dist/vector-tier.d.ts +34 -3
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +117 -22
- package/dist/vector-tier.js.map +1 -1
- package/package.json +2 -2
- package/sbom.json +60 -60
- package/src/agentdb-index.ts +158 -7
- package/src/apply-leg.ts +824 -38
- package/src/codex-hooks-assets.ts +67 -5
- package/src/codex-hooks.ts +13 -1
- package/src/index.ts +15 -2
- package/src/mutation-gate.ts +58 -2
- package/src/operations.ts +91 -10
- package/src/publish.ts +247 -30
- package/src/release-line.ts +32 -0
- package/src/setup.ts +81 -16
- package/src/skills.ts +303 -14
- package/src/vector-tier.ts +147 -24
|
@@ -141,6 +141,66 @@ function findProjectRoot(startDir) {
|
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* FR-1 (codex-hook-root-provenance): the ONE place both hooks compute their start directory and
|
|
146
|
+
* walk to a project root — replacing two independent copies of the same ternary. T1 (fix-round 1:
|
|
147
|
+
* the original reproducer had an unexported \`BASE\`, so it measured the wrong file; corrected and
|
|
148
|
+
* re-run — see the feature's change manifest for both) measured LIVE on \`codex-cli 0.154.0\`: three
|
|
149
|
+
* real \`codex exec\` sessions (project root, a nested subdirectory, a directory with no \`.dz\`
|
|
150
|
+
* anywhere in its ancestry), each with BOTH hook events (\`PreToolUse\` and \`UserPromptSubmit\`)
|
|
151
|
+
* captured SEPARATELY. \`payload.cwd\` was present and equal to both \`PWD\` and the hook's own
|
|
152
|
+
* \`process.cwd()\` in every one of the 6 captures. \`PWD\`/\`process.cwd()\` therefore stay only as a
|
|
153
|
+
* DEFENSIVE fallback for a payload shaped without \`cwd\` — not because that fallback was ever
|
|
154
|
+
* observed to fire. This is a SCOPED finding, not a claim that the "hook read the wrong project"
|
|
155
|
+
* defect class cannot exist: it was not observed on codex-cli 0.154.0 across these 3 scenarios / 6
|
|
156
|
+
* captures, and 01_requirements.md's own Ограничение C-3 is what permits cutting FR-3 (the explicit
|
|
157
|
+
* \`DZ_PROJECT_ROOT\` override) on a scoped finding like that — not a claim of nonexistence.
|
|
158
|
+
*/
|
|
159
|
+
function resolveHookRoot(payload) {
|
|
160
|
+
const hasPayloadCwd = typeof payload.cwd === 'string' && payload.cwd !== '';
|
|
161
|
+
const startDir = hasPayloadCwd ? payload.cwd : (process.env.PWD || process.cwd());
|
|
162
|
+
const source = hasPayloadCwd ? 'payload-cwd' : (process.env.PWD ? 'env-pwd' : 'process-cwd');
|
|
163
|
+
return { root: findProjectRoot(startDir), source, startDir };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Fix-round 1, item 4: a path interpolated into the provenance line below can itself carry control
|
|
168
|
+
* characters — a \`payload.cwd\` from an untrusted producer, or a \`PWD\` set to something hostile —
|
|
169
|
+
* and a bare newline in the middle of it would defeat the "ONE line" promise the diagnostic makes.
|
|
170
|
+
* Escape the whole C0 range (0x00-0x1F) plus DEL (0x7F) into a visible \`\\n\`/\\r\`/\\t\`/\\xHH\`
|
|
171
|
+
* representation; every other byte, including non-ASCII path segments, passes through unchanged.
|
|
172
|
+
*/
|
|
173
|
+
function escapeControlChars(value) {
|
|
174
|
+
return String(value).replace(/[\\x00-\\x1f\\x7f]/g, function (ch) {
|
|
175
|
+
var code = ch.charCodeAt(0);
|
|
176
|
+
if (code === 10) return '\\\\n';
|
|
177
|
+
if (code === 13) return '\\\\r';
|
|
178
|
+
if (code === 9) return '\\\\t';
|
|
179
|
+
return '\\\\x' + code.toString(16).padStart(2, '0');
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* FR-2: ONE provenance line, same shape as the Claude hook's (\`apply-leg.ts\`'s \`skip()\`), printed
|
|
185
|
+
* to stderr. When no root was found it ALWAYS prints (the walk's whole verdict was silent before
|
|
186
|
+
* this feature); when a root WAS found it prints only under \`DZ_CODEX_HOOK_DEBUG\`, so the found
|
|
187
|
+
* path stays byte-for-byte silent by default (NFR-2). Tradeoff, named plainly: this line discloses
|
|
188
|
+
* the absolute directory the hook was asked about (which can embed a username, a customer or
|
|
189
|
+
* repository name) to stderr — accepted because it is a diagnostic aimed at the person running the
|
|
190
|
+
* hook, not a return value, and redacting it would make the not-found case as silent as the bug
|
|
191
|
+
* this feature exists to fix. \`startDir\`/\`root\` are escaped via \`escapeControlChars\` first, so an
|
|
192
|
+
* adversarial value cannot itself defeat the "ONE line" guarantee.
|
|
193
|
+
*/
|
|
194
|
+
function reportRootProvenance(resolved) {
|
|
195
|
+
if (resolved.root === null) {
|
|
196
|
+
process.stderr.write(\`[\${HELPER}] skipped reason=no-project-root start=\${escapeControlChars(resolved.startDir)} (\${resolved.source})\\n\`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (process.env.DZ_CODEX_HOOK_DEBUG) {
|
|
200
|
+
process.stderr.write(\`[\${HELPER}] root=\${escapeControlChars(resolved.root)} start=\${escapeControlChars(resolved.startDir)} (\${resolved.source})\\n\`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
144
204
|
function readProjectConfig(root) {
|
|
145
205
|
try {
|
|
146
206
|
return JSON.parse(fs.readFileSync(path.join(root, '.dz', 'config.json'), 'utf8'));
|
|
@@ -229,9 +289,10 @@ async function main() {
|
|
|
229
289
|
const command = input && typeof input === 'object' ? input.command : undefined;
|
|
230
290
|
if (typeof command !== 'string' || command === '') return 0;
|
|
231
291
|
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
292
|
+
const resolved = resolveHookRoot(payload);
|
|
293
|
+
reportRootProvenance(resolved);
|
|
294
|
+
const root = resolved.root;
|
|
295
|
+
if (root === null) return 0; // inert outside an opted-in dz project: no DECISION and no WRITE — one diagnostic line on stderr (FR-2), nothing else
|
|
235
296
|
|
|
236
297
|
// (1) The destructive-command guard. Never blocks on our own failure: an absent module, a throw,
|
|
237
298
|
// or an \`undecidable\` verdict all fall through to the shell veto below (AC-10).
|
|
@@ -370,8 +431,9 @@ async function main() {
|
|
|
370
431
|
const prompt = typeof payload.prompt === 'string' ? payload.prompt : '';
|
|
371
432
|
if (prompt.trim() === '') return;
|
|
372
433
|
|
|
373
|
-
const
|
|
374
|
-
|
|
434
|
+
const resolved = resolveHookRoot(payload);
|
|
435
|
+
reportRootProvenance(resolved);
|
|
436
|
+
const root = resolved.root;
|
|
375
437
|
if (root === null) return; // inert outside an opted-in dz project
|
|
376
438
|
|
|
377
439
|
const policy = await loadCore(root, 'recall-hook-policy.js', (m) => typeof m.selectHookHits === 'function');
|
package/src/codex-hooks.ts
CHANGED
|
@@ -56,8 +56,20 @@ import { mergeManagedHookEntries } from './managed-hooks.js';
|
|
|
56
56
|
* run was indistinguishable from a clean allow. The Claude hook already failed open loudly here.
|
|
57
57
|
* Now it prints ONE line, `DZ-DESTRUCTIVE-WARN: classifier threw — <message>`, and still exits 0.
|
|
58
58
|
* A changed body ⇒ re-trust.
|
|
59
|
+
* 8 — `codex-hook-root-provenance`: both hooks now share ONE `resolveHookRoot(payload)` instead of
|
|
60
|
+
* two copies of the same `payload.cwd || PWD || cwd()` ternary, and a silent `root === null` early
|
|
61
|
+
* return now prints one provenance line (`[dz-codex-<hook>] skipped reason=no-project-root
|
|
62
|
+
* start=<startDir> (<source>)`); the found-root path stays silent unless `DZ_CODEX_HOOK_DEBUG` is
|
|
63
|
+
* set. T1 (live probe, codex-cli 0.154.0) found `payload.cwd` always present and equal to `PWD`/
|
|
64
|
+
* `process.cwd()`, so no explicit-override knob was added. A changed body ⇒ re-trust.
|
|
65
|
+
* 9 — fix-round 1: the provenance line's interpolated paths are now escaped via
|
|
66
|
+
* `escapeControlChars` (C0 range + DEL) before printing, so a hostile `payload.cwd` cannot defeat
|
|
67
|
+
* the "ONE line" promise with an embedded newline; the corrected T1 re-run (both hook events
|
|
68
|
+
* captured separately, per-scenario — the original reproducer's `BASE` was never exported) reached
|
|
69
|
+
* the SAME conclusion, scoped honestly as "not observed on codex-cli 0.154.0 across 3 scenarios / 6
|
|
70
|
+
* captures", not "does not exist". A changed body ⇒ re-trust.
|
|
59
71
|
*/
|
|
60
|
-
export const DZ_HOOK_HELPER_VERSION =
|
|
72
|
+
export const DZ_HOOK_HELPER_VERSION = 9;
|
|
61
73
|
|
|
62
74
|
/** Seconds. Probe-proven (spike S2): `timeout` is honored, the unset default is 600 s. */
|
|
63
75
|
export const DZ_HOOK_TIMEOUT_SECONDS = 5;
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,12 @@ export const HARNESS_CORE_VERSION: string =
|
|
|
12
12
|
|
|
13
13
|
export { REPOSITORY_ORIGIN } from './repository-origin.js';
|
|
14
14
|
|
|
15
|
+
// Fix-round 1 (feature recall-short-terms, Codex HIGH-1c): `dz recall`'s CLI printer needs the
|
|
16
|
+
// single source of truth for "why did this query tokenize to nothing" without harness-cli taking
|
|
17
|
+
// a new direct dependency on `@dzhechkov/memory` (a publishing-surface change outside this
|
|
18
|
+
// feature's scope) — harness-core already depends on memory, so it re-exports the one helper.
|
|
19
|
+
export { noSearchableTermsReason } from '@dzhechkov/memory';
|
|
20
|
+
|
|
15
21
|
export * from './skills.js';
|
|
16
22
|
export * from './apply.js';
|
|
17
23
|
export {
|
|
@@ -197,6 +203,9 @@ export {
|
|
|
197
203
|
mirrorPatternsToVector,
|
|
198
204
|
backfillVectorMirror,
|
|
199
205
|
mergeHybridHits,
|
|
206
|
+
compareHybridHits,
|
|
207
|
+
evidenceRank,
|
|
208
|
+
orderHitsForReRank,
|
|
200
209
|
recallHybrid,
|
|
201
210
|
teachGuard,
|
|
202
211
|
vectorTierStatus,
|
|
@@ -218,6 +227,7 @@ export type {
|
|
|
218
227
|
HybridRecall,
|
|
219
228
|
HybridRecallMode,
|
|
220
229
|
HybridHit,
|
|
230
|
+
HybridOrderKey,
|
|
221
231
|
RankedPattern,
|
|
222
232
|
VectorServiceOptions,
|
|
223
233
|
VectorTierStatus,
|
|
@@ -246,6 +256,7 @@ export {
|
|
|
246
256
|
applyLegHookEntries,
|
|
247
257
|
applyLegStatus,
|
|
248
258
|
applyLegReasonMessage,
|
|
259
|
+
probeApplyLeg,
|
|
249
260
|
resolveIdleMs,
|
|
250
261
|
IDLE_MS_INT32_MAX,
|
|
251
262
|
} from './apply-leg.js';
|
|
@@ -255,6 +266,7 @@ export type {
|
|
|
255
266
|
ApplyLegHookPresence,
|
|
256
267
|
ApplyLegStatus,
|
|
257
268
|
ApplyLegNotInstalledReason,
|
|
269
|
+
ApplyLegProbeResult,
|
|
258
270
|
ResolvedIdleMs,
|
|
259
271
|
} from './apply-leg.js';
|
|
260
272
|
// embed-socket-short-path: the ONE resolver the daemon (inlined text), the recall hook (inlined
|
|
@@ -303,7 +315,7 @@ export {
|
|
|
303
315
|
segmentRun,
|
|
304
316
|
} from './eta.js';
|
|
305
317
|
export type { CheckpointObservation, EtaEstimate, EtaInput, IncompleteCoverageSample, RunSegment, StageDurationSample, StageSample } from './eta.js';
|
|
306
|
-
export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, resetAgentdbEmbedderCache, getAgentdbEmbedderCacheStats, cosineSimilarity, importVectorsToAgentdb, reindexAgentdbRows, bumpAgentdbUses, clearAgentdbQuarantine, deleteAgentdbByDzIds, readAgentdbRowsByTaskType, DZ_OWNED_TASK_TYPES, ensureAgentdbSchema } from './agentdb-index.js';
|
|
318
|
+
export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, resetAgentdbEmbedderCache, getAgentdbEmbedderCacheStats, cosineSimilarity, importVectorsToAgentdb, reindexAgentdbRows, bumpAgentdbUses, clearAgentdbQuarantine, deleteAgentdbByDzIds, readAgentdbRowsByTaskType, DZ_OWNED_TASK_TYPES, ensureAgentdbSchema, readStoreGeneration, bumpStoreGeneration } from './agentdb-index.js';
|
|
307
319
|
export type { AgentdbSearchHit, AgentdbSearchResult, AgentdbImportRow } from './agentdb-index.js';
|
|
308
320
|
export { DEFAULT_EMBED_MODEL, LEGACY_EMBED_MODEL, DEFAULT_EMBED_DIM, KNOWN_EMBED_DIMS, resolveEmbedModel, readEmbedManifest, writeEmbedManifest, embedManifestPath, legacyEmbedManifest } from './embedding-config.js';
|
|
309
321
|
export type { EmbedModelConfig, EmbedModelSource, EmbedManifest } from './embedding-config.js';
|
|
@@ -722,7 +734,8 @@ export type {
|
|
|
722
734
|
ChainDefectAges,
|
|
723
735
|
ChainedJournal,
|
|
724
736
|
} from './event-chain.js';
|
|
725
|
-
export { decideProvenance, environmentCanMintProvenance, publishArgv, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, findUnpublishedWorkspaceFloors, rewriteWorkspaceSpecs, orderByDependencies, syncReadmeVersion, isChangelogEntryLine, changelogRegion } from './publish.js';
|
|
737
|
+
export { decideProvenance, environmentCanMintProvenance, publishArgv, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, findUnpublishedWorkspaceFloors, rewriteWorkspaceSpecs, orderByDependencies, syncReadmeVersion, isChangelogEntryLine, changelogRegion, planReadmeVersionSync } from './publish.js';
|
|
738
|
+
export type { ReadmeVersionSyncPlan, ReadmeSyncRewrite } from './publish.js';
|
|
726
739
|
export { RELEASE_LINE_RE, findReleaseLine, rewriteReleaseLine } from './release-line.js';
|
|
727
740
|
export * from './course-staleness.js';
|
|
728
741
|
export { fetchAllDownloads } from './downloads.js';
|
package/src/mutation-gate.ts
CHANGED
|
@@ -77,6 +77,22 @@ export interface MutationRegistry {
|
|
|
77
77
|
readonly testCommand?: string;
|
|
78
78
|
/** opt-in proof that the suite harness reached its clean completion path. */
|
|
79
79
|
readonly requireCompletionReceipt?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* optional per-registry suite-run ceiling in milliseconds (mutation-gate-timeout-verdict FR-3):
|
|
82
|
+
* a package whose real baseline runs longer than the executor's 300000ms default (e.g. this
|
|
83
|
+
* repo's core package, MEASURED ≈5-8 min) declares its own floor here so `dz mutation-gate` with
|
|
84
|
+
* no `--timeout` flag still succeeds — precedence is flag > this field > the 300000ms default.
|
|
85
|
+
*/
|
|
86
|
+
readonly timeoutMs?: number;
|
|
87
|
+
/**
|
|
88
|
+
* optional per-registry vitest worker ceiling (mutation-gate-baseline-honesty FR-2): baseline and
|
|
89
|
+
* mutant runs spawn the package's FULL `testCommand` at vitest's default worker count (= cpu
|
|
90
|
+
* cores), and under embedding-daemon tests (0.7-3.5 GB/process) this repo's core package measured
|
|
91
|
+
* load 62-358 and 0.4-1.8 GB free on an 8-core/16GB box — three full runs died overnight
|
|
92
|
+
* (0bb74d66). The same suite with `--maxWorkers=2` passed (6909/6909). Precedence is the
|
|
93
|
+
* `--max-workers` flag > this field > `min(4, max(1, floor(cpus/2)))`.
|
|
94
|
+
*/
|
|
95
|
+
readonly maxWorkers?: number;
|
|
80
96
|
readonly entries: readonly MutationRegistryEntry[];
|
|
81
97
|
}
|
|
82
98
|
|
|
@@ -306,10 +322,12 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
|
306
322
|
let entriesRaw: unknown;
|
|
307
323
|
let testCommand: string | undefined;
|
|
308
324
|
let requireCompletionReceipt: boolean | undefined;
|
|
325
|
+
let timeoutMs: number | undefined;
|
|
326
|
+
let maxWorkers: number | undefined;
|
|
309
327
|
if (Array.isArray(raw)) {
|
|
310
328
|
entriesRaw = raw;
|
|
311
329
|
} else if (raw && typeof raw === 'object') {
|
|
312
|
-
const obj = raw as { testCommand?: unknown; requireCompletionReceipt?: unknown; entries?: unknown };
|
|
330
|
+
const obj = raw as { testCommand?: unknown; requireCompletionReceipt?: unknown; timeoutMs?: unknown; maxWorkers?: unknown; entries?: unknown };
|
|
313
331
|
entriesRaw = obj.entries;
|
|
314
332
|
if (obj.testCommand !== undefined) {
|
|
315
333
|
if (typeof obj.testCommand !== 'string' || obj.testCommand.trim() === '') {
|
|
@@ -323,6 +341,18 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
|
323
341
|
}
|
|
324
342
|
requireCompletionReceipt = obj.requireCompletionReceipt;
|
|
325
343
|
}
|
|
344
|
+
if (obj.timeoutMs !== undefined) {
|
|
345
|
+
if (typeof obj.timeoutMs !== 'number' || !Number.isFinite(obj.timeoutMs) || obj.timeoutMs <= 0) {
|
|
346
|
+
return { registry: null, entryResults: [], errors: ['timeoutMs must be a finite number > 0 when present'] };
|
|
347
|
+
}
|
|
348
|
+
timeoutMs = obj.timeoutMs;
|
|
349
|
+
}
|
|
350
|
+
if (obj.maxWorkers !== undefined) {
|
|
351
|
+
if (typeof obj.maxWorkers !== 'number' || !Number.isInteger(obj.maxWorkers) || obj.maxWorkers < 1) {
|
|
352
|
+
return { registry: null, entryResults: [], errors: ['maxWorkers must be a positive integer when present'] };
|
|
353
|
+
}
|
|
354
|
+
maxWorkers = obj.maxWorkers;
|
|
355
|
+
}
|
|
326
356
|
}
|
|
327
357
|
if (!Array.isArray(entriesRaw)) {
|
|
328
358
|
return { registry: null, entryResults: [], errors: ['registry must be an array of entries or {testCommand?, requireCompletionReceipt?, entries: [...]}'] };
|
|
@@ -439,6 +469,8 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
|
439
469
|
registry: {
|
|
440
470
|
...(testCommand !== undefined ? { testCommand } : {}),
|
|
441
471
|
...(requireCompletionReceipt !== undefined ? { requireCompletionReceipt } : {}),
|
|
472
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
473
|
+
...(maxWorkers !== undefined ? { maxWorkers } : {}),
|
|
442
474
|
entries,
|
|
443
475
|
},
|
|
444
476
|
entryResults,
|
|
@@ -446,6 +478,26 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
|
446
478
|
};
|
|
447
479
|
}
|
|
448
480
|
|
|
481
|
+
// ── Feature scoping (qe-step-gate-scoped-to-feature) — pure registry diff, no IO ───────────────
|
|
482
|
+
//
|
|
483
|
+
// Step 8's QE mutation gate ran the FULL registry unconditionally (MEASURED 2026-09-12: 358
|
|
484
|
+
// entries on this repo's core package, 30-40 minutes, timed out INCONCLUSIVE every time), though a
|
|
485
|
+
// feature only owns its own touched files and any entries it newly declares. The `--touched` and
|
|
486
|
+
// `--added-since` CLI selectors (harness-cli's executor) scope the run; the executor reads the base
|
|
487
|
+
// registry via `git show <ref>:<path>` (I/O) and hands both parsed registries to this PURE diff so
|
|
488
|
+
// the comparison itself stays testable without a filesystem or git process (NFR-1).
|
|
489
|
+
|
|
490
|
+
/** Entry ids present in `current` but absent from `base` (by id, not by content). A `null` base
|
|
491
|
+
* means the registry did not exist at the reference point — every current entry counts as added. */
|
|
492
|
+
export function registryEntriesAddedSince(
|
|
493
|
+
base: MutationRegistry | null,
|
|
494
|
+
current: MutationRegistry,
|
|
495
|
+
): string[] {
|
|
496
|
+
if (base === null) return current.entries.map((entry) => entry.id);
|
|
497
|
+
const baseIds = new Set(base.entries.map((entry) => entry.id));
|
|
498
|
+
return current.entries.filter((entry) => !baseIds.has(entry.id)).map((entry) => entry.id);
|
|
499
|
+
}
|
|
500
|
+
|
|
449
501
|
// ── Mutation application — exact text surgery, exactly once (rule 1) ──────────────────────────
|
|
450
502
|
|
|
451
503
|
export interface AppliedMutation {
|
|
@@ -732,7 +784,11 @@ export function attributeBaselineRedness(
|
|
|
732
784
|
if (file !== null && !files.includes(file)) files.push(file);
|
|
733
785
|
};
|
|
734
786
|
|
|
735
|
-
|
|
787
|
+
// mutation-gate-baseline-honesty FR-1: vitest 3 prints an optional POOL LABEL between `FAIL` and
|
|
788
|
+
// the file path (`FAIL |serial| test/x.test.ts > case`, `FAIL |parallel| …`) — the plain
|
|
789
|
+
// `(\S+)` used to capture the label itself as "the file", which normaliseReportedFile then
|
|
790
|
+
// rejects, turning a perfectly parseable red run into `unparseable from runner output`.
|
|
791
|
+
const vitestMatches = [...output.matchAll(/^\s*FAIL\s+(?:\|[^|\n]*\|\s+)?(\S+)/gm)];
|
|
736
792
|
for (const match of vitestMatches) add(match[1] ?? '');
|
|
737
793
|
|
|
738
794
|
const tapMatches = [...output.matchAll(/^not ok \d+\s+-\s+(.+)$/gm)];
|
package/src/operations.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* @packageDocumentation
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { execFileSync, spawnSync } from 'node:child_process';
|
|
9
|
+
import { execFileSync, spawnSync, type SpawnSyncOptionsWithStringEncoding } from 'node:child_process';
|
|
10
10
|
import { randomBytes } from 'node:crypto';
|
|
11
11
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { homedir, tmpdir } from 'node:os';
|
|
@@ -1542,6 +1542,33 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
1542
1542
|
? `embed socket present at ${resolved.path}${tmpdirNote}${engineNote} — recall injection can run`
|
|
1543
1543
|
: `embed socket ABSENT at ${resolved.path}: the recall hook is wired but cannot inject (the hook self-heals on the next prompt; a persistent absence means the daemon cannot start)`,
|
|
1544
1544
|
});
|
|
1545
|
+
|
|
1546
|
+
// APPLY-LEG INJECTS — live probe (feature `apply-leg-never-silent`, ADR-001 Decision 1).
|
|
1547
|
+
// Issue #2's whole defect was that `installed`/`alive` above can ALL read green while the
|
|
1548
|
+
// leg injects nothing in every session but one (a foreign install-root symptom the file-
|
|
1549
|
+
// presence and socket-presence checks above cannot see by construction — they check for
|
|
1550
|
+
// the RIGHT FILES at the RIGHT PATH, never whether the deployed COMMAND actually resolves
|
|
1551
|
+
// to this project's store from a foreign cwd). This row is the difference: it runs the
|
|
1552
|
+
// ACTUAL configured hook command end-to-end and is green ONLY on an observed injection.
|
|
1553
|
+
// Deliberately a SEPARATE try/catch from the socket-alive check above: a probe failure
|
|
1554
|
+
// must never suppress the (already useful) socket-presence row, and vice versa.
|
|
1555
|
+
try {
|
|
1556
|
+
const { probeApplyLeg } = await import('./apply-leg.js');
|
|
1557
|
+
const probe = await probeApplyLeg(root);
|
|
1558
|
+
checks.push({
|
|
1559
|
+
name: 'apply-leg injects (live probe)',
|
|
1560
|
+
ok: probe.ok,
|
|
1561
|
+
detail: probe.ok
|
|
1562
|
+
? `live probe injected its beacon lesson in ${probe.elapsedMs}ms — the recall hook actually finds this project's store`
|
|
1563
|
+
: `installed but silent: ${probe.reason ?? 'unknown'} (probed in ${probe.elapsedMs}ms) — dz setup wrote the hook, but it is not injecting anything into real sessions`,
|
|
1564
|
+
});
|
|
1565
|
+
} catch (err) {
|
|
1566
|
+
checks.push({
|
|
1567
|
+
name: 'apply-leg injects (live probe)',
|
|
1568
|
+
ok: false,
|
|
1569
|
+
detail: `live probe could not run: ${err instanceof Error ? err.message : String(err)}`,
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1545
1572
|
}
|
|
1546
1573
|
}
|
|
1547
1574
|
} catch {
|
|
@@ -1852,18 +1879,72 @@ function probeCodexVersion(): string | null {
|
|
|
1852
1879
|
* with the helper's own self-failure note unable to fire because the process never started.
|
|
1853
1880
|
* Grading on file presence would call that "installed".
|
|
1854
1881
|
*/
|
|
1855
|
-
|
|
1882
|
+
/**
|
|
1883
|
+
* `opts` (feature `apply-leg-never-silent`, ADR-001 D1): additive, optional — every pre-existing
|
|
1884
|
+
* 2-arg caller (the Codex veto-hook liveness checks above) is unaffected. `cwd`/`env` let a caller
|
|
1885
|
+
* reproduce the EXACT conditions a real invoking session presents (a foreign cwd, an overridden
|
|
1886
|
+
* `CLAUDE_PROJECT_DIR`) rather than always running from THIS process's own cwd/env — the apply-leg
|
|
1887
|
+
* live probe needs exactly that to prove install-root resolution end-to-end, not merely structurally.
|
|
1888
|
+
* `stdout` is returned alongside `stderr`/`status` for the same reason: a UserPromptSubmit hook's
|
|
1889
|
+
* payload (`hookSpecificOutput.additionalContext`) rides stdout, not stderr (see `probeApplyLeg`'s
|
|
1890
|
+
* own doc comment for the measured stderr-visibility fact this displaces).
|
|
1891
|
+
*/
|
|
1892
|
+
export function probeHookLiveness(
|
|
1893
|
+
command: string,
|
|
1894
|
+
payload: string,
|
|
1895
|
+
opts: { readonly cwd?: string; readonly env?: Readonly<Record<string, string>>; readonly timeoutMs?: number } = {},
|
|
1896
|
+
): { readonly status: number | null; readonly stdout: string; readonly stderr: string; readonly groupKillAttempted: boolean } {
|
|
1856
1897
|
const shell = process.env['SHELL'] ?? '/bin/sh';
|
|
1898
|
+
// Fix round 1 (apply-leg-never-silent, HIGH-1): a caller previously had to INFER "was the group
|
|
1899
|
+
// kill sent" by reading this function's source — a regression removing or bypassing the
|
|
1900
|
+
// `process.kill(-pid, ...)` call below would silently invalidate that inference. `groupKillAttempted`
|
|
1901
|
+
// is the OBSERVABLE fact instead: true exactly when this call reached the point of attempting the
|
|
1902
|
+
// kill syscall (`res.pid` was a real positive pid), false when it never got that far (e.g. the
|
|
1903
|
+
// spawn itself never produced a pid). It does NOT claim the signal found a live recipient — ESRCH
|
|
1904
|
+
// ("group already gone", the common successful-exit case) still counts as "sent": the syscall was
|
|
1905
|
+
// issued, its target simply no longer existed. That is a SEPARATE fact from whether the grandchild
|
|
1906
|
+
// is actually dead by the time a caller checks — see `probeApplyLeg`'s AM-5 test for the
|
|
1907
|
+
// kill-sent-vs-death-observed split this field exists to make possible.
|
|
1908
|
+
let groupKillAttempted = false;
|
|
1857
1909
|
try {
|
|
1858
|
-
|
|
1910
|
+
// AM-5 (fix round 1, apply-leg-never-silent): `detached: true` puts the shell in its OWN
|
|
1911
|
+
// process GROUP (pgid === its own pid) instead of sharing the caller's — `spawnSync`'s own
|
|
1912
|
+
// timeout kill signals only the DIRECT child (the shell), never anything the shell forked, so a
|
|
1913
|
+
// `node "<hook>" || true` grandchild that is still running when the shell dies is orphaned but
|
|
1914
|
+
// free to keep touching whatever this probe is about to remove (the beacon, the temp cwd).
|
|
1915
|
+
// Killing the NEGATIVE pid below reaches the whole group in one signal. `@types/node`'s
|
|
1916
|
+
// `SpawnSyncOptions` does not DECLARE `detached` (only the async `SpawnOptions` does) — MEASURED
|
|
1917
|
+
// this is a typings gap, not a runtime one: a real child under `spawnSync(..., {detached:true})`
|
|
1918
|
+
// reports its own pgid === its own pid (verified with `ps -o pgid=`), exactly as it would under
|
|
1919
|
+
// async `spawn`. Widened via an inline type intersection rather than `as any` so every OTHER key
|
|
1920
|
+
// stays checked.
|
|
1921
|
+
const spawnOpts: SpawnSyncOptionsWithStringEncoding & { readonly detached?: boolean } = {
|
|
1859
1922
|
input: payload,
|
|
1860
1923
|
encoding: 'utf8',
|
|
1861
|
-
timeout: 20_000,
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1924
|
+
timeout: opts.timeoutMs ?? 20_000,
|
|
1925
|
+
detached: true,
|
|
1926
|
+
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
|
1927
|
+
env: { ...process.env, DZ_HOOK_LIVENESS_PROBE: '1', ...(opts.env ?? {}) },
|
|
1928
|
+
};
|
|
1929
|
+
const res = spawnSync(shell, ['-lc', command], spawnOpts);
|
|
1930
|
+
// Belt, run on EVERY outcome (timeout OR a clean, on-time exit): a `node` grandchild can still
|
|
1931
|
+
// be alive in the group even after the shell itself exited normally (e.g. it double-forked or
|
|
1932
|
+
// outlived a `|| true` that already returned). ESRCH — the common, successful case, everything
|
|
1933
|
+
// already exited — is swallowed; this is best-effort cleanup, never a probe failure.
|
|
1934
|
+
if (typeof res.pid === 'number' && res.pid > 0) {
|
|
1935
|
+
try {
|
|
1936
|
+
process.kill(-res.pid, 'SIGKILL');
|
|
1937
|
+
} catch {
|
|
1938
|
+
/* group already gone */
|
|
1939
|
+
} finally {
|
|
1940
|
+
// set right after the process.kill(-pid, 'SIGKILL') attempt (HIGH-1 lead decision): reached
|
|
1941
|
+
// regardless of ESRCH, because ESRCH means "no recipient", not "syscall not issued".
|
|
1942
|
+
groupKillAttempted = true;
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '', groupKillAttempted };
|
|
1865
1946
|
} catch (err) {
|
|
1866
|
-
return { status: null, stderr: String((err as Error)?.message ?? err) };
|
|
1947
|
+
return { status: null, stdout: '', stderr: String((err as Error)?.message ?? err), groupKillAttempted };
|
|
1867
1948
|
}
|
|
1868
1949
|
}
|
|
1869
1950
|
|
|
@@ -2043,7 +2124,7 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
|
|
|
2043
2124
|
// a home that never opted in (the leg-1 F12 lesson).
|
|
2044
2125
|
if (options.check === true) {
|
|
2045
2126
|
const drift = diffCodexHooks(currentText, entries, manifest);
|
|
2046
|
-
const live = drift.installed && options.liveness !== false ? probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD) : { status: null, stderr: '' };
|
|
2127
|
+
const live = drift.installed && options.liveness !== false ? probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD) : { status: null, stdout: '', stderr: '' };
|
|
2047
2128
|
const executable = drift.installed && (options.liveness === false || live.status === 0 || live.status === 2);
|
|
2048
2129
|
// `--check` must report the TRUST axis too. Without it the report said `installed && executable`
|
|
2049
2130
|
// with `trust: 'unknown'`, and the CLI printed a success word for it — the exact G-G/AM-17
|
|
@@ -2142,7 +2223,7 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
|
|
|
2142
2223
|
|
|
2143
2224
|
|
|
2144
2225
|
// (6) LIVENESS: exit 127 is ALLOW to the runtime, so it must never be graded as installed (G-L).
|
|
2145
|
-
const live = options.liveness === false ? { status: 0, stderr: '' } : probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD);
|
|
2226
|
+
const live = options.liveness === false ? { status: 0, stdout: '', stderr: '' } : probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD);
|
|
2146
2227
|
const executable = live.status === 0 || live.status === 2;
|
|
2147
2228
|
if (!executable) {
|
|
2148
2229
|
warnings.push(
|