@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
@@ -313,38 +313,66 @@ export const DEFAULT_CODE_LANDING_BACKOFF_MS = [
313
313
  ];
314
314
  export const CODE_LANDED_BARRIER_SLEEPS_SECONDS = DEFAULT_CODE_LANDING_BACKOFF_MS.map((ms) => ms / 1000);
315
315
  const CODE_LANDING_PIPELINE_PREFIXES = ['features/', '.dz/', '.agentic-qe/', 'roam/'];
316
+ /** Bumped whenever the landing protocol's meaning changes. Recorded on every code-stage result so a
317
+ * pre-protocol checkpoint reads as no-checkpoint instead of resuming into new semantics. */
318
+ export const LANDING_PROTOCOL_VERSION = 2;
319
+ /** R6: salted into the code stage's checkpoint hash PARTS (not the schema version — ADR-003 keeps
320
+ * `CKPT_SCHEMA_VERSION` at 'fa-ckpt-2' deliberately, so ONLY the code stage re-runs). */
321
+ export const LANDING_HASH_TOKEN = 'landing-v2';
322
+ /**
323
+ * The CLOSED landing-predicate vocabulary, as DATA (QE F6) — exhaustive-record derived, so `tsc`
324
+ * fails the build if the union gains or loses a member. `'expected-path'` and `'any-code-change'`
325
+ * are DELETED; a consumer that still matches on them will find them absent from this list too.
326
+ */
327
+ const CODE_LANDING_PREDICATE_SET = {
328
+ 'newly-changed': true,
329
+ 'no-expected-targets': true,
330
+ 'empty-before-timeout': true,
331
+ 'empty-after-timeout': true,
332
+ };
333
+ export const CODE_LANDING_PREDICATES = Object.keys(CODE_LANDING_PREDICATE_SET);
316
334
  function codeLandingEmptySignal(seconds) {
317
335
  return 'changed=0 after ' + seconds + 's — genuinely not landed';
318
336
  }
319
337
  export function needsCodeLandedBarrier(coderUsed) {
320
338
  return coderUsed === 'codex' || coderUsed === 'codex-fallback';
321
339
  }
