@sabaiway/agent-workflow-kit 1.34.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.
@@ -679,7 +679,7 @@ describe('codex-review.sh — source-level reverse guard (parser arms ⟷ manife
679
679
  // The normative fixture (docs: the AD-038 plan Decisions — copied verbatim; field VALUES with
680
680
  // dynamic content are asserted by shape):
681
681
  const RECEIPT_FIXTURE = JSON.parse(
682
- '{"schema":1,"artifact":"code","fresh":true,"fingerprint":"<sha256hex>","backend":"codex","verdict":"revise","grounded":true,"factsHash":null,"wrapperVersion":"2.2.0","timestamp":"2026-07-03T12:00:00Z"}',
682
+ '{"schema":1,"artifact":"code","fresh":true,"fingerprint":"<sha256hex>","backend":"codex","verdict":"revise","grounded":true,"factsHash":null,"wrapperVersion":"2.3.0","timestamp":"2026-07-03T12:00:00Z"}',
683
683
  );
684
684
  const RECEIPTS_REL = join('.git', 'agent-workflow-review-receipts.jsonl');
685
685
  const readReceipts = (repo) => {
@@ -799,3 +799,231 @@ describe('codex-review.sh — review receipts (AD-038)', () => {
799
799
  assert.equal(receipts.length, 0, 'no review ran — no receipt');
800
800
  });
801
801
  });
