@sabaiway/agent-workflow-kit 1.33.0 → 1.35.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +2 -1
  3. package/SKILL.md +6 -2
  4. package/bin/install.mjs +37 -1
  5. package/bridges/antigravity-cli-bridge/SKILL.md +13 -1
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +96 -1
  7. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +204 -1
  8. package/bridges/antigravity-cli-bridge/bin/agy.sh +94 -0
  9. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +245 -1
  10. package/bridges/antigravity-cli-bridge/capability.json +17 -1
  11. package/bridges/codex-cli-bridge/SKILL.md +15 -1
  12. package/bridges/codex-cli-bridge/bin/codex-exec.sh +113 -0
  13. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +288 -0
  14. package/bridges/codex-cli-bridge/bin/codex-review.sh +114 -1
  15. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +229 -1
  16. package/bridges/codex-cli-bridge/capability.json +29 -1
  17. package/capability.json +1 -1
  18. package/launchers/windsurf-workflow.md +3 -1
  19. package/package.json +1 -1
  20. package/references/contracts.md +3 -2
  21. package/references/modes/bootstrap.md +11 -6
  22. package/references/modes/bridge-settings.md +29 -0
  23. package/references/modes/gates.md +4 -2
  24. package/references/modes/review-state.md +1 -1
  25. package/references/modes/status.md +2 -0
  26. package/references/modes/upgrade.md +11 -5
  27. package/references/shared/deploy-tail.md +1 -1
  28. package/references/shared/report-footer.md +11 -4
  29. package/tools/atomic-write.mjs +144 -0
  30. package/tools/bridge-settings-read.mjs +221 -0
  31. package/tools/bridge-settings.mjs +288 -0
  32. package/tools/commands.mjs +22 -1
  33. package/tools/family-registry.mjs +45 -2
  34. package/tools/manifest/schema.md +31 -0
  35. package/tools/manifest/validate.mjs +85 -0
  36. package/tools/orchestration-write.mjs +8 -78
  37. package/tools/presentation.mjs +1 -0
  38. package/tools/procedures.mjs +26 -4
  39. package/tools/recipes.mjs +16 -6
  40. package/tools/renderers.mjs +17 -2
  41. package/tools/review-state.mjs +4 -2
  42. package/tools/seed-gates.mjs +413 -0
  43. package/tools/setup-backends.mjs +107 -9
  44. package/tools/velocity-profile.mjs +4 -0
  45. package/tools/view-model.mjs +20 -0
@@ -96,7 +96,7 @@ const CATALOG = [
96
96
  invocation: invocationOf('gates'),
97
97
  group: 'Inspect',
98
98
  kind: PROJECT_EXEC,
99
- oneLine: 'Run the project’s own declared gate commands (docs/ai/gates.json) as one batch — a PASS/FAIL table, one summary line.',
99
+ oneLine: 'Run the project’s own declared gate commands (docs/ai/gates.json) as one batch — a PASS/FAIL table, one summary line. The mode itself writes nothing; a separate consent-gated seeder can propose entries from your project’s own scripts (preview first, written only on your yes).',
100
100
  },
