@sabaiway/agent-workflow-kit 12.4.0 → 13.1.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/CHANGELOG.md +161 -0
- package/README.md +2 -2
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/core-evidence.md +1 -1
- package/references/modes/dispatch.md +9 -9
- package/references/modes/review-state.md +1 -1
- package/tools/claim-relation.mjs +33 -0
- package/tools/dispatch-baseline.mjs +172 -0
- package/tools/dispatch-record.mjs +28 -1
- package/tools/dispatch-store-read.mjs +6 -1
- package/tools/dispatch-store.mjs +4 -3
- package/tools/dispatch.mjs +110 -71
- package/tools/epic-shape-cli.mjs +4 -13
- package/tools/epic-shape-ledger.mjs +11 -31
- package/tools/epic-store.mjs +33 -0
- package/tools/exec-producer.mjs +57 -37
- package/tools/held-session.mjs +22 -9
- package/tools/plan-shape-facts.mjs +22 -4
- package/tools/plan-shape-ownership.mjs +83 -0
- package/tools/plan-shape.mjs +36 -8
- package/tools/review-state.mjs +9 -5
package/tools/exec-producer.mjs
CHANGED
|
@@ -1,29 +1,32 @@
|
|
|
1
1
|
// exec-producer.mjs — the GIT-side metric producer for an exec return (delegation Plan 2, Phase 1).
|
|
2
2
|
// It answers the two questions a return record cannot answer for itself: which objects the delegated
|
|
3
|
-
// run touched and how many bytes they represent, and what the canonical integration bundle is.
|
|
4
|
-
//
|
|
3
|
+
// run touched and how many bytes they represent, and what the canonical integration bundle is.
|
|
4
|
+
// Under a checkpoint base the walks use the leaf's own temporary index and forced-add blob objects;
|
|
5
|
+
// the real index is never written, and head walks spawn only git reads.
|
|
5
6
|
//
|
|
6
7
|
// dispatch-record.mjs:74-76 named this module before it existed: "the metric's byte domains are
|
|
7
8
|
// computed over STRUCTURED inputs handed in by a producer; the git-side producer is Plan 2, and it
|
|
8
9
|
// owns minting a stable objectId".
|
|
9
10
|
//
|
|
10
|
-
// ONE DOMAIN, THREE LAYERS (D7). The enumeration walks HEAD → index → worktree, exactly the layers
|
|
11
|
+
// ONE DOMAIN, THREE LAYERS (D7) under a head base. The enumeration walks HEAD → index → worktree, exactly the layers
|
|
11
12
|
// the canonical payload concatenates — never the collapsed HEAD → worktree view. The difference is
|
|
12
13
|
// not academic: a staged change reverted in the worktree keeps its bytes in the payload (and so in
|
|
13
14
|
// the DENOMINATOR) while HEAD→worktree shows nothing at all, so the object would vanish from the
|
|
14
15
|
// NUMERATOR and the two halves of one ratio would describe different change sets. The diff bytes are
|
|
15
|
-
// computeFingerprintPayload's own bytes, imported rather than rebuilt.
|
|
16
|
+
// computeFingerprintPayload's own bytes, imported rather than rebuilt. Under a checkpoint base the
|
|
17
|
+
// walk is one layer against the base tree, through the leaf's temporary index.
|
|
16
18
|
//
|
|
17
|
-
// ONE OBJECT, ONE ENTRY.
|
|
19
|
+
// ONE OBJECT, ONE ENTRY. Under a head base an object touched in both layers is a single entry, and its pre-image is
|
|
18
20
|
// always the HEAD blob — never the index blob: the numerator answers what the delegate could have
|
|
19
21
|
// spared the ORCHESTRATOR, measured against the dispatch baseline, and a metric-eligible dispatch
|
|
20
|
-
// starts from a clean tree where HEAD is that baseline.
|
|
22
|
+
// starts from a clean tree where HEAD is that baseline. Under a checkpoint base the pre-image is the base blob.
|
|
21
23
|
//
|
|
22
24
|
// RENAMES ARE NOT RECONSTRUCTED, AND THAT IS THE DESIGN — a boundary, not an omission. git's OWN
|
|
23
25
|
// rename detection is consumed where git offers it (`--raw -M` sees a staged `git mv` within a layer),
|
|
24
|
-
// but a rename a delegate actually makes — the wrapper's git-write shim blocks every git write verb,
|
|
26
|
+
// but under a head base a rename a delegate actually makes — the wrapper's git-write shim blocks every git write verb,
|
|
25
27
|
// so every one arrives as a tracked deletion plus an UNTRACKED creation — is accounted as exactly
|
|
26
|
-
// that: a deletion plus a creation.
|
|
28
|
+
// that: a deletion plus a creation. Under a checkpoint base git's own rename detection applies inside
|
|
29
|
+
// the single base-relative layer, and a detected rename is counted once as `renamed`.
|
|
27
30
|
//
|
|
28
31
|
// This module once matched those pairs by blob identity, and the machinery was SUBTRACTED after it
|
|
29
32
|
// produced silent-error edges in four consecutive review rounds: hashing that read through symlinks
|
|
@@ -35,7 +38,7 @@
|
|
|
35
38
|
// An identity heuristic buys accuracy on the ordinary refactor and pays for it in exactly that
|
|
36
39
|
// currency, so the trade was refused.
|
|
37
40
|
//
|
|
38
|
-
// The cost is stated with its DIRECTION, per case, because the direction is NOT the same in all of
|
|
41
|
+
// The head-base cost is stated with its DIRECTION, per case, because the direction is NOT the same in all of
|
|
39
42
|
// them — and a blanket "always optimistic" would be a false reassurance about an accounting number.
|
|
40
43
|
// Write O for the pre-image size:
|
|
41
44
|
// • A PLAIN RENAME `a→b` emits `deleted pre:a (O)` + `new new:b (O)` = 2O against a recognised O.
|
|
@@ -54,18 +57,11 @@
|
|
|
54
57
|
// Every object here is one git can PROVE: a tracked path with a recorded blob, or an untracked path
|
|
55
58
|
// that exists. Nothing is paired on a resemblance.
|
|
56
59
|
//
|
|
57
|
-
// CONFIG-HIDDEN PATHS ARE A STATED LIMIT, and
|
|
58
|
-
// diff`
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// dispatch opened over such a tree records baselineClean:false and its return is metric-INELIGIBLE by
|
|
63
|
-
// name. What this module must NOT do is force those flags HERE: D7 binds the numerator to the byte
|
|
64
|
-
// domain of computeFingerprintPayload, which uses the same plain `git diff` — forcing them on one
|
|
65
|
-
// side only would let the numerator count objects the denominator cannot see, a worse failure than
|
|
66
|
-
// the blindness. Closing it for real means changing the shared payload, which is a Plan-1-frozen
|
|
67
|
-
// surface the review lane also consumes; that is queued as its own item, and Phase 2's `return` owns
|
|
68
|
-
// the return-time guard.
|
|
60
|
+
// CONFIG-HIDDEN PATHS ARE A STATED LIMIT, and not this module's to close. Under a head base `git
|
|
61
|
+
// diff` skips index entries carrying assume-unchanged or skip-worktree, so a path can be changed and
|
|
62
|
+
// stay invisible to both probes below; the doors refuse a concealing tree rather than measure it. The
|
|
63
|
+
// checkpoint walks force `--ignore-submodules=none` because the checkpoint payload they are measured
|
|
64
|
+
// against does.
|
|
69
65
|
//
|
|
70
66
|
// IDENTITY CARRIES ITS DOMAIN (D6). `pre:<path>` for an object with a pre-image (keyed by the name it
|
|
71
67
|
// HAD) and `new:<path>` for a created one. The prefix is load-bearing: a rename a→b beside a
|
|
@@ -79,12 +75,12 @@
|
|
|
79
75
|
// paying it zero would drop a whole object's bytes out of the numerator on a type change. Every OTHER
|
|
80
76
|
// unknown size REFUSES — the fail-closed rule is never a silent zero.
|
|
81
77
|
//
|
|
82
|
-
//
|
|
78
|
+
// Under a head base a TRANSIENT object (staged, then deleted from the worktree) counts its INDEX image once: the
|
|
83
79
|
// delegate authored those bytes, the payload carries them in both layers, and dropping them would
|
|
84
80
|
// credit the delegate with less than it wrote.
|
|
85
81
|
//
|
|
86
|
-
// Fail closed: outside a git work tree, on an unborn branch (
|
|
87
|
-
//
|
|
82
|
+
// Fail closed: outside a git work tree, on an unborn branch (the inherited no-HEAD limit
|
|
83
|
+
// applies to both bases), on any git read that fails, and on any size it cannot establish.
|
|
88
84
|
|
|
89
85
|
import { join } from 'node:path';
|
|
90
86
|
import { createHash } from 'node:crypto';
|
|
@@ -92,7 +88,8 @@ import {
|
|
|
92
88
|
gitBuf, computeFingerprintPayload, isBinaryFile, isNeverCommittableStat, resolveBase,
|
|
93
89
|
} from './core-evidence.mjs';
|
|
94
90
|
import { lstatNoFollowRead } from './fs-read-nofollow.mjs';
|
|
95
|
-
import { frameIntegrationBundle } from './dispatch-record.mjs';
|
|
91
|
+
import { frameIntegrationBundle, HEAD_BASELINE } from './dispatch-record.mjs';
|
|
92
|
+
import { runBaseDiff, computeBasePayload } from './dispatch-baseline.mjs';
|
|
96
93
|
|
|
97
94
|
const refuse = (reason) => ({ ok: false, reason });
|
|
98
95
|
|
|
@@ -100,6 +97,8 @@ const GITLINK_MODE = '160000';
|
|
|
100
97
|
const SYMLINK_MODE = '120000';
|
|
101
98
|
const ABSENT_MODE = '000000';
|
|
102
99
|
const SUBMODULE_BYTES = 0;
|
|
100
|
+
const BASE_RAW_ARGS = ['--raw', '-z', '-M', '--no-abbrev', '--no-ext-diff', '--ignore-submodules=none'];
|
|
101
|
+
const BASE_NUMSTAT_ARGS = ['--numstat', '-z', '-M', '--no-ext-diff', '--ignore-submodules=none'];
|
|
103
102
|
|
|
104
103
|
export const PRE_IMAGE_ID_PREFIX = 'pre:';
|
|
105
104
|
export const NEW_IMAGE_ID_PREFIX = 'new:';
|
|
@@ -229,7 +228,7 @@ export const parseNumstatMarkers = (buf, where) => {
|
|
|
229
228
|
};
|
|
230
229
|
|
|
231
230
|
// ── the object model ──────────────────────────────────────────────────────────────────────────────
|
|
232
|
-
// One record per touched object: where it came from in
|
|
231
|
+
// One record per touched object: where it came from in the base tree, where it ended up, and every name it
|
|
233
232
|
// wore in between (the names the binary oracle and the type probes are asked about).
|
|
234
233
|
|
|
235
234
|
const makeObject = ({ headPath, headSha, headMode }) => ({
|
|
@@ -247,7 +246,7 @@ const kindForModes = (modes) => {
|
|
|
247
246
|
|
|
248
247
|
const buildObjects = (staged, unstaged) => {
|
|
249
248
|
const objects = [];
|
|
250
|
-
//
|
|
249
|
+
// Base tree → index. Under a head base the DESTINATION name links to the unstaged layer.
|
|
251
250
|
const byIndexPath = new Map();
|
|
252
251
|
for (const r of staged) {
|
|
253
252
|
const hasHead = !isAbsentSha(r.srcSha) && r.srcMode !== ABSENT_MODE;
|
|
@@ -312,7 +311,7 @@ const entryForObject = (top, object, binary, lstat) => {
|
|
|
312
311
|
if (typeKind !== null) {
|
|
313
312
|
// The COUNTED image decides the size, even when the emitted kind comes from another layer. A
|
|
314
313
|
// gitlink in ANY layer makes the kind `submodule` (TYPE beats STATUS, D6) — but a regular file
|
|
315
|
-
// REPLACED by a gitlink is still counted at its
|
|
314
|
+
// REPLACED by a gitlink is still counted at its base blob, and paying it the submodule's
|
|
316
315
|
// deliberate zero would drop a whole object's bytes out of the numerator on a type change. Zero
|
|
317
316
|
// belongs to a counted image that is ITSELF a gitlink: that is the case where the move costs this
|
|
318
317
|
// repository no bytes.
|
|
@@ -359,6 +358,24 @@ const entryForUntracked = (top, rel, lstat) => {
|
|
|
359
358
|
: { ok: true, entry: { kind: 'new', path: rel, objectId, postImageBytes: stat.size } };
|
|
360
359
|
};
|
|
361
360
|
|
|
361
|
+
const enumerateCheckpointObjects = (top, base, io) => {
|
|
362
|
+
const raw = runBaseDiff(top, base, BASE_RAW_ARGS, io);
|
|
363
|
+
if (raw === null) return refuse('git could not read the base-relative change set (fail closed)');
|
|
364
|
+
const parsed = parseRawRecords(raw, 'the base-relative change set');
|
|
365
|
+
if (!parsed.ok) return parsed;
|
|
366
|
+
const numstat = runBaseDiff(top, base, BASE_NUMSTAT_ARGS, io);
|
|
367
|
+
if (numstat === null) return refuse('git could not read the numstat binary markers (fail closed)');
|
|
368
|
+
const binary = parseNumstatMarkers(numstat, 'the base-relative binary markers');
|
|
369
|
+
if (!binary.ok) return binary;
|
|
370
|
+
const entries = [];
|
|
371
|
+
for (const object of buildObjects(parsed.records, [])) {
|
|
372
|
+
const built = entryForObject(top, object, binary.names, io.lstat);
|
|
373
|
+
if (!built.ok) return built;
|
|
374
|
+
entries.push(built.entry);
|
|
375
|
+
}
|
|
376
|
+
return { ok: true, entries };
|
|
377
|
+
};
|
|
378
|
+
|
|
362
379
|
// enumerateReturnedObjects(cwd, io?) → { ok: true, entries } | { ok: false, reason }. The entries are
|
|
363
380
|
// the STRUCTURED input computeNumerator consumes; this module never computes the numerator itself, so
|
|
364
381
|
// the closed vocabulary stays the single authority on what a component is. `io.lstat` and
|
|
@@ -370,6 +387,8 @@ export const enumerateReturnedObjects = (cwd = process.cwd(), io = {}) => {
|
|
|
370
387
|
if (resolveBase(top) == null) {
|
|
371
388
|
return refuse('the branch is unborn (no HEAD) — there is no pre-image to attribute delegated bytes against (fail closed)');
|
|
372
389
|
}
|
|
390
|
+
const base = io.base ?? HEAD_BASELINE;
|
|
391
|
+
if (base.kind === 'checkpoint') return enumerateCheckpointObjects(top, base, io);
|
|
373
392
|
// ABSENT and UNREADABLE are different answers. ENOENT means the path vanished between `ls-files`
|
|
374
393
|
// and the stat, which the payload itself records name-only — mirroring it keeps numerator and
|
|
375
394
|
// denominator over ONE object set. Every OTHER errno (EACCES, EIO, ENOTDIR, ELOOP on a parent) is
|
|
@@ -442,9 +461,10 @@ export const enumerateReturnedObjects = (cwd = process.cwd(), io = {}) => {
|
|
|
442
461
|
return refusedProbe() ?? { ok: true, entries };
|
|
443
462
|
};
|
|
444
463
|
|
|
445
|
-
// computeReturnedDiff(cwd) → the
|
|
446
|
-
//
|
|
447
|
-
//
|
|
464
|
+
// computeReturnedDiff(cwd) → the payload BYTES the thread's own base fingerprint digests, or a refusal:
|
|
465
|
+
// computeTreeFingerprint's under a head base, the leaf's base-relative payload under a checkpoint.
|
|
466
|
+
// This is the diff half of the integration bundle, so the bytes the denominator counts and the
|
|
467
|
+
// digest the records bind describe one state. It refuses
|
|
448
468
|
// where the enumeration refuses: one entry point may not answer for a tree the other rejects. That
|
|
449
469
|
// sentence used to hold only for the two SHARED preconditions below, which let an AMBIGUOUS rename
|
|
450
470
|
// refuse the enumeration while the diff still handed back bytes — so the enumeration is run here and
|
|
@@ -457,18 +477,18 @@ export const computeReturnedDiff = (cwd = process.cwd(), io = {}) => {
|
|
|
457
477
|
}
|
|
458
478
|
const enumerated = enumerateReturnedObjects(cwd, io);
|
|
459
479
|
if (!enumerated.ok) return enumerated;
|
|
460
|
-
// The payload builder guards its lstat but reads untracked file BYTES unguarded, so an unreadable
|
|
480
|
+
// The head payload builder guards its lstat but reads untracked file BYTES unguarded, so an unreadable
|
|
461
481
|
// or vanishing untracked file throws out of it. This entry point promises a refusal object, and a
|
|
462
482
|
// caller that framed a bundle around a thrown read would have no bytes and no reason either.
|
|
463
|
-
let payload;
|
|
464
483
|
try {
|
|
465
|
-
|
|
484
|
+
const base = io.base ?? HEAD_BASELINE;
|
|
485
|
+
const payload = base.kind === 'checkpoint' ? computeBasePayload(cwd, base, io) : computeFingerprintPayload(cwd);
|
|
486
|
+
return payload == null
|
|
487
|
+
? refuse('the canonical payload could not be computed (fail closed)')
|
|
488
|
+
: { ok: true, diff: payload };
|
|
466
489
|
} catch (err) {
|
|
467
490
|
return refuse(`the canonical payload could not be read (${err?.code ?? err?.message ?? 'read failed'}) — the returned diff is refused rather than framed around a partial read (fail closed)`);
|
|
468
491
|
}
|
|
469
|
-
return payload == null
|
|
470
|
-
? refuse('the canonical payload could not be computed (fail closed)')
|
|
471
|
-
: { ok: true, diff: payload };
|
|
472
492
|
};
|
|
473
493
|
|
|
474
494
|
// assembleIntegrationBundle(diff, report) → the framed bundle with its digest and length. The framing
|
package/tools/held-session.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { escapeForDisplay, isRenderableLine, shellQuoteArg } from './repo-lex.mjs';
|
|
2
2
|
import { wrapperCmdFor } from './detect-backends.mjs';
|
|
3
3
|
import { DISPLAY_ALIASES } from './recipes.mjs';
|
|
4
|
+
import { readsBaseline, TERMINAL_RETURN_OUTCOMES } from './dispatch-record.mjs';
|
|
4
5
|
|
|
5
6
|
const CODE_STEP = 'code';
|
|
6
7
|
const FOLD_KIND = 'fold';
|
|
7
8
|
const RETURN_KIND = 'return';
|
|
9
|
+
const DEGRADE_KIND = 'degrade';
|
|
8
10
|
const EXECUTE_BACKEND = 'codex-cli-bridge';
|
|
9
11
|
const UNPOPULATED_HELD_ID = '<held id>';
|
|
10
12
|
|
|
@@ -24,6 +26,7 @@ const findsDispatch = (dispatches, nonce) => dispatches.find((dispatch) => dispa
|
|
|
24
26
|
const findsReturn = (returns, nonce) => returns.find((returned) => returned.nonce === nonce) ?? null;
|
|
25
27
|
const hasCoveredReplacement = (returned, degrades) =>
|
|
26
28
|
degrades.some((degrade) => degrade.fingerprint === returned.postTreeDigest);
|
|
29
|
+
const expectsSession = (dispatch, baseKind) => dispatch.baselineClean === false || baseKind === 'checkpoint';
|
|
27
30
|
const collectsRetryChainNonces = (dispatches, nonce, collected = new Set()) => {
|
|
28
31
|
if (nonce === null || collected.has(nonce)) return collected;
|
|
29
32
|
const next = new Set(collected).add(nonce);
|
|
@@ -33,26 +36,28 @@ const collectsRetryChainNonces = (dispatches, nonce, collected = new Set()) => {
|
|
|
33
36
|
const accumulatesFacts = (degrades) => (state, record) => {
|
|
34
37
|
if (record.kind === 'dispatch' && state.epochNonces.has(record.nonce)) {
|
|
35
38
|
const expectedId = state.heldId;
|
|
39
|
+
const baseKind = readsBaseline(record).kind;
|
|
36
40
|
return {
|
|
37
41
|
...state,
|
|
38
42
|
dispatches: [...state.dispatches, record],
|
|
39
|
-
threads: [...state.threads, { nonce: record.nonce, expectedId, actualId: null, status: 'OPEN' }],
|
|
40
|
-
open: record
|
|
43
|
+
threads: [...state.threads, { nonce: record.nonce, expectedId, actualId: null, status: 'OPEN', baseKind }],
|
|
44
|
+
open: expectsSession(record, baseKind) ? [...state.open, { nonce: record.nonce, expectedId }] : state.open,
|
|
41
45
|
};
|
|
42
46
|
}
|
|
43
47
|
if (record.kind === RETURN_KIND && state.epochNonces.has(record.nonce)) {
|
|
44
48
|
const dispatch = findsDispatch(state.dispatches, record.nonce);
|
|
45
49
|
if (dispatch === null) return state;
|
|
50
|
+
const baseKind = readsBaseline(dispatch).kind;
|
|
46
51
|
const expectedId = state.threads.find((thread) => thread.nonce === record.nonce)?.expectedId ?? null;
|
|
47
52
|
const status = record.sessionId === null ? 'FAILED'
|
|
48
53
|
: expectedId === null ? 'FIRST'
|
|
49
54
|
: dispatch.retryOf !== null || record.sessionId === expectedId ? 'CONTINUED'
|
|
50
55
|
: 'SUBSTITUTED';
|
|
51
|
-
const substitution = status === 'SUBSTITUTED' && dispatch
|
|
52
|
-
&& !hasCoveredReplacement(record, degrades)
|
|
56
|
+
const substitution = status === 'SUBSTITUTED' && expectsSession(dispatch, baseKind)
|
|
57
|
+
&& (baseKind === 'checkpoint' ? !TERMINAL_RETURN_OUTCOMES.includes(record.outcome) : !hasCoveredReplacement(record, degrades))
|
|
53
58
|
? {
|
|
54
59
|
nonce: record.nonce, expectedId, actualId: record.sessionId,
|
|
55
|
-
postTreeDigest: record.postTreeDigest, folded: false,
|
|
60
|
+
postTreeDigest: record.postTreeDigest, folded: false, baseKind,
|
|
56
61
|
}
|
|
57
62
|
: null;
|
|
58
63
|
return {
|
|
@@ -71,7 +76,7 @@ const accumulatesFacts = (degrades) => (state, record) => {
|
|
|
71
76
|
if (dispatch === null || returned === null || returned.sessionId === null) return state;
|
|
72
77
|
const establishes = state.heldId === null;
|
|
73
78
|
const retries = dispatch.retryOf !== null;
|
|
74
|
-
const replaces = returned.sessionId !== state.heldId && hasCoveredReplacement(returned, degrades);
|
|
79
|
+
const replaces = readsBaseline(dispatch).kind === 'head' && returned.sessionId !== state.heldId && hasCoveredReplacement(returned, degrades);
|
|
75
80
|
const retryChain = retries ? collectsRetryChainNonces(state.dispatches, dispatch.retryOf) : new Set();
|
|
76
81
|
const foldedSubstitutions = state.substitutions.map((substitution) =>
|
|
77
82
|
(substitution.nonce === record.nonce ? { ...substitution, folded: true } : substitution));
|
|
@@ -81,6 +86,10 @@ const accumulatesFacts = (degrades) => (state, record) => {
|
|
|
81
86
|
if (establishes || retries || replaces) return { ...state, heldId: returned.sessionId, substitutions };
|
|
82
87
|
return { ...state, substitutions };
|
|
83
88
|
}
|
|
89
|
+
if (record.kind === DEGRADE_KIND && state.epochNonces.has(record.nonce)
|
|
90
|
+
&& state.substitutions.some((substitution) => substitution.nonce === record.nonce && substitution.baseKind === 'checkpoint')) {
|
|
91
|
+
return { ...state, heldId: null, substitutions: state.substitutions.filter((substitution) => substitution.nonce !== record.nonce) };
|
|
92
|
+
}
|
|
84
93
|
return state;
|
|
85
94
|
};
|
|
86
95
|
|
|
@@ -109,11 +118,15 @@ export const judgeHeldSession = (records, { head, backend, degrades = [] }) => {
|
|
|
109
118
|
heldId: accumulated.heldId,
|
|
110
119
|
folds: countsHeldFolds(records, epochNonces, accumulated.heldId),
|
|
111
120
|
substitution: accumulated.substitutions[0] ?? null,
|
|
112
|
-
threads: accumulated.threads
|
|
121
|
+
threads: accumulated.threads.map((thread) => ({
|
|
122
|
+
...thread, substituted: accumulated.substitutions.some((substitution) => substitution.nonce === thread.nonce),
|
|
123
|
+
})),
|
|
113
124
|
open: accumulated.open,
|
|
114
125
|
};
|
|
115
126
|
};
|
|
116
127
|
|
|
128
|
+
export const threadVerdict = (facts, nonce) => facts.threads.find((thread) => thread.nonce === nonce) ?? null;
|
|
129
|
+
|
|
117
130
|
export const judgeLedger = (ledger, { backend, degrades = [] }) => {
|
|
118
131
|
if (ledger.state === 'absent') {
|
|
119
132
|
return { state: 'absent', heldId: null, folds: 0, substitution: null, threads: [], open: [] };
|
|
@@ -137,7 +150,7 @@ export const decideHeldSession = (facts) => {
|
|
|
137
150
|
const reason = `delegated code thread "${nonce}" substituted held session "${escapeForDisplay(expectedId)}" with "${escapeForDisplay(actualId)}"`;
|
|
138
151
|
return { code: 1, reason, line: `held session: SUBSTITUTED — ${reason}` };
|
|
139
152
|
}
|
|
140
|
-
if (facts.heldId === null) return { code: 0, reason: 'no folded code thread in
|
|
153
|
+
if (facts.heldId === null) return { code: 0, reason: 'no held session stands: no folded code thread established one in this commit epoch, or the last one was withdrawn by a checkpoint thread\'s ledger degrade', line: 'held session: none' };
|
|
141
154
|
const heldId = escapeForDisplay(facts.heldId);
|
|
142
155
|
return { code: 0, reason: `held session "${heldId}" is continuous`, line: `held session: ${heldId} — ${facts.folds} fold(s) rode it` };
|
|
143
156
|
};
|
|
@@ -159,7 +172,7 @@ export const foldLaneLines = (facts) => {
|
|
|
159
172
|
` run: ${HELD_EXECUTE_WRAPPER} --resume ${heldId} --nonce <nonce> <fold-brief>`,
|
|
160
173
|
' the fold brief is a dispatch file carrying the finding and the accepted fold; dispatch open precedes the run, then dispatch return and fold follow it',
|
|
161
174
|
' the orchestrator runs the suites, verifies the returned diff, re-mints the red-proofs and owns the commit',
|
|
162
|
-
' a fresh session is a forbidden substitution; a retry of a failed thread or a recorded execute degrade is the exception; the wrapper sidecar is never read',
|
|
175
|
+
' a fresh session is a forbidden substitution; a retry of a failed thread or, for a head base, a recorded execute degrade is the exception (a checkpoint thread\'s substitution closes by its ledger degrade); the wrapper sidecar is never read',
|
|
163
176
|
];
|
|
164
177
|
return caveat === null ? lines : [...lines, ` caveat: ${caveat}`];
|
|
165
178
|
};
|
|
@@ -4,13 +4,18 @@ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
|
4
4
|
import { classIds, readShippedRobustnessLiterals } from './robustness-literals.mjs';
|
|
5
5
|
import { segmentPrefixOf, validateSourceSizeConfig } from './source-size-config.mjs';
|
|
6
6
|
import { getLineCount, isSweep, resolveAnchorCandidates, unique } from './plan-shape.mjs';
|
|
7
|
+
import { listStories } from './claim-relation.mjs';
|
|
8
|
+
import { EPICS_REL, readEpicStore } from './epic-store.mjs';
|
|
9
|
+
import { sweepSiblings } from './epic-shape-ledger.mjs';
|
|
10
|
+
import { PIN_FILE } from './plan-shape-ownership.mjs';
|
|
7
11
|
|
|
8
12
|
const SOURCE_SIZE_REL = 'docs/ai/source-size.json';
|
|
9
13
|
const PACKAGE_FILE = 'package.json';
|
|
10
|
-
const PIN_FILE = 'package-content.test.mjs';
|
|
11
14
|
const EXCLUDED_DIRECTORIES = new Set(['.git', 'node_modules']);
|
|
12
15
|
const NEGATED_CLASS = /\[[!^]/;
|
|
13
16
|
const UNSUPPORTED_GLOB = /[()\\\u0000-\u001f]/;
|
|
17
|
+
const STORE_REFUSED = 'refused';
|
|
18
|
+
const FINDING_SEPARATOR = '; ';
|
|
14
19
|
|
|
15
20
|
const usageError = (message, scope = 'repository') => Object.assign(new Error(message), { exitCode: 2, scope });
|
|
16
21
|
const planError = (message) => usageError(message, 'plan');
|
|
@@ -200,18 +205,31 @@ const describePath = (root, path, practice, repoFiles, packageCache) => {
|
|
|
200
205
|
};
|
|
201
206
|
};
|
|
202
207
|
|
|
208
|
+
const readStoryFacts = (root) => {
|
|
209
|
+
const store = readEpicStore(root);
|
|
210
|
+
if (store.outcome === STORE_REFUSED) return { stories: [], landedEpics: [], storeRefusal: `${EPICS_REL}: ${store.reason}` };
|
|
211
|
+
const sweep = sweepSiblings(store.entries, EPICS_REL);
|
|
212
|
+
return {
|
|
213
|
+
stories: sweep.ok ? listStories(sweep.epics) : [],
|
|
214
|
+
landedEpics: sweep.ok ? sweep.landedEpics : [],
|
|
215
|
+
storeRefusal: sweep.ok ? null : sweep.findings.map((finding) => finding.message).join(FINDING_SEPARATOR),
|
|
216
|
+
};
|
|
217
|
+
};
|
|
218
|
+
|
|
203
219
|
export const openRepo = (root, { robustnessDeps } = {}) => {
|
|
204
220
|
const repoRoot = realpathSync(root);
|
|
205
|
-
return { repoRoot, repoFiles: walkRegularFiles(repoRoot), practice: loadPractice(repoRoot), robustClasses: loadRobustClasses(robustnessDeps),
|
|
221
|
+
return { repoRoot, repoFiles: walkRegularFiles(repoRoot), practice: loadPractice(repoRoot), robustClasses: loadRobustClasses(robustnessDeps),
|
|
222
|
+
packageCache: new Map(), ...readStoryFacts(repoRoot) };
|
|
206
223
|
};
|
|
207
224
|
|
|
208
225
|
export const expandSweepPaths = (root, patterns) => expandPatterns(walkRegularFiles(realpathSync(root)), patterns);
|
|
209
226
|
|
|
210
227
|
export const buildFacts = (root, { paths = [], robustnessDeps, repo = openRepo(root, { robustnessDeps }) } = {}) => {
|
|
211
|
-
const { repoRoot, repoFiles, practice, robustClasses, packageCache } = repo;
|
|
228
|
+
const { repoRoot, repoFiles, practice, robustClasses, packageCache, stories, landedEpics, storeRefusal } = repo;
|
|
212
229
|
const expansions = expandPatterns(repoFiles, paths.filter(isSweep));
|
|
213
230
|
const allPaths = unique([...paths, ...Object.values(expansions).flat()]);
|
|
214
231
|
const pathFacts = Object.fromEntries(allPaths.map((path) => [path, describePath(repoRoot, path, practice, repoFiles, packageCache)]));
|
|
215
232
|
const candidates = (suffix, precedingPaths = []) => resolveAnchorCandidates(repoFiles, suffix, precedingPaths);
|
|
216
|
-
return { capDeclared: practice.capDeclared, cap: practice.cap, robustClasses, pathFacts, expansions, repoFiles, candidates
|
|
233
|
+
return { capDeclared: practice.capDeclared, cap: practice.cap, robustClasses, pathFacts, expansions, repoFiles, candidates,
|
|
234
|
+
stories, landedEpics, storeRefusal };
|
|
217
235
|
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import { hasPathOverlap } from './claim-relation.mjs';
|
|
3
|
+
|
|
4
|
+
const STORY_LINE = /^Story: (S[1-9]\d*) of ([^\s/]+)$/;
|
|
5
|
+
const STORY_FIELDS = 'stories';
|
|
6
|
+
export const PIN_FILE = 'package-content.test.mjs';
|
|
7
|
+
const MARKDOWN_SUFFIX = '.md';
|
|
8
|
+
const FIRST_LINE = 1;
|
|
9
|
+
const LANDED = 'landed';
|
|
10
|
+
const OWNS = 'owns';
|
|
11
|
+
const SHARED = 'shared';
|
|
12
|
+
const STRING_TYPE = 'string';
|
|
13
|
+
export const PIN_VERBS = new Set(['create', 'delete']);
|
|
14
|
+
const CODES = Object.freeze({ line: 'story-line', store: 'story-store', landed: 'story-epic-landed',
|
|
15
|
+
unknown: 'story-unknown', owns: 'story-owns', shared: 'story-shared', scope: 'story-scope' });
|
|
16
|
+
const ONE_STORY = 'the plan names its story with one line';
|
|
17
|
+
const NAME_STORY = 'name the story that carries the file';
|
|
18
|
+
const ORDER_STORIES = 'declare a transitive depends-on order between the stories';
|
|
19
|
+
const makeFinding = (line, code, message, rowId = null) => ({ line, code, message, rowId });
|
|
20
|
+
const sortFindings = (findings) => ({ findings: findings.sort((left, right) => left.line - right.line) });
|
|
21
|
+
const hasIdentity = (left, right) => left.path === right.path && left.id === right.id;
|
|
22
|
+
const hasClaimCover = (claim, path) => claim.ground.some((ground) => hasPathOverlap(path, ground));
|
|
23
|
+
const hasOwnCover = (own, path) => own.claims.some((claim) => hasClaimCover(claim, posix.normalize(path)));
|
|
24
|
+
|
|
25
|
+
export const readStoryLine = (lines) => {
|
|
26
|
+
const [story = null, second] = lines.flatMap(({ text, line }) => {
|
|
27
|
+
const match = STORY_LINE.exec(text.trim());
|
|
28
|
+
if (!match) return [];
|
|
29
|
+
const [, id, epic] = match;
|
|
30
|
+
return [{ id, epic, line }];
|
|
31
|
+
});
|
|
32
|
+
return { story, findings: second ? [makeFinding(second.line, CODES.line, ONE_STORY)] : [] };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const findOwnStory = (story, facts) => {
|
|
36
|
+
if (!story) return { own: null, findings: [] };
|
|
37
|
+
const epics = facts.stories.filter((record) => posix.basename(record.path, MARKDOWN_SUFFIX) === story.epic);
|
|
38
|
+
const own = epics.find((record) => record.id === story.id) ?? null;
|
|
39
|
+
if (facts.landedEpics?.includes(story.epic)) {
|
|
40
|
+
return { own: null, findings: [makeFinding(story.line, CODES.landed, `${story.epic} is landed; name a story of an open epic`)] };
|
|
41
|
+
}
|
|
42
|
+
if (!own) {
|
|
43
|
+
const name = epics.length ? `${story.epic} ${story.id}` : story.epic;
|
|
44
|
+
return { own: null, findings: [makeFinding(story.line, CODES.unknown, `${name} is unknown; name a story present in an open epic`)] };
|
|
45
|
+
}
|
|
46
|
+
return { own, findings: [] };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const checkCollision = (row, path, record, claim, own) => {
|
|
50
|
+
if (!hasClaimCover(claim, posix.normalize(path))) return [];
|
|
51
|
+
const message = `${row.id} path ${path} overlaps ${record.path} ${record.id} ${claim.field} ${claim.path}`;
|
|
52
|
+
if (claim.field === OWNS) return [makeFinding(row.line, CODES.owns, message, row.id)];
|
|
53
|
+
if (claim.field !== SHARED) return [];
|
|
54
|
+
if (own && (record.path !== own.path || own.reachable.has(record.id) || record.reachable.has(own.id))) return [];
|
|
55
|
+
return [makeFinding(row.line, CODES.shared, `${message}; ${own ? ORDER_STORIES : NAME_STORY}`, row.id)];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const hasPinCover = (path, rows, facts, own) => posix.basename(path) === PIN_FILE && rows.some((row) => {
|
|
59
|
+
const fact = facts.pathFacts?.[row.path];
|
|
60
|
+
return PIN_VERBS.has(row.verb) && fact?.shipped === true && fact.pinTest === path && hasOwnCover(own, row.path);
|
|
61
|
+
});
|
|
62
|
+
const checkScope = (row, path, rows, facts, own) => {
|
|
63
|
+
if (!own || facts.pathFacts?.[path]?.inScope !== true || hasOwnCover(own, path) || hasPinCover(path, rows, facts, own)) return [];
|
|
64
|
+
return [makeFinding(row.line, CODES.scope,
|
|
65
|
+
`${row.id} path ${path} is in the declared practice and no claim of ${own.id} covers it`, row.id)];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const checkStoryOwnership = ({ rows, goalLines, facts, getConcretePaths }) => {
|
|
69
|
+
const { story, findings } = readStoryLine(goalLines);
|
|
70
|
+
if (!Object.hasOwn(facts, STORY_FIELDS)) return { findings };
|
|
71
|
+
if (typeof facts.storeRefusal === STRING_TYPE) {
|
|
72
|
+
return sortFindings([...findings, makeFinding(FIRST_LINE, CODES.store, facts.storeRefusal)]);
|
|
73
|
+
}
|
|
74
|
+
const resolved = findOwnStory(story, facts);
|
|
75
|
+
const own = resolved.own;
|
|
76
|
+
const others = facts.stories.filter((record) => record.state !== LANDED && (!own || !hasIdentity(record, own)));
|
|
77
|
+
const validRows = rows.filter((row) => row.valid);
|
|
78
|
+
const relation = validRows.flatMap((row) => getConcretePaths(row, facts).flatMap((path) => [
|
|
79
|
+
...others.flatMap((record) => record.claims.flatMap((claim) => checkCollision(row, path, record, claim, own))),
|
|
80
|
+
...checkScope(row, path, validRows, facts, own),
|
|
81
|
+
]));
|
|
82
|
+
return sortFindings([...findings, ...resolved.findings, ...relation]);
|
|
83
|
+
};
|
package/tools/plan-shape.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
1
2
|
import { tokenizeMarkdown } from '../references/scripts/markdown-blocks.mjs';
|
|
2
3
|
import { parseRobustTag } from './robustness-literals.mjs';
|
|
4
|
+
import { unique } from './claim-relation.mjs';
|
|
5
|
+
import { checkStoryOwnership, PIN_FILE, PIN_VERBS } from './plan-shape-ownership.mjs';
|
|
3
6
|
|
|
4
7
|
export const PLAN_TITLE_PREFIX = '# Plan: ';
|
|
5
8
|
export const PLAN_HEADINGS = Object.freeze([
|
|
@@ -31,7 +34,7 @@ const BULLET = /^-\s+\S/;
|
|
|
31
34
|
export const getLineCount = (text) => text === '' ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
32
35
|
export const isSweep = (path) => GLOB_BYTE.test(path);
|
|
33
36
|
const endsWithPath = (path, suffix) => path === suffix || path.endsWith(`/${suffix}`);
|
|
34
|
-
export
|
|
37
|
+
export { unique } from './claim-relation.mjs';
|
|
35
38
|
const getFindingLine = (document, index) => document.frontLines + index + 1;
|
|
36
39
|
const makeFinding = (line, code, message, rowId = null) => ({ line, code, message, rowId });
|
|
37
40
|
const getHeading = (document, text) => document.headings.find((heading) => heading.text === text);
|
|
@@ -54,7 +57,8 @@ const getFigure = (value) => Number(String(value).replace(/^~/, ''));
|
|
|
54
57
|
const getConcretePaths = (row, facts) => isSweep(row.path) ? facts.expansions?.[row.path] ?? [] : [row.path];
|
|
55
58
|
const getPathFact = (facts, path) => facts.pathFacts?.[path] ?? null;
|
|
56
59
|
const getAnchorPath = (anchor) => anchor.replace(/:[1-9]\d*$/, '');
|
|
57
|
-
const hasPathDefect = (path) =>
|
|
60
|
+
const hasPathDefect = (path) => !(!path.includes('\\') &&
|
|
61
|
+
path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..'));
|
|
58
62
|
const hasRaiseBullet = (verificationBullets, path) => verificationBullets.some((bullet) =>
|
|
59
63
|
bullet.includes('--write-baseline') && bullet.includes('--reason') && bullet.includes(path));
|
|
60
64
|
|
|
@@ -174,6 +178,22 @@ const checkHeadings = (text, document) => {
|
|
|
174
178
|
return findings;
|
|
175
179
|
};
|
|
176
180
|
|
|
181
|
+
const isPinRow = (row, path) => row.verb === 'modify' && row.path === path && posix.basename(row.path) === PIN_FILE;
|
|
182
|
+
const checkPinBindings = (rows, owners, facts) => {
|
|
183
|
+
if (!Object.hasOwn(facts, 'pathFacts')) return [];
|
|
184
|
+
return [...owners].flatMap(([path, group]) => {
|
|
185
|
+
const pins = group.filter((row) => isPinRow(row, path));
|
|
186
|
+
if (pins.length < 2) return [];
|
|
187
|
+
const taken = new Set();
|
|
188
|
+
return pins.flatMap((pin) => {
|
|
189
|
+
const above = rows.slice(0, rows.indexOf(pin)).findLast((row) => row.valid && PIN_VERBS.has(row.verb) && !taken.has(row) &&
|
|
190
|
+
getPathFact(facts, row.path)?.shipped === true && getPathFact(facts, row.path)?.pinTest === path);
|
|
191
|
+
if (above) { taken.add(above); return []; }
|
|
192
|
+
return [makeFinding(pin.line, 'pin-unbound', `${pin.id} is a repeated pin row with no untaken shipped create or delete above it`, pin.id)];
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
};
|
|
196
|
+
|
|
177
197
|
const checkLedgerStructure = (parsed, facts) => {
|
|
178
198
|
const findings = [];
|
|
179
199
|
const nonBlank = parsed.entries.filter((entry) => entry.raw.trim() !== '');
|
|
@@ -206,11 +226,15 @@ const checkLedgerStructure = (parsed, facts) => {
|
|
|
206
226
|
const owners = new Map();
|
|
207
227
|
for (const row of parsed.rows.filter((item) => item.valid)) {
|
|
208
228
|
for (const path of getConcretePaths(row, facts)) {
|
|
209
|
-
const prior = owners.get(path);
|
|
210
|
-
|
|
211
|
-
|
|
229
|
+
const prior = owners.get(path) ?? [];
|
|
230
|
+
const conflicting = prior.find((owner) => !isPinRow(owner, path) || !isPinRow(row, path));
|
|
231
|
+
if (conflicting) {
|
|
232
|
+
findings.push(makeFinding(row.line, 'duplicate-path', `${path} is owned by both ${conflicting.id} and ${row.id}`, row.id));
|
|
233
|
+
}
|
|
234
|
+
owners.set(path, [...prior, row]);
|
|
212
235
|
}
|
|
213
236
|
}
|
|
237
|
+
findings.push(...checkPinBindings(parsed.rows, owners, facts));
|
|
214
238
|
const last = nonBlank.at(-1);
|
|
215
239
|
if (!parsed.total || (parsed.totals ?? []).length !== 1 || last?.line !== parsed.total?.line) {
|
|
216
240
|
findings.push(makeFinding(last?.line ?? 1, 'total', 'the exact total line must be the last non-blank ledger line'));
|
|
@@ -236,9 +260,9 @@ const getAnchorCandidates = (row, preceding, facts) => {
|
|
|
236
260
|
return facts.candidates(suffix, precedingPaths);
|
|
237
261
|
};
|
|
238
262
|
|
|
239
|
-
const getCurrentLines = (rows, facts, verbs) => rows
|
|
263
|
+
const getCurrentLines = (rows, facts, verbs) => unique(rows
|
|
240
264
|
.filter((row) => row.valid && verbs.has(row.verb) && row.budget !== 'n/a')
|
|
241
|
-
.flatMap((row) => getConcretePaths(row, facts))
|
|
265
|
+
.flatMap((row) => getConcretePaths(row, facts)))
|
|
242
266
|
.reduce((sum, path) => sum + (getPathFact(facts, path)?.lines ?? 0), 0);
|
|
243
267
|
|
|
244
268
|
const checkAuthoring = (parsed, facts) => {
|
|
@@ -335,7 +359,11 @@ const judge = (text, facts, stateRules) => {
|
|
|
335
359
|
return { findings: [makeFinding(line, 'block-model', parsedResult.error.message)], skips: [], parsed: null };
|
|
336
360
|
}
|
|
337
361
|
const parsed = parsedResult.parsed;
|
|
338
|
-
const
|
|
362
|
+
const goal = getHeading(parsed.document, PLAN_HEADINGS[0]);
|
|
363
|
+
const goalLines = goal ? getSectionLines(parsed.document, PLAN_HEADINGS[0])
|
|
364
|
+
.map((text, offset) => ({ text, line: getFindingLine(parsed.document, goal.index + 1 + offset) })) : [];
|
|
365
|
+
const structural = [...checkHeadings(String(text ?? ''), parsed.document), ...checkLedgerStructure(parsed, facts),
|
|
366
|
+
...checkStoryOwnership({ rows: parsed.rows, goalLines, facts, getConcretePaths }).findings];
|
|
339
367
|
const state = stateRules ? stateRules(parsed, facts) : { findings: [], skips: [] };
|
|
340
368
|
const findings = [...structural, ...state.findings].map((finding, index) => ({ ...finding, order: index }))
|
|
341
369
|
.sort((left, right) => left.line - right.line || left.order - right.order)
|
package/tools/review-state.mjs
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
// Normative `--check` exit contract (the single home of this list — SKILL.md points here):
|
|
12
12
|
// Before the review-recipe exits, configured plan-execution.execute=delegated with a plan and a
|
|
13
13
|
// dirty, fingerprintable tree audits the delegation ledger. An absent ledger is inert; an
|
|
14
|
-
// unavailable ledger
|
|
15
|
-
// substituted return's tree, standing after every later fold, accepts it,
|
|
16
|
-
// retry lifts it (codex never grants it)
|
|
14
|
+
// unavailable ledger or a HEAD error refuses. A SUBSTITUTED held session refuses: for a head-base thread,
|
|
15
|
+
// a codex-exec degrade at the substituted return's tree, standing after every later fold, accepts it,
|
|
16
|
+
// or the fold of that thread's retry lifts it (codex never grants it); a checkpoint-based thread's
|
|
17
|
+
// substitution closes only by its own ledger degrade.
|
|
17
18
|
// exit 0 when the CONFIGURED plan-execution.review recipe is solo (or the computed default is —
|
|
18
19
|
// absent config with no reviewer backend ready); when no plan is in flight (docs/plans/
|
|
19
20
|
// holds no top-level .md that is not queue.md and not scratch by the naming convention:
|
|
@@ -149,6 +150,7 @@ export {
|
|
|
149
150
|
const ACTIVITY = 'plan-execution';
|
|
150
151
|
const SLOT = 'review';
|
|
151
152
|
const CORE_EVIDENCE_TOOL = shellQuoteArg(join(dirname(fileURLToPath(import.meta.url)), 'core-evidence.mjs'));
|
|
153
|
+
const DISPATCH_TOOL = shellQuoteArg(join(dirname(fileURLToPath(import.meta.url)), 'dispatch.mjs'));
|
|
152
154
|
// --await (BUGFREE-3 / AD-049, item (d)) bounds + poll cadence. The default timeout is generous —
|
|
153
155
|
// a real grounded bridge review can take minutes — and every value is overridable (--timeout / the
|
|
154
156
|
// injectable clock) so hermetic tests never spend wall-clock.
|
|
@@ -531,6 +533,9 @@ const backendFailurePart = (b, state) => {
|
|
|
531
533
|
const describesHeldRecovery = (state) => {
|
|
532
534
|
const substitution = state.heldSession.substitution;
|
|
533
535
|
if (substitution === null) return '';
|
|
536
|
+
if (substitution.baseKind === 'checkpoint') {
|
|
537
|
+
return ` — a checkpoint-based thread: no core-evidence degrade lifts it; record its ledger degrade (node ${DISPATCH_TOOL} degrade --wave <wave> --nonce ${shellQuoteArg(substitution.nonce)} --step-class code --rationale "...") then retry it (cap permitting) or open a new thread on the held session, and fold that`;
|
|
538
|
+
}
|
|
534
539
|
const canRetry = substitution.folded === false;
|
|
535
540
|
if (state.evidenceUnavailable === true) {
|
|
536
541
|
const retry = canRetry ? '; or fold the retry of that thread' : '';
|
|
@@ -784,8 +789,7 @@ record (node core-evidence.mjs degrade) — and never all backends.
|
|
|
784
789
|
below, a configured plan-execution.execute = delegated with a plan in flight and a dirty,
|
|
785
790
|
fingerprintable tree audits the delegation ledger: an absent ledger is inert; an unreadable,
|
|
786
791
|
malformed, foreign or audit-refused ledger, a failed HEAD read, or a SUBSTITUTED held session
|
|
787
|
-
exits 1
|
|
788
|
-
tree, or the fold of that thread's retry). THEN: 0 for solo / no plan in flight /
|
|
792
|
+
exits 1. THEN: 0 for solo / no plan in flight /
|
|
789
793
|
a clean tree (under a non-solo review obligation the PASS names every plan in flight and its arm) /
|
|
790
794
|
a cwd where git ITSELF answers not-a-repository (the only passing non-work-tree state; the four others exit 1 by name) / obligations satisfied (reviewed: >=1 ship-class attestation;
|
|
791
795
|
council: every backend ship-class or degrade-recorded, >=1 real ship); 1 on a veto, an
|