802
+
803
+ // ── bridge settings file + service tier knob (bridges 2.3.0) ─────────────────────
804
+ // Same contract as codex-exec.test.mjs: KEY=VALUE lines under
805
+ // ${XDG_CONFIG_HOME:-$HOME/.config}/agent-workflow/bridge-settings.conf, parsed never
806
+ // sourced; explicit env (even empty) > file > built-in default. HOME is the sandbox
807
+ // repo, so the default settings path is hermetic per test.
808
+
809
+ const writeSettings = (sb, text) => {
810
+ const dir = join(sb.repo, '.config', 'agent-workflow');
811
+ mkdirSync(dir, { recursive: true });
812
+ const file = join(dir, 'bridge-settings.conf');
813
+ writeFileSync(file, text);
814
+ return file;
815
+ };
816
+ const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
817
+
818
+ describe('codex-review.sh — service tier knob (bridges 2.3.0)', () => {
819
+ it('default: no env, no file → NO service_tier flag in codex argv', () => {
820
+ const sb = makeSandbox();
821
+ const r = run(sb);
822
+ rmSync(sb.root, { recursive: true, force: true });
823
+ assert.equal(r.status, 0, r.stderr);
824
+ assert.doesNotMatch(r.argv, /service_tier/, 'default OFF: the flag must be absent');
825
+ assert.doesNotMatch(r.stderr, /bridge settings/, 'no file → no settings chatter');
826
+ });
827
+
828
+ it('env CODEX_SERVICE_TIER=priority → -c service_tier=priority reaches codex argv', () => {
829
+ const sb = makeSandbox();
830
+ const r = run(sb, { env: { CODEX_SERVICE_TIER: 'priority' } });
831
+ rmSync(sb.root, { recursive: true, force: true });
832
+ assert.equal(r.status, 0, r.stderr);
833
+ assert.match(r.argv, /(^|\n)service_tier=priority(\n|$)/);
834
+ });
835
+
836
+ it('a file-set tier lands (file wins over the built-in default)', () => {
837
+ const sb = makeSandbox();
838
+ writeSettings(sb, 'CODEX_SERVICE_TIER=priority\n');
839
+ const r = run(sb);
840
+ rmSync(sb.root, { recursive: true, force: true });
841
+ assert.equal(r.status, 0, r.stderr);
842
+ assert.match(r.argv, /(^|\n)service_tier=priority(\n|$)/);
843
+ });
844
+
845
+ it('an EXPLICITLY EMPTY env (CODEX_SERVICE_TIER=) disables a file-set tier for one run', () => {
846
+ const sb = makeSandbox();
847
+ writeSettings(sb, 'CODEX_SERVICE_TIER=priority\n');
848
+ const r = run(sb, { env: { CODEX_SERVICE_TIER: '' } });
849
+ rmSync(sb.root, { recursive: true, force: true });
850
+ assert.equal(r.status, 0, r.stderr);
851
+ assert.doesNotMatch(r.argv, /service_tier/, 'env wins over file — empty means knob off');
852
+ });
853
+
854
+ it('an invalid env tier warns and reviews on the standard tier (never passed to codex)', () => {
855
+ const sb = makeSandbox();
856
+ const r = run(sb, { env: { CODEX_SERVICE_TIER: 'turbo' } });
857
+ rmSync(sb.root, { recursive: true, force: true });
858
+ assert.equal(r.status, 0, r.stderr);
859
+ assert.match(r.stderr, /not a supported service tier/);
860
+ assert.doesNotMatch(r.argv, /service_tier/, 'an unvalidated value must never reach codex');
861
+ });
862
+ });
863
+
864
+ describe('codex-review.sh — bridge settings file semantics (bridges 2.3.0)', () => {
865
+ it('a file-set CODEX_REVIEW_MAX_TOTAL_BYTES is effective (switches to the temp-file path)', () => {
866
+ const sb = makeSandbox();
867
+ writeFileSync(join(sb.repo, 'big.txt'), 'B'.repeat(5000));
868
+ writeSettings(sb, 'CODEX_REVIEW_MAX_TOTAL_BYTES=100\n');
869
+ const r = run(sb);
870
+ rmSync(sb.root, { recursive: true, force: true });
871
+ assert.equal(r.status, 0, r.stderr);
872
+ assert.match(r.capStdin, /codex-review-diff\./, 'the tiny file cap must force the temp-file path');
873
+ assert.doesNotMatch(r.capStdin, /ASSEMBLED CHANGE SET:/, 'the payload must not ALSO ride inline');
874
+ });
875
+
876
+ it('env overrides file: a large env cap keeps the payload inline', () => {
877
+ const sb = makeSandbox();
878
+ writeFileSync(join(sb.repo, 'big.txt'), 'B'.repeat(5000));
879
+ writeSettings(sb, 'CODEX_REVIEW_MAX_TOTAL_BYTES=100\n');
880
+ const r = run(sb, { env: { CODEX_REVIEW_MAX_TOTAL_BYTES: '5000000' } });
881
+ rmSync(sb.root, { recursive: true, force: true });
882
+ assert.equal(r.status, 0, r.stderr);
883
+ assert.match(r.capStdin, /ASSEMBLED CHANGE SET:/, 'the env cap (large) must win over the file cap (100)');
884
+ assert.doesNotMatch(r.capStdin, /codex-review-diff\./);
885
+ });
886
+
887
+ it('duplicate key → the LAST occurrence wins (100 then 5000000 → inline)', () => {
888
+ const sb = makeSandbox();
889
+ writeFileSync(join(sb.repo, 'big.txt'), 'B'.repeat(5000));
890
+ writeSettings(sb, 'CODEX_REVIEW_MAX_TOTAL_BYTES=100\nCODEX_REVIEW_MAX_TOTAL_BYTES=5000000\n');
891
+ const r = run(sb);
892
+ rmSync(sb.root, { recursive: true, force: true });
893
+ assert.equal(r.status, 0, r.stderr);
894
+ assert.match(r.capStdin, /ASSEMBLED CHANGE SET:/);
895
+ });
896
+
897
+ it("another wrapper's / another bridge's valid key is skipped silently", () => {
898
+ const sb = makeSandbox();
899
+ writeSettings(sb, 'AGY_HARD_TIMEOUT=30m\nAGY_REVIEW_ALLOW_ADDDIR=1\n');
900
+ const r = run(sb);
901
+ rmSync(sb.root, { recursive: true, force: true });
902
+ assert.equal(r.status, 0, r.stderr);
903
+ assert.doesNotMatch(r.stderr, /bridge settings/, 'a recognized non-applied key earns NO warning');
904
+ });
905
+
906
+ it('a truly unknown key warns ONCE naming the file; the review is unaffected', () => {
907
+ const sb = makeSandbox();
908
+ writeSettings(sb, 'TOTALLY_UNKNOWN=1\nTOTALLY_UNKNOWN=2\n');
909
+ const r = run(sb);
910
+ rmSync(sb.root, { recursive: true, force: true });
911
+ assert.equal(r.status, 0, r.stderr);
912
+ const warns = r.stderr.match(/unknown key 'TOTALLY_UNKNOWN'/g) ?? [];
913
+ assert.equal(warns.length, 1, `exactly one warning per unknown key, got ${warns.length}`);
914
+ assert.match(r.stderr, /bridge-settings\.conf/, 'the warning must name the settings file');
915
+ });
916
+
917
+ it('malformed lines warn and are ignored; comments and blank lines are silent', () => {
918
+ const sb = makeSandbox();
919
+ writeSettings(sb, '# a comment\n\nNOT A KEY VALUE LINE\nCODEX_SERVICE_TIER=priority\n');
920
+ const r = run(sb);
921
+ rmSync(sb.root, { recursive: true, force: true });
922
+ assert.equal(r.status, 0, r.stderr);
923
+ const malformed = r.stderr.match(/malformed line/g) ?? [];
924
+ assert.equal(malformed.length, 1, 'comments/blank lines must NOT count as malformed');
925
+ assert.match(r.argv, /(^|\n)service_tier=priority(\n|$)/, 'valid lines still apply');
926
+ });
927
+
928
+ it('an existing-but-unreadable file warns loudly and falls back to built-ins', { skip: isRoot }, () => {
929
+ // The settings file goes OUTSIDE the repo (XDG_CONFIG_HOME): an unreadable file INSIDE the
930
+ // work tree would fail the review-payload assembly itself (untracked contents are cat'ed),
931
+ // which is pre-existing behaviour unrelated to the settings reader.
932
+ const sb = makeSandbox();
933
+ const xdg = join(sb.root, 'xdg');
934
+ mkdirSync(join(xdg, 'agent-workflow'), { recursive: true });
935
+ const file = join(xdg, 'agent-workflow', 'bridge-settings.conf');
936
+ writeFileSync(file, 'CODEX_SERVICE_TIER=priority\n');
937
+ chmodSync(file, 0o000);
938
+ const r = run(sb, { env: { XDG_CONFIG_HOME: xdg } });
939
+ rmSync(sb.root, { recursive: true, force: true });
940
+ assert.equal(r.status, 0, r.stderr);
941
+ assert.match(r.stderr, /unreadable/);
942
+ assert.doesNotMatch(r.argv, /service_tier/, 'an unreadable file must yield built-in defaults');
943
+ });
944
+
945
+ it('a settings line can NEVER execute code (command-substitution payload inert)', () => {
946
+ const sb = makeSandbox();
947
+ const pwned = join(sb.repo, 'pwned');
948
+ const pwned2 = join(sb.repo, 'pwned2');
949
+ writeSettings(
950
+ sb,
951
+ `CODEX_SERVICE_TIER=$(touch ${pwned})\nEVIL_KEY=\`touch ${pwned2}\`\n`,
952
+ );
953
+ const r = run(sb);
954
+ const executed = existsSync(pwned) || existsSync(pwned2);
955
+ rmSync(sb.root, { recursive: true, force: true });
956
+ assert.equal(r.status, 0, r.stderr);
957
+ assert.equal(executed, false, 'file content must be parsed, never evaluated');
958
+ assert.doesNotMatch(r.argv, /service_tier/, 'the payload value must fail validation');
959
+ });
960
+
961
+ it('a DIRECTORY at the settings path warns loudly and falls back to built-ins (no crash)', () => {
962
+ // Outside the repo (XDG) — an unreadable path INSIDE the work tree would fail the
963
+ // review-payload assembly itself (pre-existing behaviour, unrelated to the reader).
964
+ const sb = makeSandbox();
965
+ const xdg = join(sb.root, 'xdg');
966
+ mkdirSync(join(xdg, 'agent-workflow', 'bridge-settings.conf'), { recursive: true });
967
+ const r = run(sb, { env: { XDG_CONFIG_HOME: xdg } });
968
+ rmSync(sb.root, { recursive: true, force: true });
969
+ assert.equal(r.status, 0, `a directory must degrade honestly, not kill the run: ${r.stderr}`);
970
+ assert.match(r.stderr, /unreadable or not a regular file/);
971
+ assert.doesNotMatch(r.stderr, /Is a directory/, 'no raw bash error may leak');
972
+ });
973
+ });
974
+
975
+ // ── settings surface ⟷ manifest (drift guard, D6) — same contract as codex-exec ──
976
+ const SETTINGS_HEADER = 'Settings file (KEY=VALUE, parsed never sourced; env wins over file, file wins over built-in default):';
977
+ const SIBLING_MANIFEST = JSON.parse(readFileSync(join(HERE, '..', '..', 'antigravity-cli-bridge', 'capability.json'), 'utf8'));
978
+ const ALL_SETTINGS = [...(MANIFEST.settings ?? []), ...(SIBLING_MANIFEST.settings ?? [])];
979
+ const SETTINGS_CMD = 'codex-review';
980
+
981
+ describe('codex-review.sh — settings surface ⟷ manifest (D6, manifest-pinned)', () => {
982
+ it('--help Settings section keys set-EQUAL the manifest appliesTo subset', () => {
983
+ const help = runHelp('--help').stdout;
984
+ const section = helpSection(help, SETTINGS_HEADER);
985
+ const got = section.filter((l) => /^[A-Z][A-Z0-9_]+ —/.test(l)).map((l) => l.split(' ')[0]);
986
+ const want = (MANIFEST.settings ?? []).filter((s) => s.appliesTo.includes(SETTINGS_CMD)).map((s) => s.key);
987
+ assert.ok(want.length > 0, 'the manifest must declare settings for this wrapper');
988
+ setEq(got, want, 'help Settings keys ⟷ manifest settings.appliesTo');
989
+ assert.ok(section.some((l) => l.includes('agent-workflow/bridge-settings.conf')), 'the section names the settings file');
990
+ });
991
+
992
+ const source = readFileSync(WRAPPER, 'utf8');
993
+
994
+ it('aw_settings_known carries exactly the UNION of both bridges settings keys', () => {
995
+ const m = source.match(/aw_settings_known\(\) \{\n case " ([^"]+) " in/);
996
+ assert.ok(m, 'aw_settings_known registry case not found');
997
+ assert.ok(ALL_SETTINGS.length >= 5, 'both manifests must contribute settings');
998
+ setEq(m[1].trim().split(/\s+/), ALL_SETTINGS.map((s) => s.key), 'shell registry ⟷ manifest union');
999
+ });
1000
+
1001
+ it('AW_SETTINGS_APPLIED equals the manifest appliesTo subset for this wrapper', () => {
1002
+ const m = source.match(/^AW_SETTINGS_APPLIED="([^"]*)"$/m);
1003
+ assert.ok(m, 'AW_SETTINGS_APPLIED not found');
1004
+ const want = ALL_SETTINGS.filter((s) => s.appliesTo.includes(SETTINGS_CMD)).map((s) => s.key);
1005
+ assert.ok(want.length > 0);
1006
+ setEq(m[1].trim().split(/\s+/), want, 'applied subset ⟷ manifest appliesTo');
1007
+ });
1008
+
1009
+ it('aw_settings_valid arms carry the manifest typed constants per key', () => {
1010
+ const body = source.match(/aw_settings_valid\(\) \{[\s\S]*?\n\}/);
1011
+ assert.ok(body, 'aw_settings_valid not found');
1012
+ const armKeys = [...body[0].matchAll(/^ ([A-Z][A-Z0-9_]*)\)/gm)].map((x) => x[1]);
1013
+ setEq(armKeys, ALL_SETTINGS.map((s) => s.key), 'validation arms ⟷ manifest keys');
1014
+ for (const s of ALL_SETTINGS) {
1015
+ const arm = body[0].match(new RegExp(`^ ${s.key}\\) (.*) ;;$`, 'm'));
1016
+ assert.ok(arm, `no validation arm for ${s.key}`);
1017
+ if (s.kind === 'enum') for (const v of s.values) assert.ok(arm[1].includes(`"${v}"`), `${s.key}: enum value '${v}' not pinned`);
1018
+ if (s.kind === 'integer') {
1019
+ assert.match(arm[1], new RegExp(`>= ${s.min}\\b`), `${s.key}: min ${s.min} not pinned`);
1020
+ assert.match(arm[1], new RegExp(`<= ${s.max}\\b`), `${s.key}: max ${s.max} not pinned`);
1021
+ }
1022
+ if (s.kind === 'boolean') assert.ok(arm[1].includes('"0"') && arm[1].includes('"1"'), `${s.key}: boolean 0/1 not pinned`);
1023
+ if (s.kind === 'duration') {
1024
+ assert.ok(arm[1].includes('$dur_re'), `${s.key}: duration grammar not pinned`);
1025
+ assert.ok(arm[1].includes('$zero_re'), `${s.key}: zero-duration rejection not pinned (timeout 0 disables the cap)`);
1026
+ }
1027
+ }
1028
+ });
1029
+ });
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "codex-cli-bridge",
5
5
  "kind": "execution-backend",