101
101
  {
102
102
  key: 'setup',
@@ -126,6 +126,13 @@ const CATALOG = [
126
126
  kind: WRITER,
127
127
  oneLine: 'Auto-approve your own declared gate commands (docs/ai/gates.json) via a Claude Code hook — exact matches only, previews first (opt-in).',
128
128
  },
129
+ {
130
+ key: 'bridge-settings',
131
+ invocation: invocationOf('bridge-settings'),
132
+ group: 'Configure',
133
+ kind: GUARDED,
134
+ oneLine: 'Read or change the host-level bridge settings (e.g. the codex Fast tier) — a KEY=VALUE file that survives kit upgrades; previews first, and the Fast tier carries its extra-cost caveat.',
135
+ },
129
136
  {
130
137
  key: 'recipes',
131
138
  invocation: invocationOf('recipes'),
@@ -212,6 +219,19 @@ const KIND_TAG = {
212
219
  };
213
220
  const pad = (s, n) => (s.length >= n ? s : s + ' '.repeat(n - s.length));
214
221
 
222
+ // The "Tune" tail — the opt-in accelerator funnel, rendered AFTER the catalog groups. NOT a new
223
+ // catalog key or mode (the frozen CATALOG + GROUP_ORDER stay; the router SKILL.md is untouched):
224
+ // the same four entries the bootstrap accelerators block presents, one line of why each.
225
+ const TUNE_TAIL = Object.freeze([
226
+ '',
227
+ 'Tune — opt-in accelerators (consent-first: every writer previews before writing; nothing runs without your yes)',
228
+ ` ${BARE_INVOCATION} velocity routine read-only commands stop prompting (incl. the --kit-tools tier for the kit's own read-only tools)`,
229
+ ` ${BARE_INVOCATION} agents cheap-model subagents take the mechanical work (sweeps, changelog skeletons, gate triage)`,
230
+ ` ${BARE_INVOCATION} gates run your declared gates (docs/ai/gates.json) as one batch; its guide also offers the consent-gated seeding preview — writes only on your yes`,
231
+ ` ${BARE_INVOCATION} hook auto-approve exactly your declared gate commands (byte-exact matches only)`,
232
+ ` ${BARE_INVOCATION} set-recipe put a ready review backend to work on plans and diffs`,
233
+ ]);
234
+
215
235
  // formatHelp() → the grouped, kind-tagged human index. Deterministic; groups in GROUP_ORDER, modes in
216
236
  // catalog order within each group. The header states the index itself is read-only.
217
237
  export const formatHelp = () => {
@@ -229,6 +249,7 @@ export const formatHelp = () => {
229
249
  lines.push(` ${pad(c.invocation, invocWidth)} ${KIND_TAG[c.kind] ?? c.kind} ${c.oneLine}`);
230
250
  }
231
251
  }
252
+ lines.push(...TUNE_TAIL);
232
253
  return lines.join('\n');
233
254
  };
234
255
 
@@ -47,7 +47,13 @@ import {
47
47
  // The gate-hook writer's own wired-detection + placed path — reused by the settings survey (one
48
48
  // implementation; gate-hook imports only node builtins + velocity-profile, so no cycle).
49
49
  import { HOOK_FILE_REL as GATE_HOOK_FILE_REL, isHookWired } from './gate-hook.mjs';
50
- import { GATES_REL } from './run-gates.mjs';
50
+ // The host-level bridge-settings snapshot (fact-only, best-effort). The READ-ONLY core (never the
51
+ // writer, which pulls in the atomic-write core) so the status survey stays a pure reader.
52
+ import { settingsSnapshot } from './bridge-settings-read.mjs';
53
+ import { GATES_REL, loadDeclaration } from './run-gates.mjs';
54
+ // The cheap-agents writer's own bundle reader + placement planner — reused by the settings survey
55
+ // (one implementation, never a drifting copy; cheap-agents imports only node builtins, no cycle).
56
+ import { readBundledAgents, planPlacement } from './cheap-agents.mjs';
51
57
  // The status vocabulary (manifestState constants, internal→public maps, display names, the no-leak
52
58
  // forbidden set) lives in the frozen labels.mjs LEAF (Plan §4.2 B1) so the import graph is acyclic —
53
59
  // nothing imports family-registry for vocabulary. Imported here for internal use; the public subset is
@@ -456,21 +462,50 @@ export const surveyGateHook = (dir, deps = {}) => {
456
462
  const exists = deps.exists ?? existsSync;
457
463
  const project = readSettingsFile(join(d, SETTINGS_FILE), { ...deps, cwd: d });
458
464
  const local = readSettingsFile(join(d, SETTINGS_LOCAL_FILE), { ...deps, cwd: d });
465
+ // How many gates are actually DECLARED — the welcome-mat hook rung keys on a non-empty
466
+ // declaration (a fresh bootstrap seeds gates.json EMPTY, so file presence alone would misfire).
467
+ // 0 = absent or empty list; null = present but unreadable/malformed (unknown — never a false
468
+ // count, and never an area-wide error: the wired/placed probes still report; the validation
469
+ // REASON is preserved beside the null so status never renders a bare question mark).
470
+ const declaration = (() => {
471
+ try {
472
+ const res = loadDeclaration(d, deps);
473
+ return { declaredGates: res.outcome === 'loaded' ? res.gates.length : 0 };
474
+ } catch (err) {
475
+ return { declaredGates: null, declarationError: localizeError(err) };
476
+ }
477
+ })();
459
478
  return {
460
479
  wired: isHookWired(project.data) || isHookWired(local.data),
461
480
  filePlaced: Boolean(exists(join(d, GATE_HOOK_FILE_REL))),
462
481
  declarationPresent: Boolean(exists(join(d, GATES_REL))),
482
+ ...declaration,
463
483
  };
464
484
  } catch (err) {
465
485
  return { error: localizeError(err) };
466
486
  }
467
487
  };
468
488
 
489
+ // cheap agents: the kit-placed .claude/agents/ vehicles (Mode: agents) — how many of the bundled
490
+ // cheap-lane definitions are present in the project. A customized copy counts as PLACED (it exists;
491
+ // the writer preserves it) — the welcome-mat agents rung keys on zero placed. Read-only; REUSES the
492
+ // writer's own bundle reader + placement planner (cheap-agents.mjs — one implementation).
493
+ export const surveyCheapAgents = (dir, deps = {}) => {
494
+ try {
495
+ const templates = readBundledAgents(deps);
496
+ const plan = planPlacement(templates, resolve(dir), deps);
497
+ return { bundled: templates.length, placed: plan.filter((p) => p.action !== 'place').length };
498
+ } catch (err) {
499
+ return { error: localizeError(err) };
500
+ }
501
+ };
502
+
469
503
  // the project-scoped settings survey (needs a project dir). Each area is independently localized-on-error.
470
504
  export const surveySettings = (dir, deps = {}) => ({
471
505
  recipes: surveyRecipes(dir, deps),
472
506
  attribution: surveyAttribution(dir, deps),
473
507
  velocity: surveyVelocity(dir, deps),
508
+ agents: surveyCheapAgents(dir, deps),
474
509
  hook: surveyGateHook(dir, deps),
475
510
  });
476
511
 
@@ -481,12 +516,20 @@ export const surveySettings = (dir, deps = {}) => ({
481
516
  export const surveyBridges = (deps = {}) => {
482
517
  const { detection } = detectSafe(deps); // a detector failure → every readiness reads 'unknown' (honest)
483
518
  const probe = deps.findOnPath ?? findOnPath;
519
+ // Host-level bridge settings, read ONCE (best-effort — never throws; a corrupt bundle/fs error → error).
520
+ const snapshot = settingsSnapshot(deps);
484
521
  return FAMILY_MEMBERS.filter((m) => m.kind === 'execution-backend').map((m) => {
485
522
  const det = detection.find((d) => d.name === m.name);
486
523
  // Preserve findOnPath's THREE-state result (present | missing | unknown): an `unknown` (EACCES,
487
524
  // "cannot confirm") must stay distinct from a real `missing` — never flattened to a false boolean.
488
525
  const wrappers = m.wrapperCmds.map((cmd) => ({ cmd, state: probe(cmd, deps).state }));
489
- return { member: m.name, display: displayOf(m.name), readiness: det?.readiness ?? 'unknown', wrappers };
526
+ // Fact-only: the settings knobs ACTIVE (env/file, non-default) for THIS bridge. Model/effort are
527
+ // structurally absent from the registry, so this can NEVER carry a model claim (the survey's
528
+ // no-default-model-claim invariant). Localized-on-error like every other survey.
529
+ const settings = snapshot.error
530
+ ? { error: snapshot.error }
531
+ : { active: snapshot.active.filter((a) => a.bridge === m.name) };
532
+ return { member: m.name, display: displayOf(m.name), readiness: det?.readiness ?? 'unknown', wrappers, settings };
490
533
  });
491
534
  };
492
535
 
@@ -17,6 +17,7 @@ expansion), never via the shell. The validator is [`validate.mjs`](./validate.mj
17
17
  | `provides` | string[] | yes | subset of the **role vocabulary** |
18
18
  | `roles` | object | yes | keys ⊆ `provides`; see *Roles* |
19
19
  | `detect` | object | no | `installed` (skill on the machine) + `deployed` (substrate set up in cwd) |
20
+ | `settings` | array | no | the bridge's **settings-file surface** (typed; see *Settings*). Unlike `contract`, a malformed entry **fails** `--strict`. |
20
21
  | `install` / `uninstall` | object | no | `install.npm` is a package name, not a path |
21
22
  | `cost` / `quota` / `provenance` | misc | no | informational |
22
23
  | `available` | boolean | no | `false` = a declared-but-not-installed stub; skips fs/version checks |
@@ -54,6 +55,36 @@ Each `roles.<role>` is an object:
54
55
  `{ policy: "guarded", blocked: string[], probeRelaxed: string[] }`, matching the wrapper's
55
56
  real case-arm patterns (pinned by the source-level reverse-guard test).
56
57
 
58
+ ## Settings (bridges 2.3.0, D6 — manifest-as-source)
59
+
60
+ `settings` declares the bridge's host-level settings-file surface
61
+ (`${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf`). It is an **array** of
62
+ typed entries — a JSON object would silently dedupe duplicate keys under `JSON.parse` — and,
63
+ unlike `contract`, it is validated by `validate.mjs` itself: a malformed entry **fails**
64
+ `--strict` (the kit writer, the status renderers, and the wrapper shell constants all consume
65
+ this block). Each entry:
66
+
67
+ - `key` (string, required) — UPPER_SNAKE_CASE env-var name; unique across the array.
68
+ - `kind` (string, required) — `enum | integer | duration | boolean`.
69
+ - `enum` → `values` (string[], non-empty, unique).
70
+ - `integer` → `min` / `max` (safe integers, `min <= max`); values are decimal strings.
71
+ - `duration` → the wrappers' shell duration grammar (`5m`, `30m`, `90s`): a unit suffix
72
+ (`s|m|h|d`) is **required** — a bare integer is invalid — and **zero durations are
73
+ rejected** (`timeout 0` would silently DISABLE a hard cap).
74
+ - `boolean` → the pinned wire format `"0" | "1"` (exactly what the wrappers' env vars accept).
75
+ - `default` (string|null, **required property** — a missing key fails validation) — `null` = no
76
+ file default, the wrapper built-ins apply (state them in `effect`); a non-null default must
77
+ itself pass the kind's validation.
78
+ - `appliesTo` (string[], required) — the wrapper `cmd` names (from `roles.*.cmd` of THIS
79
+ manifest) that APPLY the key. Every wrapper still RECOGNIZES the whole family registry (the
80
+ union of both bridges' `settings` keys) and skips other wrappers' keys silently.
81
+ - `effect` (string, required) — what the knob does, incl. built-in defaults and any spend/risk
82
+ caveat (the credit-rate caveat rides here for the tier knob).
83
+
84
+ Wrappers never parse JSON at run time: each carries its own shell registry/validation constants,
85
+ drift-guarded set-equal to this block by the bridge `bin/*.test.mjs` suites (help section keys,
86
+ `aw_settings_known` registry, `AW_SETTINGS_APPLIED` subset, `aw_settings_valid` typed arms).
87
+
57
88
  ## Path-field rules (Windows-safe, traversal-safe)
58
89
 
59
90
  - **No absolute paths**, **no `..` traversal**, **no unresolved placeholders** (`{{…}}` / `${…}`)
@@ -36,6 +36,30 @@ export const VALID = 'valid';
36
36
  export const UNSUPPORTED = 'unsupported';
37
37
  export const INVALID = 'invalid';
38
38
 
39
+ // Typed settings-value grammar (D6, bridges 2.3.0) — the ONE predicate the manifest validator (for a
40
+ // declared `default`), the kit-side bridge-settings writer (for a user-supplied value), and — mirrored
41
+ // in shell as aw_settings_valid — the wrappers all use, so a value the writer accepts is exactly a
42
+ // value a wrapper honors. A value is always compared as a STRING (the settings-file wire format).
43
+ export const SETTING_KINDS = new Set(['enum', 'integer', 'duration', 'boolean']);
44
+ // The wrappers' shell duration grammar: a unit suffix is REQUIRED (a bare integer is invalid), and
45
+ // zero durations are rejected — `timeout 0` DISABLES a hard cap, so a persistent settings line could
46
+ // otherwise silently remove the stall guard.
47
+ export const DURATION_RE = /^[0-9]+(\.[0-9]+)?[smhd]$/;
48
+ export const ZERO_DURATION_RE = /^0+(\.0+)?[smhd]$/;
49
+ export const settingValueValid = (entry, value) => {
50
+ switch (entry.kind) {
51
+ case 'enum': return Array.isArray(entry.values) && entry.values.includes(value);
52
+ case 'integer': {
53
+ if (!/^[0-9]+$/.test(value)) return false;
54
+ const n = Number(value);
55
+ return Number.isSafeInteger(n) && n >= entry.min && n <= entry.max;
56
+ }
57
+ case 'duration': return DURATION_RE.test(value) && !ZERO_DURATION_RE.test(value);
58
+ case 'boolean': return value === '0' || value === '1';
59
+ default: return false;
60
+ }
61
+ };
62
+
39
63
  const hasTraversal = (p) => p.split(/[\\/]/).includes('..');
40
64
  const isUnresolved = (s) => /\{\{|\}\}|\$\{/.test(s);
41
65
 
@@ -228,6 +252,67 @@ export const validateManifest = (skillDir) => {
228
252
  if (role.template != null) checkInSkillPath(`role "${key}".template`, role.template, !isStub);
229
253
  }
230
254
 
255
+ // Typed `settings` block (bridges 2.3.0, D6 manifest-as-source): the per-bridge settings-file
256
+ // surface — an ARRAY of typed entries (a JSON object would silently dedupe duplicate keys under
257
+ // JSON.parse). Unlike the AD-033 `contract` block (validator-tolerated, externally
258
+ // drift-guarded), a malformed `settings` entry FAILS validation: the kit writer, the status
259
+ // renderers, and the wrapper shell constants all consume this block, so a bad entry would
260
+ // corrupt a host-level config surface.
261
+ const settings = manifest.settings;
262
+ if (settings != null) {
263
+ if (!Array.isArray(settings)) {
264
+ errors.push('`settings` must be an array of setting entries');
265
+ } else {
266
+ const cmds = new Set(Object.values(roles)
267
+ .map((r) => (r && typeof r === 'object' && !Array.isArray(r) ? r.cmd : null))
268
+ .filter((c) => typeof c === 'string' && c));
269
+ const seenKeys = new Set();
270
+ settings.forEach((entry, i) => {
271
+ const at = `\`settings[${i}]\``;
272
+ if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) {
273
+ errors.push(`${at} must be an object`);
274
+ return;
275
+ }
276
+ if (typeof entry.key !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(entry.key)) {
277
+ errors.push(`${at}.key must be an UPPER_SNAKE_CASE string`);
278
+ } else if (seenKeys.has(entry.key)) {
279
+ errors.push(`duplicate settings key "${entry.key}" (${at})`);
280
+ } else {
281
+ seenKeys.add(entry.key);
282
+ }
283
+ if (typeof entry.effect !== 'string' || !entry.effect) errors.push(`${at}.effect must be a non-empty string`);
284
+ if (!Array.isArray(entry.appliesTo) || entry.appliesTo.length === 0
285
+ || !entry.appliesTo.every((c) => typeof c === 'string' && c)) {
286
+ errors.push(`${at}.appliesTo must be a non-empty array of wrapper cmd names`);
287
+ } else {
288
+ for (const c of entry.appliesTo) {
289
+ if (!cmds.has(c)) errors.push(`${at}.appliesTo names "${c}" which is no roles.*.cmd of this manifest`);
290
+ }
291
+ }
292
+ if (!SETTING_KINDS.has(entry.kind)) {
293
+ errors.push(`${at}.kind must be one of enum|integer|duration|boolean`);
294
+ return; // the typed checks below are meaningless without a kind
295
+ }
296
+ if (entry.kind === 'enum'
297
+ && (!Array.isArray(entry.values) || entry.values.length === 0
298
+ || !entry.values.every((v) => typeof v === 'string' && v)
299
+ || new Set(entry.values).size !== entry.values.length)) {
300
+ errors.push(`${at}.values must be a non-empty array of unique non-empty strings (enum kind)`);
301
+ }
302
+ if (entry.kind === 'integer'
303
+ && (!Number.isSafeInteger(entry.min) || !Number.isSafeInteger(entry.max) || entry.min > entry.max)) {
304
+ errors.push(`${at}.min/.max must be integers with min <= max (integer kind)`);
305
+ }
306
+ if (!Object.hasOwn(entry, 'default')) {
307
+ errors.push(`${at}.default is required (null = the wrapper built-ins apply)`);
308
+ } else if (entry.default != null
309
+ && (typeof entry.default !== 'string' || !settingValueValid(entry, entry.default))) {
310
+ errors.push(`${at}.default must be null or a string value that passes the ${entry.kind} validation`);
311
+ }
312
+ });
313
+ }
314
+ }
315
+
231
316
  if (!isStub) {
232
317
  const auth = readAuthoritativeVersion(skillDir);
233
318
  if (auth.version == null) errors.push(`could not resolve an authoritative version (${auth.from})`);
@@ -4,94 +4,24 @@
4
4
  // can never reach a writer" is a STRUCTURAL invariant (an import-split test pins it), not just an
5
5
  // assertion. Splitting the writer out of the schema/read module keeps the read surface fs-write-free.
6
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).
7
+ // The hardened write flow (deployment gate → symlink STOPs → containment guard → exclusive-create
8
+ // tmp + rename with a TOCTOU re-check tmp cleanup; LAST-WRITER-WINS, documented) lives in the
9
+ // shared atomic-write core (tools/atomic-write.mjs, extracted from this module AD-042) and is
10
+ // consumed here with this module's own STOP identity, so the public API + error contract are
11
+ // unchanged (this file's tests pin them, characterize-first).
17
12
  //
18
13
  // Dependency-free, Node >= 18. Every fs primitive is injectable (deps.*) so the guards are unit-testable.
19
14
 
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
15
  import { CONFIG_REL, serializeConfig } from './orchestration-config.mjs';
16
+ import { writeDocsAiFileAtomic } from './atomic-write.mjs';
25
17
 
26
18
  // A typed STOP — a deliberate refusal we surface (deployment gate / symlinked leaf), distinct from a
27
19
  // native fs error. `Object.assign(new Error(), { code })`, the codebase's typed-error idiom (no classes).
28
20
  export const ORCH_WRITE_STOP = 'ORCH_WRITE_STOP';
29
21
  const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'OrchWriteStop', code: ORCH_WRITE_STOP });
30
22
 
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
23
  // writeConfig(cwd, config, deps) → { writtenPath } on success; THROWS a typed STOP (no deployment /
42
24
  // symlinked leaf) or a native fs error otherwise. The tmp is cleaned up on any failure after its
43
25
  // 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
- };
26
+ export const writeConfig = (cwd, config, deps = {}) =>
27
+ writeDocsAiFileAtomic(cwd, CONFIG_REL, serializeConfig(config), deps, { stop, noun: 'a config' });
@@ -41,6 +41,7 @@ export const SETTINGS_LABELS = Object.freeze({
41
41
  recipes: 'recipes',
42
42
  attribution: 'attribution',
43
43
  velocity: 'velocity',
44
+ agents: 'cheap agents',
44
45
  hook: 'gate hook',
45
46
  });
46
47
 
@@ -21,6 +21,9 @@ import { homedir } from 'node:os';
21
21
  import { join, dirname } from 'node:path';
22
22
  import { pathToFileURL, fileURLToPath } from 'node:url';
23
23
  import { detectBackends, wrapperCmdFor, wrapperContractFor } from './detect-backends.mjs';
24
+ // The host-level bridge-settings registry (manifest-as-source) + its allowed-value labels. READ-ONLY
25
+ // core only — never the writer — so this read-only advisor never imports the atomic-write core.
26
+ import { loadRegistry, allowedLabel } from './bridge-settings-read.mjs';
24
27
  import { ACTIVITIES, resolveActivityRecipe, planRecipe } from './recipes.mjs';
25
28
  import { resolveEngineDir, readEngineFragment, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
26
29
  // The plan-in-flight detector (AD-038) — imported from the READ-ONLY checker (review-state.mjs
@@ -130,8 +133,19 @@ export const extractSection = (text, activity) => {
130
133
 
131
134
  // ── resolution + rendering ─────────────────────────────────────────────────────────
132
135
 
133
- const resolveAllSlots = ({ activity, config, detection, overrides }) =>
134
- Object.keys(ACTIVITIES[activity].slots).map((slot) => {
136
+ const resolveAllSlots = ({ activity, config, detection, overrides }) => {
137
+ // The host-level settings knobs (manifest-as-source), best-effort: a corrupt bundle degrades to none
138
+ // and the advisor never crashes. Attached per wrapper cmd via each knob's `appliesTo`. Fact-only —
139
+ // model/effort are not knobs, so no model claim can ride here.
140
+ const registry = (() => {
141
+ try {
142
+ return loadRegistry({});
143
+ } catch {
144
+ return new Map();
145
+ }
146
+ })();
147
+ const knobsFor = (cmd) => [...registry.values()].filter((k) => (k.appliesTo ?? []).includes(cmd));
148
+ return Object.keys(ACTIVITIES[activity].slots).map((slot) => {
135
149
  const resolved = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity, slot, override: overrides[slot] });
136
150
  // The concrete wrapper set this slot's EFFECTIVE recipe dispatches (empty for solo). Reuse
137
151
  // planRecipe's drift-guarded dispatch for WHICH backends, then resolve each (backend, role) to its
@@ -144,9 +158,11 @@ const resolveAllSlots = ({ activity, config, detection, overrides }) =>
144
158
  // is NEVER gated by REVIEW_RECIPES (that set gates only the review-loop economics block).
145
159
  const contracts = dispatch
146
160
  .map((d) => ({ backend: d.backend, role: d.role, cmd: wrapperCmdFor(d.backend, d.role), contract: wrapperContractFor(d.backend, d.role) }))
147
- .filter((c) => c.cmd && c.contract);
161
+ .filter((c) => c.cmd && c.contract)
162
+ .map((c) => ({ ...c, settings: knobsFor(c.cmd).map((k) => ({ key: k.key, allowed: allowedLabel(k) })) }));
148
163
  return { slot, ...resolved, backends, contracts };
149
164
  });
165
+ };
150
166
 
151
167
  // An unsatisfiable EXPLICIT override is the only "warning" (loud, flagged for the agent to relay). A
152
168
  // graceful config/default degradation is reported as a per-slot reason, not a warning.
@@ -250,7 +266,7 @@ const costLanesAdvice = () => [
250
266
  // roles[role].contract (drift-guarded), never re-derived or re-worded here. Rendered for EVERY
251
267
  // dispatched backend of EVERY slot (review AND execute=delegated); each wrapper's --help prints the
252
268
  // same contract, so the agent never needs to open the wrapper source.
253
- const contractLines = ({ cmd, contract }) => {
269
+ const contractLines = ({ cmd, contract, settings }) => {
254
270
  const lines = [` ${cmd} — driving contract (as bundled with this kit; copy-paste — \`${cmd} --help\` prints the same):`];
255
271
  for (const inv of contract.invocations) lines.push(` ${inv}`);
256
272
  for (const f of contract.flags ?? []) lines.push(` ${f}`);
@@ -263,6 +279,12 @@ const contractLines = ({ cmd, contract }) => {
263
279
  if (contract.passthrough) {
264
280
  lines.push(` passthrough after '--' is ${contract.passthrough.policy}: blocked always: ${contract.passthrough.blocked.join(' ')}; relaxed only under CODEX_PROBE=1: ${contract.passthrough.probeRelaxed.join(' ')}`);
265
281
  }
282
+ // Host-level settings knobs this wrapper honors (fact-only, from the bundled manifests; explicit
283
+ // branch — contractLines drops any contract key it does not name, so this must be enumerated here).
284
+ if ((settings ?? []).length) {
285
+ lines.push(' host settings (survive kit upgrades — set via /agent-workflow-kit bridge-settings):');
286
+ for (const s of settings) lines.push(` ${s.key} — ${s.allowed}`);
287
+ }
266
288
  return lines;
267
289
  };
268
290
 
package/tools/recipes.mjs CHANGED
@@ -15,6 +15,9 @@
15
15
  // a backend is advisory or delegated, never autonomous. Dependency-free, Node >= 18.
16
16
 
17
17
  import { pathToFileURL } from 'node:url';
18
+ // The host-level bridge-settings snapshot (fact-only, best-effort). READ-ONLY core only — never the
19
+ // writer — so this read-only advisor never pulls in the atomic-write core.
20
+ import { settingsSnapshot } from './bridge-settings-read.mjs';
18
21
  import {
19
22
  detectBackends,
20
23
  wrapperCmdFor,
@@ -293,12 +296,19 @@ export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, s
293
296
  // DISPLAY_ALIASES — the ONE alias table the recommendation clause already uses; ordering is the
294
297
  // deterministic BACKEND_PRIORITY (codex before agy), independent of detection emission order.
295
298
  // Always exactly one line: no part may carry a newline (pinned by tests).
296
- export const composeStatusLine = (detection, recommendation) => {
299
+ export const composeStatusLine = (detection, recommendation, settings = null) => {
297
300
  const backends = [...detection]
298
301
  .sort((a, b) => priorityIndex(a.name) - priorityIndex(b.name))
299
302
  .map((b) => `${DISPLAY_ALIASES[b.name] ?? b.name} ${b.readiness === READY ? '✓' : '✗'} ${b.readiness}`)
300
303
  .join(' · ');
301
- return `backends: ${backends} — run /agent-workflow-kit backends · recipes: ${recommendation.clause} — see /agent-workflow-kit recipes`;
304
+ const base = `backends: ${backends} — run /agent-workflow-kit backends · recipes: ${recommendation.clause} — see /agent-workflow-kit recipes`;
305
+ // Fact-only suffix, ONLY when a bridge knob is actively set (env/file, non-default). Omitted otherwise,
306
+ // so the default line is byte-identical to before. A raw env value may (D3) carry newlines/control
307
+ // chars — collapse them to a single space so the "exactly one line" backend-status contract holds.
308
+ const oneLine = (s) => String(s).replace(/[\s]+/g, ' ').trim();
309
+ const active = settings?.active ?? [];
310
+ const suffix = active.length ? ` · settings: ${active.map((s) => `${oneLine(s.key)}=${oneLine(s.value)}`).join(' · ')}` : '';
311
+ return base + suffix;
302
312
  };
303
313
 
304
314
  // ── the one-line ACTIVE-recipe line (the discovery line — configured, never recommended) ───────────
@@ -340,7 +350,7 @@ export const composeActiveRecipeLine = ({ config, source } = {}, detection) => {
340
350
 
341
351
  // The structured report behind `--json` — the recipes, the recommendation, a plan per recipe, and
342
352
  // (additive) the pasteable one-line backend status composed from the same detection.
343
- export const buildReport = (detection) => {
353
+ export const buildReport = (detection, settings = null) => {
344
354
  const recommendation = recommendRecipe(detection);
345
355
  return {
346
356
  recipes: RECIPES.map(({ id, title, role, minBackends, degradesTo, summary }) => ({
@@ -353,7 +363,7 @@ export const buildReport = (detection) => {
353
363
  })),
354
364
  recommendation,
355
365
  plans: RECIPES.map((r) => planRecipe(r.id, detection)),
356
- statusLine: composeStatusLine(detection, recommendation),
366
+ statusLine: composeStatusLine(detection, recommendation, settings),
357
367
  };
358
368
  };
359
369
 
@@ -423,8 +433,8 @@ exclusive. Detection only — never writes, never commits, never runs a subscrip
423
433
  console.error(`[agent-workflow-kit] ${err.message}`);
424
434
  return err.exitCode ?? 1;
425
435
  }
426
- } else if (argv.includes('--status-line')) console.log(composeStatusLine(detection, recommendRecipe(detection)));
427
- else if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection), null, 2));
436
+ } else if (argv.includes('--status-line')) console.log(composeStatusLine(detection, recommendRecipe(detection), settingsSnapshot()));
437
+ else if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection, settingsSnapshot()), null, 2));
428
438
  else console.log(formatRecipes(detection));
429
439
  return 0;
430
440
  };
@@ -56,6 +56,13 @@ const renderBridges = (vm, { glyph, color }) => {
56
56
  for (const b of vm.bridges) {
57
57
  const wrappers = b.wrappers.map((w) => `${w.cmd} ${glyph[w.state] ?? glyph.unknown}`).join(', ') || '—';
58
58
  lines.push(` ${pad(b.display, MEMBER_COL)}${pad(b.readiness, READINESS_COL)}wrappers: ${wrappers}`);
59
+ // Fact-only host-level settings sub-line: the active knobs for this bridge, or a localized error.
60
+ // Absent when no knob is active, so the block stays byte-identical to before when nothing is set.
61
+ if (b.settings?.error) lines.push(` ${pad('', MEMBER_COL)}${glyph.note} couldn't read bridge settings (${b.settings.error})`);
62
+ else if (b.settings?.active?.length) {
63
+ const active = b.settings.active.map((s) => `${s.key}=${s.value} [${s.source}]`).join(' · ');
64
+ lines.push(` ${pad('', MEMBER_COL)}settings: ${active}`);
65
+ }
59
66
  }
60
67
  return lines;
61
68
  };
@@ -101,11 +108,19 @@ const renderSettings = (vm, { color, glyph }) => {
101
108
  else if (s.velocity) {
102
109
  lines.push(` ${pad(SETTINGS_LABELS.velocity, SETTINGS_COL)}defaultMode=${String(s.velocity.defaultMode)} · allow project/local=${s.velocity.allow.project}/${s.velocity.allow.local}`);
103
110
  }
104
- // gate hook — the opt-in PreToolUse gate-approval hook: wired / file placed / declaration present.
111
+ // cheap agents — the kit-placed .claude/agents/ vehicles: placed count vs the bundle.
112
+ if (s.agents?.error) lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}error: ${s.agents.error}`);
113
+ else if (s.agents) {
114
+ lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}placed=${s.agents.placed}/${s.agents.bundled}`);
115
+ }
116
+ // gate hook — the opt-in PreToolUse gate-approval hook: wired / file placed / declaration present /
117
+ // declared gate count (null → '?' — unknown is shown as unknown, never as a number).
105
118
  if (s.hook?.error) lines.push(` ${pad(SETTINGS_LABELS.hook, SETTINGS_COL)}error: ${s.hook.error}`);
