@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.
Files changed (97) hide show
  1. package/.dz-manifest.json +283 -103
  2. package/LICENSE +21 -0
  3. package/README.md +73 -5
  4. package/dist/agents-policy.d.ts +67 -0
  5. package/dist/agents-policy.d.ts.map +1 -0
  6. package/dist/agents-policy.js +258 -0
  7. package/dist/agents-policy.js.map +1 -0
  8. package/dist/codex-hooks-assets.d.ts +47 -0
  9. package/dist/codex-hooks-assets.d.ts.map +1 -0
  10. package/dist/codex-hooks-assets.js +287 -0
  11. package/dist/codex-hooks-assets.js.map +1 -0
  12. package/dist/codex-hooks-verify.d.ts +74 -0
  13. package/dist/codex-hooks-verify.d.ts.map +1 -0
  14. package/dist/codex-hooks-verify.js +140 -0
  15. package/dist/codex-hooks-verify.js.map +1 -0
  16. package/dist/codex-hooks.d.ts +258 -0
  17. package/dist/codex-hooks.d.ts.map +1 -0
  18. package/dist/codex-hooks.js +391 -0
  19. package/dist/codex-hooks.js.map +1 -0
  20. package/dist/discrimination-gate.d.ts +88 -15
  21. package/dist/discrimination-gate.d.ts.map +1 -1
  22. package/dist/discrimination-gate.js +343 -51
  23. package/dist/discrimination-gate.js.map +1 -1
  24. package/dist/feature-adr-checkpoints.d.ts +22 -0
  25. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  26. package/dist/feature-adr-checkpoints.js +42 -0
  27. package/dist/feature-adr-checkpoints.js.map +1 -1
  28. package/dist/feature-adr-routing.d.ts +196 -5
  29. package/dist/feature-adr-routing.d.ts.map +1 -1
  30. package/dist/feature-adr-routing.js +538 -54
  31. package/dist/feature-adr-routing.js.map +1 -1
  32. package/dist/guard.d.ts +13 -0
  33. package/dist/guard.d.ts.map +1 -1
  34. package/dist/guard.js +25 -1
  35. package/dist/guard.js.map +1 -1
  36. package/dist/index.d.ts +17 -7
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +23 -4
  39. package/dist/index.js.map +1 -1
  40. package/dist/loop-blobs.generated.js +2 -2
  41. package/dist/loop-blobs.generated.js.map +1 -1
  42. package/dist/managed-hooks.d.ts +76 -0
  43. package/dist/managed-hooks.d.ts.map +1 -0
  44. package/dist/managed-hooks.js +89 -0
  45. package/dist/managed-hooks.js.map +1 -0
  46. package/dist/mutation-gate.d.ts +14 -0
  47. package/dist/mutation-gate.d.ts.map +1 -1
  48. package/dist/mutation-gate.js +25 -2
  49. package/dist/mutation-gate.js.map +1 -1
  50. package/dist/operations.d.ts +153 -0
  51. package/dist/operations.d.ts.map +1 -1
  52. package/dist/operations.js +560 -24
  53. package/dist/operations.js.map +1 -1
  54. package/dist/parity.d.ts +38 -1
  55. package/dist/parity.d.ts.map +1 -1
  56. package/dist/parity.js +78 -5
  57. package/dist/parity.js.map +1 -1
  58. package/dist/recall-usage.d.ts +53 -0
  59. package/dist/recall-usage.d.ts.map +1 -1
  60. package/dist/recall-usage.js +125 -2
  61. package/dist/recall-usage.js.map +1 -1
  62. package/dist/setup.d.ts.map +1 -1
  63. package/dist/setup.js +14 -26
  64. package/dist/setup.js.map +1 -1
  65. package/dist/shell-veto-policy.d.ts +53 -0
  66. package/dist/shell-veto-policy.d.ts.map +1 -0
  67. package/dist/shell-veto-policy.js +103 -0
  68. package/dist/shell-veto-policy.js.map +1 -0
  69. package/dist/skills.d.ts +86 -1
  70. package/dist/skills.d.ts.map +1 -1
  71. package/dist/skills.js +116 -1
  72. package/dist/skills.js.map +1 -1
  73. package/dist/targets.d.ts +75 -0
  74. package/dist/targets.d.ts.map +1 -1
  75. package/dist/targets.js +160 -0
  76. package/dist/targets.js.map +1 -1
  77. package/package.json +20 -19
  78. package/sbom.json +552 -102
  79. package/src/agents-policy.ts +338 -0
  80. package/src/codex-hooks-assets.ts +291 -0
  81. package/src/codex-hooks-verify.ts +184 -0
  82. package/src/codex-hooks.ts +571 -0
  83. package/src/discrimination-gate.ts +456 -58
  84. package/src/feature-adr-checkpoints.ts +38 -0
  85. package/src/feature-adr-routing.ts +642 -75
  86. package/src/guard.ts +36 -1
  87. package/src/index.ts +118 -2
  88. package/src/loop-blobs.generated.ts +2 -2
  89. package/src/managed-hooks.ts +129 -0
  90. package/src/mutation-gate.ts +24 -2
  91. package/src/operations.ts +719 -28
  92. package/src/parity.ts +120 -6
  93. package/src/recall-usage.ts +184 -1
  94. package/src/setup.ts +26 -27
  95. package/src/shell-veto-policy.ts +119 -0
  96. package/src/skills.ts +174 -1
  97. 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: "e6a608d2856287aa4db2b2db769d2e7ea3ec76b6c7cf299d2e233b4d9d90d0cf",
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 normalizeCodeLandingPath(path) {\n let p = String(path || '').trim().replace(/\\\\/g, '/');\n while (p.indexOf('./') === 0)\n p = p.slice(2);\n p = p.replace(/\\/+/g, '/');\n if (!p)\n return '';\n if (p[0] === '/')\n return '';\n if (p === '..' || p.indexOf('../') === 0 || p.indexOf('/../') >= 0 || p.endsWith('/..'))\n return '';\n if (/[\\0\\r\\n\\t \"'\\x60$;&|<>*?()[\\]{}!]/.test(p))\n return '';\n if (p.endsWith('/'))\n return '';\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 '';\n }\n return p;\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 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((entry) => entry.path));\n const changed = new Set(changedPaths);\n const matchedExpectedPaths = expectedPaths.filter((path) => changed.has(path));\n if (expectedPaths.length > 0 && matchedExpectedPaths.length > 0) {\n return {\n status: 'landed',\n changed: matchedExpectedPaths.length,\n elapsedMs,\n elapsedSeconds,\n expectedPaths,\n matchedExpectedPaths,\n changedPaths,\n predicate: 'expected-path',\n qeSignalLine: 'CODEX-LANDING-SIGNAL status=landed changed=' +\n matchedExpectedPaths.length +\n ' after=' +\n elapsedSeconds +\n 's predicate=expected-path matched=' +\n matchedExpectedPaths.join(','),\n };\n }\n if (expectedPaths.length === 0 && changedPaths.length > 0) {\n return {\n status: 'landed',\n changed: changedPaths.length,\n elapsedMs,\n elapsedSeconds,\n expectedPaths,\n matchedExpectedPaths,\n changedPaths,\n predicate: 'any-code-change',\n qeSignalLine: 'CODEX-LANDING-SIGNAL status=landed changed=' +\n changedPaths.length +\n ' after=' +\n elapsedSeconds +\n 's predicate=any-code-change',\n };\n }\n if (elapsedMs < maxWaitMs) {\n return {\n status: 'not-yet-flushed',\n changed: 0,\n elapsedMs,\n elapsedSeconds,\n expectedPaths,\n matchedExpectedPaths: [],\n 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,\n elapsedSeconds: terminalSeconds,\n expectedPaths,\n matchedExpectedPaths: [],\n 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}",
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
+ }
@@ -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 (/^TAP version \d+/m.test(output) && /^#\s*duration_ms\s+[\d.]+/m.test(output)) {
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 (/^\s*RUN\s+v\d+\./m.test(output) || /^\s*Test Files\s/m.test(output)) {
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) {