@sabaiway/agent-workflow-kit 1.18.0 → 1.20.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 CHANGED
@@ -4,6 +4,47 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 1.20.0 — Fallback-template lens + the cross-package lens-mirror guard (kit)
8
+
9
+ A **feature** release. The kit's fallback `agent_rules.md` template gains the **§2.5 Right-altitude &
10
+ code-grounded** lens (byte-identical to memory's §2.6 block), and a new cross-package
11
+ `test/lens-mirror.test.mjs` ties the shared lens vocabulary across the engine canon (`planning.md` +
12
+ `procedures.md`) AND both `agent_rules.md` templates: each distinctive token must survive in every file's
13
+ lens **region**, and the two template blocks must stay byte-identical apart from the heading number. The
14
+ deployment-lineage head stays **`1.3.0`** (no `docs/ai` structural change, no migration); the kit
15
+ **package** version is a separate axis.
16
+
17
+ - **Non-vacuous guard.** Deleting the lens block (or a distinctive token) from any of the four files
18
+ fails the guard — proven by an injected-divergence dry-run.
19
+
20
+ ## 1.19.0 — One-command freshness: `init` refreshes memory too, and a capability-adaptive `status` (kit)
21
+
22
+ A **feature** release that closes the returning-user gap and modernizes the status surface. The
23
+ deployment-lineage head stays **`1.3.0`** (no `docs/ai` structural change, no migration); the kit
24
+ **package** version is a separate axis.
25
+
26
+ - **`init` now leaves no stale core member.** After installing/refreshing the kit, `npx
27
+ @sabaiway/agent-workflow-kit@latest init` also refreshes the **memory substrate** and the
28
+ **methodology engine** over npm — so a returning user is no longer left with silently stale memory.
29
+ The memory refresh is **best-effort: a miss is a loud DEGRADED success** — a warning with the exact
30
+ recovery command (and the on-disk version) plus **exit 0**, never a silent skip and never the engine's
31
+ hard STOP. New **`--no-memory`** flag skips it for air-gapped/scripted installs. The cascade
32
+ membership is derived from the one family registry and drift-guarded; bridges are still placed by
33
+ `setup`, never by `init`.
34
+ - **Capability-adaptive `status` output.** The direct-CLI status view (`node tools/family-registry.mjs`)
35
+ is rebuilt as a `surface → view-model → renderers` pipeline: it auto-detects the terminal (plain vs
36
+ ANSI, color via `NO_COLOR`/`FORCE_COLOR`, width with a 40-col floor, ASCII-glyph fallback) and renders
37
+ all four blocks (members · bridges · project deploy/visibility · settings). `--format=<auto|plain|ansi|json>`
38
+ (with `--json` as sugar) selects the surface; unknown flags and a missing `--dir` value now **reject
39
+ loudly** instead of being silently ignored.
40
+ - **Additive `--json` freshness signal.** Each `installed[]` entry gains a structural `refresh`
41
+ `{ behind, recommend }` object (derived from the registry, never parsed from a caveat). The
42
+ agent-mediated `/agent-workflow-kit status` reads it to show a localized "needs refresh" label + the
43
+ exact command **once**; every existing envelope field is unchanged.
44
+ - **Docs.** Install help + READMEs document the memory/engine refresh, `--no-memory`, the degraded-success
45
+ recovery, and that bridges are not installed by `init`. Tarball **75 → 81** (the pure member-table leaf
46
+ + five status-presenter modules).
47
+
7
48
  ## 1.18.0 — Agent-writable orchestration config (`set-recipe`), version-aware setup, durable session contracts (kit)
8
49
 
9
50
  A **feature** release. The per-project recipe config (`docs/ai/orchestration.json`) is no longer
package/README.md CHANGED
@@ -194,20 +194,25 @@ other agent). The manual path works identically but **isn't reflected in install
194
194
  <details>
195
195
  <summary><b>What <code>init</code> touches — and how to undo it</b></summary>
196
196
 
197
- `init` is **additive — it never deletes your settings.** It writes only its own namespaced slots:
197
+ `init` is **additive — it never deletes your settings.** It writes its own namespaced slots, then
198
+ refreshes the other npm core members so a returning `init` leaves **no stale core member**:
198
199
 
199
200
  | Path | What |
200
201
  |------|------|
201
202
  | `~/.claude/skills/agent-workflow-kit/` | the kit itself (refreshed on every `init`) |
203
+ | `~/.claude/skills/agent-workflow-memory/` | the **memory substrate**, refreshed via `npx @sabaiway/agent-workflow-memory@latest init` — **best-effort:** a failure is a **loud degraded success** (warning + the exact recovery command + exit 0), never silent; skip with `--no-memory` |
204
+ | `~/.claude/skills/agent-workflow-engine/` | the **methodology engine** the kit reads live, refreshed via `npx @sabaiway/agent-workflow-engine@latest init` — **required** (the live read STOPs without it); skip with `--no-engine` |
202
205
  | `~/.codex/skills/agent-workflow-kit` | a symlink — only if you have Codex |
203
206
  | `…/global_workflows/agent-workflow-kit.md` | a managed file — only if you have Devin Desktop |
204
207
 
205
- Your other Codex skills and Devin Desktop workflows are never touched. If one of those exact slots
206
- already holds a file the kit didn't write, it is **left alone** and you're told re-run with
207
- `--force` to replace it (the original is first copied to `*.bak.<timestamp>` and the restore
208
- command is printed).
208
+ The **execution-backend bridges** (`codex` / `agy`) are **not** installed by `init` set one up on
209
+ demand with `/agent-workflow-kit setup`. Your other Codex skills and Devin Desktop workflows are
210
+ never touched. If one of those exact slots already holds a file the kit didn't write, it is **left
211
+ alone** and you're told — re-run with `--force` to replace it (the original is first copied to
212
+ `*.bak.<timestamp>` and the restore command is printed).
209
213
 
210
- **Uninstall:** delete the slots above (the kit folder, the symlink, the workflow file).
214
+ **Uninstall:** delete the slots above (the kit / memory / engine folders, the symlink, the workflow
215
+ file), or run the guarded `/agent-workflow-kit uninstall`.
211
216
  </details>
212
217
 
