@sabaiway/agent-workflow-kit 10.3.0 → 10.4.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.
@@ -0,0 +1,140 @@
1
+ // carriers.mjs — the activity/slot registry plus the subagent carrier's readiness composition.
2
+ // ACTIVITIES + SLOT_RECIPES are the ONE table: recipes.mjs re-exports them, so no importer's path
3
+ // moves. The carrier half declares the `subagent` recipe, appends the executor vehicle to a
4
+ // readiness array as the single provider of the `carry` role, words the degrade a missing or
5
+ // unusable vehicle causes, and holds the dispatch-form wording every render prints (one source, a
6
+ // red line where it is a red line). Governing contract: docs/ai/specs/kit/carriers.md.
7
+ // Leaf — imports only the direct-run guard (recipes.mjs imports THIS), no fs, nothing on import.
8
+ import { refuseDirectRun } from './direct-run.mjs';
9
+
10
+ // The slot VALUE is the slot's TYPE; SLOT_RECIPES lists the values each type accepts. A `switch`
11
+ // slot is a flag, not a recipe: it resolves outside the recipe lattice and never degrades.
12
+ // `policy` marks an activity that is a SESSION with an autonomy level of its own; a routine chore
13
+ // runs inside such a session and carries none.
14
+ export const ACTIVITIES = {
15
+ 'plan-authoring': { slots: { author: 'carrier', review: 'review' }, policy: true },
16
+ 'plan-execution': { slots: { execute: 'execute', review: 'review' }, policy: true },
17
+ routine: { slots: { carrier: 'carrier', parallel: 'switch' }, policy: false },
18
+ };
19
+
20
+ export const POLICY_ACTIVITIES = Object.fromEntries(
21
+ Object.entries(ACTIVITIES).filter(([, activity]) => activity.policy),
22
+ );
23
+
24
+ export const SLOT_RECIPES = {
25
+ review: ['solo', 'reviewed', 'council'],
26
+ execute: ['solo', 'delegated', 'subagent'],
27
+ carrier: ['solo', 'subagent'],
28
+ switch: ['on', 'off'],
29
+ };
30
+
31
+ export const SWITCH_SLOT = 'switch';
32
+ export const SWITCH_DEFAULT = 'on';
33
+
34
+ export const isSwitchSlot = (slotType) => slotType === SWITCH_SLOT;
35
+
36
+ export const CARRY_ROLE = 'carry';
37
+ export const EXECUTOR_PROVIDER = 'executor';
38
+
39
+ export const SUBAGENT_RECIPE = {
40
+ id: 'subagent',
41
+ title: 'Subagent',
42
+ role: CARRY_ROLE,
43
+ minBackends: 1,
44
+ degradesTo: 'solo',
45
+ summary:
46
+ 'a full-tool frontier subagent from the placed executor vehicle carries a bounded, file-disjoint slice; the orchestrator verifies it and commits.',
47
+ };
48
+
49
+ // The readiness tokens the planner judges on. Mirrored from detect-backends.mjs rather than
50
+ // imported: this module is the leaf the planner itself imports.
51
+ const READY = 'ready';
52
+ const MISSING = 'missing';
53
+
54
+ const VEHICLE_READY_STATES = ['placed', 'customized'];
55
+
56
+ // withVehicle(readiness, survey) → a NEW array carrying the executor as the ONE provider of `carry`.
57
+ // Every caller that hands readiness to the resolver composes it this way; a role-filtered readiness
58
+ // computation is what keeps a placed executor from counting as a ready reviewer.
59
+ export const withVehicle = (readiness = [], survey = null) => [
60
+ ...readiness.filter((entry) => entry?.name !== EXECUTOR_PROVIDER),
61
+ {
62
+ name: EXECUTOR_PROVIDER,
63
+ readiness: VEHICLE_READY_STATES.includes(survey?.state) ? READY : MISSING,
64
+ vehicle: survey,
65
+ },
66
+ ];
67
+
68
+ export const EXECUTOR_APPLY = '/agent-workflow-kit agents';
69
+
70
+ // A survey reason may quote a file the user wrote; it is collapsed to one safe line before it rides
71
+ // the one-line render contracts: no escape sequences, control bytes or line breaks, and none of the
72
+ // characters those renders use as cell structure (the separator, parentheses, the equals sign).
73
+ export const safeLine = (text) => String(text ?? '')
74
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/gu, '')
75
+ .replace(/[·()=]/gu, ' ')
76
+ .replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\s]+/gu, ' ')
77
+ .trim();
78
+
79
+ export const vehicleDegradeReason = (survey, applyHint = EXECUTOR_APPLY) => {
80
+ const state = survey?.state ?? MISSING;
81
+ const reason = safeLine(survey?.reason);
82
+ const detail = reason ? ` (${reason})` : '';
83
+ const remedy = state === 'unusable'
84
+ ? `fix or remove ${safeLine(survey?.rel) || '.claude/agents/executor.md'}, then place it with: ${applyHint}`
85
+ : `place it with: ${applyHint}`;
86
+ return `the executor vehicle is ${state}${detail} — ${remedy}`;
87
+ };
88
+
89
+ // ── the dispatch form: the ONE wording source every render prints ───────────────────
90
+ // Pure constants. `procedures.mjs` prints them for a slot resolved to `subagent`; the wording is a
91
+ // red line, so a render composes these strings and never re-words one.
92
+
93
+ // The slice noun is per ACTIVITY — what a bounded slice IS differs for execution, authoring and a
94
+ // routine chore, while the four dispatch lines below are shared by all three.
95
+ export const SLICE_BY_ACTIVITY = {
96
+ 'plan-authoring': 'a slice is a brief naming the goal, the governing spec(s) and the ledger constraints; the subagent drafts the plan or the contract from it, and the orchestrator reviews the draft as its own',
97
+ 'plan-execution': 'a slice is a set of file-disjoint ledger rows; wording is copied verbatim where wording is a red line',
98
+ routine: "a slice is a bounded mechanical task; a read-only one (a sweep, gate triage) rides its placed read-only vehicle, or is carried solo with a stated reason when that vehicle is absent; a write-capable one (a regeneration, a fixture build) rides the executor; the changelog stays the orchestrator's",
99
+ };
100
+
101
+ export const VEHICLE_STATE_TOKEN = '<state>';
102
+
103
+ export const DISPATCH_LINES = [
104
+ `dispatch: the executor vehicle (.claude/agents/executor.md — ${VEHICLE_STATE_TOKEN}), in the background`,
105
+ 'the orchestrator verifies every returned slice by running its suites itself',
106
+ 'the subagent is never told to commit, never a review backend, never a bridge substitute',
107
+ 'honest limit: a Claude Code lane — on a host that cannot dispatch the vehicle, follow this form by hand and say so',
108
+ ];
109
+
110
+ export const PARALLEL_SOLO_NOTE = '(no effect while the carrier is solo)';
111
+
112
+ export const PARALLEL_LINES = {
113
+ on: 'parallel: on — file-disjoint slices dispatch concurrently',
114
+ off: 'parallel: off — one slice at a time',
115
+ };
116
+
117
+ // The slot TYPES a subagent can carry — computed from the one value table, never a second list.
118
+ export const SUBAGENT_SLOT_TYPES = Object.entries(SLOT_RECIPES)
119
+ .filter(([, values]) => values.includes(SUBAGENT_RECIPE.id))
120
+ .map(([slotType]) => slotType);
121
+
122
+ // dispatchForm({ activity, slot, state }) → the lines a `subagent`-resolved slot renders: the
123
+ // activity's slice sentence, then the four shared lines with the surveyed vehicle state filled in.
124
+ // A slot whose type cannot hold `subagent` (a review slot) and an unknown activity render nothing.
125
+ export const dispatchForm = ({ activity, slot, state } = {}) => {
126
+ const slice = SLICE_BY_ACTIVITY[activity];
127
+ const slotType = ACTIVITIES[activity]?.slots?.[slot];
128
+ if (!slice || !SUBAGENT_SLOT_TYPES.includes(slotType)) return [];
129
+ return [slice, ...DISPATCH_LINES.map((line) => line.replace(VEHICLE_STATE_TOKEN, state ?? MISSING))];
130
+ };
131
+
132
+ // parallelLine({ value, carrier }) → the `routine` switch line. The concurrency claim is TRUE only
133
+ // while the effective carrier is `subagent`; under a solo carrier the flag states its own inertness.
134
+ export const parallelLine = ({ value, carrier } = {}) => {
135
+ const inert = carrier !== SUBAGENT_RECIPE.id ? ` ${PARALLEL_SOLO_NOTE}` : '';
136
+ if (value === 'on') return inert ? `parallel: on${inert}` : PARALLEL_LINES.on;
137
+ return `${PARALLEL_LINES.off}${inert}`;
138
+ };
139
+
140
+ refuseDirectRun(import.meta.url);
@@ -0,0 +1,172 @@
1
+ // cheap-agents-read.mjs — the READ-ONLY core of the subagent-vehicle surface: the bundle, the
2
+ // placement plan and the executor vehicle's readiness. Split from cheap-agents.mjs (the writer) so
3
+ // the read-only advisor graph (procedures -> recipes) reaches these facts WITHOUT importing a module
4
+ // that can create `.claude/agents/` — by construction, pinned by the read-graph purity walk.
5
+
6
+ import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { refuseDirectRun } from './direct-run.mjs';
10
+
11
+ const HERE = dirname(fileURLToPath(import.meta.url));
12
+
13
+ export const AGENTS_DIR = '.claude/agents';
14
+ export const CLAUDE_DIR = '.claude';
15
+ export const WORKFLOW_STAMP = 'docs/ai/.workflow-version';
16
+ export const EXPECTED_WORKFLOW_VERSION = '3.0.0';
17
+ export const BUNDLED_AGENTS_DIR = resolve(HERE, '..', 'references', 'agents');
18
+
19
+ export const UTF8 = 'utf8';
20
+ const ERROR_PREFIX = '[agent-workflow-kit]';
21
+ const EXIT_PRECONDITION = 1;
22
+
23
+ export const CHEAP_AGENTS_STAMP = 'CHEAP_AGENTS_STAMP';
24
+ export const CHEAP_AGENTS_SYMLINK = 'CHEAP_AGENTS_SYMLINK';
25
+ export const CHEAP_AGENTS_BUNDLE = 'CHEAP_AGENTS_BUNDLE';
26
+
27
+ export const makeCheapAgentsError = (code, message) =>
28
+ Object.assign(new Error(`${ERROR_PREFIX} ${message}`), { name: 'CheapAgentsError', code, exitCode: EXIT_PRECONDITION });
29
+
30
+ // The injectable READ surface — four functions, none of which can create or modify a file. The
31
+ // writer shell adds its own mkdir/writeFile pair rather than widening this one.
32
+ export const readFsDeps = (deps = {}) => ({
33
+ exists: deps.exists ?? existsSync,
34
+ lstat: deps.lstat ?? lstatSync,
35
+ readFile: deps.readFile ?? readFileSync,
36
+ readdir: deps.readdir ?? readdirSync,
37
+ });
38
+
39
+ export const lstatNoFollow = (absPath, fs) => {
40
+ try {
41
+ return fs.lstat(absPath);
42
+ } catch (err) {
43
+ if (err && err.code === 'ENOENT') return null;
44
+ throw err;
45
+ }
46
+ };
47
+
48
+ // ── the bundle (the kit's own references/agents/) ─────────────────────────────────────
49
+
50
+ export const readBundledAgents = (deps = {}) => {
51
+ const fs = readFsDeps(deps);
52
+ const bundleDir = deps.bundleDir ?? BUNDLED_AGENTS_DIR;
53
+ let names;
54
+ try {
55
+ names = fs.readdir(bundleDir);
56
+ } catch (err) {
57
+ throw makeCheapAgentsError(CHEAP_AGENTS_BUNDLE, `bundled agents dir unreadable (${err.code ?? err.message}): ${bundleDir}`);
58
+ }
59
+ const templates = names
60
+ .filter((name) => name.endsWith('.md'))
61
+ .sort()
62
+ .map((name) => ({ name, content: fs.readFile(join(bundleDir, name), UTF8) }));
63
+ if (templates.length === 0) {
64
+ throw makeCheapAgentsError(CHEAP_AGENTS_BUNDLE, `no bundled agent templates found in ${bundleDir} — the kit install is incomplete`);
65
+ }
66
+ return templates;
67
+ };
68
+
69
+ // ── the preflight reads (velocity discipline: symlink-safe, stamp read, no writes) ────
70
+
71
+ export const readStamp = (absPath, fs) => {
72
+ try {
73
+ if (!fs.exists(absPath)) return null;
74
+ const stamp = String(fs.readFile(absPath, UTF8)).trim();
75
+ return stamp.length ? stamp : null;
76
+ } catch {
77
+ return null; // unreadable stamp == not a valid deployment stamp (apply STOPs; dry-run reports)
78
+ }
79
+ };
80
+
81
+ export const assertDirSafe = (absPath, relPath, fs) => {
82
+ const stat = lstatNoFollow(absPath, fs);
83
+ if (stat === null) return { absent: true };
84
+ if (stat.isSymbolicLink()) throw makeCheapAgentsError(CHEAP_AGENTS_SYMLINK, `${relPath} is a symlink — refusing to write through it`);
85
+ if (!stat.isDirectory()) throw makeCheapAgentsError(CHEAP_AGENTS_SYMLINK, `${relPath} exists but is not a directory — refusing to write through it`);
86
+ return { absent: false };
87
+ };
88
+
89
+ // Per-template placement plan: place | already-current | customized-preserved (never clobbered).
90
+ export const planPlacement = (templates, projectDir, deps = {}) => {
91
+ const fs = readFsDeps(deps);
92
+ return templates.map((template) => {
93
+ const rel = `${AGENTS_DIR}/${template.name}`;
94
+ const abs = join(projectDir, AGENTS_DIR, template.name);
95
+ const stat = lstatNoFollow(abs, fs);
96
+ if (stat === null) return { ...template, rel, abs, action: 'place' };
97
+ if (stat.isSymbolicLink() || !stat.isFile()) {
98
+ throw makeCheapAgentsError(CHEAP_AGENTS_SYMLINK, `${rel} exists but is not a regular file — refusing to touch it`);
99
+ }
100
+ const existing = fs.readFile(abs, UTF8);
101
+ if (existing === template.content) return { ...template, rel, abs, action: 'already-current' };
102
+ return { ...template, rel, abs, action: 'customized-preserved', existing };
103
+ });
104
+ };
105
+
106
+ // ── the executor vehicle's readiness (the subagent carrier's one instrument) ──────────
107
+
108
+ export const EXECUTOR_VEHICLE = 'executor.md';
109
+ export const EXECUTOR_VEHICLE_REL = `${AGENTS_DIR}/${EXECUTOR_VEHICLE}`;
110
+
111
+ // The YAML subset a vehicle's frontmatter is read with: a bare scalar, a single- or double-quoted
112
+ // scalar, or a flow sequence; an unquoted ` #comment` and surrounding whitespace are dropped first.
113
+ const cleanValue = (raw) => {
114
+ const noComment = String(raw ?? '').replace(/^((?:[^"'#]|"[^"]*"|'[^']*')*?)\s+#.*$/u, '$1').trim();
115
+ return noComment.replace(/^(["'])(.*)\1$/u, '$2').replace(/^\[(.*)\]$/u, '$1').trim();
116
+ };
117
+
118
+ // The block-sequence items under the `tools:` key: `- item` lines indented deeper than the key,
119
+ // with blank and comment lines allowed between them; the first other line ends the list.
120
+ const blockItems = (frontmatter) => {
121
+ const lines = frontmatter.split('\n');
122
+ const start = lines.findIndex((line) => /^tools:/u.test(line));
123
+ if (start === -1) return [];
124
+ const keyIndent = lines[start].match(/^[ \t]*/u)[0].length;
125
+ const items = [];
126
+ for (const line of lines.slice(start + 1)) {
127
+ if (/^[ \t]*(#.*)?$/u.test(line)) continue;
128
+ const item = line.match(/^([ \t]*)-[ \t]*(.*)$/u);
129
+ if (!item || item[1].length <= keyIndent) break;
130
+ const value = cleanValue(item[2]);
131
+ if (value) items.push(value);
132
+ }
133
+ return items;
134
+ };
135
+
136
+ const executorFrontmatterRefusal = (content) => {
137
+ const frontmatter = String(content).replace(/\r\n/gu, '\n').match(/^---\n([\s\S]*?)\n---(?:\n|$)/u)?.[1] ?? '';
138
+ for (const key of ['name', 'tools']) {
139
+ if ((frontmatter.match(new RegExp(`^${key}:`, 'gmu')) ?? []).length > 1) return `duplicate \`${key}:\` key in the frontmatter`;
140
+ }
141
+ if (cleanValue(frontmatter.match(/^name:(.*)$/mu)?.[1]) !== 'executor') return 'frontmatter does not declare `name: executor`';
142
+ if (!/^tools:/mu.test(frontmatter)) return null;
143
+ const inline = cleanValue(frontmatter.match(/^tools:(.*)$/mu)?.[1]);
144
+ const listed = inline || blockItems(frontmatter).join(', ');
145
+ if (!listed) return 'tools: is empty — grant a list that includes Bash, or drop the line';
146
+ const granted = listed.split(',').map((tool) => cleanValue(tool));
147
+ return granted.includes('Bash') ? null : `tools: ${listed} is read-only`;
148
+ };
149
+
150
+ // A symlinked, non-regular or unreadable vehicle is a STATE the carrier degrades on, so this survey
151
+ // answers it instead of throwing: its callers compose the answer into a readiness array.
152
+ export const surveyExecutorVehicle = (projectDir, deps = {}) => {
153
+ const rel = EXECUTOR_VEHICLE_REL;
154
+ try {
155
+ const template = readBundledAgents(deps).find((item) => item.name === EXECUTOR_VEHICLE);
156
+ if (!template) throw makeCheapAgentsError(CHEAP_AGENTS_BUNDLE, `${EXECUTOR_VEHICLE} is missing from the bundle`);
157
+ const fs = readFsDeps(deps);
158
+ assertDirSafe(join(projectDir, CLAUDE_DIR), CLAUDE_DIR, fs);
159
+ assertDirSafe(join(projectDir, AGENTS_DIR), AGENTS_DIR, fs);
160
+ const [placement] = planPlacement([template], projectDir, deps);
161
+ if (placement.action === 'place') return { state: 'missing', reason: null, rel };
162
+ if (placement.action === 'already-current') return { state: 'placed', reason: null, rel };
163
+ const refusal = executorFrontmatterRefusal(placement.existing);
164
+ return refusal === null
165
+ ? { state: 'customized', reason: null, rel }
166
+ : { state: 'unusable', reason: refusal, rel };
167
+ } catch (err) {
168
+ return { state: 'unusable', reason: err?.message ?? String(err), rel };
169
+ }
170
+ };
171
+
172
+ refuseDirectRun(import.meta.url);
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env node
2
- // cheap-agents.mjs — the onboarding writer behind `/agent-workflow-kit agents`: places the
3
- // bundled CHEAP-LANE subagent definitions (references/agents/*.md haiku/low-effort, bounded
4
- // read-only tools) into a project's .claude/agents/ so mechanical work (sweeps, changelog
5
- // skeletons, gate triage) stops running on a frontier model by default.
2
+ // cheap-agents.mjs — the onboarding writer behind `/agent-workflow-kit agents`: places the bundled
3
+ // subagent definitions (references/agents/*.md) into a project's .claude/agents/. FOUR vehicles
4
+ // grant NO shell three ride the cheap lane (haiku/low-effort) so mechanical work (sweeps,
5
+ // changelog skeletons, gate triage) stops running on a frontier model by default, and review-lens
6
+ // is the read-only review opinion. The fifth, `executor`, is the ONE full-tool vehicle: dispatched
7
+ // only for a bounded execution, authoring, or write-capable routine slice the orchestrator verifies, never for read-only work, and it
8
+ // never commits. `surveyExecutorVehicle` is that vehicle's readiness, for the subagent carrier.
6
9
  //
7
10
  // The family's second `.claude/` writer, the velocity-profile.mjs writer discipline verbatim:
8
11
  // • preview-then-mutate — `--dry-run` is the DEFAULT and writes nothing; `--apply` writes;
@@ -25,29 +28,48 @@
25
28
  // state, not an error); 1 precondition STOP (stamp, symlink, missing bundle); 2 usage.
26
29
  // Dependency-free, Node >= 22. No side effects on import.
27
30
 
28
- import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
29
- import { join, resolve, dirname } from 'node:path';
31
+ import { mkdirSync, writeFileSync } from 'node:fs';
32
+ import { join } from 'node:path';
30
33
  import { fileURLToPath } from 'node:url';
31
34
  import { isDirectRun } from './direct-run.mjs';
32
35
  import { shellQuoteArg } from './repo-lex.mjs';
33
-
34
- const HERE = dirname(fileURLToPath(import.meta.url));
35
-
36
- export const AGENTS_DIR = '.claude/agents';
37
- export const CLAUDE_DIR = '.claude';
38
- export const WORKFLOW_STAMP = 'docs/ai/.workflow-version';
39
- export const EXPECTED_WORKFLOW_VERSION = '3.0.0';
40
- export const BUNDLED_AGENTS_DIR = resolve(HERE, '..', 'references', 'agents');
36
+ // The READ core, never a second copy: the bundle, the placement plan and the executor survey live
37
+ // there so the read-only advisor graph can reach them without reaching this writer.
38
+ import {
39
+ AGENTS_DIR,
40
+ CLAUDE_DIR,
41
+ WORKFLOW_STAMP,
42
+ EXPECTED_WORKFLOW_VERSION,
43
+ UTF8,
44
+ CHEAP_AGENTS_STAMP,
45
+ makeCheapAgentsError,
46
+ readFsDeps,
47
+ readBundledAgents,
48
+ readStamp,
49
+ assertDirSafe,
50
+ planPlacement,
51
+ } from './cheap-agents-read.mjs';
52
+
53
+ export {
54
+ AGENTS_DIR,
55
+ CLAUDE_DIR,
56
+ WORKFLOW_STAMP,
57
+ EXPECTED_WORKFLOW_VERSION,
58
+ BUNDLED_AGENTS_DIR,
59
+ CHEAP_AGENTS_STAMP,
60
+ CHEAP_AGENTS_SYMLINK,
61
+ CHEAP_AGENTS_BUNDLE,
62
+ makeCheapAgentsError,
63
+ readBundledAgents,
64
+ planPlacement,
65
+ surveyExecutorVehicle,
66
+ EXECUTOR_VEHICLE,
67
+ EXECUTOR_VEHICLE_REL,
68
+ } from './cheap-agents-read.mjs';
41
69
 
42
70
  const EXIT_OK = 0;
43
71
  const EXIT_PRECONDITION = 1;
44
72
  const EXIT_USAGE = 2;
45
- const UTF8 = 'utf8';
46
- const ERROR_PREFIX = '[agent-workflow-kit]';
47
-
48
- export const CHEAP_AGENTS_STAMP = 'CHEAP_AGENTS_STAMP';
49
- export const CHEAP_AGENTS_SYMLINK = 'CHEAP_AGENTS_SYMLINK';
50
- export const CHEAP_AGENTS_BUNDLE = 'CHEAP_AGENTS_BUNDLE';
51
73
 
52
74
  // The fallback-lens contract, formalized where it lives (flow-orchestration #15/#3, Phase 4.3):
53
75
  // the internal-attestation evaluation consumes this sentence — a lens set claiming a configured
@@ -57,95 +79,24 @@ export const FALLBACK_LENS_ADDITIONAL_ONLY = 'review-lens is an ADDITIONAL read-
57
79
 
58
80
  const USAGE = `usage: cheap-agents [--dry-run | --apply] [--cwd <dir>] [--help]
59
81
 
60
- Places the bundled READ-ONLY subagent definitions into the project's ${AGENTS_DIR}/. No vehicle
61
- grants a shell: three ride a cheap model (haiku/low) for mechanical work — extraction sweeps,
62
- changelog fact-skeletons, gate triage — and review-lens is a read-only REVIEW vehicle on a
63
- review-capable model. Default is --dry-run (a preview; writes nothing). --apply writes.
82
+ Places the bundled subagent definitions into the project's ${AGENTS_DIR}/. Four vehicles grant NO
83
+ shell: three ride a cheap model (haiku/low) for mechanical work — extraction sweeps, changelog
84
+ fact-skeletons, gate triage — and review-lens is a read-only REVIEW vehicle on a review-capable
85
+ model. The fifth, executor, is the ONE full-tool vehicle: dispatched only for a bounded execution,
86
+ authoring, or write-capable routine slice the orchestrator verifies, never for read-only work, and
87
+ it never commits.
88
+ Default is --dry-run (a preview; writes nothing). --apply writes.
64
89
  An existing file with DIFFERENT content is preserved and reported, never overwritten.`;
65
90
 
66
91
  export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
67
92
 
68
- export const makeCheapAgentsError = (code, message) =>
69
- Object.assign(new Error(`${ERROR_PREFIX} ${message}`), { name: 'CheapAgentsError', code, exitCode: EXIT_PRECONDITION });
70
-
71
- const fsDeps = (deps = {}) => ({
72
- exists: deps.exists ?? existsSync,
73
- lstat: deps.lstat ?? lstatSync,
93
+ const writeFsDeps = (deps = {}) => ({
74
94
  mkdir: deps.mkdir ?? mkdirSync,
75
- readFile: deps.readFile ?? readFileSync,
76
95
  writeFile: deps.writeFile ?? writeFileSync,
77
- readdir: deps.readdir ?? readdirSync,
78
96
  });
79
97
 
80
- const lstatNoFollow = (absPath, fs) => {
81
- try {
82
- return fs.lstat(absPath);
83
- } catch (err) {
84
- if (err && err.code === 'ENOENT') return null;
85
- throw err;
86
- }
87
- };
88
-
89
- // ── the bundle (the kit's own references/agents/) ─────────────────────────────────────
90
-
91
- export const readBundledAgents = (deps = {}) => {
92
- const fs = fsDeps(deps);
93
- const bundleDir = deps.bundleDir ?? BUNDLED_AGENTS_DIR;
94
- let names;
95
- try {
96
- names = fs.readdir(bundleDir);
97
- } catch (err) {
98
- throw makeCheapAgentsError(CHEAP_AGENTS_BUNDLE, `bundled agents dir unreadable (${err.code ?? err.message}): ${bundleDir}`);
99
- }
100
- const templates = names
101
- .filter((name) => name.endsWith('.md'))
102
- .sort()
103
- .map((name) => ({ name, content: fs.readFile(join(bundleDir, name), UTF8) }));
104
- if (templates.length === 0) {
105
- throw makeCheapAgentsError(CHEAP_AGENTS_BUNDLE, `no bundled agent templates found in ${bundleDir} — the kit install is incomplete`);
106
- }
107
- return templates;
108
- };
109
-
110
- // ── preflight (velocity discipline: symlink-safe, stamp read, no writes) ──────────────
111
-
112
- const readStamp = (absPath, fs) => {
113
- try {
114
- if (!fs.exists(absPath)) return null;
115
- const stamp = String(fs.readFile(absPath, UTF8)).trim();
116
- return stamp.length ? stamp : null;
117
- } catch {
118
- return null; // unreadable stamp == not a valid deployment stamp (apply STOPs; dry-run reports)
119
- }
120
- };
121
-
122
- const assertDirSafe = (absPath, relPath, fs) => {
123
- const stat = lstatNoFollow(absPath, fs);
124
- if (stat === null) return { absent: true };
125
- if (stat.isSymbolicLink()) throw makeCheapAgentsError(CHEAP_AGENTS_SYMLINK, `${relPath} is a symlink — refusing to write through it`);
126
- if (!stat.isDirectory()) throw makeCheapAgentsError(CHEAP_AGENTS_SYMLINK, `${relPath} exists but is not a directory — refusing to write through it`);
127
- return { absent: false };
128
- };
129
-
130
- // Per-template placement plan: place | already-current | customized-preserved (never clobbered).
131
- export const planPlacement = (templates, projectDir, deps = {}) => {
132
- const fs = fsDeps(deps);
133
- return templates.map((template) => {
134
- const rel = `${AGENTS_DIR}/${template.name}`;
135
- const abs = join(projectDir, AGENTS_DIR, template.name);
136
- const stat = lstatNoFollow(abs, fs);
137
- if (stat === null) return { ...template, rel, abs, action: 'place' };
138
- if (stat.isSymbolicLink() || !stat.isFile()) {
139
- throw makeCheapAgentsError(CHEAP_AGENTS_SYMLINK, `${rel} exists but is not a regular file — refusing to touch it`);
140
- }
141
- const existing = fs.readFile(abs, UTF8);
142
- if (existing === template.content) return { ...template, rel, abs, action: 'already-current' };
143
- return { ...template, rel, abs, action: 'customized-preserved' };
144
- });
145
- };
146
-
147
98
  export const preflightCheapAgents = ({ cwd }, deps = {}) => {
148
- const fs = fsDeps(deps);
99
+ const fs = readFsDeps(deps);
149
100
  const projectDir = cwd ?? process.cwd();
150
101
  const templates = readBundledAgents(deps);
151
102
  const stamp = readStamp(join(projectDir, WORKFLOW_STAMP), fs);
@@ -159,7 +110,7 @@ export const preflightCheapAgents = ({ cwd }, deps = {}) => {
159
110
  // ── the writer ────────────────────────────────────────────────────────────────────────
160
111
 
161
112
  export const writeCheapAgents = ({ cwd, dryRun = true } = {}, deps = {}) => {
162
- const fs = fsDeps(deps);
113
+ const fs = writeFsDeps(deps);
163
114
  const preflight = preflightCheapAgents({ cwd }, deps);
164
115
  if (dryRun) return { wrote: false, dryRun: true, ...preflight };
165
116
 
@@ -186,8 +137,8 @@ const ACTION_LABEL = {
186
137
  export const formatResult = (result) => {
187
138
  const lines = [
188
139
  result.dryRun
189
- ? 'agent-workflow read-only subagents — DRY RUN (no changes)'
190
- : 'agent-workflow read-only subagents — APPLY',
140
+ ? 'agent-workflow subagent vehicles — DRY RUN (no changes)'
141
+ : 'agent-workflow subagent vehicles — APPLY',
191
142
  ];
192
143
  for (const item of result.plan) {
193
144
  const verb = result.dryRun && item.action === 'place' ? 'would place' : ACTION_LABEL[item.action];
@@ -197,8 +148,9 @@ export const formatResult = (result) => {
197
148
  lines.push(`note: no current deployment stamp found (${result.stamp ?? 'none'}) — --apply will refuse until init/upgrade runs.`);
198
149
  }
199
150
  lines.push(
200
- 'the vehicles are Claude Code subagents with READ-ONLY tools and NO shell — so a fan-out can never turn into a wave of approval prompts.',
201
- `three ride the cheap lane (model: haiku, effort: low) for mechanical work; ${FALLBACK_LENS_ADDITIONAL_ONLY} Writing code and running gates stay on your main lane.`,
151
+ 'four vehicles are Claude Code subagents with READ-ONLY tools and NO shell — so a fan-out can never turn into a wave of approval prompts.',
152
+ `three of those ride the cheap lane (model: haiku, effort: low) for mechanical work; ${FALLBACK_LENS_ADDITIONAL_ONLY}`,
153
+ 'executor is the one FULL-TOOL vehicle: dispatched only for a bounded execution, authoring, or write-capable routine slice the orchestrator verifies, never for read-only work, and it never commits.',
202
154
  );
203
155
  // A preview must print the EXACT command that applies it. The advisor renders this dry-run as an
204
156
  // item's one-liner, and that flow's contract is "run the printed command, no improvisation" — a
@@ -131,7 +131,7 @@ const CATALOG = [
131
131
  invocation: invocationOf('agents'),
132
132
  group: 'Configure',
133
133
  kind: WRITER,
134
- oneLine: 'Place bundled READ-ONLY subagent definitions, none of them granted a shell — three cheap-model ones for mechanical work (sweeps, changelog skeletons, gate triage) and a review lens (Claude Code; opt-in; preview first).',
134
+ oneLine: 'Place the bundled subagent vehicles: four read-only ones, none granted a shell — three cheap-model ones for mechanical work (sweeps, changelog skeletons, gate triage) and a review lens — plus the one full-tool executor that carries bounded slices you verify (Claude Code; opt-in; preview first).',
135
135
  },
136
136
  {
137
137
  key: 'hook',
@@ -180,7 +180,7 @@ const CATALOG = [
180
180
  invocation: invocationOf('recipes'),
181
181
  group: 'Orchestrate',
182
182
  kind: READ_ONLY,
183
- oneLine: 'See the orchestration recipes (Solo / Reviewed / Council / Delegated), which one fits this environment, and the configured per-activity line to paste at session start.',
183
+ oneLine: 'See the orchestration recipes (Solo / Reviewed / Council / Delegated / Subagent), which one fits this environment, and the configured per-activity line to paste at session start.',
184
184
  },
185
185
  {
186
186
  key: 'procedures',
@@ -341,7 +341,7 @@ const TUNE_TAIL = Object.freeze([
341
341
  '',
342
342
  'Tune — opt-in accelerators (consent-first: every writer previews before writing; nothing runs without your yes)',
343
343
  ` ${BARE_INVOCATION} velocity routine read-only commands stop prompting (incl. the --kit-tools tier for the kit's own read-only tools)`,
344
- ` ${BARE_INVOCATION} agents shell-free subagents: cheap ones take the mechanical work, a review lens gives an extra read-only opinion`,
344
+ ` ${BARE_INVOCATION} agents subagent vehicles: four read-only (cheap ones take the mechanical work, a review lens gives another opinion) + the one full-tool executor that carries bounded slices you verify`,
345
345
  ` ${BARE_INVOCATION} gates run your declared gates (docs/ai/gates.json) as one batch; its guide also offers the consent-gated seeding preview — writes only on your yes`,
346
346
  ` ${BARE_INVOCATION} hook auto-approve exactly your declared gate commands (byte-exact matches only)`,
347
347
  ` ${BARE_INVOCATION} set-recipe put a ready review backend to work on plans and diffs`,
@@ -56,6 +56,12 @@ export const LIBRARY_ONLY_MODULES = Object.freeze({
56
56
  // Named by references/modes/mcp.md as the read half the advisor and uninstall ask. It only ever
57
57
  // REPORTS; the command that acts on what it reports is the mode itself.
58
58
  'mcp-registration.mjs': '/agent-workflow-kit mcp',
59
+ // Named by references/modes/set-recipe.md as the activity/slot registry; the command that shows
60
+ // the recipes it defines, resolved for this environment, is the recipes advisor.
61
+ 'carriers.mjs': '/agent-workflow-kit recipes',
62
+ // The READ core of the subagent-vehicle surface: it sits one name away from the writer
63
+ // references/modes/agents.md DOES name, and reaching for it is reaching for the agents mode.
64
+ 'cheap-agents-read.mjs': '/agent-workflow-kit agents',
59
65
  });
60
66
 
61
67
  // The frozen refusal line. One line, names the module, names the command.