@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.
- package/CHANGELOG.md +86 -0
- package/README.md +2 -2
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/agents.md +4 -4
- package/references/modes/bootstrap.md +1 -1
- package/references/modes/dispatch.md +3 -3
- package/references/modes/recommendations.md +1 -1
- package/references/modes/review-state.md +1 -1
- package/references/modes/upgrade.md +6 -4
- package/references/templates/vehicles.json +1 -1
- package/tools/cheap-agents-read.mjs +37 -3
- package/tools/cheap-agents.mjs +44 -13
- package/tools/dispatch-record.mjs +8 -2
- package/tools/dispatch-store-read.mjs +35 -1
- package/tools/dispatch-store.mjs +8 -1
- package/tools/dispatch.mjs +25 -4
- package/tools/ensure-configs.mjs +15 -11
- package/tools/ensure-executor.mjs +87 -0
- package/tools/ensure-ops.mjs +2 -2
- package/tools/ensure-vocabulary.mjs +10 -3
- package/tools/held-session.mjs +57 -18
- package/tools/recommendations.mjs +15 -9
- package/tools/task-thread.mjs +70 -0
- package/tools/upgrade-runlist.mjs +2 -0
package/tools/held-session.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
998
|
-
// the user's own edit
|
|
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
|
|
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
|
-
...(
|
|
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
|
|
1065
|
+
// missing executor's placement too), so its own preflight is the last precondition.
|
|
1060
1066
|
const blocked = writerBlock(root, deps);
|
|
1061
|
-
if (blocked
|
|
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
|
|
1071
|
-
//
|
|
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',
|