6
- "version": "2.2.0",
6
+ "version": "2.3.0",
7
7
  "provides": ["execute", "review"],
8
8
  "roles": {
9
9
  "execute": {
@@ -43,6 +43,34 @@
43
43
  }
44
44
  }
45
45
  },
46
+ "settings": [
47
+ {
48
+ "key": "CODEX_SERVICE_TIER",
49
+ "kind": "enum",
50
+ "values": ["priority"],
51
+ "default": null,
52
+ "appliesTo": ["codex-exec", "codex-review"],
53
+ "effect": "codex service tier; 'priority' (catalog display name Fast) = ~1.5x token speed at a 2.5x credit rate on gpt-5.5 — quality-neutral (same model), live-probed 2026-07-05. Unset/empty ⇒ no service_tier flag (standard tier). SPEND KNOB: enabling it is a consented per-host act, never a default. codex itself accepts any -c service_tier string silently, so the wrapper validates the value."
54
+ },
55
+ {
56
+ "key": "CODEX_HARD_TIMEOUT",
57
+ "kind": "integer",
58
+ "min": 1,
59
+ "max": 86400,
60
+ "default": null,
61
+ "appliesTo": ["codex-exec", "codex-review"],
62
+ "effect": "hard wall-clock cap in seconds via timeout(1); built-in default 3600 (codex-exec) / 1800 (codex-review)."
63
+ },
64
+ {
65
+ "key": "CODEX_REVIEW_MAX_TOTAL_BYTES",
66
+ "kind": "integer",
67
+ "min": 1,
68
+ "max": 100000000,
69
+ "default": "1500000",
70
+ "appliesTo": ["codex-review"],
71
+ "effect": "codex-review code: above this assembled-payload size (bytes) the diff rides via a git-dir temp file instead of inline — never truncated."
72
+ }
73
+ ],
46
74
  "detect": {
47
75
  "installed": {
48
76
  "env": "CODEX_CLI_BRIDGE_DIR",
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.34.0",
6
+ "version": "1.35.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.34.0",
3
+ "version": "1.35.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",
@@ -0,0 +1,29 @@
1
+ ### Mode: bridge-settings
2
+
3
+ The reader + consent-gated **writer** for the **host-level** bridge settings file — the answer to *"turn on the codex Fast tier (or another bridge knob) once, predictably, so it survives kit upgrades."* The four bridge wrappers read `${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf` (`KEY=VALUE` lines, **parsed never sourced**); this is the ONLY writer for it. The file lives **outside every kit-managed tree**, so a kit refresh never writes or clobbers it — upgrade-survival is structural (D2). It **previews by default**; `--apply` writes. Hand-editing the file stays fully supported — this is an offered convenience, never a lock.
4
+
5
+ **The knobs are the bundled bridges' own `settings` blocks** (manifest-as-source, D6) — the tool never invents a key or a value rule, and what it writes always passes the wrappers' own validation. Model/effort are **NOT** settable here (the wrappers' quality-first guard is untouched, D4). Run **`node ${CLAUDE_SKILL_DIR}/tools/bridge-settings.mjs`** to see the live list; today:
6
+
7
+ | key | bridge | values | note |
8
+ |---|---|---|---|
9
+ | `CODEX_SERVICE_TIER` | codex | `priority` | **SPEND KNOB** — the "Fast" tier: ~1.5× token speed at a **2.5× credit rate** on gpt-5.5, quality-neutral (same model). Default unset ⇒ standard tier. |
10
+ | `CODEX_HARD_TIMEOUT` | codex | integer `1..86400` | hard wall-clock cap (seconds) via `timeout(1)`. |
11
+ | `CODEX_REVIEW_MAX_TOTAL_BYTES` | codex | integer `1..100000000` | codex-review payload size above which the diff rides a temp file (never truncated). |
12
+ | `AGY_HARD_TIMEOUT` | agy | duration `5m`/`30m`/`90s` (unit required, nonzero) | hard wall-clock cap via `timeout(1)`. |
13
+ | `AGY_REVIEW_ALLOW_ADDDIR` | agy | `0` \| `1` | `1` re-enables the oversized-review `--add-dir` offload (Issue-001 stall risk, bounded by the timeout). |
14
+
15
+ **Invocations:**
16
+
17
+ 1. **Read** — `node ${CLAUDE_SKILL_DIR}/tools/bridge-settings.mjs [--json]` prints every knob's **effective value + source** (`env` / `file` / `default`) and flags any unknown/duplicate/malformed lines. Read-only.
18
+ 2. **Preview a change** — `--set KEY=VALUE` (or `--unset KEY`) prints `before → after` and writes **nothing**. Re-run with **`--apply`** to write. Multiple `--set`/`--unset` ops apply in one atomic write.
19
+ 3. **`--apply`** writes via the hardened out-of-tree atomic writer (creates the dir + file on first use; symlink/parent/TOCTOU-safe; last-writer-wins). It touches **only** the `KEY=` line it owns — every comment / blank / other line is preserved verbatim.
20
+
21
+ **Precedence at run time:** explicit env (even empty — `KEY=` disables the knob for one run) **>** this file **>** the wrapper's built-in default. So an operator can always override one run without editing the file; the reader shows which source wins.
22
+
23
+ **Refusals (the guarded contract):** an **unknown key** or an **invalid / out-of-range value** → exit `2` (nothing read of the file, nothing written). A file that already carries **duplicate keys** → exit `1`, named and left **byte-untouched** (fix the duplicates by hand first — the writer never edits blindly around them). A **symlinked / non-regular / unreadable** settings file → exit `1` (refuses to write through it). `--apply` with `--dry-run`, a duplicate op for one key, or a malformed `--set` → usage exit `2`.
24
+
25
+ **Spend consent (D4):** enabling `CODEX_SERVICE_TIER=priority` costs a **2.5× credit rate** — a per-host, consented act, never a default. The preview shows the caveat (from the manifest `effect`); confirm with the user before `--apply`. The tool also warns when an env var currently shadows the key you are writing (the env value wins for that session until unset).
26
+
27
+ Output is **English/structured** — **localize it to the user's conversational language** when you narrate.
28
+
29
+ **Invariants:** writer (writes only the host settings file — outside any project/kit tree) · never commits · never runs a subscription CLI · previews by default · allowlist + value rules from the bundled manifests · model/effort never settable · the host file survives every kit refresh.
@@ -45,12 +45,18 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
45
45
  file's line cap — refused** (trim the file, re-run). The section is found by its heading — no
46
46
  markers; a renamed heading is preserved + noted. A fully absent/invalid engine is the same hard
47
47
  STOP as (c). Exit 0 covers every soft outcome; only the STOP is non-zero.
48
+
49
+ **Bridge settings reconcile — stamp-independent, same gate, BEFORE the equal-head short-circuit.**
50
+ Run `node ${CLAUDE_SKILL_DIR}/tools/bridge-settings.mjs --reconcile` and **paste its outcome line
51
+ verbatim**: it validates the deployed host settings file's keys against the bundled manifests and
52
+ **NEVER writes** it (the file lives outside every kit tree — D2), so an unknown/retired key is
53
+ flagged + preserved, never edited. Runs on **every** upgrade; exit 0 covers every outcome.
48
54
  4. **Equal-head exit — a real successful-exit report, not a bare stop.** If the stamp **equals** the head, the lineage is up to date — but step 3 (the stamp-independent reconciles) ran first and may have changed things, so this is a proper exit report, not a no-op:
49
- - **Report step 3's outcome in plain language** — for **each** pointer (workflow-methodology and orchestration-recipes) whether it was *added*, was *already present* (nothing changed), or was *skipped because the entry point is over its line limit* (the cap-refusal soft-skip from step 3, with its reason); whether the `docs/ai/orchestration.json` config was *seeded* (created from the template), had its onboarding note *refreshed*, was *already current*, or carried a *customized note that was preserved* (a user edit is never clobbered); whether the `docs/ai/gates.json` gate declaration was *seeded* or was *already present* (preserved byte-for-byte); whether the enforcement-script ensure *added* the `archive-decisions` pair to `scripts/` or found it *already present*; the **placed-bridge refresh** outcome — paste the tool's per-bridge lines verbatim (they are already plain: *refreshed* / *already current* / *skipped — not placed* / *could not refresh* + recovery); the **agent-rules lens** outcome (*refreshed* / *already current* / *custom edit preserved + note* / *file absent* / *engine too old* / *over the line cap*); and, for a hidden deployment, whether the hidden-mode footprint was *moved to project-local*, was *already project-local* (nothing changed), or needed a question (ambiguous visibility / a leftover machine-wide block). Plain wording only — never the reconcile/slot/anchor/marker terms (the never-leak-kit-internals Gotcha — `${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md`).
55
+ - **Report step 3's outcome in plain language** — for **each** pointer (workflow-methodology and orchestration-recipes) whether it was *added*, was *already present* (nothing changed), or was *skipped because the entry point is over its line limit* (the cap-refusal soft-skip from step 3, with its reason); whether the `docs/ai/orchestration.json` config was *seeded* (created from the template), had its onboarding note *refreshed*, was *already current*, or carried a *customized note that was preserved* (a user edit is never clobbered); whether the `docs/ai/gates.json` gate declaration was *seeded* or was *already present* (preserved byte-for-byte); whether the enforcement-script ensure *added* the `archive-decisions` pair to `scripts/` or found it *already present*; the **placed-bridge refresh** outcome — paste the tool's per-bridge lines verbatim (they are already plain: *refreshed* / *already current* / *skipped — not placed* / *could not refresh* + recovery); the **agent-rules lens** outcome (*refreshed* / *already current* / *custom edit preserved + note* / *file absent* / *engine too old* / *over the line cap*); the **bridge-settings reconcile** outcome (paste the tool's line verbatim); and, for a hidden deployment, whether the hidden-mode footprint was *moved to project-local*, was *already project-local* (nothing changed), or needed a question (ambiguous visibility / a leftover machine-wide block). Plain wording only — never the reconcile/slot/anchor/marker terms (the never-leak-kit-internals Gotcha — `${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md`).
50
56
  - **Never surface the structure number on this exit.** Whatever step 3 did, do **not** recite the `docs/ai` structure version, the internal versioning vocabulary, or the two-axes note here — the number is inert on an equal-head exit; it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md` (shown at the never-downgrade STOP, the explicit status view, or on an explicit ask). Frame the success itself per the final bullet: if step 3 changed anything, say **what changed** in plain human terms; only a pure zero-diff no-op is *settings already current — no update needed*.
51
57
  - **Print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason). The welcome mat closes on **one** caveat-aware next step (a behind member first, else `setup` / `recipes` / `velocity` / `agents` / `hook`).
52
58
  - **Then ask before committing — never auto-commit.** If step 3 added the slot (or anything else changed), report it and ask. If step 3 was a pure zero-diff no-op and nothing else changed, give the plain **settings already current — no update needed** message (the *Success state* contract in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`) and still print the read-only version block (installed package versions) + backend line — but **no `docs/ai` structure version and no two-axes note** (nothing changed, so the number is inert here).
53
59
  5. Show the relevant `${CLAUDE_SKILL_DIR}/CHANGELOG.md` diff (entries newer than the project's stamp).
54
60
  6. **Collect the migration answers FIRST, then apply.** If `AGENTS.md` is missing BOTH the *Communication language* and *Attribution* blocks — i.e. both blocks are missing (a pre-1.1.0 deployment) — ask the two questions as ONE structured multi-question prompt; record each answer individually, write nothing until ALL are answered, and carry the answers into the migrations below: a migration whose answer was already collected never re-asks (its own "Ask the user" step is the standalone fallback); a single missing block keeps its single ask (step 7). Then apply `${CLAUDE_SKILL_DIR}/migrations/<version>-<slug>.md` in **semver order**, only those newer than the project's stamp. Migrations are **idempotent** — safe to re-run.
55
61
  7. Reconcile drift: add any kernel files/scripts the project is missing; never clobber project-authored content (their `decisions.md`, `known_issues.md`, page specs stay). Any user question a migration raises follows the same rule as bootstrap — **structured multiple-choice where supported** (`AskUserQuestion` in Claude Code), otherwise prose. If `AGENTS.md` has no *Communication language* block (pre-1.1.0 deployment), **ask the user their conversational language** and insert the block — see `migrations/1.1.0-communication-language.md`. If it has no *Attribution* block (pre-1.2.0 deployment), **ask whether the agent may attribute work to itself / AI** and insert the block (defaulting to `off`) — see `migrations/1.2.0-agent-attribution.md`. (An answer already collected by the step-6 batched prompt is carried in — never re-asked here.)
56
- 8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`1.3.0`, not the package version — mechanics unchanged: the atomic write to the stamp file). In the report, **describe what the upgrade changed in plain human terms** — which parts of their `docs/ai` are now different (the migrations that ran), plus the step-3 **placed-bridge refresh** lines (pasted verbatim) and the step-3 **agent-rules lens** outcome (same outcome set as step 4) — rather than reciting a version number; **omit the raw structure number**, and do **not** print the two-axes note here (it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`, on demand only). Then **print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason; the welcome mat closes on one caveat-aware next step). Then **ask before committing**.
62
+ 8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`1.3.0`, not the package version — mechanics unchanged: the atomic write to the stamp file). In the report, **describe what the upgrade changed in plain human terms** — which parts of their `docs/ai` are now different (the migrations that ran), plus the step-3 **placed-bridge refresh** lines (pasted verbatim), the step-3 **agent-rules lens** outcome (same outcome set as step 4), and the step-3 **bridge-settings reconcile** outcome — rather than reciting a version number; **omit the raw structure number**, and do **not** print the two-axes note here (it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`, on demand only). Then **print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason; the welcome mat closes on one caveat-aware next step). Then **ask before committing**.
@@ -1,15 +1,19 @@
1
- // atomic-write.mjs — the family's ONE hardened atomic-write core for kit writers that target a
2
- // file under a project's docs/ai/. Extracted from orchestration-write.mjs (the only full
3
- // implementation of the discipline, AD-042) and parameterized by (rel, body, stop identity) so
4
- // BOTH consumers run the same guarded flow with zero drift:
5
- // • orchestration-write.mjs — docs/ai/orchestration.json (public API unchanged);
6
- // seed-gates.mjs docs/ai/gates.json (the consent-gated seeder).
1
+ // atomic-write.mjs — the family's ONE hardened atomic-write core for kit writers. Extracted from
2
+ // orchestration-write.mjs (the only full implementation of the discipline, AD-042) and parameterized
3
+ // by (containment ROOT, absolute target, body, stop identity) so every consumer runs the same guarded
4
+ // flow with zero drift:
5
+ // • writeDocsAiFileAtomic a file under a project's docs/ai/ (deployment-gated to cwd):
6
+ // orchestration-write.mjs → docs/ai/orchestration.json; seed-gates.mjs docs/ai/gates.json.
7
+ // • writeHostConfigFileAtomic — a file under a host config dir OUTSIDE any project tree
8
+ // (bridges 2.3.0, D6): ${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf. The
9
+ // host dir is CREATED if absent (a host config SHOULD materialize), unlike the docs/ai gate
10
+ // which REFUSES an absent deployment.
7
11
  //
8
- // The discipline (verbatim from the source implementation):
9
- // - DEPLOYMENT GATE first: refuse to scatter a file into a repo with no docs/ai/ STOP loud.
12
+ // The discipline (verbatim from the source implementation) — writeContainedFileAtomic(root, dst, …):
13
+ // - a per-consumer GATE runs first (deployment gate for docs/ai; create+verify for the host dir).
10
14
  // - refuse a SYMLINKED leaf — a rename would silently replace the link target.
11
15
  // - guard the dst + the tmp sibling with assertContainedRealPath (fs-safe) — refuses a symlinked
12
- // docs/ or docs/ai/ PARENT, not just the leaf, and refuses any escape outside cwd.
16
+ // PARENT component inside `root`, not just the leaf, and refuses any escape outside `root`.
13
17
  // - atomic: write a UNIQUE *.<rand>.tmp opened EXCLUSIVE-CREATE (wx), then rename over the dst.
14
18
  // - RE-CHECK the parent chain + the leaf immediately before the rename (TOCTOU).
15
19
  // - tmp cleaned up on any failure after its creation.
@@ -18,7 +22,7 @@
18
22
  // Dependency-free, Node >= 18. Every fs primitive is injectable (deps.*) so the guards are
19
23
  // unit-testable. NEVER imported by a read-only module (procedures.mjs — pinned by an import guard).
20
24
 
21
- import { lstatSync, writeFileSync, renameSync, rmSync } from 'node:fs';
25
+ import { lstatSync, writeFileSync, renameSync, rmSync, mkdirSync } from 'node:fs';
22
26
  import { randomBytes } from 'node:crypto';
23
27
  import { join } from 'node:path';
24
28
  import { assertContainedRealPath } from './fs-safe.mjs';
@@ -61,28 +65,42 @@ export const assertDocsAiDeployment = (cwd, deps = {}, opts = {}) => {
61
65
  }
62
66
  };
63
67
 
64
- // writeDocsAiFileAtomic(cwd, rel, body, deps, opts) { writtenPath: rel } on success; THROWS the
65
- // caller's typed STOP (via opts.stop) or a native fs error otherwise. `body` arrives pre-serialized
66
- // (each consumer owns its canonical serialization).
67
- export const writeDocsAiFileAtomic = (cwd, rel, body, deps = {}, opts = {}) => {
68
+ // Host config dir gate CREATE the dir if absent (a host config SHOULD materialize on first write,
69
+ // the opposite of the docs/ai deployment gate), then refuse a symlinked / non-directory dir we would
70
+ // write THROUGH (a rename into a symlinked dir would land outside where the user thinks). `noun` names
71
+ // what the caller writes so each consumer's STOP message stays exactly as its own tests pinned it.
72
+ export const assertHostConfigDirSafe = (dir, deps = {}, opts = {}) => {
73
+ const lstat = deps.lstat ?? lstatSync;
74
+ const mkdir = deps.mkdir ?? ((p) => mkdirSync(p, { recursive: true }));
75
+ const stop = opts.stop ?? defaultStop;
76
+ const noun = opts.noun ?? 'a host config file';
77
+ mkdir(dir);
78
+ const st = lstatNoFollow(dir, lstat);
79
+ if (st === null) throw stop(`could not create the host config dir: ${dir}`);
80
+ if (st.isSymbolicLink()) throw stop(`${dir} is a symlink — refusing to write ${noun} through it`);
81
+ if (!st.isDirectory()) throw stop(`${dir} exists but is not a directory — refusing to write ${noun}`);
82
+ };
83
+
84
+ // The hardened atomic flow, parameterized by containment ROOT + an already-passed gate. `dst` is the
85
+ // ABSOLUTE target under `root`; `opts.label` names it in a symlink-refusal message. Returns
86
+ // { writtenPath: dst }. THROWS the caller's typed STOP (opts.stop) or a native fs error.
87
+ export const writeContainedFileAtomic = (root, dst, body, deps = {}, opts = {}) => {
68
88
  const lstat = deps.lstat ?? lstatSync;
69
89
  const writeFile = deps.writeFile ?? writeFileSync;
70
90
  const rename = deps.rename ?? renameSync;
71
91
  const rm = deps.rm ?? ((p) => rmSync(p, { force: true }));
72
92
  const rand = deps.rand ?? (() => randomBytes(6).toString('hex'));
73
93
  const stop = opts.stop ?? defaultStop;
74
- const guard = (target) => assertContainedRealPath(cwd, target, { lstat });
94
+ const label = opts.label ?? dst;
95
+ const guard = (target) => assertContainedRealPath(root, target, { lstat });
75
96
 
76
- assertDocsAiDeployment(cwd, deps, { ...opts, rel });
77
-
78
- const dst = join(cwd, rel);
79
97
  // Refuse a symlinked leaf with a CLEAR message before the generic traversal guard fires (a rename
80
98
  // would silently replace the link rather than the file the user thinks they are editing).
81
99
  const leaf = lstatNoFollow(dst, lstat);
82
100
  if (leaf && leaf.isSymbolicLink()) {
83
- throw stop(`${rel} is a symlink — refusing to replace it (a write would clobber the link target)`);
101
+ throw stop(`${label} is a symlink — refusing to replace it (a write would clobber the link target)`);
84
102
  }
85
- // Guard the dst + a unique tmp SIBLING: refuses a symlinked docs/ or docs/ai/ parent, and any escape.
103
+ // Guard the dst + a unique tmp SIBLING: refuses a symlinked parent component inside root, and any escape.
86
104
  guard(dst);
87
105
  const tmp = `${dst}.${rand()}.tmp`;
88
106
  guard(tmp);
@@ -95,12 +113,32 @@ export const writeDocsAiFileAtomic = (cwd, rel, body, deps = {}, opts = {}) => {
95
113
  guard(dst);
96
114
  const leafAgain = lstatNoFollow(dst, lstat);
97
115
  if (leafAgain && leafAgain.isSymbolicLink()) {
98
- throw stop(`${rel} became a symlink — refusing to replace it`);
116
+ throw stop(`${label} became a symlink — refusing to replace it`);
99
117
  }
100
118
  rename(tmp, dst);
101
119
  } catch (err) {
102
120
  rm(tmp); // never leave a temp file behind on failure
103
121
  throw err;
104
122
  }
123
+ return { writtenPath: dst };
124
+ };
125
+
126
+ // writeDocsAiFileAtomic(cwd, rel, body, deps, opts) → { writtenPath: rel } on success; THROWS the
127
+ // caller's typed STOP (via opts.stop) or a native fs error otherwise. `body` arrives pre-serialized
128
+ // (each consumer owns its canonical serialization). Thin wrapper over the core: gate = deployment
129
+ // gate, root = cwd, target = cwd/rel; the label + return stay `rel` so its public API is unchanged.
130
+ export const writeDocsAiFileAtomic = (cwd, rel, body, deps = {}, opts = {}) => {
131
+ assertDocsAiDeployment(cwd, deps, { ...opts, rel });
132
+ const dst = join(cwd, rel);
133
+ writeContainedFileAtomic(cwd, dst, body, deps, { ...opts, label: rel });
105
134
  return { writtenPath: rel };
106
135
  };
136
+
137
+ // writeHostConfigFileAtomic(dir, filename, body, deps, opts) → { writtenPath: dir/filename }. Gate =
138
+ // create+verify the host dir; root = that dir; target = dir/filename. For the out-of-tree host config
139
+ // surface (bridge-settings.conf) that no project deployment owns.
140
+ export const writeHostConfigFileAtomic = (dir, filename, body, deps = {}, opts = {}) => {
141
+ assertHostConfigDirSafe(dir, deps, opts);
142
+ const dst = join(dir, filename);
143
+ return writeContainedFileAtomic(dir, dst, body, deps, { ...opts, label: opts.label ?? dst });
144
+ };