@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.
- package/CHANGELOG.md +38 -0
- package/README.md +1 -0
- package/SKILL.md +6 -2
- package/bin/install.mjs +15 -0
- package/bridges/antigravity-cli-bridge/SKILL.md +13 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +96 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +204 -1
- package/bridges/antigravity-cli-bridge/bin/agy.sh +94 -0
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +245 -1
- package/bridges/antigravity-cli-bridge/capability.json +17 -1
- package/bridges/codex-cli-bridge/SKILL.md +15 -1
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +113 -0
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +288 -0
- package/bridges/codex-cli-bridge/bin/codex-review.sh +114 -1
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +229 -1
- package/bridges/codex-cli-bridge/capability.json +29 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bridge-settings.md +29 -0
- package/references/modes/upgrade.md +8 -2
- package/tools/atomic-write.mjs +59 -21
- package/tools/bridge-settings-read.mjs +221 -0
- package/tools/bridge-settings.mjs +288 -0
- package/tools/commands.mjs +7 -0
- package/tools/family-registry.mjs +12 -1
- package/tools/manifest/schema.md +31 -0
- package/tools/manifest/validate.mjs +85 -0
- package/tools/procedures.mjs +26 -4
- package/tools/recipes.mjs +16 -6
- package/tools/renderers.mjs +7 -0
- package/tools/setup-backends.mjs +107 -9
- package/tools/view-model.mjs +7 -0
|
@@ -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.
|
|
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.
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "1.
|
|
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)
|
|
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**.
|
package/tools/atomic-write.mjs
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
// atomic-write.mjs — the family's ONE hardened atomic-write core for kit writers
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// •
|
|
6
|
-
//
|
|
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
|
-
// -
|
|
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
|
-
//
|
|
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
|
-
//
|
|
65
|
-
//
|
|
66
|
-
// (
|
|
67
|
-
|
|
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
|
|
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(`${
|
|
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
|
|
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(`${
|
|
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
|
+
};
|