@dzhechkov/harness-core 0.4.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +283 -103
- package/LICENSE +21 -0
- package/README.md +73 -5
- package/dist/agents-policy.d.ts +67 -0
- package/dist/agents-policy.d.ts.map +1 -0
- package/dist/agents-policy.js +258 -0
- package/dist/agents-policy.js.map +1 -0
- package/dist/codex-hooks-assets.d.ts +47 -0
- package/dist/codex-hooks-assets.d.ts.map +1 -0
- package/dist/codex-hooks-assets.js +287 -0
- package/dist/codex-hooks-assets.js.map +1 -0
- package/dist/codex-hooks-verify.d.ts +74 -0
- package/dist/codex-hooks-verify.d.ts.map +1 -0
- package/dist/codex-hooks-verify.js +140 -0
- package/dist/codex-hooks-verify.js.map +1 -0
- package/dist/codex-hooks.d.ts +258 -0
- package/dist/codex-hooks.d.ts.map +1 -0
- package/dist/codex-hooks.js +391 -0
- package/dist/codex-hooks.js.map +1 -0
- package/dist/discrimination-gate.d.ts +88 -15
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +343 -51
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/feature-adr-checkpoints.d.ts +22 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +42 -0
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +196 -5
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +538 -54
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +13 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +25 -1
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +17 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -4
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.js +2 -2
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/managed-hooks.d.ts +76 -0
- package/dist/managed-hooks.d.ts.map +1 -0
- package/dist/managed-hooks.js +89 -0
- package/dist/managed-hooks.js.map +1 -0
- package/dist/mutation-gate.d.ts +14 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +25 -2
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts +153 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +560 -24
- package/dist/operations.js.map +1 -1
- package/dist/parity.d.ts +38 -1
- package/dist/parity.d.ts.map +1 -1
- package/dist/parity.js +78 -5
- package/dist/parity.js.map +1 -1
- package/dist/recall-usage.d.ts +53 -0
- package/dist/recall-usage.d.ts.map +1 -1
- package/dist/recall-usage.js +125 -2
- package/dist/recall-usage.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +14 -26
- package/dist/setup.js.map +1 -1
- package/dist/shell-veto-policy.d.ts +53 -0
- package/dist/shell-veto-policy.d.ts.map +1 -0
- package/dist/shell-veto-policy.js +103 -0
- package/dist/shell-veto-policy.js.map +1 -0
- package/dist/skills.d.ts +86 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +116 -1
- package/dist/skills.js.map +1 -1
- package/dist/targets.d.ts +75 -0
- package/dist/targets.d.ts.map +1 -1
- package/dist/targets.js +160 -0
- package/dist/targets.js.map +1 -1
- package/package.json +20 -19
- package/sbom.json +552 -102
- package/src/agents-policy.ts +338 -0
- package/src/codex-hooks-assets.ts +291 -0
- package/src/codex-hooks-verify.ts +184 -0
- package/src/codex-hooks.ts +571 -0
- package/src/discrimination-gate.ts +456 -58
- package/src/feature-adr-checkpoints.ts +38 -0
- package/src/feature-adr-routing.ts +642 -75
- package/src/guard.ts +36 -1
- package/src/index.ts +118 -2
- package/src/loop-blobs.generated.ts +2 -2
- package/src/managed-hooks.ts +129 -0
- package/src/mutation-gate.ts +24 -2
- package/src/operations.ts +719 -28
- package/src/parity.ts +120 -6
- package/src/recall-usage.ts +184 -1
- package/src/setup.ts +26 -27
- package/src/shell-veto-policy.ts +119 -0
- package/src/skills.ts +174 -1
- package/src/targets.ts +189 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
|
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:
|
|
498
|
-
mode: '
|
|
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:
|
|
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
|
-
|
|
527
|
-
|
|
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))
|
|
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)
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
if (expectedPaths.length
|
|
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: '
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
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
|
+
}
|
|
561
1012
|
}
|
|
562
1013
|
|
|
563
|
-
|
|
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:
|
|
567
|
-
elapsedMs,
|
|
568
|
-
elapsedSeconds,
|
|
569
|
-
expectedPaths,
|
|
570
|
-
matchedExpectedPaths,
|
|
571
|
-
changedPaths,
|
|
572
|
-
predicate: '
|
|
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
|
-
|
|
1031
|
+
matchedExpectedPaths.length +
|
|
576
1032
|
' after=' +
|
|
577
1033
|
elapsedSeconds +
|
|
578
|
-
's predicate=
|
|
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
|
/**
|