@sabaiway/agent-workflow-kit 1.16.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.
@@ -18,6 +18,14 @@
18
18
  // STOP loudly (never a silent fallback) when the engine is needed but absent/invalid.
19
19
  //
20
20
  // Pure string functions (testable with byte-preservation fixtures); dependency-free, Node >= 18.
21
+ //
22
+ // Canonical-refresh (AD-025): a slot filled BEFORE a clause existed is normally preserved verbatim
23
+ // (reconcile only fills an EMPTY slot). To push a NEW canonical clause to the EXISTING filled base
24
+ // without clobbering a user's customization, reconcileMarkerSlot also REFRESHES a filled slot — but
25
+ // ONLY when its content normalize-matches a KNOWN PRIOR canonical fragment (drift-guarded, append-only
26
+ // per descriptor). A customized slot never matches → preserved verbatim + a read-only upgrade advisory.
27
+
28
+ import { normalizeCanonical } from './orchestration-config.mjs';
21
29
 
22
30
  export const START_MARKER = '<!-- workflow:methodology:start -->';
23
31
  export const END_MARKER = '<!-- workflow:methodology:end -->';
@@ -57,11 +65,29 @@ export const ORCH_ANCHOR = new RegExp(`^.*${escapeRegExp(END_MARKER)}.*$`, 'm');
57
65
  export const EMPTY_SLOT = `${START_MARKER}\n${END_MARKER}`;
58
66
  export const ORCH_EMPTY_SLOT = `${ORCH_START_MARKER}\n${ORCH_END_MARKER}`;
59
67
 
68
+ // ── known-prior canonical fragments (drift-guarded, APPEND-ONLY per slot) ───────
69
+ // The EXACT content a PREVIOUS release's engine fragment shipped (what a filled slot would carry today).
70
+ // reconcileMarkerSlot refreshes a filled slot to the current engine fragment IFF its content
71
+ // normalize-matches one of these — so the install base gains a new clause without clobbering a
72
+ // customization (which never matches). Any release that CHANGES an engine fragment must FIRST append the
73
+ // OUTGOING content here, so the immediately-previous deployments still match (a drift-guard test pins
74
+ // current-minus-one → new). NEVER edit an existing entry — only append.
75
+ export const KNOWN_PRIOR_METHODOLOGY_SLOT = [
76
+ // v1.3.0 — pre-communication-contract methodology pointer (procedures route, no §1.9 clause).
77
+ '> **Workflow methodology** — plan → execute → review. Plans are ephemeral `docs/plans/*.md` (gitignored, **never committed**); every Plan ends with a mandatory **Phase: Cleanup**; series order lives in `docs/plans/queue.md`. Full vocabulary, lifecycle, and the plan-then-execute split live in the project\'s **planning skill** (it overrides the generic `writing-plans`); summary in `docs/ai/agent_rules.md` §5. Named activities (plan-authoring, plan-execution) have procedures — see `/agent-workflow-kit procedures <activity>` for the steps + resolved recipe.',
78
+ ];
79
+ export const KNOWN_PRIOR_ORCH_SLOT = [
80
+ // v1.3.0 — pre-read-at-start orchestration pointer (recipes vocabulary, no orchestration.json clause).
81
+ '> **Orchestration recipes** — compose plan → execute → review with a named recipe: **Solo** (no backend), **Reviewed** (one backend reviews), **Council** (both review, you synthesize), **Delegated** (a backend executes a bounded sub-task); the orchestrator always commits, a backend is never autonomous. Pick + plan one for this environment with `/agent-workflow-kit recipes` (read-only); the deployed how/why lives in your `docs/ai/` workflow docs.',
82
+ ];
83
+
60
84
  // A slot descriptor bundles everything the generic engine needs to operate on ONE marker pair.
61
85
  // `leadingBlank` controls whether the inserted empty pair gets a blank separator line above it — the
62
86
  // methodology insert keeps it (readability), the orchestration insert omits it to save a cap line
