@sabaiway/agent-workflow-kit 13.3.0 → 14.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.
@@ -2,6 +2,7 @@ import { escapeForDisplay, isRenderableLine, shellQuoteArg } from './repo-lex.mj
2
2
  import { wrapperCmdFor } from './detect-backends.mjs';
3
3
  import { DISPLAY_ALIASES } from './recipes.mjs';
4
4
  import { readsBaseline, TERMINAL_RETURN_OUTCOMES } from './dispatch-record.mjs';
5
+ import { readsInsideEpoch, readsTask } from './task-thread.mjs';
5
6
 
6
7
  const CODE_STEP = 'code';
7
8
  const FOLD_KIND = 'fold';
@@ -13,12 +14,6 @@ const UNPOPULATED_HELD_ID = '<held id>';
13
14
  export const HELD_RECEIPT_BACKEND = DISPLAY_ALIASES[EXECUTE_BACKEND];
14
15
  export const HELD_EXECUTE_WRAPPER = wrapperCmdFor(EXECUTE_BACKEND, 'execute');
15
16
 
16
- const readsInsideEpoch = (dispatch, head) => {
17
- if (head.state === 'unborn') return true;
18
- const instant = Date.parse(dispatch.timestamp);
19
- return Number.isFinite(instant) && Math.floor(instant / 1000) > head.seconds;
20
- };
21
-
22
17
  const updatesThread = (threads, nonce, update) =>
23
18
  threads.map((thread) => (thread.nonce === nonce ? { ...thread, ...update } : thread));
24
19
 
@@ -101,15 +96,7 @@ const countsHeldFolds = (records, epochNonces, heldId) => {
101
96
  }).length;
102
97
  };
103
98
 
