@sabaiway/agent-workflow-kit 1.17.0 → 1.18.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,297 @@
1
+ #!/usr/bin/env node
2
+ // orchestration-config.mjs — the schema / read / pure-transform core for the per-project
3
+ // orchestration config (docs/ai/orchestration.json). It is the SINGLE source of the config contract:
4
+ //
5
+ // loadConfig / validateConfig / CONFIG_REL — the strict-JSON reader the READ-ONLY surfaces share
6
+ // (procedures.mjs re-exports CONFIG_REL; family-registry
7
+ // + procedures import loadConfig from here).
8
+ // parseOp / assertSlotRecipe — the TYPED op parser + the ONE slot/recipe validity
9
+ // table the set-recipe writer AND procedures --override
10
+ // both reuse (drift-guarded: one accept/reject table).
11
+ // applySetOps / serializeConfig — the PURE merge + the canonical (2-space, _README-first)
12
+ // serializer the writer commits.
13
+ // normalizeCanonical / refreshIfCanonical — the PURE "replace IFF it matches a known prior
14
+ // canonical, else preserve a customization" helper shared
15
+ // by the _README refresh and the injected-slot refresh.
16
+ //
17
+ // This module performs NO filesystem WRITES — only reads (loadConfig). The single fs-writer lives in
18
+ // orchestration-write.mjs, which procedures.mjs never imports, so "procedures never reaches a writer"
19
+ // is structurally true. Pure-where-possible (fs injectable), dependency-free, Node >= 18. No side
20
+ // effects on import.
21
+
22
+ import { readFileSync, lstatSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { ACTIVITIES, SLOT_RECIPES } from './recipes.mjs';
25
+
26
+ // The hand-editable / agent-writable, per-project config (strict JSON). cwd-relative — the error prefix
27
+ // uses this rel path so a user sees a path they can open, never an absolute temp/host path.
28
+ export const CONFIG_REL = 'docs/ai/orchestration.json';
29
+
30
+ // A tagged failure: a plain Error carrying the intended process exit code (2 usage / 1 config). Avoids
31
+ // a class (project rule) while letting a CLI main() map a throw to the right code in one place. Shared
32
+ // so procedures.mjs + set-recipe.mjs raise identically-typed errors.
33
+ export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
34
+
35
+ const KNOWN_ACTIVITIES = () => Object.keys(ACTIVITIES).join(', ');
36
+
37
+ // ── the ONE slot/recipe validity table (shared accept/reject) ───────────────────────
38
+ // Both the set-recipe op parser and the procedures --override parser route through these, so the
39
+ // accept/reject decision can never drift between the two surfaces (one table, drift-guarded by tests).
40
+
41
+ // True iff `recipe` is valid for the (activity, slot) pair. Pure predicate; throws nothing.
42
+ export const recipeValidForSlot = (activity, slot, recipe) => {
43
+ const slotType = ACTIVITIES[activity]?.slots?.[slot];
44
+ if (!slotType) return false;
45
+ return (SLOT_RECIPES[slotType] ?? []).includes(recipe);
46
+ };
47
+
48
+ // Assert (activity, slot) is a known slot of a known activity; return its recipe-TYPE. Loud (exit 2)
49
+ // on an unknown activity or an unknown slot — the shared "unknown slot" message both parsers emit.
50
+ export const assertSlot = (activity, slot, exitCode = 2) => {
51
+ const activityDef = ACTIVITIES[activity];
52
+ if (!activityDef) throw fail(exitCode, `unknown activity "${activity}" (known: ${KNOWN_ACTIVITIES()})`);
53
+ const slotType = activityDef.slots[slot];
54
+ if (!slotType) {
55
+ throw fail(
56
+ exitCode,
57
+ `unknown slot "${slot}" for activity "${activity}" (${activity} slots: ${Object.keys(activityDef.slots).join(', ')})`,
58
+ );
59
+ }
60
+ return slotType;
61
+ };
62
+
63
+ // Assert (activity, slot, recipe) is valid — unknown activity/slot OR invalid-recipe-for-slot → loud.
64
+ // Used by the set-recipe op parser AND the procedures --override parser, so they accept/reject in
65
+ // lockstep. `exitCode` is 2 for both CLIs (usage error); validateConfig reuses it with exitCode 1.
66
+ export const assertSlotRecipe = (activity, slot, recipe, exitCode = 2) => {
67
+ const slotType = assertSlot(activity, slot, exitCode);
68
+ if (!(SLOT_RECIPES[slotType] ?? []).includes(recipe)) {
69
+ throw fail(
70
+ exitCode,
71
+ `invalid recipe "${recipe}" for ${slotType} slot of "${activity}" (${slotType} accepts: ${SLOT_RECIPES[slotType].join(', ')})`,
72
+ );
73
+ }
74
+ return slotType;
75
+ };
76
+
77
+ // ── the typed op parser (usage errors → exit 2) ─────────────────────────────────────
78
+ // The grammar is ALWAYS fully-qualified `<activity>.<slot>` — the writer never guesses an activity. A
79
+ // bare `review=council` is rejected (name the activity). The kit performs no `all`-magic; the agent
80
+ // expands plain language like "both review" into explicit per-activity ops (asking if scope is unclear).
81
+
82
+ const parseQualified = (lhs, flag) => {
83
+ const dot = lhs.indexOf('.');
84
+ if (dot <= 0 || dot === lhs.length - 1) {
85
+ throw fail(
86
+ 2,
87
+ `${flag} must be fully-qualified <activity>.<slot> (got "${lhs}") — name the activity, e.g. plan-authoring.review / plan-execution.review`,
88
+ );
89
+ }
90
+ return { activity: lhs.slice(0, dot), slot: lhs.slice(dot + 1) };
91
+ };
92
+
93
+ // parseOp(kind, token) → a typed record:
94
+ // kind 'set' + token `<activity>.<slot>=<recipe>` → { kind:'set', activity, slot, recipe }
95
+ // kind 'unset' + token `<activity>.<slot>` → { kind:'unset', activity, slot }
96
+ // Every malformed token is a USAGE error (exit 2): a bare recipe (no activity), an unknown activity /
97
+ // slot, an invalid recipe-for-slot, a missing recipe on --set, or a stray recipe on --unset.
98
+ export const parseOp = (kind, token) => {
99
+ if (kind === 'set') {
100
+ const eq = token.indexOf('=');
101
+ if (eq <= 0) throw fail(2, `--set must be <activity>.<slot>=<recipe> (got "${token}")`);
102
+ const recipe = token.slice(eq + 1);
103
+ if (!recipe) throw fail(2, `--set must be <activity>.<slot>=<recipe> (got "${token}")`);
104
+ const { activity, slot } = parseQualified(token.slice(0, eq), '--set');
105
+ assertSlotRecipe(activity, slot, recipe);
106
+ return { kind: 'set', activity, slot, recipe };
107
+ }
108
+ if (token.includes('=')) throw fail(2, `--unset takes <activity>.<slot> without a recipe (got "${token}")`);
109
+ const { activity, slot } = parseQualified(token, '--unset');
110
+ assertSlot(activity, slot);
111
+ return { kind: 'unset', activity, slot };
112
+ };
113
+
114
+ // ── config validation (config errors → exit 1) ──────────────────────────────────────
115
+
116
+ // Validate a parsed orchestration.json object against the schema. Strict: an unknown top-level
117
+ // activity, an unknown slot for an activity, or a recipe invalid-for-slot is an error. All slots are
118
+ // optional. An optional "_README" string key is allowed + ignored (self-documentation). Never a silent
119
+ // fallback — every rejection is a loud `path: reason` (exit 1). Returns the config on success.
120
+ export const validateConfig = (config) => {
121
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
122
+ throw fail(1, `${CONFIG_REL}: must be a JSON object of activity → { slot: recipe }`);
123
+ }
124
+ for (const [key, val] of Object.entries(config)) {
125
+ if (key === '_README') {
126
+ if (typeof val !== 'string') throw fail(1, `${CONFIG_REL}: "_README" must be a string`);
127
+ continue;
128
+ }
129
+ const activityDef = ACTIVITIES[key];
130
+ if (!activityDef) {
131
+ throw fail(1, `${CONFIG_REL}: unknown activity "${key}" (known: ${KNOWN_ACTIVITIES()})`);
132
+ }
133
+ if (val === null || typeof val !== 'object' || Array.isArray(val)) {
134
+ throw fail(1, `${CONFIG_REL}: activity "${key}" must be a JSON object of slot → recipe`);
135
+ }
136
+ for (const [slot, recipe] of Object.entries(val)) {
137
+ const slotType = activityDef.slots[slot];
138
+ if (!slotType) {
139
+ throw fail(
140
+ 1,
141
+ `${CONFIG_REL}: unknown slot "${slot}" for activity "${key}" (${key} slots: ${Object.keys(activityDef.slots).join(', ')})`,
142
+ );
143
+ }
144
+ if (typeof recipe !== 'string' || !(SLOT_RECIPES[slotType] ?? []).includes(recipe)) {
145
+ throw fail(
146
+ 1,
147
+ `${CONFIG_REL}: invalid recipe "${recipe}" for ${slotType} slot of "${key}" (${slotType} accepts: ${SLOT_RECIPES[slotType].join(', ')})`,
148
+ );
149
+ }
150
+ }
151
+ }
152
+ return config;
153
+ };
154
+
155
+ // ── config IO (config errors → exit 1) ──────────────────────────────────────────────
156
+
157
+ // Load + validate the config from <cwd>/docs/ai/orchestration.json. Absent FILE → computed defaults
158
+ // (NOT an error): { config: null, source: 'none' }. Malformed JSON / schema-invalid / unreadable →
159
+ // loud `path: reason` (exit 1). The read-only status survey + procedures + the set-recipe writer all
160
+ // reuse THIS reader — one strict-JSON + loud-on-malformed contract, no second drifting implementation.
161
+ export const loadConfig = (cwd, readFile = readFileSync, lstat = lstatSync) => {
162
+ const full = join(cwd, CONFIG_REL);
163
+ // Distinguish a TRULY-absent config (no entry at all → computed defaults) from a present-but-
164
+ // unreadable one (a directory, a DANGLING SYMLINK, a permission error → loud exit 1). lstat does NOT
165
+ // follow the link, so a dangling symlink reads as "present" here and its later read failure surfaces
166
+ // loudly — never silently treated as absent (no-silent-failures Hard Constraint).
167
+ try {
168
+ lstat(full);
169
+ } catch (err) {
170
+ if (err && err.code === 'ENOENT') return { config: null, source: 'none' };
171
+ throw fail(1, `${CONFIG_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
172
+ }
173
+ let raw;
174
+ try {
175
+ raw = readFile(full, 'utf8');
176
+ } catch (err) {
177
+ throw fail(1, `${CONFIG_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
178
+ }
179
+ let parsed;
180
+ try {
181
+ parsed = JSON.parse(raw);
182
+ } catch (err) {
183
+ throw fail(1, `${CONFIG_REL}: malformed JSON (${err.message})`);
184
+ }
185
+ return { config: validateConfig(parsed), source: CONFIG_REL };
186
+ };
187
+
188
+ // ── pure merge + canonical serialization ────────────────────────────────────────────
189
+
190
+ // A pure deep-equal over the JSON-ish config shape (plain objects + string values). Used only for the
191
+ // "did anything actually change?" decision (no-op detection + seed-on-change), never for output.
192
+ const deepEqual = (a, b) => {
193
+ if (a === b) return true;
194
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
195
+ const ak = Object.keys(a);
196
+ const bk = Object.keys(b);
197
+ if (ak.length !== bk.length) return false;
198
+ return ak.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
199
+ };
200
+
201
+ // applySetOps(currentConfig, ops, { seedReadme }) → the merged config. PURE: deep-clones `current`
202
+ // (or {}), applies each set/unset, preserves `_README` + every untouched activity/slot, drops an
203
+ // activity that an unset empties (sparse), then re-runs validateConfig (loud on invalid — defensive;
204
+ // the op parser pre-validates). When (and ONLY when) the merge CHANGES the config, `_README` is absent,
205
+ // and `seedReadme` is supplied, the canonical note is seeded — so a no-op set never spuriously seeds it.
206
+ export const applySetOps = (currentConfig, ops, { seedReadme = null } = {}) => {
207
+ const base = currentConfig == null ? {} : structuredClone(currentConfig);
208
+ const next = structuredClone(base);
209
+ for (const op of ops) {
210
+ if (op.kind === 'set') {
211
+ next[op.activity] = { ...(next[op.activity] ?? {}) };
212
+ next[op.activity][op.slot] = op.recipe;
213
+ } else {
214
+ if (next[op.activity] && op.slot in next[op.activity]) {
215
+ const rest = { ...next[op.activity] };
216
+ delete rest[op.slot];
217
+ if (Object.keys(rest).length === 0) delete next[op.activity];
218
+ else next[op.activity] = rest;
219
+ }
220
+ }
221
+ }
222
+ validateConfig(next);
223
+ const changed = !deepEqual(next, base);
224
+ if (changed && seedReadme != null && next._README === undefined) next._README = seedReadme;
225
+ return next;
226
+ };
227
+
228
+ // serializeConfig(config) → strict JSON, 2-space, trailing newline, `_README` FIRST (explicitly, so the
229
+ // onboarding note never sinks below the activities). This is the canonical on-disk form: a touched
230
+ // write normalizes to it (content-preserving, NOT byte-preserving of arbitrary hand-formatting).
231
+ export const serializeConfig = (config) => {
232
+ const ordered = {};
233
+ if (config._README !== undefined) ordered._README = config._README;
234
+ for (const [k, v] of Object.entries(config)) {
235
+ if (k !== '_README') ordered[k] = v;
236
+ }
237
+ return `${JSON.stringify(ordered, null, 2)}\n`;
238
+ };
239
+
240
+ // ── canonical-refresh (shared by the _README refresh + the injected-slot refresh) ───
241
+ // normalizeCanonical: trim + LF-normalize (handles the CRLF / trailing-whitespace trap) so a byte-noisy
242
+ // copy of a canonical string still matches. refreshIfCanonical: replace `current` with `next` IFF it
243
+ // normalize-equals ANY known prior canonical; otherwise return `current` UNCHANGED (preserve a
244
+ // customization). Pure; no fs. Used for the orchestration `_README` and the two injected pointers.
245
+
246
+ export const normalizeCanonical = (s) => String(s).replace(/\r\n/g, '\n').trim();
247
+
248
+ export const refreshIfCanonical = (current, knownPriorCanonicals, next) => {
249
+ const cur = normalizeCanonical(current);
250
+ return knownPriorCanonicals.some((prior) => normalizeCanonical(prior) === cur) ? next : current;
251
+ };
252
+
253
+ // ── canonical `_README` (drift-guarded, append-only known-prior set) ─────────────────
254
+ // CANON_README is the CURRENT onboarding note — what the templates ship + what a refresh installs. It
255
+ // frames hand-edit as a still-available option AND points at the set-recipe writer (no "never written
256
+ // for you"). KNOWN_PRIOR_README is the APPEND-ONLY set of every PREVIOUS canonical note: any release
257
+ // that changes CANON_README must FIRST append the outgoing string here, so an immediately-previous
258
+ // deployment still normalize-matches and gets refreshed (a customized note never matches → preserved).
259
+ export const CANON_README =
260
+ "Per-project orchestration config: the recipe used at each step (slot) of each named activity. " +
261
+ "Easiest: tell the agent in plain language and run the `set-recipe` writer — it interprets your intent, " +
262
+ "previews the change, and writes valid JSON for you. You can still hand-edit this file directly whenever you " +
263
+ "prefer; that option never goes away. Each activity is configured independently (e.g. plan-authoring, " +
264
+ "plan-execution), and so is each slot within it. A slot's value is a recipe: a 'review' slot accepts " +
265
+ "solo | reviewed | council (you self-review / one backend reviews / both review and you synthesize); an " +
266
+ "'execute' slot accepts solo | delegated (you implement / a backend runs a bounded sub-task). The default " +
267
+ "below is 'solo' everywhere — no execution backend required. Raise a slot to reviewed or council for a second " +
268
+ "opinion, or to delegated to hand off execution; those need an execution backend set up first. Remove a slot's " +
269
+ "line (or run `set-recipe --unset <activity>.<slot>`) to fall back to the computed default (reviewed when a " +
270
+ "review backend is ready, otherwise solo). Run the read-only procedures advisor to see an activity's steps " +
271
+ "plus the recipe resolved for your environment. Strict JSON — no comments.";
272
+
273
+ export const KNOWN_PRIOR_README = [
274
+ // v1 (pre-set-recipe) — the "Hand-edit this file — it is never written for you" note. APPEND-ONLY.
275
+ "Per-project orchestration config: the recipe used at each step (slot) of each named activity. Hand-edit this file — it is never written for you. Each activity is configured independently (e.g. plan-authoring, plan-execution), and so is each slot within it. A slot's value is a recipe: a 'review' slot accepts solo | reviewed | council (you self-review / one backend reviews / both review and you synthesize); an 'execute' slot accepts solo | delegated (you implement / a backend runs a bounded sub-task). The default below is 'solo' everywhere — no execution backend required. Raise a slot to reviewed or council for a second opinion, or to delegated to hand off execution; those need an execution backend set up first. Remove a slot's line to fall back to the computed default (reviewed when a review backend is ready, otherwise solo). Run the read-only procedures advisor to see an activity's steps plus the recipe resolved for your environment, and pass a per-run override to change one slot just once. Strict JSON — no comments.",
276
+ ];
277
+
278
+ // refreshReadme(config) → { config, changed }: refresh ONLY the `_README` value when it normalize-
279
+ // matches a known prior canonical (preserve a customized note untouched); seed it when absent. The
280
+ // stamp-independent config-ensure (kit fallback + memory delegated upgrade paths) uses this so an
281
+ // install-base deployment gains the new note without a migration file — never clobbering a customization.
282
+ export const refreshReadme = (config) => {
283
+ if (config == null || typeof config !== 'object' || Array.isArray(config)) {
284
+ return { config, changed: false };
285
+ }
286
+ const had = config._README;
287
+ const nextReadme = had === undefined ? CANON_README : refreshIfCanonical(had, KNOWN_PRIOR_README, CANON_README);
288
+ if (nextReadme === had) return { config, changed: false };
289
+ const next = { _README: nextReadme };
290
+ for (const [k, v] of Object.entries(config)) {
291
+ if (k !== '_README') next[k] = v;
292
+ }
293
+ return { config: next, changed: true };
294
+ };
295
+
296
+ // The canonical seed file body (what `init` deploys + what serializeConfig round-trips byte-identically).
297
+ export const SEED_CONFIG = { _README: CANON_README, 'plan-authoring': { review: 'solo' }, 'plan-execution': { execute: 'solo', review: 'solo' } };
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ // orchestration-write.mjs — the ONLY filesystem WRITER for docs/ai/orchestration.json. It is imported
3
+ // by the set-recipe writer alone; procedures.mjs never imports it, so "the read-only procedures advisor
4
+ // can never reach a writer" is a STRUCTURAL invariant (an import-split test pins it), not just an
5
+ // assertion. Splitting the writer out of the schema/read module keeps the read surface fs-write-free.
6
+ //
7
+ // writeConfig is hardened (mirrors the setup-backends posture):
8
+ // - DEPLOYMENT GATE first: refuse to scatter a config into a repo with no docs/ai/ — STOP loud,
9
+ // pointing at init/bootstrap (a config without a deployment is meaningless + surprising).
10
+ // - refuse a SYMLINKED leaf (orchestration.json itself a symlink) — a rename would silently replace
11
+ // the link target.
12
+ // - guard the dst + the tmp sibling with assertContainedRealPath (fs-safe) — refuses a symlinked
13
+ // docs/ or docs/ai/ PARENT, not just the leaf, and refuses any escape outside cwd.
14
+ // - atomic: write a UNIQUE *.json.<rand>.tmp opened EXCLUSIVE-CREATE (wx), then rename over the dst.
15
+ // - RE-CHECK the parent chain + the leaf immediately before the rename (TOCTOU).
16
+ // - LAST-WRITER-WINS: local, single-user; no cross-process lock (documented, not silently assumed).
17
+ //
18
+ // Dependency-free, Node >= 18. Every fs primitive is injectable (deps.*) so the guards are unit-testable.
19
+
20
+ import { lstatSync, writeFileSync, renameSync, rmSync } from 'node:fs';
21
+ import { randomBytes } from 'node:crypto';
22
+ import { join } from 'node:path';
23
+ import { assertContainedRealPath } from './fs-safe.mjs';
24
+ import { CONFIG_REL, serializeConfig } from './orchestration-config.mjs';
25
+
26
+ // A typed STOP — a deliberate refusal we surface (deployment gate / symlinked leaf), distinct from a
27
+ // native fs error. `Object.assign(new Error(), { code })`, the codebase's typed-error idiom (no classes).
28
+ export const ORCH_WRITE_STOP = 'ORCH_WRITE_STOP';
29
+ const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'OrchWriteStop', code: ORCH_WRITE_STOP });
30
+
31
+ // lstat without following symlinks; null when absent. A non-ENOENT error propagates (never fail open).
32
+ const lstatNoFollow = (path, lstat) => {
33
+ try {
34
+ return lstat(path);
35
+ } catch (err) {
36
+ if (err && err.code === 'ENOENT') return null;
37
+ throw err;
38
+ }
39
+ };
40
+
41
+ // writeConfig(cwd, config, deps) → { writtenPath } on success; THROWS a typed STOP (no deployment /
42
+ // symlinked leaf) or a native fs error otherwise. The tmp is cleaned up on any failure after its
43
+ // creation. config is serialized canonically (serializeConfig: 2-space, _README-first, trailing NL).
44
+ export const writeConfig = (cwd, config, deps = {}) => {
45
+ const lstat = deps.lstat ?? lstatSync;
46
+ const writeFile = deps.writeFile ?? writeFileSync;
47
+ const rename = deps.rename ?? renameSync;
48
+ const rm = deps.rm ?? ((p) => rmSync(p, { force: true }));
49
+ const rand = deps.rand ?? (() => randomBytes(6).toString('hex'));
50
+ const guard = (target) => assertContainedRealPath(cwd, target, { lstat });
51
+
52
+ // Deployment gate — never scatter a config into a non-deployed repo (mirror velocity / setup). lstat,
53
+ // NOT existsSync: existsSync FOLLOWS a symlink, so a DANGLING `docs/ai` symlink would read as "absent"
54
+ // and mislabel a broken/symlinked deployment as "no deployment". lstat the leaf instead: a true ENOENT
55
+ // → no deployment (STOP, run init); a symlink or non-directory → STOP loud (never write through it).
56
+ const docsAi = join(cwd, 'docs', 'ai');
57
+ const docsAiStat = lstatNoFollow(docsAi, lstat);
58
+ if (docsAiStat === null) {
59
+ throw stop(`no agent-workflow deployment here (docs/ai is absent) — run init/bootstrap before writing ${CONFIG_REL}`);
60
+ }
61
+ if (docsAiStat.isSymbolicLink()) {
62
+ throw stop(`docs/ai is a symlink — refusing to write a config through it (run init/bootstrap in a real deployment)`);
63
+ }
64
+ if (!docsAiStat.isDirectory()) {
65
+ throw stop(`docs/ai exists but is not a directory — refusing to write ${CONFIG_REL}`);
66
+ }
67
+
68
+ const dst = join(cwd, CONFIG_REL);
69
+ // Refuse a symlinked leaf with a CLEAR message before the generic traversal guard fires (a rename
70
+ // would silently replace the link rather than the file the user thinks they are editing).
71
+ const leaf = lstatNoFollow(dst, lstat);
72
+ if (leaf && leaf.isSymbolicLink()) {
73
+ throw stop(`${CONFIG_REL} is a symlink — refusing to replace it (a write would clobber the link target)`);
74
+ }
75
+ // Guard the dst + a unique tmp SIBLING: refuses a symlinked docs/ or docs/ai/ parent, and any escape.
76
+ guard(dst);
77
+ const tmp = `${dst}.${rand()}.tmp`;
78
+ guard(tmp);
79
+
80
+ const body = serializeConfig(config);
81
+ // Exclusive-create (wx): never clobber a leftover tmp (a stray collision is surfaced, not silently
82
+ // overwritten). The random suffix makes a collision effectively impossible; wx makes it impossible-loud.
83
+ writeFile(tmp, body, { encoding: 'utf8', flag: 'wx' });
84
+ try {
85
+ // TOCTOU re-check: the parent chain + the leaf may have changed since the pre-checks above.
86
+ guard(dst);
87
+ const leafAgain = lstatNoFollow(dst, lstat);
88
+ if (leafAgain && leafAgain.isSymbolicLink()) {
89
+ throw stop(`${CONFIG_REL} became a symlink — refusing to replace it`);
90
+ }
91
+ rename(tmp, dst);
92
+ } catch (err) {
93
+ rm(tmp); // never leave a temp file behind on failure
94
+ throw err;
95
+ }
96
+ return { writtenPath: CONFIG_REL };
97
+ };
@@ -17,48 +17,36 @@
17
17
  // installed engine is absent / invalid / too old to ship references/procedures.md).
