@dzhechkov/harness-core 0.4.4 → 0.5.0
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 +283 -103
- package/LICENSE +21 -0
- package/README.md +73 -5
- package/dist/agents-policy.d.ts +67 -0
- package/dist/agents-policy.d.ts.map +1 -0
- package/dist/agents-policy.js +258 -0
- package/dist/agents-policy.js.map +1 -0
- package/dist/codex-hooks-assets.d.ts +47 -0
- package/dist/codex-hooks-assets.d.ts.map +1 -0
- package/dist/codex-hooks-assets.js +287 -0
- package/dist/codex-hooks-assets.js.map +1 -0
- package/dist/codex-hooks-verify.d.ts +74 -0
- package/dist/codex-hooks-verify.d.ts.map +1 -0
- package/dist/codex-hooks-verify.js +140 -0
- package/dist/codex-hooks-verify.js.map +1 -0
- package/dist/codex-hooks.d.ts +258 -0
- package/dist/codex-hooks.d.ts.map +1 -0
- package/dist/codex-hooks.js +391 -0
- package/dist/codex-hooks.js.map +1 -0
- package/dist/discrimination-gate.d.ts +88 -15
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +343 -51
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/feature-adr-checkpoints.d.ts +22 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +42 -0
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +196 -5
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +538 -54
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +13 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +25 -1
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +17 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -4
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.js +2 -2
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/managed-hooks.d.ts +76 -0
- package/dist/managed-hooks.d.ts.map +1 -0
- package/dist/managed-hooks.js +89 -0
- package/dist/managed-hooks.js.map +1 -0
- package/dist/mutation-gate.d.ts +14 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +25 -2
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts +153 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +560 -24
- package/dist/operations.js.map +1 -1
- package/dist/parity.d.ts +38 -1
- package/dist/parity.d.ts.map +1 -1
- package/dist/parity.js +78 -5
- package/dist/parity.js.map +1 -1
- package/dist/recall-usage.d.ts +53 -0
- package/dist/recall-usage.d.ts.map +1 -1
- package/dist/recall-usage.js +125 -2
- package/dist/recall-usage.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +14 -26
- package/dist/setup.js.map +1 -1
- package/dist/shell-veto-policy.d.ts +53 -0
- package/dist/shell-veto-policy.d.ts.map +1 -0
- package/dist/shell-veto-policy.js +103 -0
- package/dist/shell-veto-policy.js.map +1 -0
- package/dist/skills.d.ts +86 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +116 -1
- package/dist/skills.js.map +1 -1
- package/dist/targets.d.ts +75 -0
- package/dist/targets.d.ts.map +1 -1
- package/dist/targets.js +160 -0
- package/dist/targets.js.map +1 -1
- package/package.json +20 -19
- package/sbom.json +552 -102
- package/src/agents-policy.ts +338 -0
- package/src/codex-hooks-assets.ts +291 -0
- package/src/codex-hooks-verify.ts +184 -0
- package/src/codex-hooks.ts +571 -0
- package/src/discrimination-gate.ts +456 -58
- package/src/feature-adr-checkpoints.ts +38 -0
- package/src/feature-adr-routing.ts +642 -75
- package/src/guard.ts +36 -1
- package/src/index.ts +118 -2
- package/src/loop-blobs.generated.ts +2 -2
- package/src/managed-hooks.ts +129 -0
- package/src/mutation-gate.ts +24 -2
- package/src/operations.ts +719 -28
- package/src/parity.ts +120 -6
- package/src/recall-usage.ts +184 -1
- package/src/setup.ts +26 -27
- package/src/shell-veto-policy.ts +119 -0
- package/src/skills.ts +174 -1
- package/src/targets.ts +189 -0
package/src/guard.ts
CHANGED
|
@@ -73,6 +73,19 @@ export interface GuardFacts {
|
|
|
73
73
|
readonly skillPacks?: readonly { readonly name: string; readonly nonRegistrable: readonly string[] }[];
|
|
74
74
|
/** for readme-first: per publishable package, is a version bump staged without a README change? */
|
|
75
75
|
readonly readmeFirst?: readonly { readonly name: string; readonly versionBumped: boolean; readonly readmeChanged: boolean }[];
|
|
76
|
+
/**
|
|
77
|
+
* for agents-md-policy-sync: result of the pure policy drift detector, gathered by the CLI.
|
|
78
|
+
* `applicable:false` is a repo whose canonical policy sources are unreadable; omission means the
|
|
79
|
+
* gatherer could not obtain evidence. `fenced` says whether the repo OPTED IN — i.e. its AGENTS.md
|
|
80
|
+
* already carries a `dz:policies` fence. The two combine to separate a repo that is out of scope
|
|
81
|
+
* (never opted in — silent) from one that opted in and then lost its sources (loud), so the shared
|
|
82
|
+
* `notes` channel does not carry a permanent "not applicable" line in every consumer repo.
|
|
83
|
+
*/
|
|
84
|
+
readonly policyDrift?: {
|
|
85
|
+
readonly applicable: boolean;
|
|
86
|
+
readonly drifted: readonly string[];
|
|
87
|
+
readonly fenced?: boolean;
|
|
88
|
+
};
|
|
76
89
|
/**
|
|
77
90
|
* for lockfile-in-sync: what each workspace package DECLARES vs what pnpm-lock.yaml RECORDS for that
|
|
78
91
|
* importer. `parsed:false` (or the fact absent) ⇒ the rule reports nothing — fail-open by construction,
|
|
@@ -227,6 +240,7 @@ export const DEFAULT_RULES: readonly GuardRule[] = [
|
|
|
227
240
|
{ id: 'readme-consistency', severity: 'soft', ops: ['publish'], description: 'README counts agree (CJM header vs All Commands, etc.)' },
|
|
228
241
|
{ id: 'skills-registrable', severity: 'soft', ops: ['publish'], description: 'every skill directory in a skill pack has a depth-1 SKILL.md (a buried or missing one ships un-registrable — the health-advisor 1.2.0 class)' },
|
|
229
242
|
{ 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)' },
|
|
243
|
+
{ 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' },
|
|
230
244
|
{ 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' },
|
|
231
245
|
{ id: 'store-bloat-cap', severity: 'soft', ops: ['teach', 'consolidate'], description: 'the learned store is within its size cap' },
|
|
232
246
|
// Description ASSEMBLED from STUB_MARKERS so guard.ts itself stays clean under the scan it defines
|
|
@@ -323,6 +337,16 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
323
337
|
}
|
|
324
338
|
return out;
|
|
325
339
|
},
|
|
340
|
+
'agents-md-policy-sync': (f, _sev) => {
|
|
341
|
+
const fact = f.policyDrift;
|
|
342
|
+
if (!fact || fact.applicable !== true || !Array.isArray(fact.drifted)) return [];
|
|
343
|
+
const drifted = fact.drifted.filter((item) => typeof item === 'string');
|
|
344
|
+
return drifted.length === 0 ? [] : [{
|
|
345
|
+
rule: 'agents-md-policy-sync',
|
|
346
|
+
severity: 'soft',
|
|
347
|
+
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`,
|
|
348
|
+
}];
|
|
349
|
+
},
|
|
326
350
|
'lockfile-in-sync': (f, _sev) => {
|
|
327
351
|
// The 2026-07-28 CI break, mechanized: an overnight dep bump edited package.json and left
|
|
328
352
|
// pnpm-lock.yaml stale, so `pnpm install --frozen-lockfile` died with ERR_PNPM_OUTDATED_LOCKFILE.
|
|
@@ -417,7 +441,7 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
417
441
|
* may not understand a file, and "I might be wrong" plus "block the publish" is the wrong pair. Disabling
|
|
418
442
|
* such a rule stays allowed — only the promotion is refused.
|
|
419
443
|
*/
|
|
420
|
-
export const SOFT_ONLY_RULES: readonly string[] = ['lockfile-in-sync'];
|
|
444
|
+
export const SOFT_ONLY_RULES: readonly string[] = ['lockfile-in-sync', 'agents-md-policy-sync'];
|
|
421
445
|
|
|
422
446
|
/**
|
|
423
447
|
* A well-formed PROMOTED rule: an id the engine does not know, made enforceable by a template +
|
|
@@ -533,6 +557,17 @@ export function evaluateGuard(facts: GuardFacts, rules: readonly GuardRule[] = D
|
|
|
533
557
|
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
558
|
}
|
|
535
559
|
}
|
|
560
|
+
if (checked.includes('agents-md-policy-sync')) {
|
|
561
|
+
// A repo that never opted in (no `dz:policies` fence in AGENTS.md) is OUT OF SCOPE, not
|
|
562
|
+
// inconclusive — noting it on every run would put a permanent line in a channel that exists to
|
|
563
|
+
// flag genuine coverage gaps, and a note that is always there is a note nobody reads.
|
|
564
|
+
// Opted in but sources unreadable IS a gap, and stays loud.
|
|
565
|
+
if (facts.policyDrift === undefined) {
|
|
566
|
+
notes.push('agents-md-policy-sync: policy drift evidence was unavailable; the advisory rule skipped with a recorded coverage gap, not a silent pass');
|
|
567
|
+
} else if (facts.policyDrift.applicable !== true && facts.policyDrift.fenced === true) {
|
|
568
|
+
notes.push('agents-md-policy-sync: this repository carries a dz:policies fence but its canonical policy sources are unreadable — the advisory rule could not compare, and that gap is on the record, not a silent pass');
|
|
569
|
+
}
|
|
570
|
+
}
|
|
536
571
|
return { op, verdict, violations, checked, ...(notes.length > 0 ? { notes } : {}) };
|
|
537
572
|
}
|
|
538
573
|
|
package/src/index.ts
CHANGED
|
@@ -24,8 +24,10 @@ export {
|
|
|
24
24
|
GATE_FEATURE_IDS,
|
|
25
25
|
computeParity,
|
|
26
26
|
buildParityMatrix,
|
|
27
|
+
CAPABILITY_EVIDENCE,
|
|
28
|
+
findUnbackedCapabilities,
|
|
27
29
|
} from './parity.js';
|
|
28
|
-
export type { RuntimeCapability, FeatureForm, ParityFeature, ParityCell, ParityMatrixRow } from './parity.js';
|
|
30
|
+
export type { RuntimeCapability, FeatureForm, ParityFeature, ParityCell, ParityMatrixRow, CapabilityEvidence, UnbackedCapability } from './parity.js';
|
|
29
31
|
export * from './operations.js';
|
|
30
32
|
// workflows.ts: the ADR-005 templates are RETIRED (feature loop-designer, AM-6) — the module is a
|
|
31
33
|
// deprecation shim (empty WORKFLOW_NAMES). BREAKING for external harness-core consumers of
|
|
@@ -196,6 +198,9 @@ export {
|
|
|
196
198
|
serializeTrainingPair,
|
|
197
199
|
trainingPairPath,
|
|
198
200
|
trainingPairAppendCmd,
|
|
201
|
+
// wave1-instrument-repair (ADR-003 Condition 3): the persist allowlist + composite shape check.
|
|
202
|
+
codeCheckpointPersistAllowed,
|
|
203
|
+
codeStageResultShapeValid,
|
|
199
204
|
} from './feature-adr-checkpoints.js';
|
|
200
205
|
export type { CheckpointStage, CheckpointEntry, ResumeMode, ResumeDecision, ParsedCheckpointRead, TrainingPairFamily, TrainingPair, TrainingPairEvaluation, TrainingPairProvenance, TrainingPairTruncation } from './feature-adr-checkpoints.js';
|
|
201
206
|
export {
|
|
@@ -257,6 +262,9 @@ export {
|
|
|
257
262
|
shouldCompactRecallUsageLogSize,
|
|
258
263
|
compactRecallUsageLog,
|
|
259
264
|
compactRecallUsageLogChecked,
|
|
265
|
+
appendRecallUsage,
|
|
266
|
+
runtimeOf,
|
|
267
|
+
RUNTIMES,
|
|
260
268
|
} from './recall-usage.js';
|
|
261
269
|
export type {
|
|
262
270
|
RecallUsageReadRecord,
|
|
@@ -271,7 +279,74 @@ export type {
|
|
|
271
279
|
CompactRecallUsageOptions,
|
|
272
280
|
CompactRecallUsageResult,
|
|
273
281
|
CompactRecallUsageStatus,
|
|
282
|
+
AppendRecallUsageInput,
|
|
283
|
+
AppendRecallUsageHit,
|
|
284
|
+
Runtime,
|
|
274
285
|
} from './recall-usage.js';
|
|
286
|
+
|
|
287
|
+
/* crossrt-2-codex-hooks (leg 2 of umbrella 98ed3967) */
|
|
288
|
+
export { mergeManagedHookEntries, hookCommandsOf } from './managed-hooks.js';
|
|
289
|
+
export type { HookMergePlan, MergeManagedHookOptions, ManagedHookEntry } from './managed-hooks.js';
|
|
290
|
+
export {
|
|
291
|
+
CODEX_MANAGED_HOOKS,
|
|
292
|
+
DZ_HOOK_HELPER_VERSION,
|
|
293
|
+
DZ_HOOK_TIMEOUT_SECONDS,
|
|
294
|
+
DZ_VETO_MATCHER,
|
|
295
|
+
buildHookCommand,
|
|
296
|
+
buildManagedEntries,
|
|
297
|
+
buildCodexHookManifest,
|
|
298
|
+
codexHooksPaths,
|
|
299
|
+
diffCodexHooks,
|
|
300
|
+
emitterWriteSet,
|
|
301
|
+
expectedTrustKey,
|
|
302
|
+
isDzManagedEntry,
|
|
303
|
+
isSafeForSingleQuote,
|
|
304
|
+
looksLikeDzEntry,
|
|
305
|
+
managedByEvent,
|
|
306
|
+
parseCodexRegistry,
|
|
307
|
+
parseCodexHookManifest,
|
|
308
|
+
planCodexHooks,
|
|
309
|
+
removeCodexHooks,
|
|
310
|
+
renderTrustBlock,
|
|
311
|
+
runtimeWriteSet,
|
|
312
|
+
selectOwnHookMetadata,
|
|
313
|
+
serializeCodexRegistry,
|
|
314
|
+
codexHookSha256,
|
|
315
|
+
trustEventName,
|
|
316
|
+
upsertTrustBlock,
|
|
317
|
+
} from './codex-hooks.js';
|
|
318
|
+
export type {
|
|
319
|
+
CodexHookEvent,
|
|
320
|
+
CodexHookId,
|
|
321
|
+
CodexHookManifest,
|
|
322
|
+
CodexHookManifestEntry,
|
|
323
|
+
CodexHooksPaths,
|
|
324
|
+
CodexHooksPlan,
|
|
325
|
+
CodexRegistry,
|
|
326
|
+
DriftReport,
|
|
327
|
+
HooksListHookMetadata,
|
|
328
|
+
HookTrustRow,
|
|
329
|
+
ManagedEntry,
|
|
330
|
+
} from './codex-hooks.js';
|
|
331
|
+
export {
|
|
332
|
+
DZ_VETO_MARKER,
|
|
333
|
+
DZ_VETO_WARN_MARKER,
|
|
334
|
+
RUNTIME_BLOCK_PHRASE,
|
|
335
|
+
classifyTrust,
|
|
336
|
+
classifyVetoProbe,
|
|
337
|
+
isReadyVerdict,
|
|
338
|
+
verifyExitCode,
|
|
339
|
+
} from './codex-hooks-verify.js';
|
|
340
|
+
export type {
|
|
341
|
+
CodexHookTrustStatus,
|
|
342
|
+
VetoProbeEvidence,
|
|
343
|
+
VetoProbeResult,
|
|
344
|
+
VetoTrust,
|
|
345
|
+
VetoVerdict,
|
|
346
|
+
} from './codex-hooks-verify.js';
|
|
347
|
+
export { SHELL_VETO_RULE_ID, resolveVetoMode, vetoShellCommand } from './shell-veto-policy.js';
|
|
348
|
+
export type { VetoHit, VetoMode } from './shell-veto-policy.js';
|
|
349
|
+
export { generateCodexHelpers, generateCodexRecallHelper, generateCodexVetoHelper } from './codex-hooks-assets.js';
|
|
275
350
|
export {
|
|
276
351
|
EVENT_CHAIN_SCOPE,
|
|
277
352
|
EVENT_CHAIN_GENESIS_HASH,
|
|
@@ -423,9 +498,47 @@ export {
|
|
|
423
498
|
needsCodeLandedBarrier,
|
|
424
499
|
codeLandedBarrierPlan,
|
|
425
500
|
codeLandedBarrierHasLanded,
|
|
501
|
+
// decideCodeLanding was NEVER in this named list (a pre-existing omission found by the ADR-002
|
|
502
|
+
// Confirmation-1 sweep). Its `predicate` union NARROWS in this release, which is a breaking change
|
|
503
|
+
// to a published output type — so it has to actually BE published to be honest about it.
|
|
504
|
+
decideCodeLanding,
|
|
426
505
|
CODE_LANDED_BARRIER_SLEEPS_SECONDS,
|
|
506
|
+
// wave1-instrument-repair (ADR-003): the landed-barrier honesty surface. This list is NAMED, so a
|
|
507
|
+
// missing entry silently drops the export from the published package — ADR-002 Confirmation 1 is
|
|
508
|
+
// the test that catches exactly that.
|
|
509
|
+
LANDING_PROTOCOL_VERSION,
|
|
510
|
+
LANDING_HASH_TOKEN,
|
|
511
|
+
addExpectedCodeTarget,
|
|
512
|
+
extractExpectedCodeTargetsFromText,
|
|
513
|
+
sourceExpectedCodeTargets,
|
|
514
|
+
validateExpectedTargetsBlock,
|
|
515
|
+
posixCksum,
|
|
516
|
+
verifyPreCodeBaseline,
|
|
517
|
+
preCodeBaselineCaptureCmd,
|
|
518
|
+
parseBaselineCapture,
|
|
519
|
+
CODE_LANDING_PREDICATES,
|
|
520
|
+
parseLandingSignal,
|
|
521
|
+
codeLandingProbeCmd,
|
|
522
|
+
} from './feature-adr-routing.js';
|
|
523
|
+
export type {
|
|
524
|
+
StageOpts,
|
|
525
|
+
RoutingEnv,
|
|
526
|
+
UsageSignal,
|
|
527
|
+
UsageAction,
|
|
528
|
+
CodeLandingStatus,
|
|
529
|
+
CodeStageLandingStatus,
|
|
530
|
+
LandingInconclusiveReason,
|
|
531
|
+
CodeLandedBarrierPlan,
|
|
532
|
+
CodeLandingSnapshot,
|
|
533
|
+
CodeLandingDecision,
|
|
534
|
+
SourcedCodeTargets,
|
|
535
|
+
ExpectedTargetRejectReason,
|
|
536
|
+
ExpectedTargetsValidation,
|
|
537
|
+
BaselineEntry,
|
|
538
|
+
BaselineVerification,
|
|
539
|
+
ParsedBaselineCapture,
|
|
540
|
+
ParsedLandingSignal,
|
|
427
541
|
} from './feature-adr-routing.js';
|
|
428
|
-
export type { StageOpts, RoutingEnv, UsageSignal, UsageAction } from './feature-adr-routing.js';
|
|
429
542
|
export {
|
|
430
543
|
CLAUDE_USAGE_MODELS,
|
|
431
544
|
computeUsage,
|
|
@@ -497,6 +610,9 @@ export * from './model-recommender.js';
|
|
|
497
610
|
export * from './bto-optimize.js';
|
|
498
611
|
export * from './discrimination-gate.js';
|
|
499
612
|
export * from './guard.js';
|
|
613
|
+
// Root AGENTS.md policy projection: pure anchor extraction/render/hash drift +
|
|
614
|
+
// the shared-emitter I/O entry point exported from operations.
|
|
615
|
+
export * from './agents-policy.js';
|
|
500
616
|
// Lesson → guard-rule PROMOTION (feature guard-promotion, scout idea #1) — the cost-of-detection
|
|
501
617
|
// ladder's elevator: moves a lesson from layer 5 (agent memory) to layer 1 (a deterministic rule),
|
|
502
618
|
// but only after TWO consecutive shadow wins replayed over REAL commits. Never synthesises rule code.
|
|
@@ -79,11 +79,11 @@ export const BLOBS: Record<string, LoopBlob> = {
|
|
|
79
79
|
"codex-dispatch": {
|
|
80
80
|
name: "codex-dispatch",
|
|
81
81
|
version: "1.0.0",
|
|
82
|
-
contentHash: "
|
|
82
|
+
contentHash: "140da3714d2507683b0a27fcb01fa449ea3cb1619f1a2e181aaf24291397642d",
|
|
83
83
|
sourcePath: "packages/@dzhechkov/harness-core/src/feature-adr-routing.ts",
|
|
84
84
|
requires: [],
|
|
85
85
|
exports: ["codexDispatchMode","codexExecPlan","needsCodeLandedBarrier","decideCodeLanding"],
|
|
86
|
-
code: "const CODE_LANDING_PIPELINE_PREFIXES = ['features/', '.dz/', '.agentic-qe/', 'roam/'];\nfunction codeLandingEmptySignal(seconds) {\n return 'changed=0 after ' + seconds + 's — genuinely not landed';\n}\nfunction needsCodeLandedBarrier(coderUsed) {\n return coderUsed === 'codex' || coderUsed === 'codex-fallback';\n}\nfunction
|
|
86
|
+
code: "const CODE_LANDING_PIPELINE_PREFIXES = ['features/', '.dz/', '.agentic-qe/', 'roam/'];\nfunction codeLandingEmptySignal(seconds) {\n return 'changed=0 after ' + seconds + 's — genuinely not landed';\n}\nfunction needsCodeLandedBarrier(coderUsed) {\n return coderUsed === 'codex' || coderUsed === 'codex-fallback';\n}\nfunction stripCodeLandingPath(path) {\n let p = String(path || '').trim().replace(/\\\\/g, '/');\n while (p.indexOf('./') === 0)\n p = p.slice(2);\n return p.replace(/\\/+/g, '/');\n}\nfunction classifyCodeLandingPathReject(path) {\n const p = stripCodeLandingPath(path);\n if (!p)\n return 'empty-after-strip';\n if (p[0] === '/')\n return 'absolute-path';\n if (p === '..' || p.indexOf('../') === 0 || p.indexOf('/../') >= 0 || p.endsWith('/..'))\n return 'traversal';\n if (/[\\0\\r\\n\\t \"'\\x60$;&|<>*?()[\\]{}!]/.test(p))\n return 'not-a-path';\n if (p.endsWith('/'))\n return 'not-a-path';\n for (const prefix of CODE_LANDING_PIPELINE_PREFIXES) {\n const bare = prefix.slice(0, -1);\n if (p === bare || p.indexOf(prefix) === 0)\n return 'pipeline-artifact-path';\n }\n return null;\n}\nfunction normalizeCodeLandingPath(path) {\n return classifyCodeLandingPathReject(path) === null ? stripCodeLandingPath(path) : '';\n}\nfunction filterPollableCodePaths(paths) {\n const out = [];\n const seen = new Set();\n for (const path of paths || []) {\n const normalized = normalizeCodeLandingPath(path);\n if (!normalized || seen.has(normalized))\n continue;\n seen.add(normalized);\n out.push(normalized);\n }\n return out;\n}\nfunction isNewlyChanged(path, baseline, currentHashes) {\n if (!baseline || !baseline.ok)\n return false;\n let recorded = null;\n for (const entry of baseline.entries) {\n if (entry.path === path) {\n recorded = entry.hash;\n break;\n }\n }\n if (recorded === null)\n return true;\n const now = currentHashes ? currentHashes[path] : undefined;\n if (now === undefined)\n return false;\n return now !== recorded;\n}\nfunction decideCodeLanding(snapshot) {\n const maxWaitMs = Math.max(0, snapshot.maxWaitMs);\n const elapsedMs = Math.max(0, snapshot.elapsedMs);\n const elapsedSeconds = Math.floor(elapsedMs / 1000);\n const expectedPaths = filterPollableCodePaths(snapshot.expectedPaths);\n const changedPaths = filterPollableCodePaths(snapshot.changedEntries.map(function (entry) { return entry.path; }));\n if (expectedPaths.length === 0) {\n return {\n status: 'inconclusive',\n reason: 'empty-plan-block',\n changed: 0,\n elapsedMs: elapsedMs,\n elapsedSeconds: elapsedSeconds,\n expectedPaths: expectedPaths,\n matchedExpectedPaths: [],\n changedPaths: changedPaths,\n predicate: 'no-expected-targets',\n qeSignalLine: 'CODEX-LANDING-SIGNAL status=inconclusive predicate=no-expected-targets reason=empty-plan-block',\n };\n }\n const baseline = snapshot.baseline;\n if (!baseline || !baseline.ok) {\n const reason = baseline && baseline.reason ? baseline.reason : 'no-baseline';\n return {\n status: 'inconclusive',\n reason: reason,\n changed: 0,\n elapsedMs: elapsedMs,\n elapsedSeconds: elapsedSeconds,\n expectedPaths: expectedPaths,\n matchedExpectedPaths: [],\n changedPaths: changedPaths,\n predicate: 'newly-changed',\n qeSignalLine: 'CODEX-LANDING-SIGNAL status=inconclusive predicate=newly-changed reason=' + reason,\n };\n }\n const changed = new Set(changedPaths);\n const matchedExpectedPaths = expectedPaths.filter(function (path) {\n return changed.has(path) && isNewlyChanged(path, baseline, snapshot.currentHashes);\n });\n if (matchedExpectedPaths.length > 0) {\n return {\n status: 'landed',\n changed: matchedExpectedPaths.length,\n elapsedMs: elapsedMs,\n elapsedSeconds: elapsedSeconds,\n expectedPaths: expectedPaths,\n matchedExpectedPaths: matchedExpectedPaths,\n changedPaths: changedPaths,\n predicate: 'newly-changed',\n qeSignalLine: 'CODEX-LANDING-SIGNAL status=landed changed=' +\n matchedExpectedPaths.length +\n ' after=' +\n elapsedSeconds +\n 's predicate=newly-changed matched=' +\n matchedExpectedPaths.join(','),\n };\n }\n if (elapsedMs < maxWaitMs) {\n return {\n status: 'not-yet-flushed',\n changed: 0,\n elapsedMs: elapsedMs,\n elapsedSeconds: elapsedSeconds,\n expectedPaths: expectedPaths,\n matchedExpectedPaths: [],\n changedPaths: changedPaths,\n predicate: 'empty-before-timeout',\n qeSignalLine: 'CODEX-LANDING-SIGNAL status=not-yet-flushed changed=0 after ' + elapsedSeconds + 's — not yet flushed',\n };\n }\n const terminalSeconds = Math.ceil(maxWaitMs / 1000);\n return {\n status: 'genuinely-not-landed',\n changed: 0,\n elapsedMs: elapsedMs,\n elapsedSeconds: terminalSeconds,\n expectedPaths: expectedPaths,\n matchedExpectedPaths: [],\n changedPaths: changedPaths,\n predicate: 'empty-after-timeout',\n qeSignalLine: 'CODEX-LANDING-SIGNAL status=genuinely-not-landed ' + codeLandingEmptySignal(terminalSeconds),\n };\n}\nconst WRAPPER_STAGES = { code: 1, plan: 1 };\nfunction codexDispatchMode(stage) {\n return WRAPPER_STAGES[stage] ? 'wrapper' : 'exec';\n}\nconst CODEX_EXEC_PROMPT_CEILING_CHARS = 24000;\nfunction codexExecPlan(input) {\n if (codexDispatchMode(input.stage) === 'wrapper') {\n return { mode: 'wrapper', reason: 'deliverable is a file written out-of-band' };\n }\n if (!input.probedId) {\n return { mode: 'claude', reason: 'no codex model id answered the probe' };\n }\n if (input.promptChars > CODEX_EXEC_PROMPT_CEILING_CHARS) {\n return {\n mode: 'claude',\n reason: 'prompt is ' +\n input.promptChars +\n ' chars, over the ' +\n CODEX_EXEC_PROMPT_CEILING_CHARS +\n '-char codex exec ceiling (it would stall)',\n };\n }\n return { mode: 'exec', reason: 'codex exec on ' + input.probedId };\n}",
|
|
87
87
|
},
|
|
88
88
|
"challenge-panel": {
|
|
89
89
|
name: "challenge-panel",
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE event-level managed-hook merge (`crossrt-2-codex-hooks`, AM-3 / G-E).
|
|
3
|
+
*
|
|
4
|
+
* Both hook targets — Claude Code's `.claude/settings.json` and Codex's
|
|
5
|
+
* `$CODEX_HOME/hooks.json` — need the same operation: per event, KEEP every entry that is not ours
|
|
6
|
+
* (byte-identical, same order) and append the entries that are. Before this module the Claude half
|
|
7
|
+
* lived inline in `setup.ts` and the Codex half did not exist; a second dialect would have been the
|
|
8
|
+
* obvious way to write it and the wrong one.
|
|
9
|
+
*
|
|
10
|
+
* What differs between the targets is ONLY the attribution predicate, so that is the parameter:
|
|
11
|
+
* the Claude path passes its historical substring list verbatim (bytes must not move — AM-3), the
|
|
12
|
+
* Codex path passes sha-over-manifest (ADR-001 §3: dz never deletes what it cannot prove it wrote).
|
|
13
|
+
*
|
|
14
|
+
* The plan (AM-37) is explicit that the extracted block has **three** outputs, not one: the merged
|
|
15
|
+
* body, the REPORT tail string, and the no-write path (`changed === false`). A golden test on the
|
|
16
|
+
* merged bytes alone would pass while the report text and the no-write behaviour silently changed,
|
|
17
|
+
* so all three are part of {@link HookMergePlan}.
|
|
18
|
+
*
|
|
19
|
+
* Pure: no I/O, no clock, no environment.
|
|
20
|
+
*
|
|
21
|
+
* @packageDocumentation
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** One hook registry entry, in either the matcher-group or the legacy flat shape. */
|
|
25
|
+
export type ManagedHookEntry = unknown;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Commands of a hook entry in EITHER shape: the valid matcher-group form
|
|
29
|
+
* `{matcher?, hooks:[{type,command}]}` or the legacy flat `{type,command}`.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately duplicated from `setup.ts`'s `commandsOf` rather than imported: `setup.ts` imports
|
|
32
|
+
* this module, and the reverse edge would be a cycle. The two are pinned equal by a test.
|
|
33
|
+
*/
|
|
34
|
+
export function hookCommandsOf(entry: ManagedHookEntry): string[] {
|
|
35
|
+
const e = entry as { command?: unknown; hooks?: { command?: unknown }[] };
|
|
36
|
+
if (Array.isArray(e?.hooks)) return e.hooks.map((h) => String(h?.command ?? ''));
|
|
37
|
+
return [String(e?.command ?? '')];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface HookMergePlan {
|
|
41
|
+
/** The merged registry body, ready to serialize. */
|
|
42
|
+
readonly hooks: Record<string, ManagedHookEntry[]>;
|
|
43
|
+
/** False ⇒ the caller must NOT write (the `hooks already current` no-write path — AM-37). */
|
|
44
|
+
readonly changed: boolean;
|
|
45
|
+
/** The setup REPORT tail string. Part of the extracted block's contract, not decoration (AM-37). */
|
|
46
|
+
readonly report: string;
|
|
47
|
+
/** A legacy-shaped entry of ours was replaced (drives the Claude report wording). */
|
|
48
|
+
readonly replacedLegacy: boolean;
|
|
49
|
+
/** Entries kept because they are NOT ours, across the WHOLE registry. G-D's number. */
|
|
50
|
+
readonly foreignPreserved: number;
|
|
51
|
+
/**
|
|
52
|
+
* Entries that RESEMBLE ours but are not attributable to the manifest. They are KEPT and counted —
|
|
53
|
+
* never deleted (ADR-001 §3). Distinct from `replacedLegacy`, which is about our OWN old vintages.
|
|
54
|
+
*/
|
|
55
|
+
readonly unattributable: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface MergeManagedHookOptions {
|
|
59
|
+
/** True ⇒ this entry is OURS and is replaced by the managed set for that event. */
|
|
60
|
+
readonly isManaged: (entry: ManagedHookEntry, event: string) => boolean;
|
|
61
|
+
/**
|
|
62
|
+
* True ⇒ this entry resembles ours but could not be attributed. Optional; when omitted nothing is
|
|
63
|
+
* counted. Never causes deletion — only counting and a warning upstream.
|
|
64
|
+
*/
|
|
65
|
+
readonly looksLikeOurs?: (entry: ManagedHookEntry, event: string) => boolean;
|
|
66
|
+
/** True ⇒ this OURS entry was in a legacy shape/vintage (Claude path only). */
|
|
67
|
+
readonly isLegacy?: (entry: ManagedHookEntry, event: string) => boolean;
|
|
68
|
+
/** e.g. `'agentdb'` → `merged agentdb hooks (user hooks preserved)`. */
|
|
69
|
+
readonly reportLabel?: string;
|
|
70
|
+
/** Report text when nothing changed. */
|
|
71
|
+
readonly unchangedReport?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Merge `managed` into `existingHooks` at EVENT level.
|
|
76
|
+
*
|
|
77
|
+
* - Events absent from `managed` are copied through untouched (including unknown ones), and their
|
|
78
|
+
* entries still count toward `foreignPreserved` — the number answers "how many of the user's
|
|
79
|
+
* entries did this registry hold and keep", not "how many survived the touched events".
|
|
80
|
+
* - Within a touched event, non-ours entries keep their relative order and object identity, then
|
|
81
|
+
* the managed entries are appended.
|
|
82
|
+
* - `merge(merge(x)) === merge(x)`.
|
|
83
|
+
*/
|
|
84
|
+
export function mergeManagedHookEntries(
|
|
85
|
+
existingHooks: Record<string, ManagedHookEntry[]> | undefined,
|
|
86
|
+
managed: Record<string, ManagedHookEntry[]>,
|
|
87
|
+
options: MergeManagedHookOptions,
|
|
88
|
+
): HookMergePlan {
|
|
89
|
+
const source = existingHooks ?? {};
|
|
90
|
+
const hooks: Record<string, ManagedHookEntry[]> = {};
|
|
91
|
+
for (const event of Object.keys(source)) {
|
|
92
|
+
hooks[event] = Array.isArray(source[event]) ? [...(source[event] as ManagedHookEntry[])] : [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Census FIRST, over the whole input, so the counts do not depend on which events are touched.
|
|
96
|
+
let foreignPreserved = 0;
|
|
97
|
+
let unattributable = 0;
|
|
98
|
+
for (const [event, entries] of Object.entries(source)) {
|
|
99
|
+
if (!Array.isArray(entries)) continue;
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
if (options.isManaged(entry, event)) continue;
|
|
102
|
+
foreignPreserved += 1;
|
|
103
|
+
if (options.looksLikeOurs?.(entry, event) === true) unattributable += 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let changed = false;
|
|
108
|
+
let replacedLegacy = false;
|
|
109
|
+
for (const event of Object.keys(managed)) {
|
|
110
|
+
const current = Array.isArray(hooks[event]) ? (hooks[event] as ManagedHookEntry[]) : [];
|
|
111
|
+
const kept = current.filter((entry) => {
|
|
112
|
+
const ours = options.isManaged(entry, event);
|
|
113
|
+
if (ours && options.isLegacy?.(entry, event) === true) replacedLegacy = true;
|
|
114
|
+
return !ours;
|
|
115
|
+
});
|
|
116
|
+
const next = [...kept, ...(managed[event] ?? [])];
|
|
117
|
+
if (JSON.stringify(next) !== JSON.stringify(current)) changed = true;
|
|
118
|
+
hooks[event] = next;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const label = options.reportLabel ?? 'managed';
|
|
122
|
+
const report = !changed
|
|
123
|
+
? (options.unchangedReport ?? 'hooks already current')
|
|
124
|
+
: replacedLegacy
|
|
125
|
+
? `replaced legacy dz hooks with ${label} hooks`
|
|
126
|
+
: `merged ${label} hooks (user hooks preserved)`;
|
|
127
|
+
|
|
128
|
+
return { hooks, changed, report, replacedLegacy, foreignPreserved, unattributable };
|
|
129
|
+
}
|
package/src/mutation-gate.ts
CHANGED
|
@@ -317,6 +317,26 @@ function stripSgr(s: string): string {
|
|
|
317
317
|
|
|
318
318
|
export type RunnerKind = 'node-test' | 'vitest' | 'unknown';
|
|
319
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Which runner's output shape is this? Extracted VERBATIM from `classifyRunFailure`'s two shape
|
|
322
|
+
* checks so the discrimination gate's evidence model can identify the runner of a GREEN run too —
|
|
323
|
+
* `classifyRunFailure` classifies RED runs only (its green branch returns a red-worded
|
|
324
|
+
* 'unrecognised'), so it cannot answer "which runner produced this pass?".
|
|
325
|
+
*
|
|
326
|
+
* ONE regex family per package (the ADR-001 driver): every runner-shape regex in harness-core
|
|
327
|
+
* lives here, and discrimination-gate.ts consumes this function instead of growing a second copy.
|
|
328
|
+
* Detection is from the OUTPUT SHAPE, not the command (an `npm test` alias hides the runner):
|
|
329
|
+
* node --test needs the TAP header AND node's `# duration_ms` trailer (tape emits TAP + `# fail`
|
|
330
|
+
* but not `# duration_ms`); vitest needs its `RUN v<semver>` banner or `Test Files` summary line.
|
|
331
|
+
* Anything else is 'unknown' — the honest, narrowed gap, never a silent trust mint.
|
|
332
|
+
*/
|
|
333
|
+
export function detectRunnerKind(rawOutput: string): RunnerKind {
|
|
334
|
+
const output = stripSgr(rawOutput);
|
|
335
|
+
if (/^TAP version \d+/m.test(output) && /^#\s*duration_ms\s+[\d.]+/m.test(output)) return 'node-test';
|
|
336
|
+
if (/^\s*RUN\s+v\d+\./m.test(output) || /^\s*Test Files\s/m.test(output)) return 'vitest';
|
|
337
|
+
return 'unknown';
|
|
338
|
+
}
|
|
339
|
+
|
|
320
340
|
export interface RunFailureClassification {
|
|
321
341
|
/** which runner's output shape was recognised. */
|
|
322
342
|
readonly runner: RunnerKind;
|
|
@@ -373,9 +393,11 @@ export interface RunFailureClassification {
|
|
|
373
393
|
*/
|
|
374
394
|
export function classifyRunFailure(rawOutput: string): RunFailureClassification {
|
|
375
395
|
const output = stripSgr(rawOutput);
|
|
396
|
+
// shape detection is shared with the discrimination gate's evidence model (detectRunnerKind above).
|
|
397
|
+
const runner = detectRunnerKind(rawOutput);
|
|
376
398
|
|
|
377
399
|
// node --test (flat TAP, node ≥ 20 shape measured on v22.22.0)
|
|
378
|
-
if (
|
|
400
|
+
if (runner === 'node-test') {
|
|
379
401
|
const lines = output.split('\n');
|
|
380
402
|
let sawNotOk = false;
|
|
381
403
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -407,7 +429,7 @@ export function classifyRunFailure(rawOutput: string): RunFailureClassification
|
|
|
407
429
|
}
|
|
408
430
|
|
|
409
431
|
// vitest (`vitest run`, shape measured on 3.2.4)
|
|
410
|
-
if (
|
|
432
|
+
if (runner === 'vitest') {
|
|
411
433
|
const failedSuites = /\bFailed Suites\s+(\d+)\b/.exec(output);
|
|
412
434
|
const suiteFail = /^\s*FAIL\s+(\S+)\s+\[\s*\1\s*\]/m.exec(output);
|
|
413
435
|
if (failedSuites !== null || suiteFail !== null) {
|