322
- export function normalizeCodeLandingPath(path) {
340
+ /** The normalization half, with no accept/reject opinion. */
341
+ function stripCodeLandingPath(path) {
323
342
  let p = String(path || '').trim().replace(/\\/g, '/');
324
343
  while (p.indexOf('./') === 0)
325
344
  p = p.slice(2);
326
- p = p.replace(/\/+/g, '/');
345
+ return p.replace(/\/+/g, '/');
346
+ }
347
+ /** The rejection half, in classification order, NAMING the reason. `normalizeCodeLandingPath` and
348
+ * `validateExpectedTargetsBlock` both route through this, so the accept set cannot drift between
349
+ * "what the barrier polls" and "what the Step-6/7 boundary reports as accepted". */
350
+ function classifyCodeLandingPathReject(path) {
351
+ const p = stripCodeLandingPath(path);
327
352
  if (!p)
328
- return '';
353
+ return 'empty-after-strip';
329
354
  if (p[0] === '/')
330
- return '';
355
+ return 'absolute-path';
331
356
  if (p === '..' || p.indexOf('../') === 0 || p.indexOf('/../') >= 0 || p.endsWith('/..'))
332
- return '';
357
+ return 'traversal';
333
358
  if (/[\0\r\n\t "'\x60$;&|<>*?()[\]{}!]/.test(p))
334
- return '';
359
+ return 'not-a-path';
335
360
  if (p.endsWith('/'))
336
- return '';
361
+ return 'not-a-path';
337
362
  for (const prefix of CODE_LANDING_PIPELINE_PREFIXES) {
338
363
  const bare = prefix.slice(0, -1);
339
364
  if (p === bare || p.indexOf(prefix) === 0)
340
- return '';
365
+ return 'pipeline-artifact-path';
341
366
  }
342
- return p;
367
+ return null;
368
+ }
369
+ export function normalizeCodeLandingPath(path) {
370
+ return classifyCodeLandingPathReject(path) === null ? stripCodeLandingPath(path) : '';
343
371
  }
344
372
  export function filterPollableCodePaths(paths) {
345
373
  const out = [];
346
374
  const seen = new Set();
347
- for (const path of paths) {
375
+ for (const path of paths || []) {
348
376
  const normalized = normalizeCodeLandingPath(path);
349
377
  if (!normalized || seen.has(normalized))
350
378
  continue;
@@ -353,41 +381,372 @@ export function filterPollableCodePaths(paths) {
353
381
  }
354
382
  return out;
355
383
  }
356
- export function codeLandedBarrierPlan(coderUsed, expectedPaths = []) {
384
+ export function codeLandedBarrierPlan(coderUsed, expectedPaths = [], inconclusiveReason) {
357
385
  const enabled = needsCodeLandedBarrier(coderUsed);
358
386
  const pollWindowSeconds = DEFAULT_CODE_LANDING_MAX_WAIT_MS / 1000;
359
387
  if (!enabled) {
388
+ return { enabled: false, mode: 'any-code-change', sleepsMs: [], sleepsSeconds: [], pollWindowMs: 0, pollWindowSeconds: 0, expectedPaths: [], emptySignal: '' };
389
+ }
390
+ const filteredExpectedPaths = filterPollableCodePaths(expectedPaths || []);
391
+ if (filteredExpectedPaths.length === 0) {
360
392
  return {
361
- enabled: false,
362
- mode: 'any-code-change',
393
+ enabled: true,
394
+ mode: 'inconclusive',
395
+ inconclusiveReason: inconclusiveReason === undefined ? 'empty-plan-block' : inconclusiveReason,
363
396
  sleepsMs: [],
364
397
  sleepsSeconds: [],
365
398
  pollWindowMs: 0,
366
399
  pollWindowSeconds: 0,
367
400
  expectedPaths: [],
368
- emptySignal: '',
401
+ emptySignal: codeLandingEmptySignal(pollWindowSeconds),
369
402
  };
370
403
  }
371
- const filteredExpectedPaths = filterPollableCodePaths(expectedPaths);
372
404
  return {
373
405
  enabled: true,
374
- mode: filteredExpectedPaths.length > 0 ? 'expected-files' : 'any-code-change',
406
+ mode: 'expected-files',
375
407
  sleepsMs: DEFAULT_CODE_LANDING_BACKOFF_MS,
376
408
  sleepsSeconds: CODE_LANDED_BARRIER_SLEEPS_SECONDS,
377
409
  pollWindowMs: DEFAULT_CODE_LANDING_MAX_WAIT_MS,
378
- pollWindowSeconds,
410
+ pollWindowSeconds: pollWindowSeconds,
379
411
  expectedPaths: filteredExpectedPaths,
380
412
  emptySignal: codeLandingEmptySignal(pollWindowSeconds),
381
413
  };
382
414
  }
383
- export function codeLandedBarrierHasLanded(changedPaths, expectedPaths = []) {
415
+ /** Flatten the many shapes an expected-target value arrives in (string, array, {wrote}, {paths}). */
416
+ export function addExpectedCodeTarget(value, out) {
417
+ if (value === null || value === undefined)
418
+ return;
419
+ if (Array.isArray(value)) {
420
+ for (let i = 0; i < value.length; i++)
421
+ addExpectedCodeTarget(value[i], out);
422
+ return;
423
+ }
424
+ if (typeof value === 'object') {
425
+ const obj = value;
426
+ if (Array.isArray(obj.wrote))
427
+ addExpectedCodeTarget(obj.wrote, out);
428
+ if (Array.isArray(obj.paths))
429
+ addExpectedCodeTarget(obj.paths, out);
430
+ return;
431
+ }
432
+ const lines = String(value).split(/\r?\n/);
433
+ for (let i = 0; i < lines.length; i++) {
434
+ const candidate = String(lines[i] || '').replace(/^[-*]\s+/, '').replace(/^\x60+|\x60+$/g, '').trim();
435
+ if (candidate)
436
+ out.push(candidate);
437
+ }
438
+ }
439
+ /** Read the `EXPECTED_CODE_TARGETS:` block out of arbitrary text. The block ends at the next
440
+ * ALL-CAPS heading line — the shared grammar, used for BOTH the plan block (which establishes)
441
+ * and the Codex scrape (which only ever diagnoses). */
442
+ export function extractExpectedCodeTargetsFromText(text) {
443
+ const out = [];
444
+ const lines = String(text || '').split(/\r?\n/);
445
+ let inBlock = false;
446
+ for (let i = 0; i < lines.length; i++) {
447
+ const trimmed = String(lines[i] || '').trim();
448
+ if (/^EXPECTED_CODE_TARGETS:\s*$/i.test(trimmed)) {
449
+ inBlock = true;
450
+ continue;
451
+ }
452
+ if (!inBlock)
453
+ continue;
454
+ if (!trimmed)
455
+ continue;
456
+ if (/^[A-Z][A-Z0-9_ -]*:\s*$/.test(trimmed))
457
+ break;
458
+ out.push(trimmed.replace(/^[-*]\s+/, '').replace(/^\x60+|\x60+$/g, '').trim());
459
+ }
460
+ return out;
461
+ }
462
+ /**
463
+ * Where the barrier's expected targets come from, in ADR-003's precedence: an explicit non-empty
464
+ * ARGS override REPLACES the plan (never unions with it); otherwise the PLAN's block establishes;
465
+ * the Codex scrape establishes NOTHING.
466
+ *
467
+ * The override is NARROWING-ONLY on purpose: args provided but all-unpollable returns an EMPTY
468
+ * target set with reason 'override-unpollable' and does NOT fall through to the plan. A silent
469
+ * fall-through would mean "I asked you to watch exactly these files" quietly became "watch the
470
+ * plan's files", i.e. the operator's narrowing was ignored without a word.
471
+ */
472
+ export function sourceExpectedCodeTargets(argTargets, planBlockText, codexText) {
473
+ const scrapeRaw = [];
474
+ addExpectedCodeTarget(extractExpectedCodeTargetsFromText(codexText), scrapeRaw);
475
+ const scrapeDiagnostic = filterPollableCodePaths(scrapeRaw);
476
+ const argRaw = [];
477
+ addExpectedCodeTarget(argTargets, argRaw);
478
+ if (argRaw.length > 0) {
479
+ const argPaths = filterPollableCodePaths(argRaw);
480
+ if (argPaths.length > 0)
481
+ return { targets: argPaths, establishedBy: 'args', scrapeDiagnostic: scrapeDiagnostic };
482
+ return { targets: [], establishedBy: null, reason: 'override-unpollable', scrapeDiagnostic: scrapeDiagnostic };
483
+ }
484
+ if (planBlockText === null || planBlockText === undefined) {
485
+ return { targets: [], establishedBy: null, reason: 'no-plan-block', scrapeDiagnostic: scrapeDiagnostic };
486
+ }
487
+ const planRaw = [];
488
+ addExpectedCodeTarget(extractExpectedCodeTargetsFromText(planBlockText), planRaw);
489
+ const planPaths = filterPollableCodePaths(planRaw);
490
+ if (planPaths.length > 0)
491
+ return { targets: planPaths, establishedBy: 'plan', scrapeDiagnostic: scrapeDiagnostic };
492
+ return { targets: [], establishedBy: null, reason: 'empty-plan-block', scrapeDiagnostic: scrapeDiagnostic };
493
+ }
494
+ /**
495
+ * Line-level validation of the plan's block at the Step-6/7 boundary (R13) — BEFORE Step 7 spends
496
+ * tokens. Every rejected line is reported WITH its reason, so a whole-block typo can never present
497
+ * as "no block declared".
498
+ */
499
+ export function validateExpectedTargetsBlock(planText) {
500
+ const text = String(planText || '');
501
+ const present = /^EXPECTED_CODE_TARGETS:\s*$/im.test(text);
502
+ const lines = extractExpectedCodeTargetsFromText(text);
503
+ const accepted = [];
504
+ const rejected = [];
505
+ const seen = new Set();
506
+ for (const line of lines) {
507
+ const reason = classifyCodeLandingPathReject(line);
508
+ if (reason !== null) {
509
+ rejected.push({ line: line, reason: reason });
510
+ continue;
511
+ }
512
+ const normalized = normalizeCodeLandingPath(line);
513
+ if (seen.has(normalized))
514
+ continue;
515
+ seen.add(normalized);
516
+ accepted.push(normalized);
517
+ }
518
+ return { present: present, accepted: accepted, rejected: rejected };
519
+ }
520
+ let CKSUM_TABLE = null;
521
+ function cksumTable() {
522
+ if (CKSUM_TABLE !== null)
523
+ return CKSUM_TABLE;
524
+ const table = [];
525
+ for (let i = 0; i < 256; i++) {
526
+ let c = i << 24;
527
+ for (let k = 0; k < 8; k++)
528
+ c = (c & 0x80000000) !== 0 ? ((c << 1) ^ 0x04c11db7) >>> 0 : (c << 1) >>> 0;
529
+ table.push(c >>> 0);
530
+ }
531
+ CKSUM_TABLE = table;
532
+ return table;
533
+ }
534
+ /** UTF-8 bytes without TextEncoder — the workflow mirror runs in a sandbox with no host globals. */
535
+ function utf8Bytes(s) {
536
+ const out = [];
537
+ for (let i = 0; i < s.length; i++) {
538
+ const c = s.charCodeAt(i);
539
+ if (c < 0x80) {
540
+ out.push(c);
541
+ continue;
542
+ }
543
+ if (c < 0x800) {
544
+ out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
545
+ continue;
546
+ }
547
+ if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) {
548
+ const next = s.charCodeAt(i + 1);
549
+ if (next >= 0xdc00 && next <= 0xdfff) {
550
+ const cp = 0x10000 + ((c - 0xd800) << 10) + (next - 0xdc00);
551
+ out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
552
+ i++;
553
+ continue;
554
+ }
555
+ }
556
+ out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
557
+ }
558
+ return out;
559
+ }
560
+ /**
561
+ * POSIX `cksum` CRC (polynomial 0x04C11DB7, length-augmented, final complement) — the PURE twin of
562
+ * the shell trailer the capture command writes. Known vector: the empty body is 4294967295, and the
563
+ * test suite cross-checks one fixture against a REAL `cksum` run (H7) so the twin cannot drift.
564
+ */
565
+ export function posixCksum(body) {
566
+ const table = cksumTable();
567
+ const bytes = utf8Bytes(body === null || body === undefined ? '' : String(body));
568
+ let crc = 0;
569
+ for (let i = 0; i < bytes.length; i++) {
570
+ crc = ((crc << 8) ^ (table[((crc >>> 24) ^ (bytes[i] || 0)) & 0xff] || 0)) >>> 0;
571
+ }
572
+ let len = bytes.length;
573
+ while (len > 0) {
574
+ crc = ((crc << 8) ^ (table[((crc >>> 24) ^ (len & 0xff)) & 0xff] || 0)) >>> 0;
575
+ len = Math.floor(len / 256);
576
+ }
577
+ return (~crc) >>> 0;
578
+ }
579
+ /**
580
+ * Parse + VERIFY a captured baseline. The trailer (`count=<n> cksum=<c>`) is not decoration: a
581
+ * baseline is the only thing standing between "this path changed" and "this path was ALREADY dirty
582
+ * when the coder started", so a truncated or edited baseline must read as UNKNOWN, never as a
583
+ * smaller-but-fine baseline. Every trailer failure collapses to 'baseline-unverified'.
584
+ *
585
+ * A valid trailer with ZERO entries is OK — that is a clean tree, a real and common state.
586
+ */
587
+ export function verifyPreCodeBaseline(text) {
588
+ if (text === null || text === undefined)
589
+ return { ok: false, reason: 'no-baseline', entries: [] };
590
+ const raw = String(text);
591
+ if (raw.trim() === '')
592
+ return { ok: false, reason: 'no-baseline', entries: [] };
593
+ const lines = raw.split('\n');
594
+ let trailerIndex = -1;
595
+ for (let i = lines.length - 1; i >= 0; i--) {
596
+ if (String(lines[i] || '').trim() === '')
597
+ continue;
598
+ trailerIndex = i;
599
+ break;
600
+ }
601
+ if (trailerIndex < 0)
602
+ return { ok: false, reason: 'no-baseline', entries: [] };
603
+ const trailer = /^count=(\d+) cksum=(\d+)$/.exec(String(lines[trailerIndex] || '').trim());
604
+ if (trailer === null)
605
+ return { ok: false, reason: 'baseline-unverified', entries: [] };
606
+ const bodyLines = lines.slice(0, trailerIndex);
607
+ const body = bodyLines.length > 0 ? bodyLines.join('\n') + '\n' : '';
608
+ const entries = [];
609
+ for (const line of bodyLines) {
610
+ const trimmed = String(line || '');
611
+ if (trimmed.trim() === '')
612
+ continue;
613
+ const m = /^(\S+) (.+)$/.exec(trimmed);
614
+ if (m === null)
615
+ return { ok: false, reason: 'baseline-unverified', entries: [] };
616
+ entries.push({ hash: String(m[1] || ''), path: String(m[2] || '') });
617
+ }
618
+ if (entries.length !== Number(trailer[1]))
619
+ return { ok: false, reason: 'baseline-unverified', entries: [] };
620
+ if (posixCksum(body) !== Number(trailer[2]))
621
+ return { ok: false, reason: 'baseline-unverified', entries: [] };
622
+ return { ok: true, entries: entries };
623
+ }
624
+ /** PARSE-NEVER-SYNTHESIZE for the capture step: empty/garbage stdout is `no-signal`, an explicit
625
+ * failure line is `capture-failed`, and ONLY a well-formed `BASELINE-CAPTURED entries=<n>
626
+ * cksum=<c>` line is success. Never infer success from the absence of an error. */
627
+ export function parseBaselineCapture(text, expectedPrefix) {
628
+ const raw = text === null || text === undefined ? '' : String(text);
629
+ if (raw.trim() === '')
630
+ return { ok: false, path: null, entries: null, cksum: null, reason: 'no-signal' };
631
+ if (/BASELINE-CAPTURE-FAILED/.test(raw))
632
+ return { ok: false, path: null, entries: null, cksum: null, reason: 'capture-failed' };
633
+ const m = /BASELINE-CAPTURED path=(\S+) entries=(\d+) cksum=(\d+)/.exec(raw);
634
+ if (m === null)
635
+ return { ok: false, path: null, entries: null, cksum: null, reason: 'no-signal' };
636
+ const path = String(m[1] || '');
637
+ // The path arrives via an agent-relayed stdout, so it is UNTRUSTED text that later becomes a shell
638
+ // argument. Refuse anything with a metacharacter, and — when the caller says where it asked for the
639
+ // file — refuse a path outside that prefix. A capture that reports a path we did not ask for is a
640
+ // failed capture, not a relocated one.
641
+ if (path === '' || /[\0\r\n\t "'\x60$;&|<>*?()[\]{}!]/.test(path)) {
642
+ return { ok: false, path: null, entries: null, cksum: null, reason: 'unsafe-path' };
643
+ }
644
+ if (expectedPrefix !== undefined && path.indexOf(expectedPrefix) !== 0) {
645
+ return { ok: false, path: null, entries: null, cksum: null, reason: 'unsafe-path' };
646
+ }
647
+ return { ok: true, path: path, entries: Number(m[2]), cksum: Number(m[3]) };
648
+ }
649
+ /**
650
+ * The shell the capture agent runs immediately BEFORE the coder is dispatched.
651
+ *
652
+ * FAIL-CLOSED (QE F2), at EVERY step. The pre-fix pipeline sent `git status` through a pipe with
653
+ * its stderr discarded and its exit status unchecked, so a FAILED status call produced an empty
654
+ * path list and therefore a checksum-VALID, ZERO-ENTRY baseline — a baseline that verifies
655
+ * perfectly and claims the tree was clean. Every later path then looks absent-from-baseline, i.e.
656
+ * newly changed, i.e. LANDED. Round-2 QE found the SAME shape one layer down: the five transforms
657
+ * after the status call were themselves an unchecked pipeline. So each step now writes its own file
658
+ * and its status is checked individually; any failure emits `BASELINE-CAPTURE-FAILED` and writes
659
+ * NOTHING. The file is published (atomic `mv`) only after the
660
+ * trailer is written, so a failed attempt leaves the previous state untouched rather than a
661
+ * half-baseline — and the caller must PARSE the success signal (`parseBaselineCapture`) rather than
662
+ * assume the file on disk belongs to this attempt.
663
+ *
664
+ * ATTEMPT-UNIQUE, decided SHELL-SIDE (QE F2). A single stable path let a baseline from an EARLIER,
665
+ * failed attempt survive and be read by a later probe as if it belonged to that run — and a stale
666
+ * baseline verifies perfectly while answering about a tree it never saw. The caller passes a
667
+ * PREFIX; the shell appends `$(date +%s).$$` and REPORTS the path it wrote, so the only path any
668
+ * consumer can use is the one this execution produced. The uniqueness cannot be generated by the
669
+ * caller: the workflow that calls this runs in a sandbox with no clock and no randomness (INV-12 —
670
+ * `Date.now(` and `Math.random(` are lint-banned there, MEASURED via `loop-lint`), so a
671
+ * caller-side stamp would be a token that cannot exist at the only call site that needs it.
672
+ *
673
+ * `--untracked-files=all` (QE F3) is load-bearing, not tidiness: without it a brand-new file inside
674
+ * a brand-new directory is reported as `?? dir/` — the DIRECTORY, not the file — and the barrier's
675
+ * exact-match predicate can never match the expected path.
676
+ */
677
+ export function preCodeBaselineCaptureCmd(repo, baselinePathPrefix) {
678
+ // Every step is status-checked and writes to its OWN file in a scratch dir. The pre-fix version
679
+ // ran the five transforms as one pipeline whose status nobody read (QE round-2 F2b): a failing
680
+ // stage produced an EMPTY path list, which then produced a checksum-VALID zero-entry baseline —
681
+ // a baseline that verifies perfectly and claims the tree was clean, after which every path looks
682
+ // absent-from-baseline, i.e. newly changed, i.e. LANDED. Same failure shape as the unchecked
683
+ // `git status`, one layer down.
684
+ //
685
+ // `grep` needs its own rule: exit 1 means "no line matched", which is LEGAL and common — at
686
+ // Step-7 time the only dirty files are often the pipeline's own `features/<slug>/…` artifacts,
687
+ // and filtering all of them away is the correct answer, not a failure. Only exit > 1 is an error.
688
+ // (This is why the alternative "empty result from non-empty input ⇒ transform-failed" rule was
689
+ // NOT used: it would fail-closed on the normal feature-adr run.)
690
+ const fail = (reason) => 'rm -rf "$sc"; echo "BASELINE-CAPTURE-FAILED reason=' + reason + '"; exit 1';
691
+ return ('repo=' + codeLandingShellQuote(repo) + '; pre=' + codeLandingShellQuote(baselinePathPrefix) + '; ' +
692
+ 'sc=$(mktemp -d) || { echo "BASELINE-CAPTURE-FAILED reason=mktemp-failed"; exit 1; }; ' +
693
+ 'mkdir -p "$(dirname -- "$pre")" || { ' + fail('mkdir-failed') + '; }; ' +
694
+ 'out="$pre.$(date +%s).$$.txt"; tmp="$sc/base"; ' +
695
+ 'if ! git -C "$repo" status --porcelain --untracked-files=all > "$sc/0"; then ' + fail('git-status-failed') + '; fi; ' +
696
+ 'if ! sed -E "s/^...//" "$sc/0" > "$sc/1"; then ' + fail('transform-failed') + '; fi; ' +
697
+ 'if ! sed -E "s/.* -> //" "$sc/1" > "$sc/2"; then ' + fail('transform-failed') + '; fi; ' +
698
+ 'grep -vE "^(features/|[.]dz/|[.]agentic-qe/|roam/)" "$sc/2" > "$sc/3"; g=$?; ' +
699
+ 'if [ "$g" -gt 1 ]; then ' + fail('transform-failed') + '; fi; ' +
700
+ 'if ! sed "/^$/d" "$sc/3" > "$sc/4"; then ' + fail('transform-failed') + '; fi; ' +
701
+ 'if ! sort "$sc/4" > "$sc/paths"; then ' + fail('transform-failed') + '; fi; ' +
702
+ ': > "$tmp" || { ' + fail('transform-failed') + '; }; ' +
703
+ 'while IFS= read -r p; do h=$(git -C "$repo" hash-object -- "$p" 2>/dev/null); ' +
704
+ '[ -z "$h" ] && h="-"; printf "%s %s\\n" "$h" "$p" >> "$tmp"; done < "$sc/paths"; ' +
705
+ 'if ! sort -o "$tmp" "$tmp"; then ' + fail('transform-failed') + '; fi; ' +
706
+ 'n=$(wc -l < "$tmp" | tr -d " "); c=$(cksum < "$tmp" | awk "{print \\$1}"); ' +
707
+ 'if [ -z "$n" ] || [ -z "$c" ]; then ' + fail('transform-failed') + '; fi; ' +
708
+ 'printf "count=%s cksum=%s\\n" "$n" "$c" >> "$tmp" || { ' + fail('transform-failed') + '; }; ' +
709
+ 'if ! mv "$tmp" "$out"; then ' + fail('publish-failed') + '; fi; ' +
710
+ 'rm -rf "$sc"; ' +
711
+ 'echo "BASELINE-CAPTURED path=$out entries=$n cksum=$c"');
712
+ }
713
+ /** Was `path` changed BY THE CODER, rather than already dirty when the baseline was taken?
714
+ * Absent from the baseline ⇒ yes. Present with a DIFFERENT current hash ⇒ yes. Present with the
715
+ * same hash, or present with no current hash to compare ⇒ NO (fail-closed: without hash evidence
716
+ * a pre-dirty path must not read as landed — that read is exactly acid 003-5a). */
717
+ function isNewlyChanged(path, baseline, currentHashes) {
718
+ if (!baseline || !baseline.ok)
719
+ return false;
720
+ let recorded = null;
721
+ for (const entry of baseline.entries) {
722
+ if (entry.path === path) {
723
+ recorded = entry.hash;
724
+ break;
725
+ }
726
+ }
727
+ if (recorded === null)
728
+ return true;
729
+ const now = currentHashes ? currentHashes[path] : undefined;
730
+ if (now === undefined)
731
+ return false;
732
+ return now !== recorded;
733
+ }
734
+ export function codeLandedBarrierHasLanded(changedPaths, expectedPaths = [], baseline, currentHashes) {
384
735
  const filteredChangedPaths = filterPollableCodePaths(changedPaths);
385
736
  const filteredExpectedPaths = filterPollableCodePaths(expectedPaths);
737
+ // ADR-003: an EMPTY expected set can never establish landing. The pre-epoch `return
738
+ // filteredChangedPaths.length > 0` here is why an unrelated dirty file read as Codex's work.
386
739
  if (filteredExpectedPaths.length === 0)
387
- return filteredChangedPaths.length > 0;
740
+ return false;
388
741
  const changed = new Set(filteredChangedPaths);
389
742
  for (const expectedPath of filteredExpectedPaths) {
390
- if (changed.has(expectedPath))
743
+ if (!changed.has(expectedPath))
744
+ continue;
745
+ // no baseline supplied ⇒ this is the plain dirty-match question (decideCodeLanding does the
746
+ // delta filtering itself); with a baseline ⇒ the newly-changed predicate.
747
+ if (baseline === undefined)
748
+ return true;
749
+ if (isNewlyChanged(expectedPath, baseline, currentHashes))
391
750
  return true;
392
751
  }
393
752
  return false;
@@ -397,53 +756,72 @@ export function decideCodeLanding(snapshot) {
397
756
  const elapsedMs = Math.max(0, snapshot.elapsedMs);
398
757
  const elapsedSeconds = Math.floor(elapsedMs / 1000);
399
758
  const expectedPaths = filterPollableCodePaths(snapshot.expectedPaths);
400
- const changedPaths = filterPollableCodePaths(snapshot.changedEntries.map((entry) => entry.path));
759
+ const changedPaths = filterPollableCodePaths(snapshot.changedEntries.map(function (entry) { return entry.path; }));
760
+ // (1) nothing was ESTABLISHED to watch for. Pre-epoch this fell through to "any code change
761
+ // counts" and returned status=landed off an unrelated dirty file.
762
+ if (expectedPaths.length === 0) {
763
+ return {
764
+ status: 'inconclusive',
765
+ reason: 'empty-plan-block',
766
+ changed: 0,
767
+ elapsedMs: elapsedMs,
768
+ elapsedSeconds: elapsedSeconds,
769
+ expectedPaths: expectedPaths,
770
+ matchedExpectedPaths: [],
771
+ changedPaths: changedPaths,
772
+ predicate: 'no-expected-targets',
773
+ qeSignalLine: 'CODEX-LANDING-SIGNAL status=inconclusive predicate=no-expected-targets reason=empty-plan-block',
774
+ };
775
+ }
776
+ // (2) without a VERIFIED baseline the newly-changed delta cannot be computed at all. Answering
777
+ // the dirty-match question instead would silently re-open acid 003-5a, so we answer "unknown".
778
+ const baseline = snapshot.baseline;
779
+ if (!baseline || !baseline.ok) {
780
+ const reason = baseline && baseline.reason ? baseline.reason : 'no-baseline';
781
+ return {
782
+ status: 'inconclusive',
783
+ reason: reason,
784
+ changed: 0,
785
+ elapsedMs: elapsedMs,
786
+ elapsedSeconds: elapsedSeconds,
787
+ expectedPaths: expectedPaths,
788
+ matchedExpectedPaths: [],
789
+ changedPaths: changedPaths,
790
+ predicate: 'newly-changed',
791
+ qeSignalLine: 'CODEX-LANDING-SIGNAL status=inconclusive predicate=newly-changed reason=' + reason,
792
+ };
793
+ }
401
794
  const changed = new Set(changedPaths);
402
- const matchedExpectedPaths = expectedPaths.filter((path) => changed.has(path));
403
- if (expectedPaths.length > 0 && matchedExpectedPaths.length > 0) {
795
+ const matchedExpectedPaths = expectedPaths.filter(function (path) {
796
+ return changed.has(path) && isNewlyChanged(path, baseline, snapshot.currentHashes);
797
+ });
798
+ if (matchedExpectedPaths.length > 0) {
404
799
  return {
405
800
  status: 'landed',
406
801
  changed: matchedExpectedPaths.length,
407
- elapsedMs,
408
- elapsedSeconds,
409
- expectedPaths,
410
- matchedExpectedPaths,
411
- changedPaths,
412
- predicate: 'expected-path',
802
+ elapsedMs: elapsedMs,
803
+ elapsedSeconds: elapsedSeconds,
804
+ expectedPaths: expectedPaths,
805
+ matchedExpectedPaths: matchedExpectedPaths,
806
+ changedPaths: changedPaths,
807
+ predicate: 'newly-changed',
413
808
  qeSignalLine: 'CODEX-LANDING-SIGNAL status=landed changed=' +
414
809
  matchedExpectedPaths.length +
415
810
  ' after=' +
416
811
  elapsedSeconds +
417
- 's predicate=expected-path matched=' +
812
+ 's predicate=newly-changed matched=' +
418
813
  matchedExpectedPaths.join(','),
419
814
  };
420
815
  }
421
- if (expectedPaths.length === 0 && changedPaths.length > 0) {
422
- return {
423
- status: 'landed',
424
- changed: changedPaths.length,
425
- elapsedMs,
426
- elapsedSeconds,
427
- expectedPaths,
428
- matchedExpectedPaths,
429
- changedPaths,
430
- predicate: 'any-code-change',
431
- qeSignalLine: 'CODEX-LANDING-SIGNAL status=landed changed=' +
432
- changedPaths.length +
433
- ' after=' +
434
- elapsedSeconds +
435
- 's predicate=any-code-change',
436
- };
437
- }
438
816
  if (elapsedMs < maxWaitMs) {
439
817
  return {
440
818
  status: 'not-yet-flushed',
441
819
  changed: 0,
442
- elapsedMs,
443
- elapsedSeconds,
444
- expectedPaths,
820
+ elapsedMs: elapsedMs,
821
+ elapsedSeconds: elapsedSeconds,
822
+ expectedPaths: expectedPaths,
445
823
  matchedExpectedPaths: [],
446
- changedPaths,
824
+ changedPaths: changedPaths,
447
825
  predicate: 'empty-before-timeout',
448
826
  qeSignalLine: 'CODEX-LANDING-SIGNAL status=not-yet-flushed changed=0 after ' + elapsedSeconds + 's — not yet flushed',
449
827
  };
@@ -452,15 +830,121 @@ export function decideCodeLanding(snapshot) {
452
830
  return {
453
831
  status: 'genuinely-not-landed',
454
832
  changed: 0,
455
- elapsedMs,
833
+ elapsedMs: elapsedMs,
456
834
  elapsedSeconds: terminalSeconds,
457
- expectedPaths,
835
+ expectedPaths: expectedPaths,
458
836
  matchedExpectedPaths: [],
459
- changedPaths,
837
+ changedPaths: changedPaths,
460
838
  predicate: 'empty-after-timeout',
461
839
  qeSignalLine: 'CODEX-LANDING-SIGNAL status=genuinely-not-landed ' + codeLandingEmptySignal(terminalSeconds),
462
840
  };
463
841
  }
842
+ /**
843
+ * PARSE-NEVER-SYNTHESIZE, applied to the barrier the same way ADR-001 applies it to a QE verdict:
844
+ * a probe that returned nothing, or text with no signal line, is NOT a clean "not landed" and NOT
845
+ * a landed — it is `inconclusive` with the reason naming which failure it was. An unknown status
846
+ * token (including a stray mid-poll `not-yet-flushed` reaching a terminal position) is malformed,
847
+ * never trusted. Every consumer reads THIS function's output; no consumer re-regexes the note.
848
+ */
849
+ export function parseLandingSignal(probeText) {
850
+ const text = probeText === null || probeText === undefined ? '' : String(probeText);
851
+ if (text.trim() === '')
852
+ return { status: 'inconclusive', reason: 'probe-failure' };
853
+ const lines = text.split(/\r?\n/);
854
+ let signal = '';
855
+ for (let i = 0; i < lines.length; i++) {
856
+ if (String(lines[i] || '').indexOf('CODEX-LANDING-SIGNAL status=') >= 0) {
857
+ signal = String(lines[i] || '');
858
+ break;
859
+ }
860
+ }
861
+ if (signal === '')
862
+ return { status: 'inconclusive', reason: 'malformed-signal' };
863
+ const m = /CODEX-LANDING-SIGNAL status=([A-Za-z-]+)/.exec(signal);
864
+ const status = m === null ? '' : String(m[1] || '');
865
+ if (status === 'landed')
866
+ return { status: 'landed' };
867
+ if (status === 'genuinely-not-landed')
868
+ return { status: 'genuinely-not-landed' };
869
+ if (status !== 'inconclusive')
870
+ return { status: 'inconclusive', reason: 'malformed-signal' };
871
+ const r = /reason=([a-z-]+)/.exec(signal);
872
+ const reason = r === null ? '' : String(r[1] || '');
873
+ switch (reason) {
874
+ case 'empty-plan-block':
875
+ case 'override-unpollable':
876
+ case 'no-plan-block':
877
+ case 'no-baseline':
878
+ case 'baseline-unverified':
879
+ case 'probe-failure':
880
+ case 'malformed-signal':
881
+ return { status: 'inconclusive', reason: reason };
882
+ default:
883
+ return { status: 'inconclusive', reason: 'malformed-signal' };
884
+ }
885
+ }
886
+ function codeLandingShellQuote(value) {
887
+ return "'" + String(value).replace(/'/g, "'\"'\"'") + "'";
888
+ }
889
+ /**
890
+ * The probe shell (pure half of the workflow's inline copy). Obligations, in order:
891
+ *
892
+ * 1. mode 'inconclusive' short-circuits — polling with no established target is exactly what
893
+ * produced the false "any code change" landing verdict.
894
+ * 2. VERIFY the baseline trailer BEFORE any absent-from-baseline reasoning, because a truncated
895
+ * baseline makes every missing path look newly changed, i.e. makes everything look LANDED.
896
+ * 3. only then poll with the newly-changed predicate: in the porcelain AND (absent from the
897
+ * baseline OR `git hash-object` differs).
898
+ *
899
+ * Terminal landed / genuinely-not-landed lines keep today's grammar so parseLandingSignal covers all.
900
+ *
901
+ * Three properties of the POLL that are load-bearing rather than stylistic:
902
+ *
903
+ * - `--untracked-files=all` (QE F3): without it git reports a brand-new file inside a brand-new
904
+ * directory as `?? dir/` — the DIRECTORY — so an exact match against the expected FILE path can
905
+ * never fire and a genuinely landed file reads as not-landed.
906
+ * - the match set is NEVER truncated (QE F3): the pre-fix probe piped the porcelain through
907
+ * `head -200` and then searched THAT, so on any tree with more than 200 dirty entries the
908
+ * predicate silently stopped seeing the tail. Our own settled tree had 292. The cap now applies
909
+ * ONLY to the human-readable file list that gets echoed.
910
+ * - the probe's own scratch files live in `mktemp` space, NOT beside the baseline: a redirection
911
+ * creates its target BEFORE the command writes to it, so a scratch file next to the baseline
912
+ * appears in the very `git status` it is about to feed (MEASURED — a capture into a fixture repo
913
+ * recorded its own `.raw` file as the tree's only dirty entry). In production the path sits under
914
+ * `features/` and the pipeline-prefix filter hid it; the filter is not the guarantee.
915
+ * - the baseline lookup compares the path as a LITERAL (QE F4): it used to interpolate the path into
916
+ * a `sed -E` pattern, where `.` and `+` — both legal in the accepted path grammar — are regex
917
+ * metacharacters, so `a.b.ts` could match the recorded line for `axb.ts` and pick up the WRONG
918
+ * hash. `awk -v` with a field-split equality has no pattern semantics at all.
919
+ */
920
+ export function codeLandingProbeCmd(repo, plan, baselineAbsPath) {
921
+ if (plan.mode === 'inconclusive') {
922
+ const reason = plan.inconclusiveReason === undefined ? 'empty-plan-block' : plan.inconclusiveReason;
923
+ return 'echo "CODEX-LANDING-SIGNAL status=inconclusive predicate=no-expected-targets reason=' + reason + '"; echo "files:"; echo "(none)"';
924
+ }
925
+ const expectedList = plan.expectedPaths.length > 0 ? plan.expectedPaths.map(codeLandingShellQuote).join(' ') : "''";
926
+ return ('repo=' + codeLandingShellQuote(repo) + '; base=' + codeLandingShellQuote(baselineAbsPath) + '; ' +
927
+ 'sleeps="' + plan.sleepsSeconds.join(' ') + '"; expected_count=' + plan.expectedPaths.length + '; elapsed=0; ' +
928
+ 'if [ ! -f "$base" ]; then echo "CODEX-LANDING-SIGNAL status=inconclusive predicate=newly-changed reason=no-baseline"; echo "files:"; echo "(none)"; exit 0; fi; ' +
929
+ 'body=$(mktemp) || { echo "CODEX-LANDING-SIGNAL status=inconclusive predicate=newly-changed reason=probe-failure"; echo "files:"; echo "(none)"; exit 0; }; ' +
930
+ 'pfile=$(mktemp) || { rm -f "$body"; echo "CODEX-LANDING-SIGNAL status=inconclusive predicate=newly-changed reason=probe-failure"; echo "files:"; echo "(none)"; exit 0; }; ' +
931
+ 'bl=$(wc -l < "$base" | tr -d " "); head -n $((bl - 1)) "$base" > "$body"; ' +
932
+ 'tr_line=$(tail -n 1 "$base"); tr_n=$(printf "%s" "$tr_line" | sed -nE "s/^count=([0-9]+) cksum=([0-9]+)$/\\1/p"); ' +
933
+ 'tr_c=$(printf "%s" "$tr_line" | sed -nE "s/^count=([0-9]+) cksum=([0-9]+)$/\\2/p"); ' +
934
+ 'n_body=$(wc -l < "$body" | tr -d " "); c_body=$(cksum < "$body" | awk "{print \\$1}"); ' +
935
+ '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; ' +
936
+ '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"); ' +
937
+ 'if [ -z "$recorded" ]; then return 0; fi; now=$(git -C "$repo" hash-object -- "$1" 2>/dev/null); ' +
938
+ 'if [ -z "$now" ] || [ "$now" = "$recorded" ]; then return 1; fi; return 0; }; ' +
939
+ '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"; ' +
940
+ 'n=$(wc -l < "$pfile" | tr -d " "); ' +
941
+ 'matched=""; for p in ' + expectedList + '; do [ -z "$p" ] && continue; ' +
942
+ 'if grep -Fx -- "$p" "$pfile" >/dev/null && newly "$p"; then matched="$p"; break; fi; done; ' +
943
+ '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; }; ' +
944
+ 'poll; for wait in $sleeps; do sleep "$wait"; elapsed=$((elapsed + wait)); poll; done; ' +
945
+ 'echo "CODEX-LANDING-SIGNAL status=genuinely-not-landed ' + plan.emptySignal + '"; echo "predicate=newly-changed observed=$n"; ' +
946
+ 'echo "files:"; if [ -s "$pfile" ]; then head -40 "$pfile"; else echo "(none)"; fi; rm -f "$body" "$pfile"');
947
+ }
464
948
  /**
465
949
  * The RESOLVED model of a stage, for the run report AND the live label: a Codex spec renders as
466
950
  * `codex:<id>:<reasoning>` (plus ` (usage-switched)` when the usage-adaptive override chose it), a