106
119
  else if (s.hook) {
107
120
  const yn = (b) => (b ? 'yes' : 'no');
108
- lines.push(` ${pad(SETTINGS_LABELS.hook, SETTINGS_COL)}wired=${yn(s.hook.wired)} · file=${yn(s.hook.filePlaced)} · gates.json=${yn(s.hook.declarationPresent)}`);
121
+ // A null count carries its preserved validation reason — never a silent bare "?".
122
+ const declared = s.hook.declaredGates ?? (s.hook.declarationError ? `? (${s.hook.declarationError})` : '?');
123
+ lines.push(` ${pad(SETTINGS_LABELS.hook, SETTINGS_COL)}wired=${yn(s.hook.wired)} · file=${yn(s.hook.filePlaced)} · gates.json=${yn(s.hook.declarationPresent)} · declared=${declared}`);
109
124
  }
110
125
  return lines;
111
126
  };
@@ -5,7 +5,8 @@
5
5
  // resolves the effective `plan-execution.review` recipe (the advisor's single-source readers),
6
6
  // recomputes the CURRENT canonical uncommitted-state fingerprint, and reports — per recipe-named
7
7
  // backend — whether a FRESH, grounded, current-fingerprint receipt exists. `--check` turns the
8
- // report into a gate exit code (declare it in docs/ai/gates.json — by hand; never auto-seeded).
8
+ // report into a gate exit code (declare it in docs/ai/gates.json — by hand OR via the
9
+ // explicit-consent seeder, tools/seed-gates.mjs — never without consent, AD-021/AD-042).
9
10
  //
10
11
  // Normative `--check` exit contract (the single home of this list — SKILL.md points here):
11
12
  // exit 0 when the resolved plan-execution.review recipe is solo (configured, or degraded there —
@@ -353,7 +354,8 @@ presence + verdict + grounding for the CURRENT tree. Plan/diff-mode receipts and
353
354
  --check exits 0/1 per the normative contract in the tool header: 0 for solo / no plan in flight /
354
355
  a clean tree / not-a-git-tree / all recipe-named backends receipted-current-and-grounded; 1 when a
355
356
  recipe-named backend is missing, stale (edited after review), or grounded:false under
356
- reviewed/council. Declare it as a project gate by hand (docs/ai/gates.json) never auto-seeded.
357
+ reviewed/council. Declare it as a project gate by hand (docs/ai/gates.json) or via the
358
+ explicit-consent seeder (tools/seed-gates.mjs) — never without consent.
357
359
 
358
360
  Read-only: never writes, never commits, never runs a subscription CLI; spawns read-only git queries.
359
361
  Human residual: git commit --no-verify and receipt-file deletion remain possible — this is a