@dzhechkov/harness-core 0.4.5 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/.dz-manifest.json +489 -105
  2. package/README.md +116 -5
  3. package/dist/agents-policy.d.ts +67 -0
  4. package/dist/agents-policy.d.ts.map +1 -0
  5. package/dist/agents-policy.js +258 -0
  6. package/dist/agents-policy.js.map +1 -0
  7. package/dist/codex-hooks-assets.d.ts +47 -0
  8. package/dist/codex-hooks-assets.d.ts.map +1 -0
  9. package/dist/codex-hooks-assets.js +324 -0
  10. package/dist/codex-hooks-assets.js.map +1 -0
  11. package/dist/codex-hooks-verify.d.ts +95 -0
  12. package/dist/codex-hooks-verify.d.ts.map +1 -0
  13. package/dist/codex-hooks-verify.js +169 -0
  14. package/dist/codex-hooks-verify.js.map +1 -0
  15. package/dist/codex-hooks.d.ts +341 -0
  16. package/dist/codex-hooks.d.ts.map +1 -0
  17. package/dist/codex-hooks.js +541 -0
  18. package/dist/codex-hooks.js.map +1 -0
  19. package/dist/discrimination-gate.d.ts +88 -15
  20. package/dist/discrimination-gate.d.ts.map +1 -1
  21. package/dist/discrimination-gate.js +343 -51
  22. package/dist/discrimination-gate.js.map +1 -1
  23. package/dist/feature-adr-checkpoints.d.ts +22 -0
  24. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  25. package/dist/feature-adr-checkpoints.js +42 -0
  26. package/dist/feature-adr-checkpoints.js.map +1 -1
  27. package/dist/feature-adr-routing.d.ts +218 -5
  28. package/dist/feature-adr-routing.d.ts.map +1 -1
  29. package/dist/feature-adr-routing.js +583 -54
  30. package/dist/feature-adr-routing.js.map +1 -1
  31. package/dist/guard.d.ts +13 -0
  32. package/dist/guard.d.ts.map +1 -1
  33. package/dist/guard.js +25 -1
  34. package/dist/guard.js.map +1 -1
  35. package/dist/index.d.ts +23 -7
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +37 -4
  38. package/dist/index.js.map +1 -1
  39. package/dist/loop-blobs.generated.d.ts +1 -1
  40. package/dist/loop-blobs.generated.d.ts.map +1 -1
  41. package/dist/loop-blobs.generated.js +12 -3
  42. package/dist/loop-blobs.generated.js.map +1 -1
  43. package/dist/loop-plan.d.ts +70 -0
  44. package/dist/loop-plan.d.ts.map +1 -1
  45. package/dist/loop-plan.js +103 -0
  46. package/dist/loop-plan.js.map +1 -1
  47. package/dist/loop-render.d.ts.map +1 -1
  48. package/dist/loop-render.js +37 -129
  49. package/dist/loop-render.js.map +1 -1
  50. package/dist/loop-run-semantics.d.ts +130 -0
  51. package/dist/loop-run-semantics.d.ts.map +1 -0
  52. package/dist/loop-run-semantics.js +257 -0
  53. package/dist/loop-run-semantics.js.map +1 -0
  54. package/dist/loop-trace.d.ts +31 -2
  55. package/dist/loop-trace.d.ts.map +1 -1
  56. package/dist/loop-trace.js +89 -12
  57. package/dist/loop-trace.js.map +1 -1
  58. package/dist/managed-hooks.d.ts +86 -0
  59. package/dist/managed-hooks.d.ts.map +1 -0
  60. package/dist/managed-hooks.js +101 -0
  61. package/dist/managed-hooks.js.map +1 -0
  62. package/dist/mutation-gate.d.ts +14 -0
  63. package/dist/mutation-gate.d.ts.map +1 -1
  64. package/dist/mutation-gate.js +25 -2
  65. package/dist/mutation-gate.js.map +1 -1
  66. package/dist/named-lock.d.ts +57 -0
  67. package/dist/named-lock.d.ts.map +1 -0
  68. package/dist/named-lock.js +247 -0
  69. package/dist/named-lock.js.map +1 -0
  70. package/dist/operations.d.ts +229 -0
  71. package/dist/operations.d.ts.map +1 -1
  72. package/dist/operations.js +880 -26
  73. package/dist/operations.js.map +1 -1
  74. package/dist/parity.d.ts +106 -1
  75. package/dist/parity.d.ts.map +1 -1
  76. package/dist/parity.js +209 -5
  77. package/dist/parity.js.map +1 -1
  78. package/dist/qe-bridge.d.ts +291 -0
  79. package/dist/qe-bridge.d.ts.map +1 -0
  80. package/dist/qe-bridge.js +538 -0
  81. package/dist/qe-bridge.js.map +1 -0
  82. package/dist/recall-usage.d.ts +53 -0
  83. package/dist/recall-usage.d.ts.map +1 -1
  84. package/dist/recall-usage.js +125 -2
  85. package/dist/recall-usage.js.map +1 -1
  86. package/dist/score.d.ts.map +1 -1
  87. package/dist/score.js +43 -9
  88. package/dist/score.js.map +1 -1
  89. package/dist/setup.d.ts.map +1 -1
  90. package/dist/setup.js +14 -26
  91. package/dist/setup.js.map +1 -1
  92. package/dist/shell-veto-policy.d.ts +53 -0
  93. package/dist/shell-veto-policy.d.ts.map +1 -0
  94. package/dist/shell-veto-policy.js +103 -0
  95. package/dist/shell-veto-policy.js.map +1 -0
  96. package/dist/skills.d.ts +86 -1
  97. package/dist/skills.d.ts.map +1 -1
  98. package/dist/skills.js +116 -1
  99. package/dist/skills.js.map +1 -1
  100. package/dist/targets.d.ts +75 -0
  101. package/dist/targets.d.ts.map +1 -1
  102. package/dist/targets.js +160 -0
  103. package/dist/targets.js.map +1 -1
  104. package/dist/workflow-run-dispatch.d.ts +230 -0
  105. package/dist/workflow-run-dispatch.d.ts.map +1 -0
  106. package/dist/workflow-run-dispatch.js +363 -0
  107. package/dist/workflow-run-dispatch.js.map +1 -0
  108. package/dist/workflow-run.d.ts +498 -0
  109. package/dist/workflow-run.d.ts.map +1 -0
  110. package/dist/workflow-run.js +1359 -0
  111. package/dist/workflow-run.js.map +1 -0
  112. package/package.json +6 -5
  113. package/sbom.json +1099 -139
  114. package/src/agents-policy.ts +338 -0
  115. package/src/codex-hooks-assets.ts +328 -0
  116. package/src/codex-hooks-verify.ts +237 -0
  117. package/src/codex-hooks.ts +723 -0
  118. package/src/discrimination-gate.ts +456 -58
  119. package/src/feature-adr-checkpoints.ts +38 -0
  120. package/src/feature-adr-routing.ts +697 -75
  121. package/src/guard.ts +36 -1
  122. package/src/index.ts +162 -2
  123. package/src/loop-blobs.generated.ts +12 -3
  124. package/src/loop-plan.ts +185 -0
  125. package/src/loop-render.ts +37 -127
  126. package/src/loop-run-semantics.ts +278 -0
  127. package/src/loop-trace.ts +84 -10
  128. package/src/managed-hooks.ts +150 -0
  129. package/src/mutation-gate.ts +24 -2
  130. package/src/named-lock.ts +277 -0
  131. package/src/operations.ts +1121 -29
  132. package/src/parity.ts +295 -6
  133. package/src/qe-bridge.ts +737 -0
  134. package/src/recall-usage.ts +184 -1
  135. package/src/score.ts +50 -9
  136. package/src/setup.ts +26 -27
  137. package/src/shell-veto-policy.ts +119 -0
  138. package/src/skills.ts +174 -1
  139. package/src/targets.ts +189 -0
  140. package/src/workflow-run-dispatch.ts +459 -0
  141. package/src/workflow-run.ts +1743 -0
