@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.
- package/CHANGELOG.md +27 -0
- package/README.md +2 -1
- package/SKILL.md +37 -7
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/templates/orchestration.json +8 -3
- package/tools/commands.mjs +7 -0
- package/tools/family-registry.mjs +3 -1
- package/tools/inject-methodology.mjs +90 -16
- package/tools/orchestration-config.mjs +297 -0
- package/tools/orchestration-write.mjs +97 -0
- package/tools/procedures.mjs +17 -102
- package/tools/set-recipe.mjs +217 -0
- package/tools/setup-backends.mjs +71 -5
|
@@ -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
|
+
};
|
package/tools/procedures.mjs
CHANGED
|
@@ -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,
|
|
23
|
+
import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
|
|
25
24
|
import { resolveEngineDir, readEngineFragment, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
39
|
-
//
|
|
40
|
-
// slot
|
|
41
|
-
//
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
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) ──
|