@sabaiway/agent-workflow-kit 14.4.0 → 14.4.2

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.
@@ -111,7 +111,7 @@ const isAbsentSha = (sha) => sha === undefined || /^0+$/.test(sha);
111
111
  // refuses the whole return under a duplicate id or counts one object where there were two. So every
112
112
  // -z stream is split into BUFFER segments and each name is trusted only if it survives a byte
113
113
  // round-trip; a name that does not is a REFUSAL, never a repaired string. (The same defense runs one
114
- // module down at core-evidence.mjs:199-210 — this is the family's idiom, not a new invention.)
114
+ // module down, in flaggedIndexLag in core-evidence-tree.mjs — this is the family's idiom, not a new invention.)
115
115
  const splitZBytes = (buf) => {
116
116
  const out = [];
117
117
  let start = 0;
@@ -0,0 +1,77 @@
1
+ // review-state-await.mjs — the --timeout grammar and the poll loop.
2
+
3
+ import { detectBackends } from './detect-backends.mjs';
4
+ import { fail } from './orchestration-config.mjs';
5
+ import { decideCheck } from './review-state-judge.mjs';
6
+ import { buildState } from './review-state-build.mjs';
7
+
8
+ // --await (BUGFREE-3 / AD-049, item (d)) bounds + poll cadence. The default timeout is generous —
9
+ // a real grounded bridge review can take minutes — and every value is overridable (--timeout / the
10
+ // injectable clock) so hermetic tests never spend wall-clock.
11
+ export const DEFAULT_AWAIT_TIMEOUT_S = 900;
12
+ export const AWAIT_POLL_MS = 5000;
13
+
14
+ // ── --await: block until the configured review obligations are SATISFIED ───────────────
15
+ // It waits until `--check` would pass: under `reviewed`, a ship-class attestation from any
16
+ // review-capable backend; under `council`, every configured backend ship-class OR carrying a
17
+ // current-tree degrade RECORD in the core-evidence store (never all backends) — once such a
18
+ // record lands, --await stops waiting for that backend and returns READY (before, it waited
19
+ // forever for a receipt that never comes). It inherits everything for FREE — it polls the SAME
20
+ // decideCheck(buildState()) `--check` computes. The completion signal is the RECEIPT/RECORD
21
+ // (i.e. `--check` would PASS), NEVER a process event — a harness "completed" notification fires
22
+ // early and a bridge's output late-flushes, so polling state is the durable mechanization of
23
+ // receipts-not-pgrep. Stays read-only (it only re-reads state — now the evidence store too, a
24
+ // few KB per tick); the clock is injectable (ctx.now / ctx.sleep / ctx.pollMs) so hermetic tests
25
+ // never spend wall-clock.
26
+
27
+ const AWAIT_ALLOWED_ARGS = new Set(['--await', '--timeout']);
28
+
29
+ const parseAwaitTimeoutS = (argv) => {
30
+ const i = argv.indexOf('--timeout');
31
+ if (i === -1) return DEFAULT_AWAIT_TIMEOUT_S;
32
+ const raw = argv[i + 1];
33
+ if (!raw || !/^\d+$/.test(raw) || Number(raw) < 1) throw fail(2, '--timeout requires a positive integer number of seconds');
34
+ return Number(raw);
35
+ };
36
+
37
+ export const mainAwait = async (argv, ctx = {}) => {
38
+ const cwd = ctx.cwd ?? process.cwd();
39
+ const env = ctx.env ?? process.env;
40
+ const detect = ctx.detect ?? detectBackends;
41
+ const now = ctx.now ?? (() => Date.now());
42
+ const sleep = ctx.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
43
+ const pollMs = ctx.pollMs ?? AWAIT_POLL_MS;
44
+ const build = ctx.buildState ?? buildState;
45
+ try {
46
+ for (let i = 0; i < argv.length; i += 1) {
47
+ const a = argv[i];
48
+ if (a === '--timeout') { i += 1; continue; } // its value is consumed by parseAwaitTimeoutS
49
+ if (!AWAIT_ALLOWED_ARGS.has(a)) throw fail(2, `--await accepts only --timeout <s> (got ${a})`);
50
+ }
51
+ const timeoutS = parseAwaitTimeoutS(argv);
52
+ const timeoutMs = timeoutS * 1000;
53
+ const start = now();
54
+ // Poll the SAME normative decision --check computes: ready == `--check` would pass (solo / no
55
+ // plan / a clean tree / not-a-git-tree all resolve instantly — nothing to await). Re-read state
56
+ // every poll so a landed receipt (or a tree edit that re-staled one) is seen fresh. The DEADLINE
57
+ // is checked BEFORE readiness (codex council R2): once elapsed reaches the timeout the await is
58
+ // over, so a receipt that only lands AT/after the deadline never flips it to READY; and each
59
+ // sleep is BOUNDED to the remaining time so a full poll interval can never overshoot the timeout.
60
+ let lastReason = 'no poll completed before the deadline';
61
+ for (;;) {
62
+ const elapsed = now() - start;
63
+ if (elapsed >= timeoutMs) return { code: 1, stdout: '', stderr: `review-state --await: TIMEOUT after ${timeoutS}s — ${lastReason}` };
64
+ const check = decideCheck(build({ cwd, env, detect }));
65
+ lastReason = check.reason;
66
+ if (check.code === 0) return { code: 0, stdout: `review-state --await: READY — ${check.reason}`, stderr: '' };
67
+ if (check.heldSession === true) return { code: 1, stdout: '', stderr: `review-state --await: HELD SESSION — ${check.reason}` };
68
+ // Decision 4 (#50): an AUTHORITATIVE veto ends the wait loudly BEFORE the deadline — the
69
+ // dispatched review answered with a landed negative; classifying that as a timeout was the
70
+ // misclassification this amendment closes. Only a fresh review can move it, never waiting.
71
+ if (check.veto === true) return { code: 1, stdout: '', stderr: `review-state --await: VETO — ${check.reason}` };
72
+ await sleep(Math.min(pollMs, timeoutMs - elapsed));
73
+ }
74
+ } catch (err) {
75
+ return { code: err.exitCode ?? 1, stdout: '', stderr: `review-state: ${err.message}` };
76
+ }
77
+ };
@@ -0,0 +1,163 @@
1
+ // review-state-build.mjs - read-only git plumbing and buildState.
2
+
3
+ import { lstatSync, readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { detectBackends } from './detect-backends.mjs';
7
+ import { resolveActivityRecipe, DISPLAY_ALIASES, requiredBackendsForConfiguredRecipe, composeReadiness } from './recipes.mjs';
8
+ import { loadConfig } from './orchestration-config.mjs';
9
+ import { plansInFlight } from './plan-files.mjs';
10
+ import { GIT_MAX_BUFFER, resolveGitLocation, stripGitLocationEnv, withGitPath } from './git-env.mjs';
11
+ import {
12
+ computeTreeFingerprint,
13
+ isTreeClean,
14
+ isNeverCommittableStat,
15
+ resolveReceiptsPath,
16
+ readReceipts,
17
+ resolveBase,
18
+ } from './core-evidence.mjs';
19
+ import {
20
+ ACTIVITY, SLOT, backendReceiptStatus, buildHeldSessionState, degradeRecordSet, selectHeldSessionDegrades,
21
+ } from './review-state-judge.mjs';
22
+ import {
23
+ attestedPlanIdsFor, computePlanAdoptionCoverage, consumeVetoOverride, liftStaleRow, readFlowArming,
24
+ } from './review-state-flow.mjs';
25
+
26
+ // ── git plumbing (read-only queries; injectable for tests) ─────────────────────────
27
+
28
+ const gitRaw = (args, cwd, env) =>
29
+ spawnSync('git', args, { cwd, env: withGitPath(env ?? process.env), maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
30
+
31
+ // stdout Buffer of a git query, or null when git fails (not a repo / git absent).
32
+ const gitBuf = (args, cwd, env) => {
33
+ const r = gitRaw(args, cwd, env);
34
+ if (r.error || r.status !== 0) return null;
35
+ return r.stdout;
36
+ };
37
+
38
+ const gitLine = (args, cwd, env) => {
39
+ const buf = gitBuf(args, cwd, env);
40
+ return buf == null ? null : buf.toString('utf8').replace(/\r?\n$/, '');
41
+ };
42
+
43
+ // ── the sandbox-masks advisory (D lane, AD-044 Plan 4 Phase 1.5) ────────────────────
44
+
45
+ // Count the never-committable untracked paths the STANDARD walk still shows. The review domain
46
+ // ignores them by construction; this count only feeds ONE non-failing advisory line naming the
47
+ // cosmetic sandbox-masks apply — an applied managed block hides the paths from --exclude-standard,
48
+ // so the advisory disappears exactly when the status noise does (no standing detector).
49
+ export const countNeverCommittableUntracked = (cwd, { lstat = lstatSync, env } = {}) => {
50
+ const top = gitLine(['rev-parse', '--show-toplevel'], cwd, env);
51
+ if (top == null) return 0;
52
+ const untrackedZ = gitBuf(['ls-files', '--others', '--exclude-standard', '-z'], top, env);
53
+ if (untrackedZ == null) return 0;
54
+ return untrackedZ
55
+ .toString('utf8')
56
+ .split('\0')
57
+ .filter(Boolean)
58
+ .filter((rel) => {
59
+ try {
60
+ return isNeverCommittableStat(lstat(join(top, rel)));
61
+ } catch {
62
+ return false;
63
+ }
64
+ }).length;
65
+ };
66
+
67
+ // buildState({ cwd, env, detect }) → everything both renders need. Pure I/O at the edges.
68
+ // EVERY project-relative read (orchestration config, docs/plans, receipts) anchors at the git
69
+ // work-tree ROOT when one exists — the fingerprint is root-anchored, so a subdirectory invocation
70
+ // must read the same config/plans or a dirty unreceipted tree could false-PASS as "no plan in
71
+ // flight". Outside a git tree the cwd is the only anchor (and --check exits 0).
72
+ export const buildState = ({ cwd, env = process.env, detect = detectBackends, surveyVehicle, lstat = lstatSync, readFile = readFileSync } = {}) => {
73
+ // WHICH repository, first; the config/plan anchor is the stripped discovery's top (from a subdirectory the bare cwd would miss docs/plans — decideCheck refuses on an unanchored root).
74
+ const location = resolveGitLocation(cwd, { env });
75
+ const anchoredTop = location.top ?? gitLine(['rev-parse', '--show-toplevel'], cwd, stripGitLocationEnv(env));
76
+ const root = anchoredTop ?? cwd;
77
+ const { config, source: configSource } = loadConfig(root);
78
+ // A bridge-detector throw reaches the hook, never a catch that would also lose the surveyed
79
+ // executor vehicle: bridge readiness goes unknown (fail closed below), the carrier stays known.
80
+ let detectionWarning = null;
81
+ const onDetectError = (err) => {
82
+ detectionWarning = `backend detection failed (${(err && err.message) || err}) — bridge readiness unknown.`;
83
+ };
84
+ const detection = composeReadiness(root, { detect, surveyVehicle, onDetectError });
85
+ // The resolver stays for DISPLAY only; the OBLIGATIONS come from the configured recipe (never the readiness-degraded effective one — no silent solo).
86
+ const resolved = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity: ACTIVITY, slot: SLOT });
87
+ const obligations = requiredBackendsForConfiguredRecipe({ config: config ?? {}, readiness: detection, detectionFailed: detectionWarning != null });
88
+ const requiredBackends = obligations.backends;
89
+ const plans = plansInFlight(root);
90
+ // The injected lstat threads through EVERY stat-dependent computation (fingerprint, clean, the
91
+ // mask count) — a partial injection would let a test observe an inconsistent state.
92
+ const fingerprint = location.state === 'work-tree' ? computeTreeFingerprint(cwd, { lstat, env }) : null;
93
+ const clean = fingerprint == null ? null : isTreeClean(cwd, { lstat, env });
94
+ const receiptsPath = resolveReceiptsPath(cwd, env);
95
+ const receiptsRead = receiptsPath ? readReceipts(receiptsPath) : { receipts: [], malformed: 0 };
96
+ const { receipts, malformed } = receiptsRead;
97
+ const receiptsReadError = receiptsRead.readError ?? null;
98
+ const backends = requiredBackends.map((b) => ({ backend: b, ...backendReceiptStatus(receipts, b, fingerprint) }));
99
+ // The D3(b) degrade escape: read the core-evidence store ONLY here, ONLY for the exemption —
100
+ // the gate never otherwise depends on it (an unavailable store denies the exemption CLOSED,
101
+ // never fails a tree whose receipts independently satisfy the gate).
102
+ const base = resolveBase(cwd);
103
+ const degrade = degradeRecordSet({ cwd, env, fingerprint });
104
+ const degradedExempt = requiredBackends.filter((b) => degrade.set.has(b));
105
+ const heldSession = buildHeldSessionState({
106
+ cwd,
107
+ env,
108
+ configuredExecute: [config?.[ACTIVITY]?.execute, config?.task?.execute].find((recipe) => recipe === 'delegated'),
109
+ plans,
110
+ fingerprint,
111
+ clean,
112
+ degrades: selectHeldSessionDegrades(degrade.records),
113
+ });
114
+ // ── the Phase-2 flow arms' state (two-tier activation, P3) ──
115
+ const flow = readFlowArming({ cwd, env, lstat });
116
+ const planCoverage = flow.flowArmed
117
+ ? computePlanAdoptionCoverage({ root, plans, records: flow.records, owner: flow.flowOwner, readFile })
118
+ : [];
119
+ const { attestedPlanIds, lensRefusedAttestations } = flow.flowArmed
120
+ ? attestedPlanIdsFor({ records: flow.records, base, fingerprint })
121
+ : { attestedPlanIds: [], lensRefusedAttestations: [] };
122
+ const arms = { flowArmed: flow.flowArmed, receipts, records: flow.records, base, fingerprint, flowConfig: config?.flow ?? null };
123
+ const armedBackends = backends.map((b) => consumeVetoOverride(liftStaleRow(b, arms), arms));
124
+ // The solo-class veto probe (#48): under an armed solo recipe every review-capable backend's
125
+ // CURRENT-tree receipt is still consulted, so a recipe flip to solo never buries a standing veto.
126
+ const soloVetoRows = flow.flowArmed && obligations.recipe === 'solo'
127
+ ? Object.values(DISPLAY_ALIASES).map((b) => consumeVetoOverride(liftStaleRow({ backend: b, ...backendReceiptStatus(receipts, b, fingerprint) }, arms), arms))
128
+ : [];
129
+ return {
130
+ resolved,
131
+ configSource,
132
+ obligations,
133
+ requiredBackends,
134
+ backends: armedBackends,
135
+ plans,
136
+ root,
137
+ rootAnchored: anchoredTop !== null,
138
+ location,
139
+ fingerprint,
140
+ clean,
141
+ receiptsPath,
142
+ receiptCount: receipts.length,
143
+ malformed,
144
+ receiptsReadError,
145
+ base,
146
+ evidenceStorePath: degrade.storePath,
147
+ evidenceMalformed: degrade.malformed,
148
+ evidenceReadError: degrade.readError,
149
+ evidenceUnavailable: degrade.unavailable,
150
+ degradedExempt,
151
+ heldSession,
152
+ maskedUntracked: countNeverCommittableUntracked(cwd, { lstat, env }),
153
+ detectionWarning,
154
+ flowPresent: flow.flowPresent,
155
+ flowArmed: flow.flowArmed,
156
+ flowBrokenReason: flow.flowBrokenReason,
157
+ flowOwner: flow.flowOwner,
158
+ planCoverage,
159
+ attestedPlanIds,
160
+ lensRefusedAttestations,
161
+ soloVetoRows,
162
+ };
163
+ };
@@ -0,0 +1,110 @@
1
+ // review-state-flow.mjs - plan-adoption coverage and the armed-flow arms.
2
+
3
+ import { readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { createHash } from 'node:crypto';
6
+ import { DISPLAY_ALIASES } from './recipes.mjs';
7
+ import { resolveFlowStorePath, readFlowStore, deriveFlowOwner, readPlanFrontmatterId } from './flow-store.mjs';
8
+ import { CHAIN_KIND, authoritativeFlowRecords } from './flow-record.mjs';
9
+ import { selectReliedOnReceipt, evaluateVetoOverride, evaluateInternalAttestationLenses } from './flow-check.mjs';
10
+ import { PLANS_REL } from './plan-files.mjs';
11
+ import { summarizeReviewReceiptsForTree, isShipVerdict } from './core-evidence.mjs';
12
+
13
+ // The in-flight-plan → adopted-chain coverage map (P13/P19): ONE bounded read + ONE hash per plan
14
+ // file; coverage requires the frontmatter planId, the FULL content digest, and the owner to match
15
+ // the adoption record — a mismatch on any axis refuses internal-only arming by name.
16
+ export const computePlanAdoptionCoverage = ({ root, plans, records, owner, readFile = readFileSync }) =>
17
+ plans.map((plan) => {
18
+ let bytes;
19
+ try {
20
+ bytes = readFile(join(root, PLANS_REL, plan));
21
+ } catch (err) {
22
+ return { plan, planId: null, covered: false, reason: `the plan file is unreadable (${(err && err.code) || (err && err.message) || err})` };
23
+ }
24
+ const buf = typeof bytes === 'string' ? Buffer.from(bytes) : bytes;
25
+ const planId = readPlanFrontmatterId(buf.toString('utf8'));
26
+ if (planId == null) return { plan, planId: null, covered: false, reason: 'no frontmatter planId — plan filenames are never chain identity (#58); adopt the plan through the flow writer' };
27
+ const adoptionRecord = records.find((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption' && r.planId === planId);
28
+ if (adoptionRecord === undefined) return { plan, planId, covered: false, reason: `no adoption record for planId "${planId}"` };
29
+ if (adoptionRecord.planDigest !== createHash('sha256').update(buf).digest('hex')) {
30
+ return { plan, planId, covered: false, reason: 'the plan content no longer matches its adoption record (edited after adoption)' };
31
+ }
32
+ if (adoptionRecord.owner !== owner) {
33
+ return { plan, planId, covered: false, reason: `the adoption is owned by "${adoptionRecord.owner}", not this worktree ("${owner}") — a foreign-owner adoption never arms internal-only here` };
34
+ }
35
+ return { plan, planId, covered: true };
36
+ });
37
+
38
+ // Tier 1 is store-file PRESENCE: no file ⇒ every field below stays inert and the decision is
39
+ // byte-identical to the pre-flow checker (the unarmed fast path also reads NO plan file).
40
+ export const readFlowArming = ({ cwd, env, lstat }) => {
41
+ const flowPath = resolveFlowStorePath(cwd, env);
42
+ const flowStat = (() => {
43
+ if (flowPath == null) return null;
44
+ try {
45
+ return lstat(flowPath);
46
+ } catch (err) {
47
+ return err && err.code === 'ENOENT' ? null : 'unstatable';
48
+ }
49
+ })();
50
+ const flowPresent = flowStat !== null;
51
+ const flowRead = flowPresent && flowStat !== 'unstatable' ? readFlowStore(flowPath) : null;
52
+ const flowBrokenReason = !flowPresent ? null
53
+ : flowStat === 'unstatable' ? 'the store leaf cannot be stat-ed (fail closed)'
54
+ : flowRead.readError != null ? `read error: ${flowRead.readError}`
55
+ : flowRead.malformed > 0 ? `${flowRead.malformed} malformed line(s) (${flowRead.malformedReasons[0]})`
56
+ : null;
57
+ const flowArmed = flowPresent && flowBrokenReason == null
58
+ && flowRead.records.some((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption');
59
+ const flowOwner = flowArmed ? deriveFlowOwner(cwd) : null;
60
+ return { flowPresent, flowArmed, flowBrokenReason, flowOwner, records: flowArmed ? flowRead.records : [] };
61
+ };
62
+
63
+ // The lens-substitution rung (#15/#3): an attestation whose lens set claims a review provider's
64
+ // slot without a then-active down-mark never counts — and its refusal is NAMED, never silent
65
+ // (lensRefusedAttestations feeds the internal-only floor's failure reason).
66
+ export const attestedPlanIdsFor = ({ records, base, fingerprint }) => {
67
+ const lensRefusedAttestations = [];
68
+ const attestedPlanIds = [...new Set(authoritativeFlowRecords(records)
69
+ .filter((r) => r.kind === 'internal-attestation' && r.base === base && r.fingerprint === fingerprint)
70
+ .filter((r) => {
71
+ const lensCheck = evaluateInternalAttestationLenses({ record: r, records, providerBackends: Object.values(DISPLAY_ALIASES) });
72
+ if (!lensCheck.ok) {
73
+ lensRefusedAttestations.push({ planId: r.planId, reason: lensCheck.reason });
74
+ return false;
75
+ }
76
+ return true;
77
+ })
78
+ .map((r) => r.planId))];
79
+ return { attestedPlanIds, lensRefusedAttestations };
80
+ };
81
+
82
+ // #61: the SHARED relied-on selector (one home with the flow-check coverage rung) — an unbroken
83
+ // declared-path bookkeeping-delta chain lifts the backend's last receipt; any break, fork, or
84
+ // cap exhaustion stays stale.
85
+ export const liftStaleRow = (row, { flowArmed, receipts, records, base, fingerprint, flowConfig }) => {
86
+ if (!flowArmed || row.state !== 'stale') return row;
87
+ const selected = selectReliedOnReceipt({
88
+ receipts, backend: row.backend, tree: { base, fingerprint },
89
+ records,
90
+ declaredPaths: [flowConfig?.debtQueue, flowConfig?.convergenceSummary].filter((p) => typeof p === 'string'),
91
+ refreshCap: flowConfig?.councilRounds,
92
+ });
93
+ if (selected.receipt == null || selected.lifted === 0) return row;
94
+ return {
95
+ ...row, state: 'current', verdict: selected.receipt.verdict ?? 'unknown',
96
+ shipClass: isShipVerdict(selected.receipt.verdict), grounded: true,
97
+ timestamp: selected.receipt.timestamp ?? null, deltaLift: selected.lifted,
98
+ };
99
+ };
100
+
101
+ // #56/#38: a standing CURRENT-tree veto consults its override instance; a delta-lifted veto has
102
+ // no override lane here (the bound set pins the veto receipt's own tree).
103
+ export const consumeVetoOverride = (row, { flowArmed, receipts, records, base, fingerprint }) => {
104
+ if (!flowArmed || !(row.state === 'current' && !row.shipClass) || row.deltaLift != null) return row;
105
+ const own = receipts.filter((r) => r.backend === row.backend);
106
+ const summary = summarizeReviewReceiptsForTree(own, fingerprint);
107
+ if (summary.state !== 'current') return row;
108
+ const lift = evaluateVetoOverride({ records, vetoReceipt: summary.receipt, tree: { base, fingerprint } });
109
+ return lift.lifted ? { ...row, overrideLabel: lift.label } : row;
110
+ };