104
- export const judgeHeldSession = (records, { head, backend, degrades = [] }) => {
105
- if (head.state === 'error') {
106
- return { state: 'error', cause: 'head', reason: head.reason, heldId: null, folds: 0, substitution: null, threads: [], open: [] };
107
- }
108
- const dispatches = records.filter((record) =>
109
- record.kind === 'dispatch'
110
- && record.stepClass === CODE_STEP
111
- && record.backend === backend
112
- && readsInsideEpoch(record, head));
99
+ const judgeChain = (records, dispatches, degrades) => {
113
100
  const epochNonces = new Set(dispatches.map((dispatch) => dispatch.nonce));
114
101
  const initial = { epochNonces, dispatches: [], returns: [], heldId: null, substitutions: [], threads: [], open: [] };
115
102
  const accumulated = records.reduce(accumulatesFacts(degrades), initial);
@@ -125,6 +112,44 @@ export const judgeHeldSession = (records, { head, backend, degrades = [] }) => {
125
112
  };
126
113
  };
127
114
 
115
+ export const judgeHeldSession = (records, { head, backend, degrades = [] }) => {
116
+ if (head.state === 'error') {
117
+ return { state: 'error', cause: 'head', reason: head.reason, heldId: null, folds: 0, substitution: null, threads: [], open: [] };
118
+ }
119
+ const dispatches = records.filter((record) =>
120
+ record.kind === 'dispatch'
121
+ && record.stepClass === CODE_STEP
122
+ && record.backend === backend
123
+ && readsInsideEpoch(record, head));
124
+ if (!dispatches.some((dispatch) => readsTask(dispatch) !== null)) return judgeChain(records, dispatches, degrades);
125
+ const groups = new Map();
126
+ for (const dispatch of dispatches) {
127
+ const key = readsTask(dispatch)?.brief ?? null;
128
+ if (!groups.has(key)) groups.set(key, []);
129
+ groups.get(key).push(dispatch);
130
+ }
131
+ const reports = new Map([...groups].map(([key, entries]) => {
132
+ const nonces = new Set(entries.map((dispatch) => dispatch.nonce));
133
+ return [key, judgeChain(records.filter((record) => nonces.has(record.nonce)), entries, degrades)];
134
+ }));
135
+ const untasked = reports.get(null);
136
+ const threads = new Map([...reports].flatMap(([chainKey, report]) =>
137
+ report.threads.map((thread) => [thread.nonce, { ...thread, chainKey }])));
138
+ const substitutions = new Map([...reports.values()]
139
+ .filter((report) => report.substitution !== null)
140
+ .map((report) => [report.substitution.nonce, report.substitution]));
141
+ const firstReturn = records.find((record) => record.kind === RETURN_KIND && substitutions.has(record.nonce));
142
+ return {
143
+ state: 'ok',
144
+ heldId: untasked?.heldId ?? null,
145
+ folds: untasked?.folds ?? 0,
146
+ substitution: firstReturn === undefined ? null : substitutions.get(firstReturn.nonce),
147
+ threads: dispatches.map((dispatch) => threads.get(dispatch.nonce)),
148
+ open: untasked?.open ?? [],
149
+ chains: [...reports].map(([key, { heldId, folds, open }]) => ({ key, heldId, folds, open })),
150
+ };
151
+ };
152
+
128
153
  export const threadVerdict = (facts, nonce) => facts.threads.find((thread) => thread.nonce === nonce) ?? null;
129
154
 
130
155
  export const judgeLedger = (ledger, { backend, degrades = [] }) => {
@@ -150,6 +175,11 @@ export const decideHeldSession = (facts) => {
150
175
  const reason = `delegated code thread "${nonce}" substituted held session "${escapeForDisplay(expectedId)}" with "${escapeForDisplay(actualId)}"`;
151
176
  return { code: 1, reason, line: `held session: SUBSTITUTED — ${reason}` };
152
177
  }
178
+ if (facts.chains !== undefined) {
179
+ const entries = facts.chains.map(({ key, heldId, folds }) =>
180
+ `${key === null ? 'untasked' : escapeForDisplay(key)}: ${heldId === null ? 'none' : escapeForDisplay(heldId)} (${folds})`);
181
+ return { code: 0, reason: 'task chains have no unresolved substitution', line: `held sessions: ${facts.chains.length} chain(s) — ${entries.join(' · ')}` };
182
+ }
153
183
  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' };
154
184
  const heldId = escapeForDisplay(facts.heldId);
155
185
  return { code: 0, reason: `held session "${heldId}" is continuous`, line: `held session: ${heldId} — ${facts.folds} fold(s) rode it` };
@@ -164,15 +194,24 @@ const describesLaneCaveat = (facts) => {
164
194
  return null;
165
195
  };
166
196
 
197
+ const chainLaneLines = (facts, chain) => {
198
+ const substitution = facts.threads.find((thread) => thread.chainKey === chain.key && thread.substituted) ?? null;
199
+ const caveat = describesLaneCaveat({ state: facts.state, heldId: chain.heldId, substitution });
200
+ const task = chain.key === null ? '' : ` (task ${escapeForDisplay(chain.key)})`;
201
+ if (caveat !== null) return [` caveat: ${caveat}${task}`];
202
+ return [` run: ${HELD_EXECUTE_WRAPPER} --resume ${shellQuoteArg(chain.heldId)} --nonce <nonce> <fold-brief>${task}`];
203
+ };
204
+
167
205
  export const foldLaneLines = (facts) => {
168
- const caveat = describesLaneCaveat(facts);
169
- const heldId = caveat === null ? shellQuoteArg(facts.heldId) : UNPOPULATED_HELD_ID;
170
206
  const lines = [
171
207
  'Fold lane (execute = delegated) — a fold rides the delegate\'s HELD session:',
172
- ` run: ${HELD_EXECUTE_WRAPPER} --resume ${heldId} --nonce <nonce> <fold-brief>`,
173
208
  ' 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',
174
209
  ' the orchestrator runs the suites, verifies the returned diff, re-mints the red-proofs and owns the commit',
175
210
  ' 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',
176
211
  ];
212
+ if (facts.chains !== undefined) return [...lines, ...facts.chains.flatMap((chain) => chainLaneLines(facts, chain))];
213
+ const caveat = describesLaneCaveat(facts);
214
+ const heldId = caveat === null ? shellQuoteArg(facts.heldId) : UNPOPULATED_HELD_ID;
215
+ lines.splice(1, 0, ` run: ${HELD_EXECUTE_WRAPPER} --resume ${heldId} --nonce <nonce> <fold-brief>`);
177
216
  return caveat === null ? lines : [...lines, ` caveat: ${caveat}`];
178
217
  };
@@ -85,7 +85,7 @@ import { loadWorktreesConfig, resolveProbeDir } from './worktrees.mjs';
85
85
  import { preflightCheapAgents, EXECUTOR_VEHICLE } from './cheap-agents.mjs';
86
86
  import { NODE_EVIDENCE, NODE_EVIDENCE_SCRIPTS, probeNodeEvidence } from './node-evidence.mjs';
87
87
  // The vehicle READINESS comes from the read-only half — the same survey `recipes` and `status` read.
88
- import { surveyExecutorVehicle, readStamp, readFsDeps, WORKFLOW_STAMP, EXPECTED_WORKFLOW_VERSION } from './cheap-agents-read.mjs';
88
+ import { surveyExecutorVehicle, readExecutorPosture, executorTemplate, readStamp, readFsDeps, WORKFLOW_STAMP, EXPECTED_WORKFLOW_VERSION } from './cheap-agents-read.mjs';
89
89
  // The ack store's path, keys, lane registry, fingerprint and guarded reader live in their own leaf
90
90
  // (contract: kit/ack-store) — `status` reads the same store, and a second copy is what drifts.
91
91
  import {
@@ -994,11 +994,16 @@ const probeStateBlockHook = ({ root, deps, add, skip }) => {
994
994
  // surfaced its absence: `agents` is the family's SECOND `.claude/` writer, the `help` Tune tail
995
995
  // advertises it, and the advisor had no entry for it — so a user who never runs `help` never learned
996
996
  // it existed while the advisor reported the deployment optimal. Found by the guard, not by an incident.
997
- // Only a PLACE action counts as a gap: `already-current` is converged and `customized-preserved` is
998
- // the user's own edit, which the writer never clobbers and the advisor must never nag about.
997
+ // Only a PLACE action counts as a gap: `already-current` is converged; a customized read-only vehicle or lens is
998
+ // the user's own edit the writer never clobbers and the advisor never nags about; the executor is not preserved but re-derived from docs/ai/vehicles.json (its `re-derive` answer is not a gap of this offer).
999
999
  const probeCheapAgents = ({ root, deps, add, skip }) => {
1000
1000
  try {
1001
- const preflight = preflightCheapAgents({ cwd: root }, deps);
1001
+ const { posture, reason } = readExecutorPosture(root, deps);
1002
+ if (posture === null) {
1003
+ skip('agents', new Error(`${reason} — fix that file first`));
1004
+ return;
1005
+ }
1006
+ const preflight = preflightCheapAgents({ cwd: root, derived: [executorTemplate(posture, deps)] }, deps);
1002
1007
  // The writer refuses below the expected lineage, so offering its command would hand the user a
1003
1008
  // guaranteed failure — the honest surface is a stated skip naming the recovery.
1004
1009
  if (!preflight.stampOk) {
@@ -1050,15 +1055,16 @@ const probeExecutorVehicle = ({ root, deps, add, skip }) => {
1050
1055
  const survey = (deps.surveyVehicle ?? surveyExecutorVehicle)(root, deps);
1051
1056
  if (!VEHICLE_BROKEN_STATES.includes(survey.state)) return;
1052
1057
  const reason = safeLine(survey.reason ?? '');
1058
+ const { posture, reason: settingsReason } = readExecutorPosture(root, deps);
1053
1059
  const stamp = readStamp(join(root, WORKFLOW_STAMP), readFsDeps(deps));
1054
1060
  const preconditions = [
1055
1061
  ...(stamp === EXPECTED_WORKFLOW_VERSION ? [] : [`run /agent-workflow-kit upgrade first (deployment stamp ${stamp ?? 'none'}, expected ${EXPECTED_WORKFLOW_VERSION})`]),
1056
- ...(survey.state === 'unusable' ? [`${reason || 'the vehicle file is unusable'} — fix that`] : []),
1062
+ ...(posture === null ? [`${safeLine(settingsReason)} — fix that`] : []),
1057
1063
  ];
1058
1064
  // The writer refuses on ANY vehicle path it cannot touch (a symlinked read-only vehicle blocks a
1059
- // missing executor's placement too), so its own preflight is the last precondition — named once.
1065
+ // missing executor's placement too), so its own preflight is the last precondition.
1060
1066
  const blocked = writerBlock(root, deps);
1061
- if (blocked && blocked !== reason) preconditions.push(`${blocked} — fix that`);
1067
+ if (blocked) preconditions.push(`${blocked} — fix that`);
1062
1068
  const room = templateBudget(WHATS['executor-vehicle']) - String(configured.length).length - survey.state.length - 2;
1063
1069
  add(
1064
1070
  'executor-vehicle',
@@ -1067,8 +1073,8 @@ const probeExecutorVehicle = ({ root, deps, add, skip }) => {
1067
1073
  state: survey.state,
1068
1074
  reason: reason && room > 0 ? `: ${truncatedTo(reason, room)}` : '',
1069
1075
  }),
1070
- // The writer places a MISSING vehicle; an unusable path (a symlink, a read-only customization)
1071
- // is kept or refused, so that state's apply is a hand-apply precondition before the writer.
1076
+ // The writer places a missing vehicle and re-derives a placed executor whose bytes differ from the derived body (an invalid
1077
+ // frontmatter or read-only tools included), so those states need no precondition; every precondition above is a refusal the apply would meet.
1072
1078
  `${preconditions.length ? `HAND-APPLY: ${preconditions.join('; ')}, then run: ` : ''}node ${q(toolPath('cheap-agents.mjs'))} --apply --cwd ${q(root)}`,
1073
1079
  'executor-vehicle',
1074
1080
  `hidden-mode deployments only: after the apply, run node ${q(toolPath('hide-footprint.mjs'))} --dir ${q(root)} --reconcile so the placed .claude/agents/ stays invisible to git status`,
@@ -0,0 +1,70 @@
1
+ import { hasPathOverlap } from './claim-relation.mjs';
2
+
3
+ const TASK_BRIEF = /^docs\/plans\/TASK-[^/]+-T[1-9][0-9]*\.md$/u;
4
+ const INVALID_SEGMENTS = Object.freeze(['', '.', '..', '.git']);
5
+ const HEAD_BASELINE = Object.freeze({ kind: 'head', treeOid: null });
6
+
7
+ export const byteOrder = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
8
+
9
+ export const readsTask = (dispatch) => Object.hasOwn(dispatch, 'task')
10
+ ? { brief: dispatch.task.brief, files: [...dispatch.task.files] }
11
+ : null;
12
+
13
+ export const validateTask = (task, baseKind) => {
14
+ if (typeof task.brief !== 'string' || !TASK_BRIEF.test(task.brief)) {
15
+ return { ok: false, reason: 'task-brief: expected docs/plans/TASK-<stem>-T<n>.md with a positive task number' };
16
+ }
17
+ if (!Array.isArray(task.files) || task.files.length === 0) {
18
+ return { ok: false, reason: 'task-files: expected a non-empty array of paths' };
19
+ }
20
+ for (let index = 0; index < task.files.length; index += 1) {
21
+ const path = task.files[index];
22
+ if (typeof path !== 'string' || path.split('/').some((segment) => INVALID_SEGMENTS.includes(segment))) {
23
+ return { ok: false, reason: 'task-files: expected normalized repo-relative paths without empty, dot or .git segments' };
24
+ }
25
+ if (index > 0 && byteOrder(task.files[index - 1], path) >= 0) {
26
+ return { ok: false, reason: 'task-files: paths must be distinct and in byte order' };
27
+ }
28
+ if (task.files.slice(0, index).some((earlier) => hasPathOverlap(earlier, path))) {
29
+ return { ok: false, reason: 'task-files: one path is a prefix of another' };
30
+ }
31
+ }
32
+ if (baseKind === 'head') {
33
+ return { ok: false, reason: 'task-base: a task requires a checkpoint baseline' };
34
+ }
35
+ return { ok: true };
36
+ };
37
+
38
+ export const readsInsideEpoch = (dispatch, head) => {
39
+ if (head.state === 'unborn') return true;
40
+ const instant = Date.parse(dispatch.timestamp);
41
+ return Number.isFinite(instant) && Math.floor(instant / 1000) > head.seconds;
42
+ };
43
+
44
+ const collectClaims = (records, head, isTerminal) => {
45
+ const claims = [];
46
+ records.forEach((dispatch, index) => {
47
+ if (dispatch.kind !== 'dispatch' || !readsInsideEpoch(dispatch, head)) return;
48
+ const task = readsTask(dispatch);
49
+ if (task === null) return;
50
+ const later = records.slice(index + 1).filter((record) => record.nonce === dispatch.nonce);
51
+ const folded = later.some((record) => record.kind === 'fold');
52
+ if (!folded && later.some(isTerminal)) return;
53
+ // Keep the baseline reader local to preserve the acyclic ledger boundary.
54
+ const baseline = Object.hasOwn(dispatch, 'baseline') ? dispatch.baseline : HEAD_BASELINE;
55
+ claims.push({
56
+ nonce: dispatch.nonce, brief: task.brief, files: task.files,
57
+ state: folded ? 'folded' : 'open', treeOid: baseline.treeOid,
58
+ });
59
+ });
60
+ return claims;
61
+ };
62
+
63
+ export const claimedPaths = (records, treeOid, head, isTerminal) => [...new Set(
64
+ collectClaims(records, head, isTerminal)
65
+ .filter((claim) => claim.treeOid === treeOid)
66
+ .flatMap((claim) => claim.files),
67
+ )].sort(byteOrder);
68
+
69
+ export const epochClaims = (records, head, isTerminal) => collectClaims(records, head, isTerminal)
70
+ .map(({ nonce, brief, files, state }) => ({ nonce, brief, files, state }));
@@ -40,10 +40,12 @@ export const UPGRADE_RUNLIST = Object.freeze([
40
40
  'note-refreshed',
41
41
  'refreshed',
42
42
  'regenerated',
43
+ 're-derived',
43
44
  'already-current',
44
45
  'customized-preserved',
45
46
  'malformed-preserved',
46
47
  'already-present',
48
+ 'not-placed',
47
49
  'skipped-no-node-evidence',
48
50
  'old-adr-layout-migration-instructed',
49
51
  'failed',