@@ -411,7 +411,36 @@ const CODE_LANDING_PIPELINE_PREFIXES = ['features/', '.dz/', '.agentic-qe/', 'ro
411
411
 
412
412
  export type CodeLandedBarrierCoder = 'claude' | 'codex' | 'codex-fallback';
413
413
 
414
- export type CodeLandingStatus = 'landed' | 'not-yet-flushed' | 'genuinely-not-landed';
414
+ /** Probe-side landing status. `'inconclusive'` (NEW) is the honest fourth state: the barrier RAN
415
+ * but established nothing — an empty target set, an unverifiable baseline, a dead or unparseable
416
+ * probe. Pre-epoch there were only three values, so "the instrument did not answer" had to be
417
+ * spelled as one of the three answers, and it was spelled `landed`. */
418
+ export type CodeLandingStatus = 'landed' | 'not-yet-flushed' | 'genuinely-not-landed' | 'inconclusive';
419
+
420
+ /** Code-STAGE result domain (ADR-003 Condition 3). `'synchronous'` is deliberately NOT in the probe
421
+ * union: only the non-barrier (Claude-coder) branch may set it, so a codex run can never LABEL its
422
+ * way past the persist allowlist. `'not-yet-flushed'` is likewise absent — it is a mid-poll state,
423
+ * never a stage outcome. */
424
+ export type CodeStageLandingStatus = 'landed' | 'genuinely-not-landed' | 'inconclusive' | 'synchronous';
425
+
426
+ /** Bumped whenever the landing protocol's meaning changes. Recorded on every code-stage result so a
427
+ * pre-protocol checkpoint reads as no-checkpoint instead of resuming into new semantics. */
428
+ export const LANDING_PROTOCOL_VERSION = 2;
429
+
430
+ /** R6: salted into the code stage's checkpoint hash PARTS (not the schema version — ADR-003 keeps
431
+ * `CKPT_SCHEMA_VERSION` at 'fa-ckpt-2' deliberately, so ONLY the code stage re-runs). */
432
+ export const LANDING_HASH_TOKEN = 'landing-v2';
433
+
434
+ /** Why a landing verdict is INCONCLUSIVE. Typed, because "we do not know" needs to say WHICH
435
+ * unknown — the operator repair differs per reason and a single untyped bucket reads as noise. */
436
+ export type LandingInconclusiveReason =
437
+ | 'empty-plan-block'
438
+ | 'override-unpollable'
439
+ | 'no-plan-block'
440
+ | 'no-baseline'
441
+ | 'baseline-unverified'
442
+ | 'probe-failure'
443
+ | 'malformed-signal';
415
444
 
416
445
  export interface GitStatusEntry {
417
446
  readonly raw: string;
@@ -425,23 +454,49 @@ export interface CodeLandingSnapshot {
425
454
  readonly maxWaitMs: number;
426
455
  readonly expectedPaths: readonly string[];
427
456
  readonly changedEntries: readonly GitStatusEntry[];
457
+ /** the pre-Step-7 baseline. Absent/null ⇒ 'no-baseline' (the delta cannot be computed). */
458
+ readonly baseline?: BaselineVerification | null;
459
+ /** path → `git hash-object` NOW, for the paths in `changedEntries`. */
460
+ readonly currentHashes?: Readonly<Record<string, string>>;
428
461
  }
429
462
 
430
463
  export interface CodeLandingDecision {
431
464
  readonly status: CodeLandingStatus;
465
+ readonly reason?: LandingInconclusiveReason;
432
466
  readonly changed: number;
433
467
  readonly elapsedMs: number;
434
468
  readonly elapsedSeconds: number;
435
469
  readonly expectedPaths: readonly string[];
470
+ /** NEWLY-CHANGED matches only — a path that was already dirty before the coder ran is not a match. */
436
471
  readonly matchedExpectedPaths: readonly string[];
437
472
  readonly changedPaths: readonly string[];
438
- readonly predicate: 'expected-path' | 'any-code-change' | 'empty-before-timeout' | 'empty-after-timeout';
473
+ /** `'expected-path'` and `'any-code-change'` are DELETED with their branches (ADR-003 / H4):
474
+ * matching an ESTABLISHED target is now the only landing predicate, and "any code change" was
475
+ * the fallback that read an unrelated dirty file as Codex's work. */
476
+ readonly predicate: 'newly-changed' | 'no-expected-targets' | 'empty-before-timeout' | 'empty-after-timeout';
439
477
  readonly qeSignalLine: string;
440
478
  }
441
479
 
480
+ /**
481
+ * The CLOSED landing-predicate vocabulary, as DATA (QE F6) — exhaustive-record derived, so `tsc`
482
+ * fails the build if the union gains or loses a member. `'expected-path'` and `'any-code-change'`
483
+ * are DELETED; a consumer that still matches on them will find them absent from this list too.
484
+ */
485
+ const CODE_LANDING_PREDICATE_SET: Record<CodeLandingDecision['predicate'], true> = {
486
+ 'newly-changed': true,
487
+ 'no-expected-targets': true,
488
+ 'empty-before-timeout': true,
489
+ 'empty-after-timeout': true,
490
+ };
491
+ export const CODE_LANDING_PREDICATES = Object.keys(CODE_LANDING_PREDICATE_SET) as readonly CodeLandingDecision['predicate'][];
492
+
442
493
  export interface CodeLandedBarrierPlan {
443
494
  readonly enabled: boolean;
444
- readonly mode: 'expected-files' | 'any-code-change';
495
+ /** `'any-code-change'` survives ONLY as the disabled-barrier placeholder — it is unreachable
496
+ * while `enabled` is true (an enabled barrier with no targets is now `'inconclusive'`). */
497
+ readonly mode: 'expected-files' | 'any-code-change' | 'inconclusive';
498
+ /** set iff mode === 'inconclusive'. */
499
+ readonly inconclusiveReason?: LandingInconclusiveReason;
445
500
  readonly sleepsMs: readonly number[];
446
501
  readonly sleepsSeconds: readonly number[];
447
502
  readonly pollWindowMs: number;
@@ -458,26 +513,55 @@ export function needsCodeLandedBarrier(coderUsed: string | null | undefined): bo
458
513
  return coderUsed === 'codex' || coderUsed === 'codex-fallback';
459
514
  }
460
515
 
461
- export function normalizeCodeLandingPath(path: string): string {
516
+ /** Why a declared expected-target line was refused. Pre-epoch `normalizeCodeLandingPath` returned
517
+ * `''` for every reject with NO reason, so a plan whose whole block was mistyped looked exactly
518
+ * like a plan with no block — and both degraded to "any code change counts". */
519
+ export type ExpectedTargetRejectReason =
520
+ | 'empty-after-strip'
521
+ | 'absolute-path'
522
+ | 'traversal'
523
+ | 'pipeline-artifact-path'
524
+ | 'not-a-path';
525
+
526
+ export interface ExpectedTargetsValidation {
527
+ /** the EXPECTED_CODE_TARGETS: block exists in the plan text at all. */
528
+ readonly present: boolean;
529
+ readonly accepted: readonly string[];
530
+ readonly rejected: readonly { readonly line: string; readonly reason: ExpectedTargetRejectReason }[];
531
+ }
532
+
533
+ /** The normalization half, with no accept/reject opinion. */
534
+ function stripCodeLandingPath(path: string): string {
462
535
  let p = String(path || '').trim().replace(/\\/g, '/');
463
536
  while (p.indexOf('./') === 0) p = p.slice(2);
464
- p = p.replace(/\/+/g, '/');
465
- if (!p) return '';
466
- if (p[0] === '/') return '';
467
- if (p === '..' || p.indexOf('../') === 0 || p.indexOf('/../') >= 0 || p.endsWith('/..')) return '';
468
- if (/[\0\r\n\t "'\x60$;&|<>*?()[\]{}!]/.test(p)) return '';
469
- if (p.endsWith('/')) return '';
537
+ return p.replace(/\/+/g, '/');
538
+ }
539
+
540
+ /** The rejection half, in classification order, NAMING the reason. `normalizeCodeLandingPath` and
541
+ * `validateExpectedTargetsBlock` both route through this, so the accept set cannot drift between
542
+ * "what the barrier polls" and "what the Step-6/7 boundary reports as accepted". */
543
+ function classifyCodeLandingPathReject(path: string): ExpectedTargetRejectReason | null {
544
+ const p = stripCodeLandingPath(path);
545
+ if (!p) return 'empty-after-strip';
546
+ if (p[0] === '/') return 'absolute-path';
547
+ if (p === '..' || p.indexOf('../') === 0 || p.indexOf('/../') >= 0 || p.endsWith('/..')) return 'traversal';
548
+ if (/[\0\r\n\t "'\x60$;&|<>*?()[\]{}!]/.test(p)) return 'not-a-path';
549
+ if (p.endsWith('/')) return 'not-a-path';
470
550
  for (const prefix of CODE_LANDING_PIPELINE_PREFIXES) {
471
551
  const bare = prefix.slice(0, -1);
472
- if (p === bare || p.indexOf(prefix) === 0) return '';
552
+ if (p === bare || p.indexOf(prefix) === 0) return 'pipeline-artifact-path';
473
553
  }
474
- return p;
554
+ return null;
555
+ }
556
+
557
+ export function normalizeCodeLandingPath(path: string): string {
558
+ return classifyCodeLandingPathReject(path) === null ? stripCodeLandingPath(path) : '';
475
559
  }
476
560
 
477
561
  export function filterPollableCodePaths(paths: readonly string[]): readonly string[] {
478
562
  const out: string[] = [];
479
563
  const seen = new Set<string>();
480
- for (const path of paths) {
564
+ for (const path of paths || []) {
481
565
  const normalized = normalizeCodeLandingPath(path);
482
566
  if (!normalized || seen.has(normalized)) continue;
483
567
  seen.add(normalized);
@@ -489,122 +573,605 @@ export function filterPollableCodePaths(paths: readonly string[]): readonly stri
489
573
  export function codeLandedBarrierPlan(
490
574
  coderUsed: string | null | undefined,
491
575
  expectedPaths: readonly string[] = [],
576
+ inconclusiveReason?: LandingInconclusiveReason,
492
577
  ): CodeLandedBarrierPlan {
493
- const enabled = needsCodeLandedBarrier(coderUsed);
494
- const pollWindowSeconds = DEFAULT_CODE_LANDING_MAX_WAIT_MS / 1000;
578
+ const enabled = needsCodeLandedBarrier(coderUsed)
579
+ const pollWindowSeconds = DEFAULT_CODE_LANDING_MAX_WAIT_MS / 1000
495
580
  if (!enabled) {
581
+ return { enabled: false, mode: 'any-code-change', sleepsMs: [], sleepsSeconds: [], pollWindowMs: 0, pollWindowSeconds: 0, expectedPaths: [], emptySignal: '' }
582
+ }
583
+ const filteredExpectedPaths = filterPollableCodePaths(expectedPaths || [])
584
+ if (filteredExpectedPaths.length === 0) {
496
585
  return {
497
- enabled: false,
498
- mode: 'any-code-change',
586
+ enabled: true,
587
+ mode: 'inconclusive',
588
+ inconclusiveReason: inconclusiveReason === undefined ? 'empty-plan-block' : inconclusiveReason,
499
589
  sleepsMs: [],
500
590
  sleepsSeconds: [],
501
591
  pollWindowMs: 0,
502
592
  pollWindowSeconds: 0,
503
593
  expectedPaths: [],
504
- emptySignal: '',
505
- };
594
+ emptySignal: codeLandingEmptySignal(pollWindowSeconds),
595
+ }
506
596
  }
507
- const filteredExpectedPaths = filterPollableCodePaths(expectedPaths);
508
597
  return {
509
598
  enabled: true,
510
- mode: filteredExpectedPaths.length > 0 ? 'expected-files' : 'any-code-change',
599
+ mode: 'expected-files',
511
600
  sleepsMs: DEFAULT_CODE_LANDING_BACKOFF_MS,
512
601
  sleepsSeconds: CODE_LANDED_BARRIER_SLEEPS_SECONDS,
513
602
  pollWindowMs: DEFAULT_CODE_LANDING_MAX_WAIT_MS,
514
- pollWindowSeconds,
603
+ pollWindowSeconds: pollWindowSeconds,
515
604
  expectedPaths: filteredExpectedPaths,
516
605
  emptySignal: codeLandingEmptySignal(pollWindowSeconds),
517
- };
606
+ }
607
+ }
608
+
609
+ // ── Expected-target sourcing (ADR-003) ───────────────────────────────────────────────────────
610
+
611
+ export interface SourcedCodeTargets {
612
+ /** the match set IS the established set — a path that did not ESTABLISH cannot MATCH. */
613
+ readonly targets: readonly string[];
614
+ readonly establishedBy: 'args' | 'plan' | null;
615
+ readonly reason?: 'override-unpollable' | 'empty-plan-block' | 'no-plan-block';
616
+ /** what the coder SAID it would write. Diagnostic only: NEVER unioned into targets, NEVER matched.
617
+ * Pre-epoch this was unioned in, which let the agent under test declare its own success criteria. */
618
+ readonly scrapeDiagnostic: readonly string[];
619
+ }
620
+
621
+ /** Flatten the many shapes an expected-target value arrives in (string, array, {wrote}, {paths}). */
622
+ export function addExpectedCodeTarget(value: unknown, out: string[]): void {
623
+ if (value === null || value === undefined) return
624
+ if (Array.isArray(value)) {
625
+ for (let i = 0; i < value.length; i++) addExpectedCodeTarget(value[i], out)
626
+ return
627
+ }
628
+ if (typeof value === 'object') {
629
+ const obj = value as { wrote?: unknown; paths?: unknown }
630
+ if (Array.isArray(obj.wrote)) addExpectedCodeTarget(obj.wrote, out)
631
+ if (Array.isArray(obj.paths)) addExpectedCodeTarget(obj.paths, out)
632
+ return
633
+ }
634
+ const lines = String(value).split(/\r?\n/)
635
+ for (let i = 0; i < lines.length; i++) {
636
+ const candidate = String(lines[i] || '').replace(/^[-*]\s+/, '').replace(/^\x60+|\x60+$/g, '').trim()
637
+ if (candidate) out.push(candidate)
638
+ }
639
+ }
640
+
641
+ /** Read the `EXPECTED_CODE_TARGETS:` block out of arbitrary text. The block ends at the next
642
+ * ALL-CAPS heading line — the shared grammar, used for BOTH the plan block (which establishes)
643
+ * and the Codex scrape (which only ever diagnoses). */
644
+ export function extractExpectedCodeTargetsFromText(text: string | null | undefined): string[] {
645
+ const out: string[] = []
646
+ const lines = String(text || '').split(/\r?\n/)
647
+ let inBlock = false
648
+ for (let i = 0; i < lines.length; i++) {
649
+ const trimmed = String(lines[i] || '').trim()
650
+ if (/^EXPECTED_CODE_TARGETS:\s*$/i.test(trimmed)) { inBlock = true; continue }
651
+ if (!inBlock) continue
652
+ if (!trimmed) continue
653
+ if (/^[A-Z][A-Z0-9_ -]*:\s*$/.test(trimmed)) break
654
+ out.push(trimmed.replace(/^[-*]\s+/, '').replace(/^\x60+|\x60+$/g, '').trim())
655
+ }
656
+ return out
657
+ }
658
+
659
+ /**
660
+ * Where the barrier's expected targets come from, in ADR-003's precedence: an explicit non-empty
661
+ * ARGS override REPLACES the plan (never unions with it); otherwise the PLAN's block establishes;
662
+ * the Codex scrape establishes NOTHING.
663
+ *
664
+ * The override is NARROWING-ONLY on purpose: args provided but all-unpollable returns an EMPTY
665
+ * target set with reason 'override-unpollable' and does NOT fall through to the plan. A silent
666
+ * fall-through would mean "I asked you to watch exactly these files" quietly became "watch the
667
+ * plan's files", i.e. the operator's narrowing was ignored without a word.
668
+ */
669
+ export function sourceExpectedCodeTargets(
670
+ argTargets: unknown,
671
+ planBlockText: string | null,
672
+ codexText: string,
673
+ ): SourcedCodeTargets {
674
+ const scrapeRaw: string[] = []
675
+ addExpectedCodeTarget(extractExpectedCodeTargetsFromText(codexText), scrapeRaw)
676
+ const scrapeDiagnostic = filterPollableCodePaths(scrapeRaw)
677
+
678
+ const argRaw: string[] = []
679
+ addExpectedCodeTarget(argTargets, argRaw)
680
+ if (argRaw.length > 0) {
681
+ const argPaths = filterPollableCodePaths(argRaw)
682
+ if (argPaths.length > 0) return { targets: argPaths, establishedBy: 'args', scrapeDiagnostic: scrapeDiagnostic }
683
+ return { targets: [], establishedBy: null, reason: 'override-unpollable', scrapeDiagnostic: scrapeDiagnostic }
684
+ }
685
+
686
+ if (planBlockText === null || planBlockText === undefined) {
687
+ return { targets: [], establishedBy: null, reason: 'no-plan-block', scrapeDiagnostic: scrapeDiagnostic }
688
+ }
689
+ const planRaw: string[] = []
690
+ addExpectedCodeTarget(extractExpectedCodeTargetsFromText(planBlockText), planRaw)
691
+ const planPaths = filterPollableCodePaths(planRaw)
692
+ if (planPaths.length > 0) return { targets: planPaths, establishedBy: 'plan', scrapeDiagnostic: scrapeDiagnostic }
693
+ return { targets: [], establishedBy: null, reason: 'empty-plan-block', scrapeDiagnostic: scrapeDiagnostic }
694
+ }
695
+
696
+ /**
697
+ * Line-level validation of the plan's block at the Step-6/7 boundary (R13) — BEFORE Step 7 spends
698
+ * tokens. Every rejected line is reported WITH its reason, so a whole-block typo can never present
699
+ * as "no block declared".
700
+ */
701
+ export function validateExpectedTargetsBlock(planText: string | null): ExpectedTargetsValidation {
702
+ const text = String(planText || '')
703
+ const present = /^EXPECTED_CODE_TARGETS:\s*$/im.test(text)
704
+ const lines = extractExpectedCodeTargetsFromText(text)
705
+ const accepted: string[] = []
706
+ const rejected: { line: string; reason: ExpectedTargetRejectReason }[] = []
707
+ const seen = new Set<string>()
708
+ for (const line of lines) {
709
+ const reason = classifyCodeLandingPathReject(line)
710
+ if (reason !== null) { rejected.push({ line: line, reason: reason }); continue }
711
+ const normalized = normalizeCodeLandingPath(line)
712
+ if (seen.has(normalized)) continue
713
+ seen.add(normalized)
714
+ accepted.push(normalized)
715
+ }
716
+ return { present: present, accepted: accepted, rejected: rejected }
717
+ }
718
+
719
+ // ── Pre-Step-7 baseline (R5 + ADR-003 Condition 2) ───────────────────────────────────────────
720
+
721
+ export interface BaselineEntry {
722
+ /** `git hash-object` of the path at capture time; `'-'` when unhashable (deleted, unreadable). */
723
+ readonly hash: string;
724
+ readonly path: string;
725
+ }
726
+
727
+ export interface BaselineVerification {
728
+ readonly ok: boolean;
729
+ readonly reason?: 'no-baseline' | 'baseline-unverified';
730
+ /** [] unless ok — a baseline that failed verification has no usable entries, by construction. */
731
+ readonly entries: readonly BaselineEntry[];
732
+ }
733
+
734
+ let CKSUM_TABLE: number[] | null = null;
735
+
736
+ function cksumTable(): number[] {
737
+ if (CKSUM_TABLE !== null) return CKSUM_TABLE;
738
+ const table: number[] = [];
739
+ for (let i = 0; i < 256; i++) {
740
+ let c = i << 24;
741
+ for (let k = 0; k < 8; k++) c = (c & 0x80000000) !== 0 ? ((c << 1) ^ 0x04c11db7) >>> 0 : (c << 1) >>> 0;
742
+ table.push(c >>> 0);
743
+ }
744
+ CKSUM_TABLE = table;
745
+ return table;
746
+ }
747
+
748
+ /** UTF-8 bytes without TextEncoder — the workflow mirror runs in a sandbox with no host globals. */
749
+ function utf8Bytes(s: string): number[] {
750
+ const out: number[] = [];
751
+ for (let i = 0; i < s.length; i++) {
752
+ const c = s.charCodeAt(i);
753
+ if (c < 0x80) { out.push(c); continue; }
754
+ if (c < 0x800) { out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); continue; }
755
+ if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) {
756
+ const next = s.charCodeAt(i + 1);
757
+ if (next >= 0xdc00 && next <= 0xdfff) {
758
+ const cp = 0x10000 + ((c - 0xd800) << 10) + (next - 0xdc00);
759
+ out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
760
+ i++;
761
+ continue;
762
+ }
763
+ }
764
+ out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
765
+ }
766
+ return out;
767
+ }
768
+
769
+ /**
770
+ * POSIX `cksum` CRC (polynomial 0x04C11DB7, length-augmented, final complement) — the PURE twin of
771
+ * the shell trailer the capture command writes. Known vector: the empty body is 4294967295, and the
772
+ * test suite cross-checks one fixture against a REAL `cksum` run (H7) so the twin cannot drift.
773
+ */
774
+ export function posixCksum(body: string): number {
775
+ const table = cksumTable();
776
+ const bytes = utf8Bytes(body === null || body === undefined ? '' : String(body));
777
+ let crc = 0;
778
+ for (let i = 0; i < bytes.length; i++) {
779
+ crc = ((crc << 8) ^ (table[((crc >>> 24) ^ (bytes[i] || 0)) & 0xff] || 0)) >>> 0;
780
+ }
781
+ let len = bytes.length;
782
+ while (len > 0) {
783
+ crc = ((crc << 8) ^ (table[((crc >>> 24) ^ (len & 0xff)) & 0xff] || 0)) >>> 0;
784
+ len = Math.floor(len / 256);
785
+ }
786
+ return (~crc) >>> 0;
787
+ }
788
+
789
+ /**
790
+ * Parse + VERIFY a captured baseline. The trailer (`count=<n> cksum=<c>`) is not decoration: a
791
+ * baseline is the only thing standing between "this path changed" and "this path was ALREADY dirty
792
+ * when the coder started", so a truncated or edited baseline must read as UNKNOWN, never as a
793
+ * smaller-but-fine baseline. Every trailer failure collapses to 'baseline-unverified'.
794
+ *
795
+ * A valid trailer with ZERO entries is OK — that is a clean tree, a real and common state.
796
+ */
797
+ export function verifyPreCodeBaseline(text: string | null | undefined): BaselineVerification {
798
+ if (text === null || text === undefined) return { ok: false, reason: 'no-baseline', entries: [] }
799
+ const raw = String(text)
800
+ if (raw.trim() === '') return { ok: false, reason: 'no-baseline', entries: [] }
801
+ const lines = raw.split('\n')
802
+ let trailerIndex = -1
803
+ for (let i = lines.length - 1; i >= 0; i--) {
804
+ if (String(lines[i] || '').trim() === '') continue
805
+ trailerIndex = i
806
+ break
807
+ }
808
+ if (trailerIndex < 0) return { ok: false, reason: 'no-baseline', entries: [] }
809
+ const trailer = /^count=(\d+) cksum=(\d+)$/.exec(String(lines[trailerIndex] || '').trim())
810
+ if (trailer === null) return { ok: false, reason: 'baseline-unverified', entries: [] }
811
+ const bodyLines = lines.slice(0, trailerIndex)
812
+ const body = bodyLines.length > 0 ? bodyLines.join('\n') + '\n' : ''
813
+ const entries: BaselineEntry[] = []
814
+ for (const line of bodyLines) {
815
+ const trimmed = String(line || '')
816
+ if (trimmed.trim() === '') continue
817
+ const m = /^(\S+) (.+)$/.exec(trimmed)
818
+ if (m === null) return { ok: false, reason: 'baseline-unverified', entries: [] }
819
+ entries.push({ hash: String(m[1] || ''), path: String(m[2] || '') })
820
+ }
821
+ if (entries.length !== Number(trailer[1])) return { ok: false, reason: 'baseline-unverified', entries: [] }
822
+ if (posixCksum(body) !== Number(trailer[2])) return { ok: false, reason: 'baseline-unverified', entries: [] }
823
+ return { ok: true, entries: entries }
824
+ }
825
+
826
+ /** Parsed verdict of a baseline CAPTURE attempt (QE F2). The capture agent's stdout is a signal the
827
+ * workflow must READ, not a formality: a capture that failed leaves either no file or a stale one
828
+ * from an earlier attempt, and both make the barrier's later answer meaningless. */
829
+ export interface ParsedBaselineCapture {
830
+ readonly ok: boolean;
831
+ /** the baseline file THIS attempt actually wrote, as reported by the capture itself. */
832
+ readonly path: string | null;
833
+ readonly entries: number | null;
834
+ readonly cksum: number | null;
835
+ readonly reason?: 'capture-failed' | 'no-signal' | 'unsafe-path';
836
+ }
837
+
838
+ /** PARSE-NEVER-SYNTHESIZE for the capture step: empty/garbage stdout is `no-signal`, an explicit
839
+ * failure line is `capture-failed`, and ONLY a well-formed `BASELINE-CAPTURED entries=<n>
840
+ * cksum=<c>` line is success. Never infer success from the absence of an error. */
841
+ export function parseBaselineCapture(text: string | null | undefined, expectedPrefix?: string): ParsedBaselineCapture {
842
+ const raw = text === null || text === undefined ? '' : String(text)
843
+ if (raw.trim() === '') return { ok: false, path: null, entries: null, cksum: null, reason: 'no-signal' }
844
+ if (/BASELINE-CAPTURE-FAILED/.test(raw)) return { ok: false, path: null, entries: null, cksum: null, reason: 'capture-failed' }
845
+ const m = /BASELINE-CAPTURED path=(\S+) entries=(\d+) cksum=(\d+)/.exec(raw)
846
+ if (m === null) return { ok: false, path: null, entries: null, cksum: null, reason: 'no-signal' }
847
+ const path = String(m[1] || '')
848
+ // The path arrives via an agent-relayed stdout, so it is UNTRUSTED text that later becomes a shell
849
+ // argument. Refuse anything with a metacharacter, and — when the caller says where it asked for the
850
+ // file — refuse a path outside that prefix. A capture that reports a path we did not ask for is a
851
+ // failed capture, not a relocated one.
852
+ if (path === '' || /[\0\r\n\t "'\x60$;&|<>*?()[\]{}!]/.test(path)) {
853
+ return { ok: false, path: null, entries: null, cksum: null, reason: 'unsafe-path' }
854
+ }
855
+ if (expectedPrefix !== undefined && path.indexOf(expectedPrefix) !== 0) {
856
+ return { ok: false, path: null, entries: null, cksum: null, reason: 'unsafe-path' }
857
+ }
858
+ return { ok: true, path: path, entries: Number(m[2]), cksum: Number(m[3]) }
859
+ }
860
+
861
+ /**
862
+ * The shell the capture agent runs immediately BEFORE the coder is dispatched.
863
+ *
864
+ * FAIL-CLOSED (QE F2), at EVERY step. The pre-fix pipeline sent `git status` through a pipe with
865
+ * its stderr discarded and its exit status unchecked, so a FAILED status call produced an empty
866
+ * path list and therefore a checksum-VALID, ZERO-ENTRY baseline — a baseline that verifies
867
+ * perfectly and claims the tree was clean. Every later path then looks absent-from-baseline, i.e.
868
+ * newly changed, i.e. LANDED. Round-2 QE found the SAME shape one layer down: the five transforms
869
+ * after the status call were themselves an unchecked pipeline. So each step now writes its own file
870
+ * and its status is checked individually; any failure emits `BASELINE-CAPTURE-FAILED` and writes
871
+ * NOTHING. The file is published (atomic `mv`) only after the
872
+ * trailer is written, so a failed attempt leaves the previous state untouched rather than a
873
+ * half-baseline — and the caller must PARSE the success signal (`parseBaselineCapture`) rather than
874
+ * assume the file on disk belongs to this attempt.
875
+ *
876
+ * ATTEMPT-UNIQUE, decided SHELL-SIDE (QE F2). A single stable path let a baseline from an EARLIER,
877
+ * failed attempt survive and be read by a later probe as if it belonged to that run — and a stale
878
+ * baseline verifies perfectly while answering about a tree it never saw. The caller passes a
879
+ * PREFIX; the shell appends `$(date +%s).$$` and REPORTS the path it wrote, so the only path any
880
+ * consumer can use is the one this execution produced. The uniqueness cannot be generated by the
881
+ * caller: the workflow that calls this runs in a sandbox with no clock and no randomness (INV-12 —
882
+ * `Date.now(` and `Math.random(` are lint-banned there, MEASURED via `loop-lint`), so a
883
+ * caller-side stamp would be a token that cannot exist at the only call site that needs it.
884
+ *
885
+ * `--untracked-files=all` (QE F3) is load-bearing, not tidiness: without it a brand-new file inside
886
+ * a brand-new directory is reported as `?? dir/` — the DIRECTORY, not the file — and the barrier's
887
+ * exact-match predicate can never match the expected path.
888
+ */
889
+ export function preCodeBaselineCaptureCmd(repo: string, baselinePathPrefix: string): string {
890
+ // Every step is status-checked and writes to its OWN file in a scratch dir. The pre-fix version
891
+ // ran the five transforms as one pipeline whose status nobody read (QE round-2 F2b): a failing
892
+ // stage produced an EMPTY path list, which then produced a checksum-VALID zero-entry baseline —
893
+ // a baseline that verifies perfectly and claims the tree was clean, after which every path looks
894
+ // absent-from-baseline, i.e. newly changed, i.e. LANDED. Same failure shape as the unchecked
895
+ // `git status`, one layer down.
896
+ //
897
+ // `grep` needs its own rule: exit 1 means "no line matched", which is LEGAL and common — at
898
+ // Step-7 time the only dirty files are often the pipeline's own `features/<slug>/…` artifacts,
899
+ // and filtering all of them away is the correct answer, not a failure. Only exit > 1 is an error.
900
+ // (This is why the alternative "empty result from non-empty input ⇒ transform-failed" rule was
901
+ // NOT used: it would fail-closed on the normal feature-adr run.)
902
+ const fail = (reason: string): string =>
903
+ 'rm -rf "$sc"; echo "BASELINE-CAPTURE-FAILED reason=' + reason + '"; exit 1';
904
+ return (
905
+ 'repo=' + codeLandingShellQuote(repo) + '; pre=' + codeLandingShellQuote(baselinePathPrefix) + '; ' +
906
+ 'sc=$(mktemp -d) || { echo "BASELINE-CAPTURE-FAILED reason=mktemp-failed"; exit 1; }; ' +
907
+ 'mkdir -p "$(dirname -- "$pre")" || { ' + fail('mkdir-failed') + '; }; ' +
908
+ 'out="$pre.$(date +%s).$$.txt"; tmp="$sc/base"; ' +
909
+ 'if ! git -C "$repo" status --porcelain --untracked-files=all > "$sc/0"; then ' + fail('git-status-failed') + '; fi; ' +
910
+ 'if ! sed -E "s/^...//" "$sc/0" > "$sc/1"; then ' + fail('transform-failed') + '; fi; ' +
911
+ 'if ! sed -E "s/.* -> //" "$sc/1" > "$sc/2"; then ' + fail('transform-failed') + '; fi; ' +
912
+ 'grep -vE "^(features/|[.]dz/|[.]agentic-qe/|roam/)" "$sc/2" > "$sc/3"; g=$?; ' +
913
+ 'if [ "$g" -gt 1 ]; then ' + fail('transform-failed') + '; fi; ' +
914
+ 'if ! sed "/^$/d" "$sc/3" > "$sc/4"; then ' + fail('transform-failed') + '; fi; ' +
915
+ 'if ! sort "$sc/4" > "$sc/paths"; then ' + fail('transform-failed') + '; fi; ' +
916
+ ': > "$tmp" || { ' + fail('transform-failed') + '; }; ' +
917
+ 'while IFS= read -r p; do h=$(git -C "$repo" hash-object -- "$p" 2>/dev/null); ' +
918
+ '[ -z "$h" ] && h="-"; printf "%s %s\\n" "$h" "$p" >> "$tmp"; done < "$sc/paths"; ' +
919
+ 'if ! sort -o "$tmp" "$tmp"; then ' + fail('transform-failed') + '; fi; ' +
920
+ 'n=$(wc -l < "$tmp" | tr -d " "); c=$(cksum < "$tmp" | awk "{print \\$1}"); ' +
921
+ 'if [ -z "$n" ] || [ -z "$c" ]; then ' + fail('transform-failed') + '; fi; ' +
922
+ 'printf "count=%s cksum=%s\\n" "$n" "$c" >> "$tmp" || { ' + fail('transform-failed') + '; }; ' +
923
+ 'if ! mv "$tmp" "$out"; then ' + fail('publish-failed') + '; fi; ' +
924
+ 'rm -rf "$sc"; ' +
925
+ 'echo "BASELINE-CAPTURED path=$out entries=$n cksum=$c"'
926
+ );
927
+ }
928
+
929
+ /** Was `path` changed BY THE CODER, rather than already dirty when the baseline was taken?
930
+ * Absent from the baseline ⇒ yes. Present with a DIFFERENT current hash ⇒ yes. Present with the
931
+ * same hash, or present with no current hash to compare ⇒ NO (fail-closed: without hash evidence
932
+ * a pre-dirty path must not read as landed — that read is exactly acid 003-5a). */
933
+ function isNewlyChanged(
934
+ path: string,
935
+ baseline: BaselineVerification | null | undefined,
936
+ currentHashes: Readonly<Record<string, string>> | undefined,
937
+ ): boolean {
938
+ if (!baseline || !baseline.ok) return false
939
+ let recorded = null
940
+ for (const entry of baseline.entries) {
941
+ if (entry.path === path) { recorded = entry.hash; break }
942
+ }
943
+ if (recorded === null) return true
944
+ const now = currentHashes ? currentHashes[path] : undefined
945
+ if (now === undefined) return false
946
+ return now !== recorded
518
947
  }
519
948
 
520
949
  export function codeLandedBarrierHasLanded(
521
950
  changedPaths: readonly string[],
522
951
  expectedPaths: readonly string[] = [],
952
+ baseline?: BaselineVerification | null,
953
+ currentHashes?: Readonly<Record<string, string>>,
523
954
  ): boolean {
524
- const filteredChangedPaths = filterPollableCodePaths(changedPaths);
525
- const filteredExpectedPaths = filterPollableCodePaths(expectedPaths);
526
- if (filteredExpectedPaths.length === 0) return filteredChangedPaths.length > 0;
527
- const changed = new Set(filteredChangedPaths);
955
+ const filteredChangedPaths = filterPollableCodePaths(changedPaths)
956
+ const filteredExpectedPaths = filterPollableCodePaths(expectedPaths)
957
+ // ADR-003: an EMPTY expected set can never establish landing. The pre-epoch `return
958
+ // filteredChangedPaths.length > 0` here is why an unrelated dirty file read as Codex's work.
959
+ if (filteredExpectedPaths.length === 0) return false
960
+ const changed = new Set(filteredChangedPaths)
528
961
  for (const expectedPath of filteredExpectedPaths) {
529
- if (changed.has(expectedPath)) return true;
962
+ if (!changed.has(expectedPath)) continue
963
+ // no baseline supplied ⇒ this is the plain dirty-match question (decideCodeLanding does the
964
+ // delta filtering itself); with a baseline ⇒ the newly-changed predicate.
965
+ if (baseline === undefined) return true
966
+ if (isNewlyChanged(expectedPath, baseline, currentHashes)) return true
530
967
  }
531
- return false;
968
+ return false
532
969
  }
533
970
 
534
971
  export function decideCodeLanding(snapshot: CodeLandingSnapshot): CodeLandingDecision {
535
- const maxWaitMs = Math.max(0, snapshot.maxWaitMs);
536
- const elapsedMs = Math.max(0, snapshot.elapsedMs);
537
- const elapsedSeconds = Math.floor(elapsedMs / 1000);
538
- const expectedPaths = filterPollableCodePaths(snapshot.expectedPaths);
539
- const changedPaths = filterPollableCodePaths(snapshot.changedEntries.map((entry) => entry.path));
540
- const changed = new Set(changedPaths);
541
- const matchedExpectedPaths = expectedPaths.filter((path) => changed.has(path));
542
-
543
- if (expectedPaths.length > 0 && matchedExpectedPaths.length > 0) {
972
+ const maxWaitMs = Math.max(0, snapshot.maxWaitMs)
973
+ const elapsedMs = Math.max(0, snapshot.elapsedMs)
974
+ const elapsedSeconds = Math.floor(elapsedMs / 1000)
975
+ const expectedPaths = filterPollableCodePaths(snapshot.expectedPaths)
976
+ const changedPaths = filterPollableCodePaths(snapshot.changedEntries.map(function (entry) { return entry.path }))
977
+
978
+ // (1) nothing was ESTABLISHED to watch for. Pre-epoch this fell through to "any code change
979
+ // counts" and returned status=landed off an unrelated dirty file.
980
+ if (expectedPaths.length === 0) {
544
981
  return {
545
- status: 'landed',
546
- changed: matchedExpectedPaths.length,
547
- elapsedMs,
548
- elapsedSeconds,
549
- expectedPaths,
550
- matchedExpectedPaths,
551
- changedPaths,
552
- predicate: 'expected-path',
553
- qeSignalLine:
554
- 'CODEX-LANDING-SIGNAL status=landed changed=' +
555
- matchedExpectedPaths.length +
556
- ' after=' +
557
- elapsedSeconds +
558
- 's predicate=expected-path matched=' +
559
- matchedExpectedPaths.join(','),
560
- };
982
+ status: 'inconclusive',
983
+ reason: 'empty-plan-block',
984
+ changed: 0,
985
+ elapsedMs: elapsedMs,
986
+ elapsedSeconds: elapsedSeconds,
987
+ expectedPaths: expectedPaths,
988
+ matchedExpectedPaths: [],
989
+ changedPaths: changedPaths,
990
+ predicate: 'no-expected-targets',
991
+ qeSignalLine: 'CODEX-LANDING-SIGNAL status=inconclusive predicate=no-expected-targets reason=empty-plan-block',
992
+ }
561
993
  }
562
994
 
563
- if (expectedPaths.length === 0 && changedPaths.length > 0) {
995
+ // (2) without a VERIFIED baseline the newly-changed delta cannot be computed at all. Answering
996
+ // the dirty-match question instead would silently re-open acid 003-5a, so we answer "unknown".
997
+ const baseline = snapshot.baseline
998
+ if (!baseline || !baseline.ok) {
999
+ const reason = baseline && baseline.reason ? baseline.reason : 'no-baseline'
1000
+ return {
1001
+ status: 'inconclusive',
1002
+ reason: reason,
1003
+ changed: 0,
1004
+ elapsedMs: elapsedMs,
1005
+ elapsedSeconds: elapsedSeconds,
1006
+ expectedPaths: expectedPaths,
1007
+ matchedExpectedPaths: [],
1008
+ changedPaths: changedPaths,
1009
+ predicate: 'newly-changed',
1010
+ qeSignalLine: 'CODEX-LANDING-SIGNAL status=inconclusive predicate=newly-changed reason=' + reason,
1011
+ }
1012
+ }
1013
+
1014
+ const changed = new Set(changedPaths)
1015
+ const matchedExpectedPaths = expectedPaths.filter(function (path) {
1016
+ return changed.has(path) && isNewlyChanged(path, baseline, snapshot.currentHashes)
1017
+ })
1018
+
1019
+ if (matchedExpectedPaths.length > 0) {
564
1020
  return {
565
1021
  status: 'landed',
566
- changed: changedPaths.length,
567
- elapsedMs,
568
- elapsedSeconds,
569
- expectedPaths,
570
- matchedExpectedPaths,
571
- changedPaths,
572
- predicate: 'any-code-change',
1022
+ changed: matchedExpectedPaths.length,
1023
+ elapsedMs: elapsedMs,
1024
+ elapsedSeconds: elapsedSeconds,
1025
+ expectedPaths: expectedPaths,
1026
+ matchedExpectedPaths: matchedExpectedPaths,
1027
+ changedPaths: changedPaths,
1028
+ predicate: 'newly-changed',
573
1029
  qeSignalLine:
574
1030
  'CODEX-LANDING-SIGNAL status=landed changed=' +
575
- changedPaths.length +
1031
+ matchedExpectedPaths.length +
576
1032
  ' after=' +
577
1033
  elapsedSeconds +
578
- 's predicate=any-code-change',
579
- };
1034
+ 's predicate=newly-changed matched=' +
1035
+ matchedExpectedPaths.join(','),
1036
+ }
580
1037
  }
581
1038
 
582
1039
  if (elapsedMs < maxWaitMs) {
583
1040
  return {
584
1041
  status: 'not-yet-flushed',
585
1042
  changed: 0,
586
- elapsedMs,
587
- elapsedSeconds,
588
- expectedPaths,
1043
+ elapsedMs: elapsedMs,
1044
+ elapsedSeconds: elapsedSeconds,
1045
+ expectedPaths: expectedPaths,
589
1046
  matchedExpectedPaths: [],
590
- changedPaths,
1047
+ changedPaths: changedPaths,
591
1048
  predicate: 'empty-before-timeout',
592
1049
  qeSignalLine: 'CODEX-LANDING-SIGNAL status=not-yet-flushed changed=0 after ' + elapsedSeconds + 's — not yet flushed',
593
- };
1050
+ }
594
1051
  }
595
1052
 
596
- const terminalSeconds = Math.ceil(maxWaitMs / 1000);
1053
+ const terminalSeconds = Math.ceil(maxWaitMs / 1000)
597
1054
  return {
598
1055
  status: 'genuinely-not-landed',
599
1056
  changed: 0,
600
- elapsedMs,
1057
+ elapsedMs: elapsedMs,
601
1058
  elapsedSeconds: terminalSeconds,
602
- expectedPaths,
1059
+ expectedPaths: expectedPaths,
603
1060
  matchedExpectedPaths: [],
604
- changedPaths,
1061
+ changedPaths: changedPaths,
605
1062
  predicate: 'empty-after-timeout',
606
1063
  qeSignalLine: 'CODEX-LANDING-SIGNAL status=genuinely-not-landed ' + codeLandingEmptySignal(terminalSeconds),
607
- };
1064
+ }
1065
+ }
1066
+
1067
+ // ── The single normalization helper: probe stdout → status, parsed EXACTLY ONCE ───────────────
1068
+
1069
+ export interface ParsedLandingSignal {
1070
+ readonly status: 'landed' | 'genuinely-not-landed' | 'inconclusive';
1071
+ readonly reason?: LandingInconclusiveReason;
1072
+ }
1073
+
1074
+ /**
1075
+ * PARSE-NEVER-SYNTHESIZE, applied to the barrier the same way ADR-001 applies it to a QE verdict:
1076
+ * a probe that returned nothing, or text with no signal line, is NOT a clean "not landed" and NOT
1077
+ * a landed — it is `inconclusive` with the reason naming which failure it was. An unknown status
1078
+ * token (including a stray mid-poll `not-yet-flushed` reaching a terminal position) is malformed,
1079
+ * never trusted. Every consumer reads THIS function's output; no consumer re-regexes the note.
1080
+ */
1081
+ export function parseLandingSignal(probeText: string | null | undefined): ParsedLandingSignal {
1082
+ const text = probeText === null || probeText === undefined ? '' : String(probeText)
1083
+ if (text.trim() === '') return { status: 'inconclusive', reason: 'probe-failure' }
1084
+ const lines = text.split(/\r?\n/)
1085
+ let signal = ''
1086
+ for (let i = 0; i < lines.length; i++) {
1087
+ if (String(lines[i] || '').indexOf('CODEX-LANDING-SIGNAL status=') >= 0) { signal = String(lines[i] || ''); break }
1088
+ }
1089
+ if (signal === '') return { status: 'inconclusive', reason: 'malformed-signal' }
1090
+ const m = /CODEX-LANDING-SIGNAL status=([A-Za-z-]+)/.exec(signal)
1091
+ const status = m === null ? '' : String(m[1] || '')
1092
+ if (status === 'landed') return { status: 'landed' }
1093
+ if (status === 'genuinely-not-landed') return { status: 'genuinely-not-landed' }
1094
+ if (status !== 'inconclusive') return { status: 'inconclusive', reason: 'malformed-signal' }
1095
+ const r = /reason=([a-z-]+)/.exec(signal)
1096
+ const reason = r === null ? '' : String(r[1] || '')
1097
+ switch (reason) {
1098
+ case 'empty-plan-block':
1099
+ case 'override-unpollable':
1100
+ case 'no-plan-block':
1101
+ case 'no-baseline':
1102
+ case 'baseline-unverified':
1103
+ case 'probe-failure':
1104
+ case 'malformed-signal':
1105
+ return { status: 'inconclusive', reason: reason }
1106
+ default:
1107
+ return { status: 'inconclusive', reason: 'malformed-signal' }
1108
+ }
1109
+ }
1110
+
1111
+ function codeLandingShellQuote(value: string): string {
1112
+ return "'" + String(value).replace(/'/g, "'\"'\"'") + "'"
1113
+ }
1114
+
1115
+ /**
1116
+ * The probe shell (pure half of the workflow's inline copy). Obligations, in order:
1117
+ *
1118
+ * 1. mode 'inconclusive' short-circuits — polling with no established target is exactly what
1119
+ * produced the false "any code change" landing verdict.
1120
+ * 2. VERIFY the baseline trailer BEFORE any absent-from-baseline reasoning, because a truncated
1121
+ * baseline makes every missing path look newly changed, i.e. makes everything look LANDED.
1122
+ * 3. only then poll with the newly-changed predicate: in the porcelain AND (absent from the
1123
+ * baseline OR `git hash-object` differs).
1124
+ *
1125
+ * Terminal landed / genuinely-not-landed lines keep today's grammar so parseLandingSignal covers all.
1126
+ *
1127
+ * Three properties of the POLL that are load-bearing rather than stylistic:
1128
+ *
1129
+ * - `--untracked-files=all` (QE F3): without it git reports a brand-new file inside a brand-new
1130
+ * directory as `?? dir/` — the DIRECTORY — so an exact match against the expected FILE path can
1131
+ * never fire and a genuinely landed file reads as not-landed.
1132
+ * - the match set is NEVER truncated (QE F3): the pre-fix probe piped the porcelain through
1133
+ * `head -200` and then searched THAT, so on any tree with more than 200 dirty entries the
1134
+ * predicate silently stopped seeing the tail. Our own settled tree had 292. The cap now applies
1135
+ * ONLY to the human-readable file list that gets echoed.
1136
+ * - the probe's own scratch files live in `mktemp` space, NOT beside the baseline: a redirection
1137
+ * creates its target BEFORE the command writes to it, so a scratch file next to the baseline
1138
+ * appears in the very `git status` it is about to feed (MEASURED — a capture into a fixture repo
1139
+ * recorded its own `.raw` file as the tree's only dirty entry). In production the path sits under
1140
+ * `features/` and the pipeline-prefix filter hid it; the filter is not the guarantee.
1141
+ * - the baseline lookup compares the path as a LITERAL (QE F4): it used to interpolate the path into
1142
+ * a `sed -E` pattern, where `.` and `+` — both legal in the accepted path grammar — are regex
1143
+ * metacharacters, so `a.b.ts` could match the recorded line for `axb.ts` and pick up the WRONG
1144
+ * hash. `awk -v` with a field-split equality has no pattern semantics at all.
1145
+ */
1146
+ export function codeLandingProbeCmd(repo: string, plan: CodeLandedBarrierPlan, baselineAbsPath: string): string {
1147
+ if (plan.mode === 'inconclusive') {
1148
+ const reason = plan.inconclusiveReason === undefined ? 'empty-plan-block' : plan.inconclusiveReason
1149
+ return 'echo "CODEX-LANDING-SIGNAL status=inconclusive predicate=no-expected-targets reason=' + reason + '"; echo "files:"; echo "(none)"'
1150
+ }
1151
+ const expectedList = plan.expectedPaths.length > 0 ? plan.expectedPaths.map(codeLandingShellQuote).join(' ') : "''"
1152
+ return (
1153
+ 'repo=' + codeLandingShellQuote(repo) + '; base=' + codeLandingShellQuote(baselineAbsPath) + '; ' +
1154
+ 'sleeps="' + plan.sleepsSeconds.join(' ') + '"; expected_count=' + plan.expectedPaths.length + '; elapsed=0; ' +
1155
+ 'if [ ! -f "$base" ]; then echo "CODEX-LANDING-SIGNAL status=inconclusive predicate=newly-changed reason=no-baseline"; echo "files:"; echo "(none)"; exit 0; fi; ' +
1156
+ 'body=$(mktemp) || { echo "CODEX-LANDING-SIGNAL status=inconclusive predicate=newly-changed reason=probe-failure"; echo "files:"; echo "(none)"; exit 0; }; ' +
1157
+ 'pfile=$(mktemp) || { rm -f "$body"; echo "CODEX-LANDING-SIGNAL status=inconclusive predicate=newly-changed reason=probe-failure"; echo "files:"; echo "(none)"; exit 0; }; ' +
1158
+ 'bl=$(wc -l < "$base" | tr -d " "); head -n $((bl - 1)) "$base" > "$body"; ' +
1159
+ 'tr_line=$(tail -n 1 "$base"); tr_n=$(printf "%s" "$tr_line" | sed -nE "s/^count=([0-9]+) cksum=([0-9]+)$/\\1/p"); ' +
1160
+ 'tr_c=$(printf "%s" "$tr_line" | sed -nE "s/^count=([0-9]+) cksum=([0-9]+)$/\\2/p"); ' +
1161
+ 'n_body=$(wc -l < "$body" | tr -d " "); c_body=$(cksum < "$body" | awk "{print \\$1}"); ' +
1162
+ 'if [ -z "$tr_n" ] || [ "$tr_n" != "$n_body" ] || [ "$tr_c" != "$c_body" ]; then rm -f "$body" "$pfile"; echo "CODEX-LANDING-SIGNAL status=inconclusive predicate=newly-changed reason=baseline-unverified"; echo "files:"; echo "(none)"; exit 0; fi; ' +
1163
+ 'newly(){ recorded=$(awk -v want="$1" \'{ i = index($0, " "); if (i > 0 && substr($0, i + 1) == want) { print substr($0, 1, i - 1); exit } }\' "$body"); ' +
1164
+ 'if [ -z "$recorded" ]; then return 0; fi; now=$(git -C "$repo" hash-object -- "$1" 2>/dev/null); ' +
1165
+ 'if [ -z "$now" ] || [ "$now" = "$recorded" ]; then return 1; fi; return 0; }; ' +
1166
+ 'poll(){ git -C "$repo" status --porcelain --untracked-files=all 2>/dev/null | sed -E "s/^...//" | sed -E "s/.* -> //" | grep -vE "^(features/|[.]dz/|[.]agentic-qe/|roam/)" | sed "/^$/d" > "$pfile"; ' +
1167
+ 'n=$(wc -l < "$pfile" | tr -d " "); ' +
1168
+ 'matched=""; for p in ' + expectedList + '; do [ -z "$p" ] && continue; ' +
1169
+ 'if grep -Fx -- "$p" "$pfile" >/dev/null && newly "$p"; then matched="$p"; break; fi; done; ' +
1170
+ 'if [ -n "$matched" ]; then echo "CODEX-LANDING-SIGNAL status=landed changed=1 after=${elapsed}s predicate=newly-changed"; echo "matched=$matched"; echo "files:"; head -40 "$pfile"; rm -f "$body" "$pfile"; exit 0; fi; }; ' +
1171
+ 'poll; for wait in $sleeps; do sleep "$wait"; elapsed=$((elapsed + wait)); poll; done; ' +
1172
+ 'echo "CODEX-LANDING-SIGNAL status=genuinely-not-landed ' + plan.emptySignal + '"; echo "predicate=newly-changed observed=$n"; ' +
1173
+ 'echo "files:"; if [ -s "$pfile" ]; then head -40 "$pfile"; else echo "(none)"; fi; rm -f "$body" "$pfile"'
1174
+ );
608
1175
  }
609
1176
 
610
1177
  /**
@@ -912,3 +1479,58 @@ export function checkArtifactRoot(root: string): string | null {
912
1479
  if (hasDotDotSegment(root)) return 'artifact root contains a ".." segment: ' + root;
913
1480
  return null;
914
1481
  }
1482
+
1483
+
1484
+ // ── K2 plan-completeness gate (feature fa-plan-gate-wiring) ────────────────────────────────────
1485
+ // Step 7 must not start on an incomplete plan. The gate is a SCRIPT the pipeline shells out to;
1486
+ // these two pure halves build its command and PARSE its verdict. The workflow inlines byte-equivalent
1487
+ // copies (drift-guarded), because the workflow sandbox cannot import.
1488
+
1489
+ /** The one path the pipeline calls. Repo-relative — the command `cd`s into the repo root first. */
1490
+ export const PLAN_GATE_SCRIPT = '.claude/skills/feature-adr/scripts/check-plan-completeness.mjs';
1491
+
1492
+ export interface PlanGateVerdict {
1493
+ /** 'pass' ONLY when the script's own last line AND its exit code agree on PASS. */
1494
+ verdict: 'pass' | 'fail' | 'not-established';
1495
+ /** The script's exit code as reported by the `K2_EXIT=` trailer, or null when unparseable. */
1496
+ exit: number | null;
1497
+ reason: string;
1498
+ output: string;
1499
+ }
1500
+
1501
+ /**
1502
+ * The EXACT command the gate agent runs. `2>&1` folds stderr in (a crash must be visible, not
1503
+ * silently empty) and the `K2_EXIT=` trailer carries the exit code back through an agent that can
1504
+ * only return text.
1505
+ */
1506
+ export function planCompletenessGateCmd(repo: string, featureDir: string, tier?: string | null): string {
1507
+ const q = (s: string) => "'" + String(s).replace(/'/g, "'\\''") + "'"
1508
+ const t = (typeof tier === 'string' && tier !== '') ? ' --tier=' + q(tier) : ''
1509
+ return 'cd ' + q(repo) + ' && node ' + q(PLAN_GATE_SCRIPT) + ' ' + q(featureDir) + t + ' 2>&1; echo K2_EXIT=$?'
1510
+ }
1511
+
1512
+ /**
1513
+ * PARSE-NEVER-SYNTHESIZE. An empty reply, a reply with no verdict line, a missing/unknown exit code,
1514
+ * or a verdict line that DISAGREES with the exit code are all `not-established` — never a pass. The
1515
+ * quiet failure this forecloses: a dead or chatty agent reading as a clean gate.
1516
+ */
1517
+ export function parsePlanGateVerdict(raw: string | null | undefined): PlanGateVerdict {
1518
+ const text = String(raw === null || raw === undefined ? '' : raw)
1519
+ const output = text.slice(0, 2000)
1520
+ if (text.trim() === '') return { verdict: 'not-established', exit: null, reason: 'empty-agent-reply', output: output }
1521
+ // G-F1 (reproduced by execution): the checker ECHOES plan-controlled content, so a forged verdict
1522
+ // line and a forged K2_EXIT trailer can appear EARLIER in this stream. The script always writes its
1523
+ // own verdict LAST, so both halves anchor to the LAST match — a first-match read let a planted
1524
+ // 'K2 plan-completeness: PASS (0) K2_EXIT=0' target line forge a pass on a genuine FAIL/1 run.
1525
+ const exitAll = text.match(/K2_EXIT=(\d+)/g)
1526
+ const lastExit = exitAll === null ? null : /K2_EXIT=(\d+)/.exec(String(exitAll[exitAll.length - 1]))
1527
+ const exitCode = lastExit === null ? null : Number(lastExit[1])
1528
+ const verdictAll = text.match(/K2 plan-completeness:\s*(PASS|FAIL|NOT-ESTABLISHED)/g)
1529
+ if (verdictAll === null) return { verdict: 'not-established', exit: exitCode, reason: 'no-verdict-line', output: output }
1530
+ const lastVerdict = String(verdictAll[verdictAll.length - 1])
1531
+ const byName = lastVerdict.indexOf('PASS') >= 0 ? 'pass' : (lastVerdict.indexOf('FAIL') >= 0 ? 'fail' : 'not-established')
1532
+ const byExit = exitCode === 0 ? 'pass' : (exitCode === 1 ? 'fail' : (exitCode === 3 ? 'not-established' : null))
1533
+ if (byExit === null) return { verdict: 'not-established', exit: exitCode, reason: 'unknown-exit-code', output: output }
1534
+ if (byExit !== byName) return { verdict: 'not-established', exit: exitCode, reason: 'verdict-exit-mismatch', output: output }
1535
+ return { verdict: byName, exit: exitCode, reason: 'script-verdict', output: output }
1536
+ }