213
218
  ---
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '1.18.0'
6
+ version: '1.20.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
@@ -28,6 +28,10 @@ when it is present and healthy, and otherwise uses its **own bundled copy** (`re
28
28
  memory substrate**. (The methodology slot is a separate axis: its fragment is read **live from the
29
29
  installed `agent-workflow-engine`**, which `npx @sabaiway/agent-workflow-kit@latest init` installs — a
30
30
  runtime dependency placed by `init` and read live; see *Methodology slot reconciliation* below.)
31
+ `init` also **refreshes the installed memory substrate** (best-effort — a miss is a loud DEGRADED
32
+ success: a warning with the exact recovery command + exit 0, never silent, never the engine's hard
33
+ STOP; `--no-memory` skips it), so a returning `init` leaves **no stale core member**. The
34
+ execution-backend bridges are still **not** installed by `init` (placed on demand by `setup`).
31
35
 
32
36
  **Detection (kit-owned, decided BEFORE any project write).** Run the kit's **own shipped**
33
37
  validator — `node ${CLAUDE_SKILL_DIR}/tools/manifest/validate.mjs <memory-skill-dir>` — never a
@@ -336,7 +340,9 @@ Read-only. The **single answer to "versions + deployment + settings + bridges"**
336
340
 
337
341
  Run `node ${CLAUDE_SKILL_DIR}/tools/family-registry.mjs --json [--dir <project>]` and render it **compact**, in the user's conversational language — **never paste the JSON or any internal field name** (no-leak rule). Map the **`installed[].state`** token via the value→plain-language map under *The version block + welcome mat* (the `visibility` and wrapper states have their own phrasings, below). Present, each area on its own line(s), routing detail to its domain mode:
338
342
 
339
- 1. **Versions — the version block** (from `installed[]` + `deploymentHead`): the deployment-structure head, the installed package versions by `display`, the two-axes disambiguation, plus any member `notes` (e.g. a behind member's refresh+restart line). This is the shared version block above.
343
+ 1. **Versions — a status-only render from `installed[]` + `deploymentHead`** (this is **NOT** the shared notes-based version block — see the separation note below): the deployment-structure head, then each member by its `display` showing its `version` (or, when there is no version, the plain phrase for its `state`, mapped above), plus the two-axes disambiguation. **Freshness comes from `installed[].refresh`, not from `notes`:** for each member whose **`refresh.behind`** is `true`, show a **localized "needs refresh"** label and the **verbatim `refresh.recommend`** command **exactly once** (the command/package name stays source-language; **do not also paste the English `notes` caveats** — `refresh.recommend` is the single source of the recovery step, so the command is never duplicated on this surface). Lead with a one-line **headline count** derived from `installed[].state` + `refresh.behind` (e.g. *"5 members installed · 1 needs a refresh"*).
344
+
345
+ > **Status reads `refresh`; the shared version block + the bootstrap/upgrade footers stay `notes`-based (unchanged this release).** *Mode: status* has its OWN status-only render (above), keyed on `installed[].refresh.behind` / `refresh.recommend`. The shared **version block** (under *The version block + welcome mat*) and the bootstrap (step 11) + every upgrade (steps 4 / 8) report footer still consume `installed[].notes` verbatim — that wiring is deliberately **untouched** here (their migration onto `refresh` is deferred). Do not rewrite those footers onto `refresh`.
340
346
  2. **Deployment (`--dir`)** (from `project`): whether `docs/ai/` is deployed + the deploy stamps by `display`; and **visibility** — render `project.visibility.state` in **user-safe words only**: *visible (tracked)* / *hidden (git-ignored, local-only)* / *unclear (uncommitted or partially set up)* — **never** the words "hidden fence" or any marker term. A `visibility.error` → surface it plainly.
341
347
  3. **Settings (`--dir`, one line each)** (from `project.settings`):
342
348
  - **recipes** — the effective recipe per slot (detail → `/agent-workflow-kit procedures` / `recipes`); a `recipes.detectError` → say the backends couldn't be checked, so recipes floored at solo.
package/bin/install.mjs CHANGED
@@ -18,9 +18,13 @@
18
18
  // compares the version already on disk (the installed SKILL.md) against this runner's own version,
19
19
  // never the registry — which is why `@latest` (above) is the documented form: a bare `npx … init`
20
20
  // can reuse an OLDER cached build of this installer, so a returning user must bypass the cache to
21
- // actually upgrade (see decisions.md AD-012). One step DOES contact a server: `init` fetches and
22
- // installs the methodology engine the kit reads live (`npx @sabaiway/agent-workflow-engine@latest
23
- // init`), skippable with `--no-engine` (Plan 3D / AD-016). No tracking either way.
21
+ // actually upgrade (see decisions.md AD-012). Two steps DO contact a server: after the kit copy,
22
+ // `init` cascades the other npm-installable CORE members so a returning run leaves NO stale core
23
+ // member the memory substrate (`npx @sabaiway/agent-workflow-memory@latest init`, best-effort: a
24
+ // failure is a loud DEGRADED success, never silent — skippable with `--no-memory`) and the
25
+ // methodology engine the kit reads live (`npx @sabaiway/agent-workflow-engine@latest init`, fatal —
26
+ // the live read STOPs without it — skippable with `--no-engine`). The bridges are NOT installed by
27
+ // `init` (placed by `/agent-workflow-kit setup`). No tracking either way.
24
28
  //
25
29
  // Dependency-free, Node >= 18.
26
30
 
@@ -31,6 +35,11 @@ import { fileURLToPath } from 'node:url';
31
35
  import { homedir } from 'node:os';
32
36
  import { spawnSync } from 'node:child_process';
33
37
  import { copyTreeRefresh } from '../tools/fs-safe.mjs';
38
+ // The ONE registry of family members (npm packages, kinds). The init-refresh cascade derives its
39
+ // membership from this table — no second source of "who gets refreshed" — so it can't drift from the
40
+ // manifests (a drift-guard test pins the derivation). Imported from the DATA LEAF (family-members.mjs),
41
+ // NOT family-registry.mjs, so the npx cold-start path stays lean (no status/presenter graph pulled in).
42
+ import { FAMILY_MEMBERS } from '../tools/family-members.mjs';
34
43
 
35
44
  const __dirname = dirname(fileURLToPath(import.meta.url));
36
45
  const PKG_ROOT = resolve(__dirname, '..');
@@ -146,6 +155,7 @@ const parseArgs = (argv) => {
146
155
  help: argv.includes('--help') || argv.includes('-h'),
147
156
  version: argv.includes('--version') || argv.includes('-v'),
148
157
  noLaunchers: argv.includes('--no-launchers'),
158
+ noMemory: argv.includes('--no-memory'),
149
159
  noEngine: argv.includes('--no-engine'),
150
160
  force: argv.includes('--force'),
151
161
  allowDowngrade: argv.includes('--allow-downgrade'),
@@ -153,47 +163,72 @@ const parseArgs = (argv) => {
153
163
  };
154
164
  };
155
165
 
156
- // Mandatory engine install (Plan 3D / AD-016). The kit reads the methodology fragment LIVE from the
157
- // installed agent-workflow-engine, so init places it as a CORE part of the kit (not an optional
158
- // execution-backendthis deliberately diverges from AD-011 §5). It is fetched over npm, consistent
159
- // with the kit's own npx install context; NO engine canon is bundled into the kit (that would
160
- // re-create the mirror Plan 3D deletes). --no-engine skips it for air-gapped/scripted installs.
161
- export const ENGINE_PACKAGE = '@sabaiway/agent-workflow-engine';
162
-
163
- // The exact command + argv to install the engine. Windows resolution: spawn `npx.cmd` on win32,
164
- // `npx` elsewhere, WITHOUT shell:true (no shell-parse overhead/inconsistency; the repo has no
165
- // npx-spawn precedent to inherit a shell from). Pure unit-tested in-process, no network.
166
- export const engineInstallArgv = (platform) => ({
166
+ // Init-refresh cascade (Plan 3D / AD-016 for the engine; the memory cascade closes the
167
+ // returning-user "no stale core member" journey). After the kit copy, init refreshes the OTHER
168
+ // npm-installable CORE members the memory substrate + the methodology engine each over npm,
169
+ // consistent with the kit's own npx install context (NO engine/memory canon is bundled into the kit;
170
+ // that would re-create the mirror Plan 3D deletes). The npm package names are read from the ONE
171
+ // registry (FAMILY_MEMBERS) so there is no second source of truth. --no-memory / --no-engine skip
172
+ // each for air-gapped/scripted installs.
173
+ const npmFor = (name) => {
174
+ const member = FAMILY_MEMBERS.find((m) => m.name === name);
175
+ if (!member?.npm) throw new Error(`[agent-workflow-kit] no npm package registered for ${name}`);
176
+ return member.npm;
177
+ };
178
+ export const ENGINE_PACKAGE = npmFor('agent-workflow-engine');
179
+ export const MEMORY_PACKAGE = npmFor('agent-workflow-memory');
180
+
181
+ // The npm-installable core members the cascade refreshes, DERIVED from FAMILY_MEMBERS (every npm
182
+ // member that is neither the kit — the composition root this very runner installs — nor an
183
+ // execution-backend, which `setup` places, not init). Order + policy are part of the contract:
184
+ // NON-FATAL first (memory — a miss is a degraded success), then FATAL (engine — the live methodology
185
+ // read STOPs without it), so a hard engine failure never pre-empts the best-effort memory refresh.
186
+ // A drift-guard test pins this derivation, so adding a future npm core member can't silently bypass
187
+ // the cascade. Per-member messaging stays explicit in main() (the degraded-vs-STOP wording differs).
188
+ const FATAL_KINDS = new Set(['methodology-engine']);
189
+ export const cascadePlan = () =>
190
+ FAMILY_MEMBERS
191
+ .filter((m) => m.npm && m.kind !== 'execution-backend' && m.name !== 'agent-workflow-kit')
192
+ .map((m) => ({ name: m.name, npm: m.npm, fatal: FATAL_KINDS.has(m.kind) }))
193
+ .sort((a, b) => Number(a.fatal) - Number(b.fatal)); // non-fatal (memory) before fatal (engine)
194
+
195
+ // The exact command + argv to install a member over npx. Windows resolution: spawn `npx.cmd` on
196
+ // win32, `npx` elsewhere, WITHOUT shell:true (no shell-parse overhead/inconsistency; the repo has no
197
+ // npx-spawn precedent to inherit a shell from). `@latest` exactly — never a pinned version, so the
198
+ // cascade always fetches the newest published member. Pure → unit-tested in-process, no network.
199
+ export const memberInstallArgv = (npmPackage, platform) => ({
167
200
  command: platform === 'win32' ? 'npx.cmd' : 'npx',
168
- args: [`${ENGINE_PACKAGE}@latest`, 'init'],
201
+ args: [`${npmPackage}@latest`, 'init'],
169
202
  options: { stdio: 'inherit' }, // note: no `shell: true`
170
203
  });
204
+ export const engineInstallArgv = (platform) => memberInstallArgv(ENGINE_PACKAGE, platform);
205
+ export const memoryInstallArgv = (platform) => memberInstallArgv(MEMORY_PACKAGE, platform);
171
206
 
172
207
  // The default runner — the only place that actually spawns. Injected in tests so the suite never
173
208
  // hits the network.
174
- const spawnEngine = ({ command, args, options }) => spawnSync(command, args, options);
209
+ const spawnMember = ({ command, args, options }) => spawnSync(command, args, options);
175
210
 
176
211
  // Synchronous backoff before the single retry. The common first-attempt failure is a TRANSIENT
177
212
  // npm/network blip (rate-limit, registry hiccup, momentary DNS) — an immediate retry tends to hit the
178
213
  // same blip, so wait briefly first. Atomics.wait is a dependency-free sync sleep (the install flow is
179
214
  // already synchronous here). Injected in tests as a 0ms no-op so the suite never actually sleeps.
180
- const ENGINE_RETRY_DELAY_MS = 1500;
215
+ const RETRY_DELAY_MS = 1500;
181
216
  const sleepSync = (ms) => {
182
217
  if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
183
218
  };
184
219
 
185
220
  // D1 failure policy: attempt → wait → retry-once → fail. Retry exactly once before giving up. Pure
186
221
  // aside from the injected runner/sleep; returns { ok } so the caller prints the loud manual-recovery
187
- // message + nonzero exit on a hard failure (the kit is already on disk — recovery is one step; never
188
- // a silent skip).
189
- export const installEngine = (platform, runner, { sleep = sleepSync, retryDelayMs = ENGINE_RETRY_DELAY_MS } = {}) => {
190
- const descriptor = engineInstallArgv(platform);
191
- const ranOk = (label) => {
222
+ // message (the engine's nonzero exit, the memory's degraded warning) on a hard failure (the kit is
223
+ // already on disk — recovery is one step; never a silent skip). `label` names the member in the
224
+ // per-attempt warning (e.g. "methodology engine", "memory substrate").
225
+ const installMember = (label, descriptor, runner, { sleep = sleepSync, retryDelayMs = RETRY_DELAY_MS } = {}) => {
226
+ const ranOk = (attempt) => {
192
227
  const res = runner(descriptor);
193
228
  const ok = (res?.status ?? 1) === 0 && !res?.error;
194
229
  if (!ok) {
195
230
  const why = res?.error ? `: ${res.error.message}` : ` (exit ${res?.status ?? 'unknown'})`;
196
- console.warn(`[agent-workflow-kit] methodology engine install ${label} failed${why}.`);
231
+ console.warn(`[agent-workflow-kit] ${label} install ${attempt} failed${why}.`);
197
232
  }
198
233
  return ok;
199
234
  };
@@ -203,17 +238,47 @@ export const installEngine = (platform, runner, { sleep = sleepSync, retryDelayM
203
238
  return { ok: false };
204
239
  };
205
240
 
241
+ export const installEngine = (platform, runner, opts = {}) =>
242
+ installMember('methodology engine', engineInstallArgv(platform), runner, opts);
243
+ export const installMemory = (platform, runner, opts = {}) =>
244
+ installMember('memory substrate', memoryInstallArgv(platform), runner, opts);
245
+
206
246
  const resolveTarget = (dirArg) => {
207
247
  if (dirArg) return resolve(dirArg);
208
248
  if (process.env.AGENT_WORKFLOW_KIT_DIR) return resolve(process.env.AGENT_WORKFLOW_KIT_DIR);
209
249
  return resolve(homedir(), '.claude/skills/agent-workflow-kit');
210
250
  };
211
251
 
252
+ // Where the memory substrate lives — its env override else its home-relative default, both read from
253
+ // the ONE registry (FAMILY_MEMBERS), expanding a leading `~/`. Used only by the DEGRADED warning to
254
+ // report the on-disk memory version after a refresh miss (init never WRITES here — that is memory's
255
+ // own installer's job). getenv/home injectable for hermetic tests.
256
+ const resolveMemoryTarget = (getenv = process.env, home = homedir()) => {
257
+ const member = FAMILY_MEMBERS.find((m) => m.name === 'agent-workflow-memory');
258
+ const envDir = getenv[member.installed.env];
259
+ if (envDir) return resolve(envDir);
260
+ const dflt = member.installed.default;
261
+ return dflt.startsWith('~/') ? resolve(home, dflt.slice(2)) : resolve(dflt);
262
+ };
263
+
264
+ // Crash-proof on-disk version read for the degraded memory warning: readInstalledVersion returns null
265
+ // for an ABSENT SKILL.md and THROWS for a present-but-UNREADABLE one — but the warning must NEVER
266
+ // become fatal, so any throw is swallowed to null here. Both branches (absent → null directly,
267
+ // unreadable → caught → null) keep the warning best-effort and exit 0 (degraded success, no silent
268
+ // skip). Returns the semver string or null.
269
+ export const readMemberVersionSafe = async (dir) => {
270
+ try {
271
+ return await readInstalledVersion(dir);
272
+ } catch {
273
+ return null;
274
+ }
275
+ };
276
+
212
277
  const printHelp = (version) => {
213
278
  console.log(`agent-workflow-kit ${version}
214
279
 
215
280
  Usage:
216
- npx @sabaiway/agent-workflow-kit@latest init [--dir <path>] [--no-launchers] [--no-engine] [--force] [--allow-downgrade]
281
+ npx @sabaiway/agent-workflow-kit@latest init [--dir <path>] [--no-launchers] [--no-memory] [--no-engine] [--force] [--allow-downgrade]
217
282
  npx @sabaiway/agent-workflow-kit@latest --version
218
283
  npx @sabaiway/agent-workflow-kit@latest --help
219
284
 
@@ -222,11 +287,15 @@ Use the @latest form: a bare \`npx … init\` (no @latest) can reuse an OLDER ca
222
287
 
223
288
  Installs/refreshes the kit at ~/.claude/skills/agent-workflow-kit
224
289
  (override with --dir <path> or AGENT_WORKFLOW_KIT_DIR), then wires any
225
- Codex / Devin Desktop you have, then installs the methodology engine the kit reads
226
- live (npx ${ENGINE_PACKAGE}@latest init). --no-launchers skips the
227
- launcher wiring; --no-engine skips the engine install (the live methodology read then
228
- STOPs until you install it by hand); --force replaces a pre-existing non-kit launcher
229
- file (backed up first). init is additive it never deletes your settings. If the
290
+ Codex / Devin Desktop you have, then refreshes the other npm core members so no stale
291
+ one is left behind: the memory substrate (npx ${MEMORY_PACKAGE}@latest init)
292
+ and the methodology engine the kit reads live (npx ${ENGINE_PACKAGE}@latest init).
293
+ --no-launchers skips the launcher wiring; --no-memory skips the memory refresh (if it is
294
+ stale, refresh it yourself + restarta miss is otherwise a non-fatal degraded success,
295
+ never the engine's hard STOP); --no-engine skips the engine install (the live methodology
296
+ read then STOPs until you install it by hand); --force replaces a pre-existing non-kit
297
+ launcher file (backed up first). The bridges are NOT installed by init (placed by
298
+ /agent-workflow-kit setup). init is additive — it never deletes your settings. If the
230
299
  installed kit is newer than the version you ran, init refuses (no network — it compares
231
300
  the version on disk) and points you at @latest; --allow-downgrade overrides that
232
301
  refusal (distinct from --force, which is launcher-only).
@@ -335,9 +404,35 @@ const main = async () => {
335
404
  }
336
405
  }
337
406
 
338
- // Mandatory engine install — AFTER the kit + launchers but BEFORE the success block, so a failure
339
- // never first claims everything succeeded. The kit reads the methodology fragment live from the
340
- // installed engine; this places it (over npm, no canon bundled into the kit). --no-engine opts out.
407
+ // Memory substrate refresh — AFTER the kit + launchers, BEFORE the (fatal) engine and the success
408
+ // block, so a returning `init` leaves no stale memory. Unlike the engine, a memory miss is a
409
+ // DEGRADED success: warn with the exact recovery command + the on-disk version (read crash-proof)
410
+ // and keep exit 0 — never silently skip (Hard Constraint), never the engine's hard STOP.
411
+ const memoryCmd = `npx ${MEMORY_PACKAGE}@latest init`;
412
+ if (args.noMemory) {
413
+ console.log(
414
+ `[agent-workflow-kit] --no-memory: skipped refreshing the memory substrate. If it is stale, ` +
415
+ `refresh it yourself and restart the session:\n ${memoryCmd}`,
416
+ );
417
+ } else {
418
+ console.log(`[agent-workflow-kit] refreshing the memory substrate: ${memoryCmd}`);
419
+ const memory = installMemory(process.platform, spawnMember);
420
+ if (memory.ok) {
421
+ console.log('[agent-workflow-kit] memory substrate refreshed.');
422
+ } else {
423
+ const onDisk = await readMemberVersionSafe(resolveMemoryTarget());
424
+ const where = onDisk ? `on disk: v${onDisk}` : 'not found on disk';
425
+ console.warn(
426
+ `[agent-workflow-kit] could not refresh the memory substrate (${where}) — continuing; the ` +
427
+ `kit itself IS installed and works. To get the latest memory, run it yourself and restart ` +
428
+ `the session:\n ${memoryCmd}`,
429
+ );
430
+ }
431
+ }
432
+
433
+ // Mandatory engine install — AFTER the kit + launchers + memory but BEFORE the success block, so a
434
+ // failure never first claims everything succeeded. The kit reads the methodology fragment live from
435
+ // the installed engine; this places it (over npm, no canon bundled into the kit). --no-engine opts out.
341
436
  const engineCmd = `npx ${ENGINE_PACKAGE}@latest init`;
342
437
  if (args.noEngine) {
343
438
  console.log(
@@ -347,7 +442,7 @@ const main = async () => {
347
442
  );
348
443
  } else {
349
444
  console.log(`[agent-workflow-kit] installing the methodology engine the kit reads live: ${engineCmd}`);
350
- const engine = installEngine(process.platform, spawnEngine);
445
+ const engine = installEngine(process.platform, spawnMember);
351
446
  if (!engine.ok) {
352
447
  // D1: two attempts failed → loud error + concrete recommendations + nonzero exit. The kit IS on
353
448
  // disk, so recovery is one step. Never a silent skip (Hard Constraint: no silent failures).
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "1.18.0",
6
+ "version": "1.20.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "1.18.0",
3
+ "version": "1.20.0",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -61,6 +61,11 @@ Before proposing changes or committing, review against:
61
61
  ### 2.4. Quality Gates
62
62
  - Always run type-checker, linter, and all tests before committing.
63
63
 
64
+ ### 2.5. Right-altitude & code-grounded (planning + review)
65
+ - **Fold by code, not prose.** Before folding a code-touching finding into a plan or change, read the cited `file:line` and cite it — a prose fold drifts from the code and seeds the next bug.
66
+ - **Right altitude.** Pin intent + invariants + acceptance criteria (named tests); leave fine code-mechanics to Execute, where prose cannot diverge from reality.
67
+ - **Convergence heuristic.** When a review round keeps finding mechanism issues on a stable architecture, stop refining prose — raise the altitude or hand it to Execute. (Full canon: the project's planning methodology / workflow-methodology canon.)
68
+
64
69
  ---
65
70
 
66
71
  ## 3. Token & Session Optimization
@@ -0,0 +1,67 @@
1
+ // family-members.mjs — the pure DATA LEAF: the one authoritative table of agent-workflow family members.
2
+ //
3
+ // Extracted from family-registry.mjs so the two consumers that only need the static table — the npx
4
+ // installer (bin/install.mjs, which derives its init-refresh cascade from it) and family-registry
5
+ // itself — can import the DATA without dragging in the whole status/presenter graph
6
+ // (detect-backends, the manifest validator, hide-footprint, engine-source, recipes, the renderers …).
7
+ // install.mjs runs on the npx cold-start hot path, so the leaner import matters; single-source-of-truth
8
+ // + the drift-guard (family-registry.test.mjs pins FAMILY_MEMBERS to the 5 in-repo capability.json)
9
+ // are preserved — the table just lives in a dependency-free leaf now.
10
+ //
11
+ // Pure data, no imports, no side effects, Node >= 18.
12
+
13
+ // ── the unified registry ───────────────────────────────────────────────────────
14
+ // One entry per family member. `installed` is the detect.installed spec (env + home-relative default
15
+ // + marker file); `deployed` is the project-relative stamp a deploy writes (kit + memory only);
16
+ // `npm` is the install package (null for the bridges, which are placed by `setup`, not npm);
17
+ // `wrapperCmds` is the deduped roles[].cmd set the `setup` linker creates on PATH (bridges only).
18
+ // Kept in lockstep with the 5 in-repo capability.json by the drift-guard test. The two release skills
19
+ // (release-engineering / release-marketing) are deliberately NOT here — they are not family members
20
+ // (AD-013): no capability.json, not in the kit tarball, not in the role vocabulary.
21
+ export const FAMILY_MEMBERS = [
22
+ {
23
+ name: 'agent-workflow-kit',
24
+ kind: 'composition-root',
25
+ installed: { env: 'AGENT_WORKFLOW_KIT_DIR', default: '~/.claude/skills/agent-workflow-kit', file: 'SKILL.md' },
26
+ deployed: { file: 'docs/ai/.workflow-version' },
27
+ npm: '@sabaiway/agent-workflow-kit',
28
+ wrapperCmds: [],
29
+ },
30
+ {
31
+ name: 'agent-workflow-memory',
32
+ kind: 'memory-substrate',
33
+ installed: { env: 'AGENT_WORKFLOW_MEMORY_DIR', default: '~/.claude/skills/agent-workflow-memory', file: 'SKILL.md' },
34
+ deployed: { file: 'docs/ai/.memory-version' },
35
+ npm: '@sabaiway/agent-workflow-memory',
36
+ wrapperCmds: [],
37
+ },
38
+ {
39
+ name: 'agent-workflow-engine',
40
+ kind: 'methodology-engine',
41
+ installed: { env: 'AGENT_WORKFLOW_ENGINE_DIR', default: '~/.claude/skills/agent-workflow-engine', file: 'SKILL.md' },
42
+ deployed: null,
43
+ npm: '@sabaiway/agent-workflow-engine',
44
+ wrapperCmds: [],
45
+ },
46
+ {
47
+ name: 'codex-cli-bridge',
48
+ kind: 'execution-backend',
49
+ installed: { env: 'CODEX_CLI_BRIDGE_DIR', default: '~/.claude/skills/codex-cli-bridge', file: 'SKILL.md' },
50
+ deployed: null,
51
+ npm: null,
52
+ wrapperCmds: ['codex-exec', 'codex-review'],
53
+ },
54
+ {
55
+ name: 'antigravity-cli-bridge',
56
+ kind: 'execution-backend',
57
+ installed: { env: 'ANTIGRAVITY_CLI_BRIDGE_DIR', default: '~/.claude/skills/antigravity-cli-bridge', file: 'SKILL.md' },
58
+ deployed: null,
59
+ npm: null,
60
+ wrapperCmds: ['agy-run'],
61
+ },
62
+ ];
63
+
64
+ // A GLOBAL skill (lives under ~/.claude/skills) may be shared by other projects on the host — the
65
+ // uninstaller warns before removing one (there is no cross-project dependency tracking). All current
66
+ // members are global skills; the field is explicit so the warning is data-driven, not hardcoded.
67
+ export const isGlobalSkill = (member) => member.kind !== undefined; // every member is a global skill today
@@ -41,73 +41,52 @@ import {
41
41
  readSettingsFile,
42
42
  resolveEffectiveMode,
43
43
  } from './velocity-profile.mjs';
44
-
45
- // ── manifestState values (the detect-backends precedence, generalized to any member kind) ──────────
46
- export const NOT_INSTALLED = 'not-installed';
47
- export const UNSUPPORTED_SCHEMA = 'unsupported-schema';
48
- export const INVALID_MANIFEST = 'invalid-manifest';
49
- export const STUB = 'stub';
50
- export const FOREIGN = 'foreign';
51
- export const OK = 'ok';
52
- // The marker could not be probed (a non-ENOENT fs error — EACCES/EIO). Surfaced explicitly instead of
53
- // being masked as not-installed (no silent failure); uninstall treats it as "do not touch" (skip).
54
- export const UNKNOWN = 'unknown';
44
+ // The status vocabulary (manifestState constants, internal→public maps, display names, the no-leak
45
+ // forbidden set) lives in the frozen labels.mjs LEAF (Plan §4.2 B1) so the import graph is acyclic —
46
+ // nothing imports family-registry for vocabulary. Imported here for internal use; the public subset is
47
+ // re-exported below.
48
+ import {
49
+ NOT_INSTALLED,
50
+ UNSUPPORTED_SCHEMA,
51
+ INVALID_MANIFEST,
52
+ STUB,
53
+ FOREIGN,
54
+ OK,
55
+ UNKNOWN,
56
+ STATE_PUBLIC,
57
+ VISIBILITY_PUBLIC,
58
+ DISPLAY_NAMES,
59
+ displayOf,
60
+ } from './labels.mjs';
61
+ // The capability-adaptive direct-CLI presenter (Plan §4.2/§4.5): the surface detector, the
62
+ // envelope→ViewModel transform, and the plain/ansi renderers. main() composes them; the agent-mediated
63
+ // `status` surface ignores them (it consumes --json). These are leaves — no import cycle.
64
+ import { detectSurface } from './surface.mjs';
65
+ import { toViewModel } from './view-model.mjs';
66
+ import { render } from './renderers.mjs';
67
+
68
+ // ── manifestState values — re-export the EXACT public subset family-registry exported before B1 ─────
69
+ // (the 7 state constants + DISPLAY_NAMES) so every existing importer (uninstall.mjs, the test suites)
70
+ // stays green. STATE_PUBLIC / VISIBILITY_PUBLIC / displayOf were private here and are NOT re-exported.
71
+ export {
72
+ NOT_INSTALLED,
73
+ UNSUPPORTED_SCHEMA,
74
+ INVALID_MANIFEST,
75
+ STUB,
76
+ FOREIGN,
77
+ OK,
78
+ UNKNOWN,
79
+ DISPLAY_NAMES,
80
+ };
55
81
 
56
82
  // ── the unified registry ───────────────────────────────────────────────────────
57
- // One entry per family member. `installed` is the detect.installed spec (env + home-relative default
58
- // + marker file); `deployed` is the project-relative stamp a deploy writes (kit + memory only);
59
- // `npm` is the install package (null for the bridges, which are placed by `setup`, not npm);
60
- // `wrapperCmds` is the deduped roles[].cmd set the `setup` linker creates on PATH (bridges only).
61
- // Kept in lockstep with the 5 in-repo capability.json by the drift-guard test. The two release skills
62
- // (release-engineering / release-marketing) are deliberately NOT here — they are not family members
63
- // (AD-013): no capability.json, not in the kit tarball, not in the role vocabulary.
64
- export const FAMILY_MEMBERS = [
65
- {
66
- name: 'agent-workflow-kit',
67
- kind: 'composition-root',
68
- installed: { env: 'AGENT_WORKFLOW_KIT_DIR', default: '~/.claude/skills/agent-workflow-kit', file: 'SKILL.md' },
69
- deployed: { file: 'docs/ai/.workflow-version' },
70
- npm: '@sabaiway/agent-workflow-kit',
71
- wrapperCmds: [],
72
- },
73
- {
74
- name: 'agent-workflow-memory',
75
- kind: 'memory-substrate',
76
- installed: { env: 'AGENT_WORKFLOW_MEMORY_DIR', default: '~/.claude/skills/agent-workflow-memory', file: 'SKILL.md' },
77
- deployed: { file: 'docs/ai/.memory-version' },
78
- npm: '@sabaiway/agent-workflow-memory',
79
- wrapperCmds: [],
80
- },
81
- {
82
- name: 'agent-workflow-engine',
83
- kind: 'methodology-engine',
84
- installed: { env: 'AGENT_WORKFLOW_ENGINE_DIR', default: '~/.claude/skills/agent-workflow-engine', file: 'SKILL.md' },
85
- deployed: null,
86
- npm: '@sabaiway/agent-workflow-engine',
87
- wrapperCmds: [],
88
- },
89
- {
90
- name: 'codex-cli-bridge',
91
- kind: 'execution-backend',
92
- installed: { env: 'CODEX_CLI_BRIDGE_DIR', default: '~/.claude/skills/codex-cli-bridge', file: 'SKILL.md' },
93
- deployed: null,
94
- npm: null,
95
- wrapperCmds: ['codex-exec', 'codex-review'],
96
- },
97
- {
98
- name: 'antigravity-cli-bridge',
99
- kind: 'execution-backend',
100
- installed: { env: 'ANTIGRAVITY_CLI_BRIDGE_DIR', default: '~/.claude/skills/antigravity-cli-bridge', file: 'SKILL.md' },
101
- deployed: null,
102
- npm: null,
103
- wrapperCmds: ['agy-run'],
104
- },
105
- ];
106
-
107
- // A GLOBAL skill (lives under ~/.claude/skills) may be shared by other projects on the host — the
108
- // uninstaller warns before removing one (there is no cross-project dependency tracking). All current
109
- // members are global skills; the field is explicit so the warning is data-driven, not hardcoded.
110
- export const isGlobalSkill = (member) => member.kind !== undefined; // every member is a global skill today
83
+ // FAMILY_MEMBERS (+ isGlobalSkill) is the one authoritative member table. It moved to the
84
+ // dependency-free DATA LEAF family-members.mjs so the npx installer can derive its init-refresh cascade
85
+ // from the table WITHOUT importing this whole status/presenter graph (a leaner npx cold-start path).
86
+ // Re-exported here so every existing importer (uninstall.mjs, the test suites) stays green, and the
87
+ // drift-guard (family-registry.test.mjs) still pins the table to the 5 in-repo capability.json.
88
+ export { FAMILY_MEMBERS, isGlobalSkill } from './family-members.mjs';
89
+ import { FAMILY_MEMBERS } from './family-members.mjs';
111
90
 
112
91
  // ── pure probes ──────────────────────────────────────────────────────────────────
113
92
  // Wrapped marker probe → 'present' (a regular file) | 'absent' (ENOENT / not a regular file) |
@@ -268,62 +247,10 @@ export const surveyProject = (projectDir, deps = {}) => {
268
247
  };
269
248
 
270
249
  // ── report ───────────────────────────────────────────────────────────────────────
271
- const pad = (s, n) => (s.length >= n ? s : s + ' '.repeat(n - s.length));
272
-
273
- // The human (non-JSON) settings render. A dev view the agent consumes `--json` + renders in plain
274
- // language (Mode: status). Each area shows its `error` field loudly when one fired.
275
- const formatSettings = (s) => {
276
- const out = ['', 'settings'];
277
- if (s.recipes?.error) out.push(` ${pad('recipes', 14)}error: ${s.recipes.error}`);
278
- else {
279
- const parts = [];
280
- for (const [act, slots] of Object.entries(s.recipes?.activities ?? {})) {
281
- for (const [slot, r] of Object.entries(slots)) parts.push(`${act}.${slot}=${r.recipe}`);
282
- }
283
- out.push(` ${pad('recipes', 14)}${parts.join(' · ') || '—'}`);
284
- if (s.recipes?.detectError) out.push(` ${pad('', 14)}↳ couldn't check backends (${s.recipes.detectError}); recipes floored at solo`);
285
- }
286
- if (s.attribution?.error) out.push(` ${pad('attribution', 14)}error: ${s.attribution.error}`);
287
- else out.push(` ${pad('attribution', 14)}includeCoAuthoredBy effective=${String(s.attribution?.effective)}`);
288
- if (s.velocity?.error) out.push(` ${pad('velocity', 14)}error: ${s.velocity.error}`);
289
- else out.push(` ${pad('velocity', 14)}defaultMode=${String(s.velocity?.defaultMode)} · allow project/local=${s.velocity?.allowEntries?.project}/${s.velocity?.allowEntries?.local}`);
290
- return out;
291
- };
292
-
293
- export const formatStatus = (family, project = null, extras = {}) => {
294
- const lines = ['agent-workflow family — installed skills (skill axis)', ''];
295
- for (const m of family) {
296
- const ver = m.version ? `v${m.version}` : '—';
297
- lines.push(` ${pad(m.name, 26)}[${pad(m.manifestState, 16)}] ${pad(ver, 10)} ${m.kind}`);
298
- for (const c of m.caveats ?? []) lines.push(` ↳ ${c}`);
299
- }
300
- if (extras.bridges) {
301
- const WRAP_MARK = { present: '✓', missing: '✗', unknown: '?' };
302
- lines.push('', 'execution backends (host)', '');
303
- for (const b of extras.bridges) {
304
- const w = b.wrappers.map((x) => `${x.cmd} ${WRAP_MARK[x.state] ?? '?'}`).join(', ') || '—';
305
- lines.push(` ${pad(b.display, 20)}${pad(b.readiness, 18)}wrappers: ${w}`);
306
- }
307
- }
308
- if (project) {
309
- lines.push('', `project deployment (${project.dir})`, '');
310
- if (!project.deployed) {
311
- lines.push(' no agent-workflow deployment detected here (no docs/ai, no version stamp).');
312
- } else {
313
- for (const s of project.stamps) {
314
- lines.push(` ${pad(s.file, 26)}${s.version ?? '—'}`);
315
- }
316
- lines.push(` ${pad('docs/ai present', 26)}${project.docsAiPresent ? 'yes' : 'no'}`);
317
- if (extras.visibility) {
318
- lines.push(` ${pad('visibility', 26)}${extras.visibility.error ? `error: ${extras.visibility.error}` : extras.visibility.state}`);
319
- } else {
320
- lines.push(` ${pad('hidden-mode fence', 26)}${project.hiddenFence ? 'present' : 'absent'}`);
321
- }
322
- }
323
- if (extras.settings) lines.push(...formatSettings(extras.settings));
324
- }
325
- return lines.join('\n');
326
- };
250
+ // The direct-CLI human render (formatStatus + formatSettings) was REPLACED by the capability-adaptive
251
+ // presenter pipeline (surface → view-model → renderers, Plan §4.2/§4.5): main() builds the no-leak
252
+ // envelope once, then renders it via toViewModel + render (plain/ansi) or prints it as JSON. ONE data
253
+ // source for both surfaces the agent-mediated `status` consumes `--json`, the direct CLI renders it.
327
254
 
328
255
  // ── the no-leak --json envelope ──────────────────────────────────────────────────
329
256
  // A machine-readable view with USER-SAFE field names only — NEVER the internal manifestState /
@@ -331,28 +258,8 @@ export const formatStatus = (family, project = null, extras = {}) => {
331
258
  // consumes THIS, never the human table verbatim. An envelope-shape test pins its shape so later phases
332
259
  // (the settings/visibility block) can't silently break the Phase-2 version consumer.
333
260
 
334
- // internal manifestState a STABLE, user-safe token (SKILL.md owns the value→plain-language phrasing).
335
- // Deliberately NOT the internal literals (foreign/stub/…): those must never leak past this boundary.
336
- const STATE_PUBLIC = {
337
- [OK]: 'installed',
338
- [NOT_INSTALLED]: 'absent',
339
- [FOREIGN]: 'other-tool',
340
- [STUB]: 'placeholder',
341
- [INVALID_MANIFEST]: 'invalid',
342
- [UNSUPPORTED_SCHEMA]: 'unsupported',
343
- [UNKNOWN]: 'uncheckable',
344
- };
345
-
346
- // Short, user-facing labels for the version block (kit · memory · engine · codex-bridge · …), so the
347
- // render is deterministic and the agent never invents a label.
348
- export const DISPLAY_NAMES = {
349
- 'agent-workflow-kit': 'kit',
350
- 'agent-workflow-memory': 'memory',
351
- 'agent-workflow-engine': 'engine',
352
- 'codex-cli-bridge': 'codex-bridge',
353
- 'antigravity-cli-bridge': 'antigravity-bridge',
354
- };
355
- const displayOf = (name) => DISPLAY_NAMES[name] ?? name;
261
+ // STATE_PUBLIC (internal→public token map) + DISPLAY_NAMES + displayOf now live in labels.mjs (B1)
262
+ // imported at the top of this file. They are used below exactly as before.
356
263
 
357
264
  // ── the settings survey (Phase 3) — read-only, honest, localized-on-error ──────────
358
265
  // Each sub-survey returns a small user-safe object OR a single `{ error }` field (a localized message,
@@ -380,8 +287,8 @@ const detectSafe = (deps) => {
380
287
  };
381
288
 
382
289
  // visibility: the THREE honest states from inferVisibility (NOT the single hiddenFence bit) → user-safe
383
- // words. Never the internal "hidden fence" / marker terms. A git/probe error → a localized error field.
384
- const VISIBILITY_PUBLIC = { visible: 'visible', hidden: 'hidden', ambiguous: 'unclear' };
290
+ // words (VISIBILITY_PUBLIC, from labels.mjs). Never the internal "hidden fence" / marker terms. A
291
+ // git/probe error a localized error field.
385
292
  export const surveyVisibility = (dir, deps = {}) => {
386
293
  try {
387
294
  const vis = inferVisibility(deps, resolve(dir));
@@ -469,6 +376,20 @@ export const surveyBridges = (deps = {}) => {
469
376
  });
470
377
  };
471
378
 
379
+ // INV-2 (structural refresh — additive, derived, never parsed from a caveat string). A member is
380
+ // `behind` when it is a refreshable CORE member (memory / engine) carrying a refresh-recommending
381
+ // caveat — surveyFamily attaches caveats ONLY to those two kinds, and today every such caveat is
382
+ // refresh-recommending. `recommend` is composed from the member's npm package in the ONE registry
383
+ // (FAMILY_MEMBERS[].npm), never extracted from the caveat text. The kit (composition-root) + the two
384
+ // bridges (execution-backend) never carry such a caveat, so they derive { behind:false, recommend:null }
385
+ // — gated by the CAVEAT, not by npm-nullness (the kit's npm is non-null; only the bridges are null).
386
+ const REFRESHABLE_KINDS = new Set(['memory-substrate', 'methodology-engine']);
387
+ const npmOf = (name) => FAMILY_MEMBERS.find((m) => m.name === name)?.npm ?? null;
388
+ const refreshOf = (m) => {
389
+ const behind = REFRESHABLE_KINDS.has(m.kind) && Boolean(m.caveats?.length);
390
+ return { behind, recommend: behind ? `npx ${npmOf(m.name)}@latest init` : null };
391
+ };
392
+
472
393
  export const buildEnvelope = (family, project = null, extras = {}) => {
473
394
  const installed = family.map((m) => {
474
395
  const entry = {
@@ -478,6 +399,7 @@ export const buildEnvelope = (family, project = null, extras = {}) => {
478
399
  state: STATE_PUBLIC[m.manifestState] ?? STATE_PUBLIC[UNKNOWN],
479
400
  };
480
401
  if (m.caveats?.length) entry.notes = m.caveats; // plain-language observations (Steps 2.2 / engine)
402
+ entry.refresh = refreshOf(m); // additive (INV-1): always present; { behind, recommend } (INV-2)
481
403
  return entry;
482
404
  });
483
405
  const envelope = { deploymentHead: EXPECTED_WORKFLOW_VERSION, installed };
@@ -498,27 +420,72 @@ export const buildEnvelope = (family, project = null, extras = {}) => {
498
420
  };
499
421
 
500
422
  // ── CLI ────────────────────────────────────────────────────────────────────────
501
- const parseArgs = (argv) => {
502
- const dirFlag = argv.indexOf('--dir');
503
- return {
504
- help: argv.includes('--help') || argv.includes('-h'),
505
- json: argv.includes('--json'),
506
- dir: dirFlag >= 0 ? argv[dirFlag + 1] : undefined,
507
- };
423
+ // Parse contract (Plan §4.5, grounded): the old parseArgs SILENTLY ignored unknown args + a missing
424
+ // --dir value. With the explicit format surface added, the parse now rejects LOUDLY (no silent
425
+ // failure): an unknown flag, a `--dir` with no value, and an invalid `--format` (validated by
426
+ // resolveFormat) all throw. `--json` vs `--format=*` precedence is deterministic (last-wins, in
427
+ // surface.mjs). Returns only { dir } — help is handled first, the format/mode comes from the surface.
428
+ const HELP_FLAGS = new Set(['--help', '-h']);
429
+ // --dir and --format carry/consume a value, so they are handled explicitly below — only the valueless
430
+ // flags live in KNOWN_FLAGS.
431
+ const KNOWN_FLAGS = new Set(['--help', '-h', '--json']);
432
+ // Single left-to-right pass (functional reduce, no `let`): EVERY `--dir` must carry a value — a
433
+ // trailing or REPEATED `--dir` with no value rejects loudly, never silently (a first-occurrence-only
434
+ // check let `--dir /p --dir` slip through as a "known flag"). The token right after a `--dir` is its
435
+ // value (skipped), so a path is never mistaken for an unknown flag; the last `--dir` wins.
436
+ export const parseArgs = (argv) => {
437
+ const { dir } = argv.reduce(
438
+ (state, a, i) => {
439
+ if (state.skip) return { dir: state.dir, skip: false }; // this token was the preceding --dir value
440
+ if (a === '--dir') {
441
+ const value = argv[i + 1];
442
+ if (value === undefined || value.startsWith('-')) {
443
+ throw new Error('[agent-workflow-kit] --dir needs a value: --dir <project>');
444
+ }
445
+ return { dir: value, skip: true }; // consume the value; last --dir wins
446
+ }
447
+ if (KNOWN_FLAGS.has(a) || a === '--format' || a.startsWith('--format=')) return state;
448
+ throw new Error(`[agent-workflow-kit] unknown argument: ${a}`);
449
+ },
450
+ { dir: undefined, skip: false },
451
+ );
452
+ return { dir };
508
453
  };
509
454
 
510
- const main = (argv) => {
511
- const args = parseArgs(argv);
512
- if (args.help) {
513
- console.log(`family-registry — read-only view of the agent-workflow family.
455
+ const HELP = `family-registry read-only view of the agent-workflow family.
514
456
 
515
457
  Usage:
516
- node family-registry.mjs [--dir <project>] [--json]
517
- # skill axis always; deploy axis when --dir is given; --json = the no-leak machine envelope
458
+ node family-registry.mjs [--dir <project>] [--format=<auto|plain|ansi|json>] [--json]
459
+ # skill axis always; deploy axis when --dir is given.
460
+ # --format: auto (default — ansi on a TTY, plain otherwise) | plain | ansi | json.
461
+ # --json is sugar for --format=json (the no-leak machine envelope). AGENT_WORKFLOW_FORMAT
462
+ # sets the default; a flag beats it. Width/color follow the terminal (NO_COLOR / FORCE_COLOR).
518
463
 
519
- Detection only — never writes, never commits, never runs a subscription CLI.`);
464
+ Detection only — never writes, never commits, never runs a subscription CLI.`;
465
+
466
+ const main = (argv) => {
467
+ if (argv.some((a) => HELP_FLAGS.has(a))) {
468
+ console.log(HELP);
520
469
  return;
521
470
  }
471
+ // Validate args + resolve the output surface BEFORE any survey work; a bad flag/format → loud exit 1.
472
+ const args = (() => {
473
+ try {
474
+ const parsed = parseArgs(argv);
475
+ const surface = detectSurface({
476
+ argv,
477
+ env: process.env,
478
+ isTTY: Boolean(process.stdout.isTTY),
479
+ columns: process.stdout.columns,
480
+ platform: process.platform,
481
+ });
482
+ return { ...parsed, surface };
483
+ } catch (err) {
484
+ console.error(err?.message ?? String(err));
485
+ process.exit(1);
486
+ }
487
+ })();
488
+
522
489
  const family = surveyFamily();
523
490
  const bridges = surveyBridges();
524
491
  const project = args.dir ? surveyProject(args.dir) : null;
@@ -527,7 +494,10 @@ Detection only — never writes, never commits, never runs a subscription CLI.`)
527
494
  extras.visibility = surveyVisibility(args.dir);
528
495
  extras.settings = surveySettings(args.dir);
529
496
  }
530
- console.log(args.json ? JSON.stringify(buildEnvelope(family, project, extras), null, 2) : formatStatus(family, project, extras));
497
+ const envelope = buildEnvelope(family, project, extras);
498
+ // Two surfaces, one data source: JSON prints the envelope verbatim; plain/ansi render it through the
499
+ // capability-adaptive presenter pipeline (view-model → renderers). No-leak inherited from the envelope.
500
+ console.log(args.surface.mode === 'json' ? JSON.stringify(envelope, null, 2) : render(toViewModel(envelope), args.surface));
531
501
  };
532
502
 
533
503
  const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
@@ -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,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).';
@@ -0,0 +1,119 @@
1
+ // renderers.mjs — the direct-CLI presenters (plain + ansi) for the four status blocks. Plan §4.2/§4.5.
2
+ //
3
+ // Input: the ViewModel (view-model.mjs, built from the no-leak envelope) + a resolved surface
4
+ // (surface.mjs: { mode, width, color, ascii }). Output: a single string. `mode` selects plain vs ansi;
5
+ // the JSON mode is handled by the caller (it prints the envelope, not a render). The renderers do
6
+ // LAYOUT + GLYPHS only — phrasing + headline counts already live in the ViewModel.
7
+ //
8
+ // Pure, no side effects, Node >= 18.
9
+
10
+ import { BLOCK_TITLES, SETTINGS_LABELS, glyphsFor, NO_DEPLOYMENT } from './presentation.mjs';
11
+
12
+ const MEMBER_COL = 20;
13
+ const VERSION_COL = 12;
14
+ const READINESS_COL = 14;
15
+ const STAMP_COL = 26;
16
+ const SETTINGS_COL = 14;
17
+
18
+ const SGR = Object.freeze({ bold: '\x1b[1m', reset: '\x1b[0m' });
19
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
20
+ export const visibleLength = (s) => s.replace(ANSI_RE, '').length;
21
+
22
+ const pad = (s, n) => (s.length >= n ? s : s + ' '.repeat(n - s.length));
23
+ // A styled span only when color is on — so color:false emits ZERO SGR (plain output, byte-clean).
24
+ const heading = (text, color) => (color ? `${SGR.bold}${text}${SGR.reset}` : text);
25
+
26
+ const renderMembers = (vm, { color, glyph }) => {
27
+ const lines = [heading(BLOCK_TITLES.members, color), ''];
28
+ for (const m of vm.members) {
29
+ const ver = m.version ? `v${m.version}` : '—';
30
+ const tail = m.statePhrase == null ? '' : m.statePhrase; // installed → the version IS the info
31
+ lines.push(` ${pad(m.display, MEMBER_COL)}${pad(ver, VERSION_COL)}${tail}`.trimEnd());
32
+ for (const note of m.notes) lines.push(` ${glyph.note} ${note}`); // verbatim caveats (INV-3)
33
+ }
34
+ // refresh.behind drives a HEADLINE COUNT only — the recovery command stays in the verbatim notes
35
+ // above (the direct CLI never dedupes, never re-prints it). Omitted when nothing is behind.
36
+ if (vm.headline.behind > 0) {
37
+ lines.push(` ${vm.headline.behind} member(s) need a refresh (see the ${glyph.note} notes above).`);
38
+ }
39
+ return lines;
40
+ };
41
+
42
+ const renderBridges = (vm, { glyph, color }) => {
43
+ if (!vm.bridges) return [];
44
+ const lines = ['', heading(BLOCK_TITLES.bridges, color), ''];
45
+ for (const b of vm.bridges) {
46
+ const wrappers = b.wrappers.map((w) => `${w.cmd} ${glyph[w.state] ?? glyph.unknown}`).join(', ') || '—';
47
+ lines.push(` ${pad(b.display, MEMBER_COL)}${pad(b.readiness, READINESS_COL)}wrappers: ${wrappers}`);
48
+ }
49
+ return lines;
50
+ };
51
+
52
+ const renderProject = (vm, { color }) => {
53
+ const p = vm.project;
54
+ if (!p) return [];
55
+ const lines = ['', heading(BLOCK_TITLES.project(p.dir), color), ''];
56
+ if (!p.deployed) {
57
+ lines.push(` ${NO_DEPLOYMENT}`);
58
+ return lines;
59
+ }
60
+ for (const s of p.deployStamps) lines.push(` ${pad(s.display, STAMP_COL)}${s.version ?? '—'}`);
61
+ lines.push(` ${pad('docs/ai present', STAMP_COL)}${p.docsAi ? 'yes' : 'no'}`);
62
+ if (p.visibility) {
63
+ const v = p.visibility.error ? `error: ${p.visibility.error}` : p.visibility.phrase;
64
+ lines.push(` ${pad('visibility', STAMP_COL)}${v}`);
65
+ }
66
+ return lines;
67
+ };
68
+
69
+ const renderSettings = (vm, { color, glyph }) => {
70
+ const s = vm.project?.settings;
71
+ if (!s) return [];
72
+ const lines = ['', heading(BLOCK_TITLES.settings, color)];
73
+ // recipes — the effective recipe per slot, or a loud error; a detector floor adds a sub-line.
74
+ if (s.recipes?.error) lines.push(` ${pad(SETTINGS_LABELS.recipes, SETTINGS_COL)}error: ${s.recipes.error}`);
75
+ else if (s.recipes) {
76
+ const joined = s.recipes.pairs.map((p) => `${p.key}=${p.recipe}`).join(' · ') || '—';
77
+ lines.push(` ${pad(SETTINGS_LABELS.recipes, SETTINGS_COL)}${joined}`);
78
+ if (s.recipes.detectError) {
79
+ lines.push(` ${pad('', SETTINGS_COL)}${glyph.note} couldn't check backends (${s.recipes.detectError}); recipes floored at solo`);
80
+ }
81
+ }
82
+ // attribution — effective includeCoAuthoredBy; a real local override is called out.
83
+ if (s.attribution?.error) lines.push(` ${pad(SETTINGS_LABELS.attribution, SETTINGS_COL)}error: ${s.attribution.error}`);
84
+ else if (s.attribution) {
85
+ const override = s.attribution.override ? ' (local override)' : '';
86
+ lines.push(` ${pad(SETTINGS_LABELS.attribution, SETTINGS_COL)}includeCoAuthoredBy effective=${String(s.attribution.effective)}${override}`);
87
+ }
88
+ // velocity — effective defaultMode + per-source allow counts.
89
+ if (s.velocity?.error) lines.push(` ${pad(SETTINGS_LABELS.velocity, SETTINGS_COL)}error: ${s.velocity.error}`);
90
+ else if (s.velocity) {
91
+ lines.push(` ${pad(SETTINGS_LABELS.velocity, SETTINGS_COL)}defaultMode=${String(s.velocity.defaultMode)} · allow project/local=${s.velocity.allow.project}/${s.velocity.allow.local}`);
92
+ }
93
+ return lines;
94
+ };
95
+
96
+ // Pad each NON-empty line UP to the surface width (by VISIBLE length, so SGR codes don't count); empty
97
+ // separator lines stay empty. `width` is a MIN target for a consistent block, NOT a hard max: a long
98
+ // content line — a verbatim caveat `↳`, a long recipe row — is left INTACT and is NEVER truncated,
99
+ // because truncating could hide part of a recovery command (a no-silent-failure violation). The
100
+ // terminal soft-wraps an over-width line; data integrity wins over a perfectly rectangular block.
101
+ const padLineTo = (line, width) => {
102
+ if (line === '') return '';
103
+ const vis = visibleLength(line);
104
+ return vis < width ? line + ' '.repeat(width - vis) : line;
105
+ };
106
+
107
+ export const render = (vm, surface = {}) => {
108
+ const glyph = glyphsFor(Boolean(surface.ascii));
109
+ const color = surface.mode === 'ansi' && Boolean(surface.color); // color applies in ansi mode only
110
+ const ctx = { color, glyph };
111
+ const lines = [
112
+ ...renderMembers(vm, ctx),
113
+ ...renderBridges(vm, ctx),
114
+ ...renderProject(vm, ctx),
115
+ ...renderSettings(vm, ctx),
116
+ ];
117
+ if (surface.mode === 'ansi') return lines.map((l) => padLineTo(l, surface.width ?? 80)).join('\n');
118
+ return lines.join('\n');
119
+ };
@@ -0,0 +1,75 @@
1
+ // surface.mjs — capability detection for the kit's OWN stdout (the direct CLI). Plan §4.5.
2
+ //
3
+ // Resolves how `node tools/family-registry.mjs` should render: the requested format, the effective
4
+ // render mode (json | plain | ansi), whether to emit color, the terminal width, and whether to fall
5
+ // back to ASCII glyphs. This governs ONLY the kit's direct-CLI output — the agent-mediated
6
+ // `/agent-workflow-kit status` surface always consumes `--json` and localizes itself.
7
+ //
8
+ // Pure + fully injectable (argv / env / isTTY / columns / platform are inputs), no side effects on
9
+ // import, Node >= 18. Tested in isolation against the §4.5 table.
10
+
11
+ export const FORMATS = Object.freeze(['auto', 'plain', 'ansi', 'json']);
12
+ export const FORMAT_ENV = 'AGENT_WORKFLOW_FORMAT';
13
+ export const MIN_WIDTH = 40; // below this, force plain ASCII (a box can't lay out under ~40 cols)
14
+ export const DEFAULT_WIDTH = 80;
15
+ const FORMAT_FLAG = '--format=';
16
+
17
+ // Resolve the requested format. A FLAG (--json or --format=X) beats the AGENT_WORKFLOW_FORMAT env;
18
+ // among flags the LAST on argv wins (deterministic, standard last-wins). `--json` is exact sugar for
19
+ // `--format=json`. A bare `--format` (no value), an empty value, or an unknown value → a LOUD reject
20
+ // (never a silent fallback — Hard Constraint). Absent everywhere → 'auto'.
21
+ export const resolveFormat = (argv = [], env = {}) => {
22
+ let fromFlag = null;
23
+ for (const a of argv) {
24
+ if (a === '--json') fromFlag = 'json';
25
+ else if (a === '--format') throw new Error(`[agent-workflow-kit] --format needs a value: --format=<${FORMATS.join('|')}>`);
26
+ else if (a.startsWith(FORMAT_FLAG)) fromFlag = a.slice(FORMAT_FLAG.length);
27
+ }
28
+ const requested = fromFlag ?? env[FORMAT_ENV] ?? 'auto';
29
+ if (!FORMATS.includes(requested)) {
30
+ throw new Error(`[agent-workflow-kit] invalid format "${requested}" — expected one of ${FORMATS.join(', ')}`);
31
+ }
32
+ return requested;
33
+ };
34
+
35
+ // Terminal width: stdout.columns (>0) wins, else $COLUMNS (>0), else the 80-col default. A garbage /
36
+ // zero / undefined value falls through to the next source — never NaN.
37
+ export const resolveWidth = ({ columns, env = {} } = {}) => {
38
+ const fromStdout = Number(columns);
39
+ if (Number.isFinite(fromStdout) && fromStdout > 0) return fromStdout;
40
+ const fromEnv = Number(env.COLUMNS);
41
+ if (Number.isFinite(fromEnv) && fromEnv > 0) return fromEnv;
42
+ return DEFAULT_WIDTH;
43
+ };
44
+
45
+ // Color is ORTHOGONAL to the render mode (the ansi renderer applies it; plain ignores it). Precedence:
46
+ // CLICOLOR_FORCE / FORCE_COLOR present → on (unless explicitly '0'/'false'); else NO_COLOR present
47
+ // (incl. empty value) → off; else follow isTTY. FORCE beats NO_COLOR.
48
+ export const resolveColor = ({ env = {}, isTTY = false } = {}) => {
49
+ if ('CLICOLOR_FORCE' in env || 'FORCE_COLOR' in env) {
50
+ const v = 'CLICOLOR_FORCE' in env ? env.CLICOLOR_FORCE : env.FORCE_COLOR;
51
+ return !(v === '0' || String(v).toLowerCase() === 'false');
52
+ }
53
+ if ('NO_COLOR' in env) return false; // any value, incl. empty, disables (the NO_COLOR spec)
54
+ return Boolean(isTTY);
55
+ };
56
+
57
+ const isUtf8Env = (env = {}) => /utf-?8/i.test(env.LC_ALL || env.LC_CTYPE || env.LANG || '');
58
+
59
+ // The full resolved surface. mode: 'json' (machine envelope) | 'plain' | 'ansi'. For a non-json
60
+ // format: an explicit plain/ansi is honored; 'auto' detects a tier (plain when NOT a TTY, or TERM=dumb,
61
+ // or any CI env; else ansi). A width below MIN_WIDTH FORCES plain. ascii glyphs when narrow, or on a
62
+ // Windows TTY without a UTF-8 locale (shallow — deeper Windows/UTF-8 depth is deferred, Plan §9).
63
+ export const detectSurface = ({ argv = [], env = {}, isTTY = false, columns, platform = 'linux' } = {}) => {
64
+ const format = resolveFormat(argv, env);
65
+ const width = resolveWidth({ columns, env });
66
+ if (format === 'json') return { format, mode: 'json', width, color: false, ascii: false };
67
+
68
+ const autoTier = !isTTY || env.TERM === 'dumb' || 'CI' in env ? 'plain' : 'ansi';
69
+ const requestedMode = format === 'auto' ? autoTier : format; // 'plain' | 'ansi'
70
+ const narrow = width < MIN_WIDTH;
71
+ const mode = narrow ? 'plain' : requestedMode; // the width floor wins over an ansi request
72
+ const ascii = narrow || (platform === 'win32' && !isUtf8Env(env));
73
+ const color = resolveColor({ env, isTTY }); // orthogonal — the ansi renderer gates on mode + color
74
+ return { format, mode, width, color, ascii };
75
+ };
@@ -0,0 +1,89 @@
1
+ // view-model.mjs — transform the no-leak `--json` envelope (buildEnvelope output) into a render-ready
2
+ // ViewModel for the direct-CLI renderers. Plan §4.2 / §4.5: surface → VIEW-MODEL → renderers.
3
+ //
4
+ // One data source: the renderers never touch the raw surveys, only this VM, which is built from the
5
+ // ENVELOPE — so the renderers inherit the envelope's no-leak guarantee (internal manifestState / stamp
6
+ // filenames never reach them). This module resolves public tokens → English phrases (presentation.mjs)
7
+ // and computes the headline counts; the renderers do layout + glyphs only.
8
+ //
9
+ // Pure, no side effects, Node >= 18.
10
+
11
+ import { STATE_PHRASING, VISIBILITY_PHRASING } from './presentation.mjs';
12
+
13
+ const memberVm = (m) => {
14
+ const refresh = m.refresh ?? { behind: false, recommend: null };
15
+ return {
16
+ display: m.display,
17
+ version: m.version ?? null,
18
+ state: m.state,
19
+ // 'installed' → null (the renderer shows the version instead); every other public token → a phrase.
20
+ statePhrase: m.state in STATE_PHRASING ? STATE_PHRASING[m.state] : m.state,
21
+ notes: m.notes ?? [], // the verbatim caveats — printed as ↳ sub-lines (INV-3: no dedupe)
22
+ behind: Boolean(refresh.behind), // used ONLY for the headline count on the direct CLI (INV-3)
23
+ recommend: refresh.recommend ?? null,
24
+ };
25
+ };
26
+
27
+ const bridgeVm = (b) => ({
28
+ display: b.display,
29
+ readiness: b.readiness,
30
+ // preserve the three-state wrapper status (present | missing | unknown) — the renderer maps to a glyph.
31
+ wrappers: (b.wrappers ?? []).map((w) => ({ cmd: w.cmd, state: w.state })),
32
+ });
33
+
34
+ const visibilityVm = (v) => {
35
+ if (!v) return null;
36
+ if (v.error) return { error: v.error };
37
+ return { phrase: v.state in VISIBILITY_PHRASING ? VISIBILITY_PHRASING[v.state] : v.state };
38
+ };
39
+
40
+ const recipesVm = (r) => {
41
+ if (!r) return null;
42
+ if (r.error) return { error: r.error };
43
+ const pairs = [];
44
+ for (const [activity, slots] of Object.entries(r.activities ?? {})) {
45
+ for (const [slot, v] of Object.entries(slots)) pairs.push({ key: `${activity}.${slot}`, recipe: v.recipe });
46
+ }
47
+ return { pairs, detectError: r.detectError ?? null };
48
+ };
49
+
50
+ const attributionVm = (a) => {
51
+ if (!a) return null;
52
+ if (a.error) return { error: a.error };
53
+ // A local OVERRIDE only when local actually set the key (non-null) AND it differs from project — a
54
+ // null local means the key is absent there, so the project value stands (that is not an override).
55
+ const override = a.local != null && a.local !== a.project;
56
+ return { effective: a.effective ?? null, override };
57
+ };
58
+
59
+ const velocityVm = (v) => {
60
+ if (!v) return null;
61
+ if (v.error) return { error: v.error };
62
+ return { defaultMode: v.defaultMode ?? null, allow: { project: v.allowEntries?.project ?? 0, local: v.allowEntries?.local ?? 0 } };
63
+ };
64
+
65
+ const settingsVm = (s) =>
66
+ s ? { recipes: recipesVm(s.recipes), attribution: attributionVm(s.attribution), velocity: velocityVm(s.velocity) } : null;
67
+
68
+ const projectVm = (p) =>
69
+ p
70
+ ? {
71
+ dir: p.dir,
72
+ deployed: p.deployed,
73
+ docsAi: p.docsAi,
74
+ deployStamps: (p.deployStamps ?? []).map((st) => ({ display: st.display, version: st.version ?? null })),
75
+ visibility: visibilityVm(p.visibility),
76
+ settings: settingsVm(p.settings),
77
+ }
78
+ : null;
79
+
80
+ export const toViewModel = (envelope = {}) => {
81
+ const members = (envelope.installed ?? []).map(memberVm);
82
+ return {
83
+ deploymentHead: envelope.deploymentHead ?? null,
84
+ members,
85
+ headline: { total: members.length, behind: members.filter((m) => m.behind).length },
86
+ bridges: envelope.bridges ? envelope.bridges.map(bridgeVm) : null,
87
+ project: projectVm(envelope.project),
88
+ };
89
+ };