18
18
 
19
19
  import { readFileSync, lstatSync } from 'node:fs';
20
- import { join } from 'node:path';
21
20
  import { homedir } from 'node:os';
22
21
  import { pathToFileURL } from 'node:url';
23
22
  import { detectBackends } from './detect-backends.mjs';
24
- import { ACTIVITIES, SLOT_RECIPES, resolveActivityRecipe } from './recipes.mjs';
23
+ import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
25
24
  import { resolveEngineDir, readEngineFragment, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
26
-
27
- // The hand-edited, per-project config (strict JSON). cwd-relative the error prefix uses this rel path
28
- // so a user sees a path they can open, never an absolute temp/host path.
29
- export const CONFIG_REL = 'docs/ai/orchestration.json';
30
-
31
- // A tagged failure: a plain Error carrying the intended process exit code (2 usage / 1 config|engine).
32
- // Avoids a class (project rule) while letting main() map a throw to the right code in one place.
33
- const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
25
+ // The config schema/read core lives in orchestration-config.mjs (the single config contract). procedures
26
+ // is READ-ONLY: it imports the reader + the SHARED slot/recipe validity, never the fs-writer
27
+ // (orchestration-write.mjs) so "the read-only advisor can never reach a writer" is STRUCTURALLY true
28
+ // (an import-split test pins it). CONFIG_REL is RE-EXPORTED so existing importers (procedures.test.mjs,
29
+ // historically) keep their import site working.
30
+ import { CONFIG_REL, fail, loadConfig, assertSlotRecipe } from './orchestration-config.mjs';
31
+ export { CONFIG_REL };
34
32
 
35
33
  // ── argument + override parsing (usage errors → exit 2) ─────────────────────────────
36
34
 
37
35
  // Parse the activity's --override <slot>=<recipe> tokens into a { slot: recipe } map, validating each
38
- // against the activity's slots + SLOT_RECIPES. Every malformed token is a USAGE error (exit 2): a bare
39
- // `<recipe>` (no slot), an unknown slot for the activity, an invalid recipe-for-slot, or a duplicate
40
- // slot. (An override naming a recipe whose backend merely is not `ready` is NOT a usage error it
41
- // degrades loudly at resolution time, exit 0.)
42
- const parseOverrides = (tokens, activity, activityDef) => {
36
+ // against the SHARED slot/recipe validity table (assertSlotRecipe the SAME accept/reject the set-recipe
37
+ // op parser uses, drift-guarded). Every malformed token is a USAGE error (exit 2): a bare `<recipe>` (no
38
+ // slot), an unknown slot for the activity, an invalid recipe-for-slot, or a duplicate slot. (An override
39
+ // naming a recipe whose backend merely is not `ready` is NOT a usage error — it degrades loudly at
40
+ // resolution time, exit 0.) The `--override` grammar stays activity-SCOPED (the activity comes from the
41
+ // CLI arg), unlike the fully-qualified `--set <activity>.<slot>=<recipe>` the writer takes.
42
+ const parseOverrides = (tokens, activity) => {
43
43
  const overrides = {};
44
44
  for (const tok of tokens) {
45
45
  const eq = tok.indexOf('=');
46
46
  if (eq <= 0) throw fail(2, `--override must be <slot>=<recipe> (got "${tok}")`);
47
47
  const slot = tok.slice(0, eq);
48
48
  const recipe = tok.slice(eq + 1);
49
- const slotType = activityDef.slots[slot];
50
- if (!slotType) {
51
- throw fail(
52
- 2,
53
- `--override: unknown slot "${slot}" for activity "${activity}" (${activity} slots: ${Object.keys(activityDef.slots).join(', ')})`,
54
- );
55
- }
56
- if (!(SLOT_RECIPES[slotType] ?? []).includes(recipe)) {
57
- throw fail(
58
- 2,
59
- `--override: invalid recipe "${recipe}" for ${slotType} slot of "${activity}" (${slotType} accepts: ${SLOT_RECIPES[slotType].join(', ')})`,
60
- );
61
- }
49
+ assertSlotRecipe(activity, slot, recipe); // shared validity (unknown slot / invalid recipe → exit 2)
62
50
  if (slot in overrides) throw fail(2, `--override: duplicate override for slot "${slot}"`);
63
51
  overrides[slot] = recipe;
64
52
  }
@@ -94,80 +82,7 @@ const parseArgs = (argv) => {
94
82
  if (!activity) throw fail(2, `missing <activity> (known: ${KNOWN_ACTIVITIES()})`);
95
83
  const activityDef = ACTIVITIES[activity];
96
84
  if (!activityDef) throw fail(2, `unknown activity "${activity}" (known: ${KNOWN_ACTIVITIES()})`);
97
- return { activity, overrides: parseOverrides(overrideTokens, activity, activityDef), json };
98
- };
99
-
100
- // ── config IO + validation (config errors → exit 1) ────────────────────────────────
101
-
102
- // Validate a parsed orchestration.json object against the §2.0 schema. Strict: an unknown top-level
103
- // activity, an unknown slot for an activity, or a recipe invalid-for-slot is an error. All slots are
104
- // optional. An optional "_README" string key is allowed + ignored (self-documentation). Never a silent
105
- // fallback — every rejection is a loud `path: reason`.
106
- const validateConfig = (config) => {
107
- if (config === null || typeof config !== 'object' || Array.isArray(config)) {
108
- throw fail(1, `${CONFIG_REL}: must be a JSON object of activity → { slot: recipe }`);
109
- }
110
- for (const [key, val] of Object.entries(config)) {
111
- if (key === '_README') {
112
- if (typeof val !== 'string') throw fail(1, `${CONFIG_REL}: "_README" must be a string`);
113
- continue;
114
- }
115
- const activityDef = ACTIVITIES[key];
116
- if (!activityDef) {
117
- throw fail(1, `${CONFIG_REL}: unknown activity "${key}" (known: ${KNOWN_ACTIVITIES()})`);
118
- }
119
- if (val === null || typeof val !== 'object' || Array.isArray(val)) {
120
- throw fail(1, `${CONFIG_REL}: activity "${key}" must be a JSON object of slot → recipe`);
121
- }
122
- for (const [slot, recipe] of Object.entries(val)) {
123
- const slotType = activityDef.slots[slot];
124
- if (!slotType) {
125
- throw fail(
126
- 1,
127
- `${CONFIG_REL}: unknown slot "${slot}" for activity "${key}" (${key} slots: ${Object.keys(activityDef.slots).join(', ')})`,
128
- );
129
- }
130
- if (typeof recipe !== 'string' || !(SLOT_RECIPES[slotType] ?? []).includes(recipe)) {
131
- throw fail(
132
- 1,
133
- `${CONFIG_REL}: invalid recipe "${recipe}" for ${slotType} slot of "${key}" (${slotType} accepts: ${SLOT_RECIPES[slotType].join(', ')})`,
134
- );
135
- }
136
- }
137
- }
138
- return config;
139
- };
140
-
141
- // Load + validate the config from <cwd>/docs/ai/orchestration.json. Absent FILE → computed defaults
142
- // (NOT an error): { config: null, source: 'none' }. Malformed JSON / schema-invalid / unreadable →
143
- // loud `path: reason` (exit 1). The resolver receives the parsed+validated object (§2.2 IO/resolver split).
144
- // Exported so the read-only status settings-survey (family-registry.mjs) reuses ONE config reader —
145
- // same strict-JSON + loud-on-malformed contract, no second drifting implementation (Plan 3.1).
146
- export const loadConfig = (cwd, readFile = readFileSync, lstat = lstatSync) => {
147
- const full = join(cwd, CONFIG_REL);
148
- // Distinguish a TRULY-absent config (no entry at all → computed defaults) from a present-but-
149
- // unreadable one (a directory, a DANGLING SYMLINK, a permission error → loud exit 1). lstat does NOT
150
- // follow the link, so a dangling symlink reads as "present" here and its later read failure surfaces
151
- // loudly — never silently treated as absent (no-silent-failures Hard Constraint).
152
- try {
153
- lstat(full);
154
- } catch (err) {
155
- if (err && err.code === 'ENOENT') return { config: null, source: 'none' };
156
- throw fail(1, `${CONFIG_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
157
- }
158
- let raw;
159
- try {
160
- raw = readFile(full, 'utf8');
161
- } catch (err) {
162
- throw fail(1, `${CONFIG_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
163
- }
164
- let parsed;
165
- try {
166
- parsed = JSON.parse(raw);
167
- } catch (err) {
168
- throw fail(1, `${CONFIG_REL}: malformed JSON (${err.message})`);
169
- }
170
- return { config: validateConfig(parsed), source: CONFIG_REL };
85
+ return { activity, overrides: parseOverrides(overrideTokens, activity), json };
171
86
  };
172
87
 
173
88
  // ── engine canon: live read + per-activity section extraction (engine errors → exit 1) ──