@sabaiway/agent-workflow-kit 10.3.0 → 10.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/README.md +5 -5
- package/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +7 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +69 -17
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +73 -2
- package/bridges/antigravity-cli-bridge/capability.json +2 -2
- package/bridges/antigravity-cli-bridge/references/review-prompt.md +3 -0
- package/bridges/codex-cli-bridge/SKILL.md +8 -1
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +1 -1
- package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +1 -1
- package/bridges/codex-cli-bridge/bin/codex-review.sh +89 -18
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +55 -2
- package/bridges/codex-cli-bridge/capability.json +2 -2
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/agents/executor.md +40 -0
- package/references/agents/review-lens.md +5 -3
- package/references/modes/agents.md +9 -4
- package/references/modes/procedures.md +21 -8
- package/references/modes/recipes.md +7 -4
- package/references/modes/recommendations.md +3 -1
- package/references/modes/set-recipe.md +23 -6
- package/references/modes/status.md +2 -2
- package/references/modes/upgrade.md +1 -1
- package/references/modes/velocity.md +1 -0
- package/references/shared/composition-handoff.md +1 -1
- package/references/shared/deploy-tail.md +1 -1
- package/references/templates/orchestration.json +1 -1
- package/tools/autonomy-config.mjs +1 -1
- package/tools/bridge-posture.mjs +48 -0
- package/tools/carriers.mjs +152 -0
- package/tools/cheap-agents-read.mjs +234 -0
- package/tools/cheap-agents.mjs +101 -109
- package/tools/commands.mjs +3 -3
- package/tools/detect-backends.mjs +2 -2
- package/tools/direct-run.mjs +9 -0
- package/tools/family-registry.mjs +38 -18
- package/tools/flow-check.mjs +2 -7
- package/tools/fold-scope.mjs +5 -60
- package/tools/grounding.mjs +2 -2
- package/tools/inject-methodology.mjs +4 -0
- package/tools/orchestration-config.mjs +23 -61
- package/tools/orchestration-readme.mjs +70 -0
- package/tools/plan-shape-cli.mjs +112 -0
- package/tools/plan-shape-facts.mjs +204 -0
- package/tools/plan-shape.mjs +348 -0
- package/tools/procedures.mjs +197 -83
- package/tools/recipes.mjs +183 -230
- package/tools/recommendations.mjs +77 -11
- package/tools/renderers.mjs +27 -7
- package/tools/repo-lex.mjs +40 -0
- package/tools/review-roster-resolve.mjs +104 -0
- package/tools/review-roster.mjs +128 -0
- package/tools/review-rounds-cli.mjs +92 -0
- package/tools/review-rounds.mjs +115 -0
- package/tools/review-state.mjs +10 -11
- package/tools/set-recipe-roster.mjs +167 -0
- package/tools/set-recipe.mjs +138 -42
- package/tools/velocity-profile.mjs +8 -22
- package/tools/view-model.mjs +17 -3
|
@@ -51,7 +51,7 @@ import { loadAutonomy, isSparseSeedConfig, AUTONOMY_REL } from './autonomy-confi
|
|
|
51
51
|
import { deriveDoctorPlan } from './autonomy-doctor.mjs';
|
|
52
52
|
import { detectBackends, findOnPath } from './detect-backends.mjs';
|
|
53
53
|
import { isDirectRun } from './direct-run.mjs';
|
|
54
|
-
import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
|
|
54
|
+
import { ACTIVITIES, resolveActivityRecipe, composeReadiness, safeLine } from './recipes.mjs';
|
|
55
55
|
import { surveyFamily, surveyGateHook, surveyAdrLayoutStrict } from './family-registry.mjs';
|
|
56
56
|
import { probeSandboxMasks, needsMasksApply } from './sandbox-masks.mjs';
|
|
57
57
|
import { shellQuoteArg } from './review-state.mjs';
|
|
@@ -82,7 +82,9 @@ import { loadConfig } from './orchestration-config.mjs';
|
|
|
82
82
|
import { DEFAULT_BUNDLE_ROOT } from './bridge-settings-read.mjs';
|
|
83
83
|
import { assertContainedRealPath } from './fs-safe.mjs';
|
|
84
84
|
import { loadWorktreesConfig, resolveProbeDir } from './worktrees.mjs';
|
|
85
|
-
import { preflightCheapAgents } from './cheap-agents.mjs';
|
|
85
|
+
import { preflightCheapAgents, EXECUTOR_VEHICLE } from './cheap-agents.mjs';
|
|
86
|
+
// The vehicle READINESS comes from the read-only half — the same survey `recipes` and `status` read.
|
|
87
|
+
import { surveyExecutorVehicle, readStamp, readFsDeps, WORKFLOW_STAMP, EXPECTED_WORKFLOW_VERSION } from './cheap-agents-read.mjs';
|
|
86
88
|
// The ack store's path, keys, lane registry, fingerprint and guarded reader live in their own leaf
|
|
87
89
|
// (contract: kit/ack-store) — `status` reads the same store, and a second copy is what drifts.
|
|
88
90
|
import {
|
|
@@ -176,6 +178,10 @@ export const SEVERITIES = Object.freeze({
|
|
|
176
178
|
'mcp-channel.masked': SEVERITY_OPTIONAL,
|
|
177
179
|
'mcp-channel.differing': SEVERITY_ATTENTION,
|
|
178
180
|
agents: SEVERITY_OPTIONAL,
|
|
181
|
+
// The `agents` row above is an OFFER to place vehicles. This one reports a config that ALREADY
|
|
182
|
+
// names the subagent carrier over a vehicle that cannot carry it — a configured declaration that
|
|
183
|
+
// is broken, which is what attention means.
|
|
184
|
+
'executor-vehicle': SEVERITY_ATTENTION,
|
|
179
185
|
'family-freshness': SEVERITY_ATTENTION,
|
|
180
186
|
'adr-store-migration': SEVERITY_ATTENTION,
|
|
181
187
|
'sandbox-masks': SEVERITY_OPTIONAL,
|
|
@@ -250,7 +256,8 @@ export const WHATS = Object.freeze({
|
|
|
250
256
|
'mcp-channel': "the kit's read-only MCP server is not registered here — path questions and literal searches stay shell strings",
|
|
251
257
|
'mcp-channel.masked': '{rel} is a {className} here (a sandbox device mask is the usual cause), so the entry to merge is printed instead',
|
|
252
258
|
'mcp-channel.differing': 'an "{server}" MCP entry is already declared here and DIFFERS from the registration this kit copy would write',
|
|
253
|
-
agents: '{n}
|
|
259
|
+
agents: '{n} bundled subagent vehicle(s) not placed (Claude Code) — {ro} read-only, {ex} the full-tool executor; the apply PREVIEWS first',
|
|
260
|
+
'executor-vehicle': '{n} slot(s) configured subagent but the executor vehicle is {state}{reason} — every such slot runs solo until it is usable',
|
|
254
261
|
'family-freshness': '{parts}',
|
|
255
262
|
'adr-store-migration': 'still on the retired 3-tier ADR layout — {shape}',
|
|
256
263
|
'sandbox-masks': '{n} sandbox device mask(s) clutter git status — the managed exclude block is absent or stale',
|
|
@@ -309,7 +316,8 @@ export const BENEFITS = Object.freeze({
|
|
|
309
316
|
'read-lane': 'velocity — pipes/chains of your seeded read-only commands auto-approve instead of prompting (opt-in, conservatively classified)',
|
|
310
317
|
'state-block': 'no silent stalls — a turn ending on «you are not needed», or on work it never started, warns at once instead of waiting to be spotted',
|
|
311
318
|
'mcp-channel': 'velocity — path facts and literal searches arrive as typed tool calls whose arguments are JSON fields, never a shell string',
|
|
312
|
-
agents: 'cost and quiet — mechanical work
|
|
319
|
+
agents: 'cost and quiet — cheap-model mechanical work, no shell on a read-only vehicle (no prompt flood); the executor carries slices you verify',
|
|
320
|
+
'executor-vehicle': 'carrier readiness — a slot you configured subagent dispatches the subagent it names instead of silently running solo',
|
|
313
321
|
'family-freshness': 'currency — placed family members carry the latest shipped fixes and features',
|
|
314
322
|
'adr-store-migration': 'durability — every decision becomes its own file with a generated navigator, instead of one hand-rotated pile',
|
|
315
323
|
'sandbox-masks': 'zero clutter — git status shows only your changes (the review domain already ignores the masks by construction)',
|
|
@@ -367,6 +375,10 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
|
|
|
367
375
|
// which is why the review-recipe benefit is worded for either slot rather than for review alone.
|
|
368
376
|
{ id: 'delegated-execution', mode: 'set-recipe', advisorKey: 'review-recipe' },
|
|
369
377
|
{ id: 'agents', mode: 'agents', advisorKey: 'agents' },
|
|
378
|
+
// A DISTINCT capability from the offer above: the offer converges the moment nothing is left to
|
|
379
|
+
// PLACE, which a customized-but-unusable executor also satisfies — so it can never observe the
|
|
380
|
+
// state that makes a configured subagent carrier run solo.
|
|
381
|
+
{ id: 'executor-vehicle', mode: 'agents', advisorKey: 'executor-vehicle' },
|
|
370
382
|
// Exempt, not un-audited. `acceptEdits` auto-applies Edit/Write and auto-runs mkdir/touch/mv/cp:
|
|
371
383
|
// a TRUST-POSTURE change. The kit never nudges a user toward weakening their approval posture (the
|
|
372
384
|
// same doctrine that keeps sandbox network/filesystem allowances HAND-APPLY); velocity presents the
|
|
@@ -477,11 +489,12 @@ const probeReviewRecipe = ({ root, deps, add, skip }) => {
|
|
|
477
489
|
// The VALIDATED reader (Segment B): a schema-invalid config (unknown activity/slot,
|
|
478
490
|
// bad recipe) throws here and becomes a stated skip — raw JSON.parse would silently ignore it.
|
|
479
491
|
const { config } = loadConfig(root, deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
|
|
480
|
-
const
|
|
492
|
+
const readiness = composeReadiness(root, { ...deps, detect: deps.detect ?? (() => detectBackends(deps)), onDetectError: (err) => { throw err; } });
|
|
481
493
|
const degraded = [];
|
|
482
494
|
for (const [activity, def] of Object.entries(ACTIVITIES)) {
|
|
483
495
|
for (const slot of Object.keys(def.slots)) {
|
|
484
|
-
|
|
496
|
+
if (config?.[activity]?.[slot] === 'subagent') continue; // the executor vehicle has its own probe
|
|
497
|
+
const r = resolveActivityRecipe({ config, readiness, activity, slot });
|
|
485
498
|
if (r.degradedFrom) degraded.push(`${activity}.${slot}: configured ${r.degradedFrom} degrades to ${r.recipe} (${r.reason})`);
|
|
486
499
|
}
|
|
487
500
|
}
|
|
@@ -952,11 +965,9 @@ const probeCheapAgents = ({ root, deps, add, skip }) => {
|
|
|
952
965
|
// skipped the per-vehicle plan the user is supposed to see before consenting.
|
|
953
966
|
// The hidden-mode reconcile rides the detail, never the apply line: it is wrong to run on a
|
|
954
967
|
// VISIBLE deployment, and the apply slot must stay one pure executable command.
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
`node ${q(toolPath('cheap-agents.mjs'))} --cwd ${q(root)}`,
|
|
959
|
-
'agents',
|
|
968
|
+
const executors = toPlace.filter((item) => item.name === EXECUTOR_VEHICLE).length;
|
|
969
|
+
add('agents', fillTemplate(WHATS.agents, { n: toPlace.length, ex: executors, ro: toPlace.length - executors }),
|
|
970
|
+
`node ${q(toolPath('cheap-agents.mjs'))} --cwd ${q(root)}`, 'agents',
|
|
960
971
|
`hidden-mode deployments only: after the --apply the preview prints, run node ${q(toolPath('hide-footprint.mjs'))} --dir ${q(root)} --reconcile so the placed .claude/agents/ stays invisible to git status`,
|
|
961
972
|
);
|
|
962
973
|
} catch (err) {
|
|
@@ -964,6 +975,60 @@ const probeCheapAgents = ({ root, deps, add, skip }) => {
|
|
|
964
975
|
}
|
|
965
976
|
};
|
|
966
977
|
|
|
978
|
+
// The subagent carrier's ONE instrument (contract: kit/carriers). Neither neighbour can report this
|
|
979
|
+
// state: `probeReviewRecipe` skips a slot configured `subagent` by construction, and the offer above
|
|
980
|
+
// judges only what is left to PLACE — a customized-but-broken vehicle leaves nothing to place. So a
|
|
981
|
+
// project could declare the carrier and have every such slot run solo, unseen.
|
|
982
|
+
const VEHICLE_BROKEN_STATES = Object.freeze(['missing', 'unusable']);
|
|
983
|
+
|
|
984
|
+
const writerBlock = (root, deps) => {
|
|
985
|
+
try {
|
|
986
|
+
preflightCheapAgents({ cwd: root }, deps);
|
|
987
|
+
return null;
|
|
988
|
+
} catch (err) {
|
|
989
|
+
return safeLine(err?.message ?? String(err));
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
const probeExecutorVehicle = ({ root, deps, add, skip }) => {
|
|
994
|
+
try {
|
|
995
|
+
// The VALIDATED reader, as probeReviewRecipe uses it: a schema-invalid config is a stated skip,
|
|
996
|
+
// never an item computed over a shape nothing accepted.
|
|
997
|
+
const { config } = loadConfig(root, deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
|
|
998
|
+
const configured = Object.entries(ACTIVITIES).flatMap(([activity, def]) =>
|
|
999
|
+
Object.keys(def.slots).filter((slot) => config?.[activity]?.[slot] === 'subagent'));
|
|
1000
|
+
if (configured.length === 0) return;
|
|
1001
|
+
const survey = (deps.surveyVehicle ?? surveyExecutorVehicle)(root, deps);
|
|
1002
|
+
if (!VEHICLE_BROKEN_STATES.includes(survey.state)) return;
|
|
1003
|
+
const reason = safeLine(survey.reason ?? '');
|
|
1004
|
+
const stamp = readStamp(join(root, WORKFLOW_STAMP), readFsDeps(deps));
|
|
1005
|
+
const preconditions = [
|
|
1006
|
+
...(stamp === EXPECTED_WORKFLOW_VERSION ? [] : [`run /agent-workflow-kit upgrade first (deployment stamp ${stamp ?? 'none'}, expected ${EXPECTED_WORKFLOW_VERSION})`]),
|
|
1007
|
+
...(survey.state === 'unusable' ? [`${reason || 'the vehicle file is unusable'} — fix that`] : []),
|
|
1008
|
+
];
|
|
1009
|
+
// The writer refuses on ANY vehicle path it cannot touch (a symlinked read-only vehicle blocks a
|
|
1010
|
+
// missing executor's placement too), so its own preflight is the last precondition — named once.
|
|
1011
|
+
const blocked = writerBlock(root, deps);
|
|
1012
|
+
if (blocked && blocked !== reason) preconditions.push(`${blocked} — fix that`);
|
|
1013
|
+
const room = templateBudget(WHATS['executor-vehicle']) - String(configured.length).length - survey.state.length - 2;
|
|
1014
|
+
add(
|
|
1015
|
+
'executor-vehicle',
|
|
1016
|
+
fillTemplate(WHATS['executor-vehicle'], {
|
|
1017
|
+
n: configured.length,
|
|
1018
|
+
state: survey.state,
|
|
1019
|
+
reason: reason && room > 0 ? `: ${truncatedTo(reason, room)}` : '',
|
|
1020
|
+
}),
|
|
1021
|
+
// The writer places a MISSING vehicle; an unusable path (a symlink, a read-only customization)
|
|
1022
|
+
// is kept or refused, so that state's apply is a hand-apply precondition before the writer.
|
|
1023
|
+
`${preconditions.length ? `HAND-APPLY: ${preconditions.join('; ')}, then run: ` : ''}node ${q(toolPath('cheap-agents.mjs'))} --apply --cwd ${q(root)}`,
|
|
1024
|
+
'executor-vehicle',
|
|
1025
|
+
`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`,
|
|
1026
|
+
);
|
|
1027
|
+
} catch (err) {
|
|
1028
|
+
skip('executor-vehicle', err);
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
|
|
967
1032
|
const probeFamilyFreshness = ({ deps, add, skip }) => {
|
|
968
1033
|
try {
|
|
969
1034
|
const survey = deps.surveyFamily ?? surveyFamily;
|
|
@@ -1485,6 +1550,7 @@ const PROBES = Object.freeze([
|
|
|
1485
1550
|
probeReadLane,
|
|
1486
1551
|
probeStateBlockHook,
|
|
1487
1552
|
probeCheapAgents,
|
|
1553
|
+
probeExecutorVehicle,
|
|
1488
1554
|
probeFamilyFreshness,
|
|
1489
1555
|
probeAdrStore,
|
|
1490
1556
|
probeMasksItem,
|
package/tools/renderers.mjs
CHANGED
|
@@ -20,6 +20,10 @@ const SETTINGS_COL = 14;
|
|
|
20
20
|
// checks, so a future token joins the render by joining this line.
|
|
21
21
|
const ACTIONABLE_ADR_LAYOUTS = Object.freeze(['old', 'old-unrotated']);
|
|
22
22
|
|
|
23
|
+
const DEGRADE_ARROW = '←';
|
|
24
|
+
const EMPTY_CELL = '—';
|
|
25
|
+
const UNKNOWN_EXECUTOR = 'unknown';
|
|
26
|
+
|
|
23
27
|
const SGR = Object.freeze({ bold: '\x1b[1m', reset: '\x1b[0m' });
|
|
24
28
|
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
25
29
|
export const visibleLength = (s) => s.replace(ANSI_RE, '').length;
|
|
@@ -116,13 +120,22 @@ const renderSettings = (vm, { color, glyph }) => {
|
|
|
116
120
|
const s = vm.project?.settings;
|
|
117
121
|
if (!s) return [];
|
|
118
122
|
const lines = ['', heading(BLOCK_TITLES.settings, color)];
|
|
119
|
-
// recipes —
|
|
123
|
+
// recipes — one line per slot: the effective recipe, where it came from, and the requested value
|
|
124
|
+
// a degrade replaced (a joined single line could not carry three activities and their slots), or a
|
|
125
|
+
// loud error; a detector floor adds a sub-line.
|
|
120
126
|
if (s.recipes?.error) lines.push(` ${pad(SETTINGS_LABELS.recipes, SETTINGS_COL)}error: ${s.recipes.error}`);
|
|
121
127
|
else if (s.recipes) {
|
|
122
|
-
const
|
|
123
|
-
|
|
128
|
+
const rows = s.recipes.pairs.map((p) => {
|
|
129
|
+
const source = p.source ? ` (${p.source})` : '';
|
|
130
|
+
const recovery = p.degradedFrom === 'subagent' ? ' — runs solo until the executor vehicle is usable (/agent-workflow-kit agents)' : '';
|
|
131
|
+
const degraded = p.degradedFrom ? ` ${DEGRADE_ARROW} degraded from ${p.degradedFrom}${recovery}` : '';
|
|
132
|
+
return `${p.key}: ${p.recipe}${source}${degraded}`;
|
|
133
|
+
});
|
|
134
|
+
(rows.length ? rows : [EMPTY_CELL]).forEach((row, i) => {
|
|
135
|
+
lines.push(` ${pad(i === 0 ? SETTINGS_LABELS.recipes : '', SETTINGS_COL)}${row}`);
|
|
136
|
+
});
|
|
124
137
|
if (s.recipes.detectError) {
|
|
125
|
-
lines.push(` ${pad('', SETTINGS_COL)}${glyph.note} couldn't check backends (${s.recipes.detectError}); recipes floored at solo`);
|
|
138
|
+
lines.push(` ${pad('', SETTINGS_COL)}${glyph.note} couldn't check backends (${s.recipes.detectError}); bridge-backed recipes floored at solo; the executor vehicle is unaffected`);
|
|
126
139
|
}
|
|
127
140
|
}
|
|
128
141
|
// attribution — effective includeCoAuthoredBy; a real local override is called out.
|
|
@@ -136,10 +149,17 @@ const renderSettings = (vm, { color, glyph }) => {
|
|
|
136
149
|
else if (s.velocity) {
|
|
137
150
|
lines.push(` ${pad(SETTINGS_LABELS.velocity, SETTINGS_COL)}defaultMode=${String(s.velocity.defaultMode)} · allow project/local=${s.velocity.allow.project}/${s.velocity.allow.local}`);
|
|
138
151
|
}
|
|
139
|
-
// cheap agents — the kit-placed .claude/agents/ vehicles: placed count vs the bundle
|
|
140
|
-
|
|
152
|
+
// cheap agents — the kit-placed .claude/agents/ vehicles: placed count vs the bundle, then the
|
|
153
|
+
// split the subagent carrier turns on — the read-only vehicles and the ONE executor, whose state
|
|
154
|
+
// decides whether a slot configured `subagent` can ride it (an unusable one carries its reason).
|
|
155
|
+
if (s.agents?.error) {
|
|
156
|
+
const partial = s.agents.executor ? ` — executor ${s.agents.executor}${s.agents.executorReason ? ` (${s.agents.executorReason})` : ''}` : '';
|
|
157
|
+
lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}error: ${s.agents.error}${partial}`);
|
|
158
|
+
}
|
|
141
159
|
else if (s.agents) {
|
|
142
|
-
|
|
160
|
+
const reason = s.agents.executorReason ? ` (${s.agents.executorReason})` : '';
|
|
161
|
+
const executor = s.agents.executor == null ? `${UNKNOWN_EXECUTOR} (the installed kit predates the field)` : `${s.agents.executor}${reason}`;
|
|
162
|
+
lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}${s.agents.placed}/${s.agents.bundled} placed — ${s.agents.readOnly} read-only, executor ${executor}`);
|
|
143
163
|
}
|
|
144
164
|
// gate hook — the opt-in PreToolUse gate-approval hook: wired / file placed / declaration present /
|
|
145
165
|
// declared gate count (null → '?' — unknown is shown as unknown, never as a number).
|
package/tools/repo-lex.mjs
CHANGED
|
@@ -20,3 +20,43 @@ export const lexicalRepoRelative = (rel) => {
|
|
|
20
20
|
|
|
21
21
|
// POSIX single-quote for pasteable command rendering (display only — never an execution boundary).
|
|
22
22
|
export const shellQuoteArg = (s) => (/^[A-Za-z0-9_/.\-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
|
|
23
|
+
|
|
24
|
+
// The bytes a settings-level allow rule cannot see past: command separators, redirections,
|
|
25
|
+
// expansions and globs. ONE home for the seeder (velocity-profile.mjs, the allow-rule writer) and
|
|
26
|
+
// the renders that must spell a path in the seeded byte-form (procedures.mjs) — two predicates for
|
|
27
|
+
// one rule drift apart, and a drifted render is a dead allow rule that simply prompts.
|
|
28
|
+
export const SHELL_METACHARACTERS = Object.freeze([
|
|
29
|
+
'&', '|', ';', '<', '>', '$', '`', '(', ')',
|
|
30
|
+
'\n', '\r', '\t', '\\', '{', '}', '*', '?', '#', '~', '!',
|
|
31
|
+
]);
|
|
32
|
+
export const hasShellMetacharacter = (cmd) => SHELL_METACHARACTERS.some((ch) => cmd.includes(ch));
|
|
33
|
+
|
|
34
|
+
// A string a one-line render can carry: no control character (C0 or C1) and no Unicode line or
|
|
35
|
+
// paragraph separator. The receipt-derived fields are REFUSED on it; a plan name is escaped for display.
|
|
36
|
+
const LINE_BREAKING_SOURCE = '[\\p{Cc}\\p{Zl}\\p{Zp}]';
|
|
37
|
+
const LINE_BREAKING = new RegExp(LINE_BREAKING_SOURCE, 'u');
|
|
38
|
+
const LINE_BREAKING_ALL = new RegExp(LINE_BREAKING_SOURCE, 'gu');
|
|
39
|
+
export const isRenderableLine = (value) => typeof value === 'string' && !LINE_BREAKING.test(value);
|
|
40
|
+
export const escapeForDisplay = (value) => String(value).replace(LINE_BREAKING_ALL, (ch) => `\\u${ch.codePointAt(0).toString(16).padStart(4, '0')}`);
|
|
41
|
+
|
|
42
|
+
// The receipt encoder's carriability rule for an artifact path (S21), the JS twin of the wrappers'
|
|
43
|
+
// refuse_uncarriable_artifact_byte: a quote, a backslash, a C0 control or DEL — deliberately NOT
|
|
44
|
+
// \p{Cc} (C1 is the declared residual, and the two normalizations are parity-pinned on this set).
|
|
45
|
+
// ONE home: the round table's refusal names the byte, the advisor's fallback reads the boolean.
|
|
46
|
+
export const uncarriableArtifactByte = (value) =>
|
|
47
|
+
value.includes('"') ? 'a double quote' : value.includes('\\') ? 'a backslash' : /[\u0000-\u001f\u007f]/u.test(value) ? 'a control' : null;
|
|
48
|
+
export const isArtifactPathCarriable = (value) => typeof value === 'string' && uncarriableArtifactByte(value) === null;
|
|
49
|
+
|
|
50
|
+
// Characters that survive whitespace tokenization but break an UNQUOTED byte-exact path rule:
|
|
51
|
+
// shell quoting syntax and glob brackets (SHELL_METACHARACTERS owns the command-level separators/
|
|
52
|
+
// redirections/expansions — `*`/`?` globs included — but not these four).
|
|
53
|
+
const PATH_BREAKING_CHARACTERS = Object.freeze(["'", '"', '[', ']']);
|
|
54
|
+
|
|
55
|
+
// A path token that can be seeded UNQUOTED into a byte-exact allow rule: POSIX-absolute, no
|
|
56
|
+
// whitespace, no shell metacharacter, no quoting/glob syntax.
|
|
57
|
+
export const isSeedablePathToken = (token) =>
|
|
58
|
+
typeof token === 'string' &&
|
|
59
|
+
token.startsWith('/') &&
|
|
60
|
+
!/\s/u.test(token) &&
|
|
61
|
+
!hasShellMetacharacter(token) &&
|
|
62
|
+
!PATH_BREAKING_CHARACTERS.some((ch) => token.includes(ch));
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { safeLine, REVIEW_CMD_ALIASES } from './carriers.mjs';
|
|
2
|
+
import { READY, NEEDS_SKILL, NEEDS_CLI, NEEDS_CREDENTIALS, DEGRADED } from './detect-backends.mjs';
|
|
3
|
+
import { parseSlotToken, validateRoster } from './review-roster.mjs';
|
|
4
|
+
import { refuseDirectRun } from './direct-run.mjs';
|
|
5
|
+
|
|
6
|
+
const READY_STATES = new Set([READY, 'placed', 'customized']);
|
|
7
|
+
|
|
8
|
+
const setupTarget = (entry) => entry?.setupHint?.local ?? entry?.setupHint?.url ?? null;
|
|
9
|
+
|
|
10
|
+
export const remedyFor = (entry = {}) => {
|
|
11
|
+
const setup = setupTarget(entry);
|
|
12
|
+
if (entry.readiness === NEEDS_SKILL) return setup ? `bridge skill not installed — ${safeLine(setup)}` : 'bridge skill not installed — run /agent-workflow-kit setup';
|
|
13
|
+
if (entry.readiness === NEEDS_CLI) return setup ? `the CLI is not installed — ${safeLine(setup)}` : 'the CLI is not installed';
|
|
14
|
+
if (entry.readiness === NEEDS_CREDENTIALS) return 'not signed in (credentials missing)';
|
|
15
|
+
if (entry.readiness === DEGRADED) return 'wrapper not on PATH — run /agent-workflow-kit setup';
|
|
16
|
+
return entry.readiness ? safeLine(entry.readiness) : 'bridge readiness unavailable';
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const lensVehicleSpec = (member) => {
|
|
20
|
+
const parsed = typeof member === 'string' ? parseSlotToken(member) : member;
|
|
21
|
+
if (parsed.kind !== 'lens') throw new Error(`not a lens member: ${parsed.member ?? member}`);
|
|
22
|
+
return {
|
|
23
|
+
stem: parsed.stem,
|
|
24
|
+
template: parsed.template,
|
|
25
|
+
model: parsed.model,
|
|
26
|
+
effort: parsed.effort,
|
|
27
|
+
tools: 'read-only',
|
|
28
|
+
derived: parsed.derived,
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const deriveLensTemplate = (template, spec) => {
|
|
33
|
+
if (!spec?.derived) return String(template);
|
|
34
|
+
return String(template)
|
|
35
|
+
.replace(/^name:.*$/mu, `name: ${spec.stem}`)
|
|
36
|
+
.replace(/^model:.*$/mu, `model: ${spec.model}`)
|
|
37
|
+
.replace(/^effort:.*$/mu, `effort: ${spec.effort}`);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const postureValue = (postures, receiptId) => {
|
|
41
|
+
const value = postures?.[receiptId];
|
|
42
|
+
if (typeof value === 'string') return value;
|
|
43
|
+
return value?.state === 'valid' ? value.posture : null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const bridgeRow = (parsed, readiness, postures) => {
|
|
47
|
+
const alias = REVIEW_CMD_ALIASES[parsed.instrument];
|
|
48
|
+
const entry = readiness.find((candidate) => candidate?.name === alias.backend);
|
|
49
|
+
const state = entry?.readiness ?? NEEDS_SKILL;
|
|
50
|
+
return {
|
|
51
|
+
member: parsed.member,
|
|
52
|
+
stem: parsed.stem,
|
|
53
|
+
kind: 'bridge',
|
|
54
|
+
state,
|
|
55
|
+
reason: state === READY ? null : remedyFor({ ...entry, readiness: state }),
|
|
56
|
+
posture: postureValue(postures, parsed.stem),
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const lensRow = (parsed, surveyLens) => {
|
|
61
|
+
if (typeof surveyLens !== 'function') {
|
|
62
|
+
return {
|
|
63
|
+
member: parsed.member, stem: parsed.stem, kind: 'lens', state: 'unsurveyed',
|
|
64
|
+
reason: null, posture: null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const survey = surveyLens(lensVehicleSpec(parsed)) ?? {};
|
|
68
|
+
const state = survey.state ?? 'unusable';
|
|
69
|
+
const posture = READY_STATES.has(state) && survey.model && survey.effort
|
|
70
|
+
? `model=${safeLine(survey.model)} effort=${safeLine(survey.effort)}`
|
|
71
|
+
: null;
|
|
72
|
+
return {
|
|
73
|
+
member: parsed.member,
|
|
74
|
+
stem: parsed.stem,
|
|
75
|
+
kind: 'lens',
|
|
76
|
+
state,
|
|
77
|
+
reason: survey.reason == null ? null : safeLine(survey.reason),
|
|
78
|
+
posture,
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const resolveRoster = ({ value, readiness = [], surveyLens, postures } = {}) => {
|
|
83
|
+
validateRoster(value);
|
|
84
|
+
return value.map((member) => {
|
|
85
|
+
const parsed = parseSlotToken(member);
|
|
86
|
+
return parsed.kind === 'bridge'
|
|
87
|
+
? bridgeRow(parsed, readiness, postures)
|
|
88
|
+
: lensRow(parsed, surveyLens);
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const isReadyMember = (row) => READY_STATES.has(row.state);
|
|
93
|
+
|
|
94
|
+
export const skippedLine = (row, remedy = row.reason) =>
|
|
95
|
+
`skipped this round — ${safeLine(row.state)}: ${remedy ?? 'no remedy recorded'}`;
|
|
96
|
+
|
|
97
|
+
export const rosterLabel = (roster, { states = true } = {}) => roster.map((row) => {
|
|
98
|
+
if (!states || isReadyMember(row)) return row.member;
|
|
99
|
+
return `${row.member} (${safeLine(row.state)})`;
|
|
100
|
+
}).join(' + ');
|
|
101
|
+
|
|
102
|
+
export const activeLineCell = (roster, options) => `[${rosterLabel(roster, options)}]`;
|
|
103
|
+
|
|
104
|
+
refuseDirectRun(import.meta.url);
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { REVIEW_CMD_ALIASES, receiptIdOfCmd, LENS_VERDICTS } from './carriers.mjs';
|
|
2
|
+
import { KNOWN_BACKENDS } from './detect-backends.mjs';
|
|
3
|
+
import { refuseDirectRun } from './direct-run.mjs';
|
|
4
|
+
|
|
5
|
+
export { LENS_VERDICTS };
|
|
6
|
+
|
|
7
|
+
export const BUNDLED_LENS_TEMPLATES = Object.freeze(['review-lens']);
|
|
8
|
+
const MEMBER_TOKEN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
9
|
+
const SUFFIX_TOKEN = /^[a-z0-9]+$/u;
|
|
10
|
+
|
|
11
|
+
const reviewCommands = () => KNOWN_BACKENDS
|
|
12
|
+
.map((backend) => backend.roleCmds?.review)
|
|
13
|
+
.filter(Boolean);
|
|
14
|
+
|
|
15
|
+
const failRoster = (message, code = null) => Object.assign(new Error(`review roster: ${message}`), { code });
|
|
16
|
+
|
|
17
|
+
export const parseSlotToken = (member) => {
|
|
18
|
+
if (typeof member !== 'string' || member.length === 0) {
|
|
19
|
+
throw failRoster('every member must be a non-empty string');
|
|
20
|
+
}
|
|
21
|
+
const bridgeCommands = reviewCommands();
|
|
22
|
+
if (bridgeCommands.includes(member)) {
|
|
23
|
+
const alias = REVIEW_CMD_ALIASES[member];
|
|
24
|
+
if (!alias) throw failRoster(`bridge member "${member}" has no receipt alias`);
|
|
25
|
+
return {
|
|
26
|
+
member,
|
|
27
|
+
instrument: member,
|
|
28
|
+
kind: 'bridge',
|
|
29
|
+
stem: alias.receiptId,
|
|
30
|
+
model: null,
|
|
31
|
+
effort: null,
|
|
32
|
+
template: null,
|
|
33
|
+
derived: false,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const parts = member.split(':');
|
|
37
|
+
if (parts.length !== 1 && parts.length !== 3) {
|
|
38
|
+
if (bridgeCommands.includes(parts[0])) throw failRoster(`bridge member "${parts[0]}" takes no suffix`);
|
|
39
|
+
throw failRoster(`member "${member}" must be a bare stem or carry both model and effort`);
|
|
40
|
+
}
|
|
41
|
+
const [instrument, model = null, effort = null] = parts;
|
|
42
|
+
if (bridgeCommands.includes(instrument)) throw failRoster(`bridge member "${instrument}" takes no suffix`);
|
|
43
|
+
if (!MEMBER_TOKEN.test(instrument)) throw failRoster(`member instrument "${instrument}" is not a slug`);
|
|
44
|
+
if (model !== null && (!SUFFIX_TOKEN.test(model) || !SUFFIX_TOKEN.test(effort))) {
|
|
45
|
+
throw failRoster(`member "${member}" has an invalid model or effort token`);
|
|
46
|
+
}
|
|
47
|
+
if (model !== null && !BUNDLED_LENS_TEMPLATES.includes(instrument)) {
|
|
48
|
+
throw failRoster(`derived member "${member}" has no bundled lens template`);
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
member,
|
|
52
|
+
instrument,
|
|
53
|
+
kind: 'lens',
|
|
54
|
+
stem: model === null ? instrument : `${instrument}-${model}-${effort}`,
|
|
55
|
+
model,
|
|
56
|
+
effort,
|
|
57
|
+
template: BUNDLED_LENS_TEMPLATES.includes(instrument) ? instrument : null,
|
|
58
|
+
derived: model !== null,
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const validateRoster = (value) => {
|
|
63
|
+
if (!Array.isArray(value)) throw failRoster('value must be an array');
|
|
64
|
+
if (value.length === 0) throw failRoster('array must not be empty');
|
|
65
|
+
const parsed = value.map(parseSlotToken);
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
for (const member of parsed) {
|
|
68
|
+
if (seen.has(member.stem)) throw failRoster(`duplicate resolved stem "${member.stem}"`);
|
|
69
|
+
seen.add(member.stem);
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const expandShorthand = (value) => {
|
|
75
|
+
if (value === 'solo') return { lossless: true, members: [] };
|
|
76
|
+
if (value === 'council') return { lossless: true, members: reviewCommands() };
|
|
77
|
+
return { lossless: false, members: null };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const bridgeMembersOf = (value) => validateRoster(value)
|
|
81
|
+
.filter((member) => parseSlotToken(member).kind === 'bridge');
|
|
82
|
+
|
|
83
|
+
export const lensMembersOf = (value) => {
|
|
84
|
+
const values = Array.isArray(value)
|
|
85
|
+
? [value]
|
|
86
|
+
: Object.values(value ?? {}).flatMap((activity) => (
|
|
87
|
+
Array.isArray(activity?.review) ? [activity.review] : []
|
|
88
|
+
));
|
|
89
|
+
return values.flat().filter((member) => parseSlotToken(member).kind === 'lens');
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const obligationsOf = (value) => {
|
|
93
|
+
const backends = bridgeMembersOf(value).map(receiptIdOfCmd);
|
|
94
|
+
if (backends.length === 0) return { recipe: 'solo', backends, minShip: 0, perBackend: false };
|
|
95
|
+
return {
|
|
96
|
+
recipe: backends.length === 1 ? 'reviewed' : 'council',
|
|
97
|
+
backends,
|
|
98
|
+
minShip: 1,
|
|
99
|
+
perBackend: true,
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const explicitMembers = (value) => {
|
|
104
|
+
if (Array.isArray(value)) return [...value];
|
|
105
|
+
const expanded = expandShorthand(value);
|
|
106
|
+
if (!expanded.lossless) throw failRoster('reviewed has no lossless roster expansion');
|
|
107
|
+
return [...expanded.members];
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const addReviewer = (value, member) => {
|
|
111
|
+
const next = explicitMembers(value);
|
|
112
|
+
const parsed = parseSlotToken(member);
|
|
113
|
+
if (next.some((entry) => parseSlotToken(entry).stem === parsed.stem)) return next;
|
|
114
|
+
next.push(member);
|
|
115
|
+
validateRoster(next);
|
|
116
|
+
return next;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const removeReviewer = (value, member) => {
|
|
120
|
+
const next = explicitMembers(value);
|
|
121
|
+
const stem = parseSlotToken(member).stem;
|
|
122
|
+
const filtered = next.filter((entry) => parseSlotToken(entry).stem !== stem);
|
|
123
|
+
if (next.length > 0 && filtered.length === 0) throw failRoster('removing the last member requires the solo shorthand', 'last-member');
|
|
124
|
+
if (filtered.length > 0) validateRoster(filtered);
|
|
125
|
+
return filtered;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
refuseDirectRun(import.meta.url);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
6
|
+
import { uncarriableArtifactByte } from './repo-lex.mjs';
|
|
7
|
+
import { readReceipts, resolveReceiptsPath } from './core-evidence.mjs';
|
|
8
|
+
import { loadConfig } from './orchestration-config.mjs';
|
|
9
|
+
import { ACTIVITIES, composeReadiness, requiredBackendsForConfiguredRecipe } from './recipes.mjs';
|
|
10
|
+
import { groupRounds, renderRounds } from './review-rounds.mjs';
|
|
11
|
+
|
|
12
|
+
const REVIEW_ACTIVITIES = new Set(Object.entries(ACTIVITIES).filter(([, def]) => Object.hasOwn(def.slots, 'review')).map(([name]) => name));
|
|
13
|
+
|
|
14
|
+
const assertArtifactPathCarryable = (value) => {
|
|
15
|
+
const byte = uncarriableArtifactByte(value);
|
|
16
|
+
if (byte !== null) throw new Error(`artifact path contains ${byte} byte, which the receipt encoder cannot carry`);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const gitTopLevel = (cwd, run = spawnSync) => {
|
|
20
|
+
const result = run('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
|
|
21
|
+
return result.status === 0 ? result.stdout.replace(/\r?\n$/u, '') : null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const normalizeArtifactPath = (path, { cwd = process.cwd(), run = spawnSync, realpath = realpathSync, top = gitTopLevel(cwd, run) } = {}) => {
|
|
25
|
+
assertArtifactPathCarryable(path);
|
|
26
|
+
const absolute = realpath(resolve(cwd, path));
|
|
27
|
+
const root = top === null ? null : realpath(top);
|
|
28
|
+
const rel = root === null ? null : relative(root, absolute);
|
|
29
|
+
const contained = rel !== null && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
30
|
+
const normalized = contained ? rel.split(sep).join('/') : absolute.split(sep).join('/');
|
|
31
|
+
assertArtifactPathCarryable(normalized);
|
|
32
|
+
return normalized;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const parseArgs = (argv) => {
|
|
36
|
+
const values = { activity: 'plan-authoring', artifact: null };
|
|
37
|
+
for (const [index, arg] of argv.entries()) {
|
|
38
|
+
if (arg === '--artifact' || arg === '--activity') {
|
|
39
|
+
const value = argv[index + 1];
|
|
40
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`${arg} needs a value`);
|
|
41
|
+
if (arg === '--artifact') values.artifact = value;
|
|
42
|
+
else values.activity = value;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (index > 0 && (argv[index - 1] === '--artifact' || argv[index - 1] === '--activity')) continue;
|
|
46
|
+
throw new Error(`unknown argument: ${arg}`);
|
|
47
|
+
}
|
|
48
|
+
if (values.artifact === null) throw new Error('--artifact needs a <path>');
|
|
49
|
+
if (!REVIEW_ACTIVITIES.has(values.activity)) throw new Error(`--activity must be one of ${[...REVIEW_ACTIVITIES].join(', ')}, got ${values.activity}`);
|
|
50
|
+
return values;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Every refusal exits 2: usage, an unreadable store, a malformed config, a detection failure.
|
|
54
|
+
export const main = (argv, deps = {}) => {
|
|
55
|
+
try {
|
|
56
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
57
|
+
const env = deps.env ?? process.env;
|
|
58
|
+
const { artifact, activity } = parseArgs(argv);
|
|
59
|
+
const run = deps.run ?? spawnSync;
|
|
60
|
+
const top = gitTopLevel(cwd, run);
|
|
61
|
+
const artifactPath = normalizeArtifactPath(artifact, { cwd, run, top });
|
|
62
|
+
const root = top ?? cwd;
|
|
63
|
+
const { config } = loadConfig(root);
|
|
64
|
+
const detection = { failed: false };
|
|
65
|
+
const readiness = config?.[activity]?.review == null
|
|
66
|
+
? composeReadiness(root, { onDetectError: () => { detection.failed = true; }, ...(deps.readinessDeps ?? {}) })
|
|
67
|
+
: [];
|
|
68
|
+
const obligation = requiredBackendsForConfiguredRecipe({ config, readiness, detectionFailed: detection.failed, activity });
|
|
69
|
+
if (obligation.unknowable) throw new Error('backend detection failed — the review obligation is unknowable');
|
|
70
|
+
const receiptsPath = resolveReceiptsPath(root, env);
|
|
71
|
+
if (receiptsPath === null) throw new Error('the review receipts store cannot be resolved (not a git work tree and AW_REVIEW_RECEIPTS is unset)');
|
|
72
|
+
const read = readReceipts(receiptsPath);
|
|
73
|
+
if (read.readError !== undefined) throw new Error(`the review receipts store is unreadable: ${read.readError}`);
|
|
74
|
+
const selected = read.receipts.filter((receipt) => receipt.artifactPath === artifactPath);
|
|
75
|
+
const pathless = read.receipts.filter((receipt) => ['plan', 'diff'].includes(receipt.artifact) && !Object.hasOwn(receipt, 'artifactPath')).length;
|
|
76
|
+
const grouped = groupRounds(selected, obligation);
|
|
77
|
+
return {
|
|
78
|
+
code: 0,
|
|
79
|
+
stdout: renderRounds({ ...grouped, obligation, artifactPath, pathless, malformed: read.malformed }),
|
|
80
|
+
stderr: '',
|
|
81
|
+
};
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return { code: 2, stdout: '', stderr: `review-rounds: ${err.message}` };
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (isDirectRun(import.meta.url)) {
|
|
88
|
+
const result = main(process.argv.slice(2));
|
|
89
|
+
if (result.stdout) process.stdout.write(`${result.stdout}\n`);
|
|
90
|
+
if (result.stderr) process.stderr.write(`${result.stderr}\n`);
|
|
91
|
+
process.exitCode = result.code;
|
|
92
|
+
}
|