@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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
362
|
-
mode: '
|
|
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:
|
|
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
|
-
|
|
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
|
|
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)
|
|
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)
|
|
403
|
-
|
|
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: '
|
|
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=
|
|
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
|