@sabaiway/agent-workflow-kit 1.17.0 → 1.19.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,81 @@
1
+ // labels.mjs — the frozen vocabulary LEAF of the status surface (Plan: One-init-freshness §4.2 B1).
2
+ //
3
+ // This module owns the internal↔public token vocabulary that used to live inline in
4
+ // family-registry.mjs: the manifestState constants, the internal→public state map, the visibility
5
+ // map, the short display names, and the no-leak forbidden-term set. It is a LEAF — it imports
6
+ // nothing from the family (only the language is here), so the import graph stays acyclic: nobody
7
+ // imports family-registry for vocabulary, and family-registry imports + re-exports the public subset
8
+ // it exported before (the 7 state constants + DISPLAY_NAMES), so every existing importer stays green.
9
+ //
10
+ // Pure data, no side effects, Node >= 18. Every export is frozen — this is a contract, not a mutable.
11
+
12
+ // ── manifestState values (internal; the detect-backends precedence, generalized to any member) ──────
13
+ // These are INTERNAL — they must never reach a user surface verbatim (mapped through STATE_PUBLIC
14
+ // first). The two surfaces are pinned separately; the no-leak guard (INTERNAL_RENDER_FORBIDDEN) keeps
15
+ // the internal literals out of the serialized envelope.
16
+ export const NOT_INSTALLED = 'not-installed';
17
+ export const UNSUPPORTED_SCHEMA = 'unsupported-schema';
18
+ export const INVALID_MANIFEST = 'invalid-manifest';
19
+ export const STUB = 'stub';
20
+ export const FOREIGN = 'foreign';
21
+ export const OK = 'ok';
22
+ // The marker could not be probed (a non-ENOENT fs error — EACCES/EIO). Surfaced explicitly instead of
23
+ // being masked as not-installed (no silent failure). NOTE: 'unknown' is ALSO a PUBLIC value — it is a
24
+ // bridge wrappers[].state ("couldn't check"), so it is deliberately EXCLUDED from the no-leak set.
25
+ export const UNKNOWN = 'unknown';
26
+
27
+ // internal manifestState → a STABLE, user-safe token. SKILL.md owns the value→plain-language phrasing;
28
+ // presentation.mjs owns the direct-CLI English phrasing. Deliberately NOT the internal literals
29
+ // (foreign/stub/…): those must never leak past this boundary.
30
+ export const STATE_PUBLIC = Object.freeze({
31
+ [OK]: 'installed',
32
+ [NOT_INSTALLED]: 'absent',
33
+ [FOREIGN]: 'other-tool',
34
+ [STUB]: 'placeholder',
35
+ [INVALID_MANIFEST]: 'invalid',
36
+ [UNSUPPORTED_SCHEMA]: 'unsupported',
37
+ [UNKNOWN]: 'uncheckable',
38
+ });
39
+
40
+ // visibility: the THREE honest states from inferVisibility → user-safe words. Never the internal
41
+ // "hidden fence" / marker terms. ('hidden' here is the PUBLIC visibility word, not the internal fence.)
42
+ export const VISIBILITY_PUBLIC = Object.freeze({ visible: 'visible', hidden: 'hidden', ambiguous: 'unclear' });
43
+
44
+ // Short, user-facing labels for the version block (kit · memory · engine · codex-bridge · …), so the
45
+ // render is deterministic and the agent never invents a label.
46
+ export const DISPLAY_NAMES = Object.freeze({
47
+ 'agent-workflow-kit': 'kit',
48
+ 'agent-workflow-memory': 'memory',
49
+ 'agent-workflow-engine': 'engine',
50
+ 'codex-cli-bridge': 'codex-bridge',
51
+ 'antigravity-cli-bridge': 'antigravity-bridge',
52
+ });
53
+ export const displayOf = (name) => DISPLAY_NAMES[name] ?? name;
54
+
55
+ // ── the no-leak forbidden set (Plan §4.3 INV-4) ────────────────────────────────────────────────────
56
+ // The terms that must NEVER appear in the serialized buildEnvelope output (the agent reads the JSON
57
+ // directly, so the no-leak boundary is the envelope, not just the rendered text). The INV-4 test
58
+ // iterates this set over JSON.stringify(envelope). Deliberately EXCLUDES:
59
+ // • 'unknown' — also a PUBLIC bridge wrappers[].state value (a `state:"unknown"` fixture must pass);
60
+ // • 'ok' — too generic to forbid as a substring, and not sensitive (its public form is 'installed').
61
+ // Sourced here as the ONE definition so the guard can't drift from a hand-copied list in the test.
62
+ export const INTERNAL_RENDER_FORBIDDEN = Object.freeze([
63
+ // internal manifestState literals that must never reach a surface
64
+ NOT_INSTALLED,
65
+ UNSUPPORTED_SCHEMA,
66
+ INVALID_MANIFEST,
67
+ STUB,
68
+ FOREIGN,
69
+ // internal field names (the structural keys of the surveys, never the envelope)
70
+ 'manifestState',
71
+ 'hiddenFence',
72
+ // the hidden-mode marker / fence jargon (the SKILL communication firewall) — never the public
73
+ // visibility words ('hidden' / 'visible' / 'unclear'), only the internal compound terms + operations
74
+ 'hidden fence',
75
+ 'hidden-mode fence',
76
+ 'reconcile',
77
+ 'ensureSlot',
78
+ // raw deployment-stamp FILENAMES (the envelope exposes member/display/version, never the file)
79
+ '.workflow-version',
80
+ '.memory-version',
81
+ ]);
@@ -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
+ };
@@ -0,0 +1,55 @@
1
+ // presentation.mjs — the frozen ENGLISH/neutral vocabulary the direct-CLI renderers use to draw the
2
+ // four status blocks (members · bridges · project deploy/visibility · settings). Plan §4.2.
3
+ //
4
+ // This is the DIRECT-CLI surface only (`node tools/family-registry.mjs` in a terminal). The
5
+ // agent-mediated surface (`/agent-workflow-kit status`) consumes the `--json` envelope and localizes
6
+ // in the user's language per SKILL.md §4.4 — it never reads this file. Keeping the vocabulary here (a
7
+ // frozen leaf) means the renderers carry no inline strings and the phrasing is pinned by tests.
8
+ //
9
+ // Pure data, no side effects, Node >= 18. Every export frozen.
10
+
11
+ // public state token (STATE_PUBLIC value) → the direct-CLI English phrase. `installed` is null because
12
+ // an installed member shows its VERSION, not a phrase (the renderer special-cases it). Mirrors the
13
+ // SKILL.md value→plain-language map so the two surfaces stay semantically aligned.
14
+ export const STATE_PHRASING = Object.freeze({
15
+ installed: null, // → show the version instead
16
+ absent: 'not installed',
17
+ 'other-tool': 'a different tool occupies that skill slot',
18
+ placeholder: 'a placeholder, not a working install',
19
+ invalid: "installed but its manifest didn't validate",
20
+ unsupported: 'installed but its manifest schema is too new for this kit',
21
+ uncheckable: "couldn't be checked (a permission error)",
22
+ });
23
+
24
+ // public visibility (VISIBILITY_PUBLIC value) → phrase. Never the internal "fence"/marker terms.
25
+ export const VISIBILITY_PHRASING = Object.freeze({
26
+ visible: 'visible (tracked)',
27
+ hidden: 'hidden (git-ignored, local-only)',
28
+ unclear: 'unclear (uncommitted or partially set up)',
29
+ });
30
+
31
+ // Block titles. `project` is a function of the resolved dir.
32
+ export const BLOCK_TITLES = Object.freeze({
33
+ members: 'agent-workflow family — installed members (skill axis)',
34
+ bridges: 'execution backends (host)',
35
+ project: (dir) => `project deployment (${dir})`,
36
+ settings: 'settings',
37
+ });
38
+
39
+ // Per-area settings row labels (the left column of the settings block).
40
+ export const SETTINGS_LABELS = Object.freeze({
41
+ recipes: 'recipes',
42
+ attribution: 'attribution',
43
+ velocity: 'velocity',
44
+ });
45
+
46
+ // Glyph sets — Unicode for a capable terminal, an ASCII fallback for a narrow / Windows-legacy one.
47
+ // The renderer picks one set by the resolved surface's `ascii` flag (surface.mjs).
48
+ export const GLYPHS = Object.freeze({
49
+ unicode: Object.freeze({ note: '↳', present: '✓', missing: '✗', unknown: '?', bullet: '•' }),
50
+ ascii: Object.freeze({ note: '->', present: '+', missing: 'x', unknown: '?', bullet: '*' }),
51
+ });
52
+ export const glyphsFor = (ascii) => (ascii ? GLYPHS.ascii : GLYPHS.unicode);
53
+
54
+ // The "no deployment here" line for an undeployed project.
55
+ export const NO_DEPLOYMENT = 'no agent-workflow deployment detected here (no docs/ai, no version stamp).';