63
- // (the pair already sits right under the methodology block).
64
- const METHODOLOGY_DESCRIPTOR = {
87
+ // (the pair already sits right under the methodology block). `knownPriorCanonicals` drives the refresh;
88
+ // `upgradeSignature` + `upgradeAdvice` drive the read-only advisory for a CUSTOMIZED (non-matching) slot
89
+ // that predates the current clause.
90
+ export const METHODOLOGY_DESCRIPTOR = {
65
91
  startMarker: START_MARKER,
66
92
  endMarker: END_MARKER,
67
93
  anchor: METHODOLOGY_ANCHOR,
@@ -69,6 +95,10 @@ const METHODOLOGY_DESCRIPTOR = {
69
95
  leadingBlank: true,
70
96
  markerName: 'methodology',
71
97
  anchorLabel: 'methodology anchor (the "Read it before any code change." Session-Protocols line)',
98
+ knownPriorCanonicals: KNOWN_PRIOR_METHODOLOGY_SLOT,
99
+ upgradeSignature: 'Communication',
100
+ upgradeAdvice:
101
+ 'the workflow-methodology pointer predates the communication-contract clause (deliver the artifact inline, lead with the result) — refresh it to the current canon, or add the clause by hand; the contract still applies.',
72
102
  };
73
103
  export const ORCHESTRATION_DESCRIPTOR = {
74
104
  startMarker: ORCH_START_MARKER,
@@ -78,6 +108,10 @@ export const ORCHESTRATION_DESCRIPTOR = {
78
108
  leadingBlank: false,
79
109
  markerName: 'orchestration',
80
110
  anchorLabel: 'orchestration anchor (the methodology end-marker line)',
111
+ knownPriorCanonicals: KNOWN_PRIOR_ORCH_SLOT,
112
+ upgradeSignature: '/agent-workflow-kit set-recipe',
113
+ upgradeAdvice:
114
+ 'the orchestration-recipes pointer predates the read-at-start clause — add "read `docs/ai/orchestration.json` at session start; set it with /agent-workflow-kit set-recipe", or set your preference now with /agent-workflow-kit set-recipe.',
81
115
  };
82
116
 
83
117
  // ── generic marker-slot engine (descriptor-parameterized) ──────────────────────
@@ -160,16 +194,27 @@ export const ensureMarkerSlot = (text, descriptor) => {
160
194
  // Bootstrap/upgrade reconciliation policy (pure): ensure the slot exists, then fill it ONLY IF it
161
195
  // is empty (a filled/customized slot is preserved verbatim), enforcing the line cap — all as one step
162
196
  // the CLI commits with a single atomic write. On ANY error the INPUT bytes are returned unchanged.
163
- // reconciled-inserted — slot was absent, inserted at the anchor, then filled.
164
- // reconciled-filled — slot existed but was empty, now filled.
165
- // present-filled — slot already carried contentpreserved verbatim.
166
- // error malformed slot, 0/>1 anchors, or cap exceeded input unchanged.
197
+ // reconciled-inserted — slot was absent, inserted at the anchor, then filled.
198
+ // reconciled-filled — slot existed but was empty, now filled.
199
+ // reconciled-refreshed — slot was filled with a KNOWN PRIOR canonical replaced with `fragment`.
200
+ // present-filled — slot already carried CUSTOM contentpreserved verbatim.
201
+ // error — malformed slot, 0/>1 anchors, or cap exceeded → input unchanged.
167
202
  export const reconcileMarkerSlot = (text, descriptor, fragment, { maxLines } = {}) => {
168
203
  const ensured = ensureMarkerSlot(text, descriptor);
169
204
  if (ensured.status === 'error') return { status: 'error', text, error: ensured.error };
170
205
  const current = extractMarkerSlot(ensured.text, descriptor);
171
206
  const isEmpty = current == null || current.trim() === '';
172
207
  if (!isEmpty) {
208
+ // Canonical-refresh: a slot whose content normalize-matches a known prior canonical is STALE — replace
209
+ // it with the current `fragment`. A customized slot never matches → preserved verbatim. (`fragment`
210
+ // is empty for a customized slot — main() only sources it when markerSlotNeedsFill is true.)
211
+ const priors = descriptor.knownPriorCanonicals ?? [];
212
+ const matchesPrior = fragment && fragment.trim() !== '' && priors.some((p) => normalizeCanonical(p) === normalizeCanonical(current));
213
+ if (matchesPrior) {
214
+ const injected = injectIntoSlot(ensured.text, descriptor, fragment, { maxLines });
215
+ if (injected.status !== 'injected') return { status: 'error', text, error: injected.error };
216
+ return { status: 'reconciled-refreshed', text: injected.text };
217
+ }
173
218
  if (maxLines != null && lineCount(ensured.text) > maxLines) {
174
219
  return { status: 'error', text, error: `AGENTS.md is ${lineCount(ensured.text)} lines (cap ${maxLines}) — trim the file (a customized ${descriptor.markerName} slot must still fit the cap)` };
175
220
  }
@@ -189,7 +234,11 @@ export const markerSlotNeedsFill = (text, descriptor) => {
189
234
  const ensured = ensureMarkerSlot(text, descriptor);
190
235
  if (ensured.status === 'error') return false;
191
236
  const current = extractMarkerSlot(ensured.text, descriptor);
192
- return current == null || current.trim() === '';
237
+ if (current == null || current.trim() === '') return true; // empty → fill
238
+ // filled-but-STALE (content matches a known prior canonical) → needs a refresh, so main() must
239
+ // re-source the fragment. A customized slot doesn't match → no source needed (preserved verbatim).
240
+ const priors = descriptor.knownPriorCanonicals ?? [];
241
+ return priors.some((p) => normalizeCanonical(p) === normalizeCanonical(current));
193
242
  };
194
243
 
195
244
  // ── methodology-slot exports (delegate to the generic engine, byte-for-byte) ────
@@ -217,6 +266,19 @@ export const methodologyProceduresHint = (text) => {
217
266
  return `the methodology pointer has no procedures route — add "${PROCEDURES_POINTER} <activity>" for auto-discovery; the activity procedures are reachable now via ${PROCEDURES_POINTER}.`;
218
267
  };
219
268
 
269
+ // Generic read-only upgrade advisory (AD-025; the §1.6a/§1.9 reach to CUSTOMIZED filled slots): a slot
270
+ // that is present + FILLED but lacks the descriptor's current-canon `upgradeSignature` (and so could not
271
+ // be canonical-refreshed — its content is customized) gets a one-line note the upgrade flow surfaces.
272
+ // Returns null for an absent / empty / malformed slot, or one that already carries the signature. Pure;
273
+ // never edits the file (a customization is never rewritten — only the user decides).
274
+ export const markerSlotUpgradeHint = (text, descriptor) => {
275
+ if (!descriptor.upgradeSignature) return null;
276
+ const content = extractMarkerSlot(text, descriptor);
277
+ if (content == null || content.trim() === '') return null; // only a FILLED slot
278
+ if (content.includes(descriptor.upgradeSignature)) return null; // already carries the current clause
279
+ return descriptor.upgradeAdvice;
280
+ };
281
+
220
282
  // A cap-refusal is a SOFT, reported skip (distinct from a malformed/anchor STOP) — keyed off the
221
283
  // stable "(cap N)" substring both cap messages carry, so the dual-slot reconcile can skip the
222
284
  // orchestration pointer (loud) while keeping the methodology fill, instead of aborting both.
@@ -317,18 +379,23 @@ const main = async (argv) => {
317
379
  console.error(`[inject-methodology] reconcile refused — ${methResult.error}`);
318
380
  process.exit(1);
319
381
  }
320
- const afterMeth = methResult.text; // === text when the methodology slot was already filled
382
+ const afterMeth = methResult.text; // === text when the methodology slot was already filled (custom)
321
383
  const describeMeth = {
322
384
  'reconciled-inserted': 'inserted the workflow-methodology pointer at the Session-Protocols anchor and filled it',
323
385
  'reconciled-filled': 'filled the empty workflow-methodology pointer',
386
+ 'reconciled-refreshed': 'refreshed the workflow-methodology pointer to the current canon',
324
387
  'present-filled': 'workflow-methodology pointer already present',
325
388
  }[methResult.status];
326
- // Read-only upgrade advisory (AD-019 §3.1a): a pre-existing FILLED methodology pointer that predates
327
- // the procedures clause won't be re-rendered (reconcile preserves it verbatim), so surface a hint to
328
- // add the procedures route. No mutation — purely a reported note appended to the success report.
329
- const proceduresNote = methResult.status === 'present-filled' ? methodologyProceduresHint(afterMeth) : null;
330
- const reportNote = () => {
331
- if (proceduresNote) console.log(`[inject-methodology] note: ${proceduresNote}`);
389
+ // Read-only upgrade advisories for a CUSTOMIZED methodology pointer that reconcile preserved verbatim
390
+ // (a refreshed/filled/inserted slot already carries the current canon, so it gets none): the AD-019
391
+ // procedures route AND the §1.9 communication-contract clause. No mutation — reported notes only.
392
+ const notes = [];
393
+ if (methResult.status === 'present-filled') {
394
+ const p = methodologyProceduresHint(afterMeth); if (p) notes.push(p);
395
+ const u = markerSlotUpgradeHint(afterMeth, METHODOLOGY_DESCRIPTOR); if (u) notes.push(u);
396
+ }
397
+ const reportNotes = () => {
398
+ for (const n of notes) console.log(`[inject-methodology] note: ${n}`);
332
399
  };
333
400
 
334
401
  // ── Explicit [fragment.md] binds methodology ONLY → skip the orchestration reconcile ──
@@ -374,8 +441,15 @@ const main = async (argv) => {
374
441
  describeOrch = {
375
442
  'reconciled-inserted': 'inserted the orchestration-recipes pointer below it and filled it',
376
443
  'reconciled-filled': 'filled the empty orchestration-recipes pointer',
444
+ 'reconciled-refreshed': 'refreshed the orchestration-recipes pointer to the current canon',
377
445
  'present-filled': 'orchestration-recipes pointer already present',
378
446
  }[orchResult.status];
447
+ // §1.6a advisory: a CUSTOMIZED orchestration pointer preserved verbatim, lacking the read-at-start
448
+ // clause, gets a read-only nudge (a refreshed/filled slot already carries it).
449
+ if (orchResult.status === 'present-filled') {
450
+ const u = markerSlotUpgradeHint(finalText, ORCHESTRATION_DESCRIPTOR);
451
+ if (u) notes.push(u);
452
+ }
379
453
  }
380
454
  }
381
455
 
@@ -384,12 +458,12 @@ const main = async (argv) => {
384
458
  // Byte-unchanged. Still report a cap-skip (it is not "nothing to do" — a pointer was withheld).
385
459
  if (orchSkipped) console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}.`);
386
460
  else console.log('[inject-methodology] reconcile: both pointers already present and filled — nothing to do (zero-diff).');
387
- reportNote();
461
+ reportNotes();
388
462
  return;
389
463
  }
390
464
  await writeAtomic(finalText);
391
465
  console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}.`);
392
- reportNote();
466
+ reportNotes();
393
467
  return;
394
468
  }
395
469
 
@@ -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
+ };