@sabaiway/agent-workflow-kit 1.34.0 → 1.36.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 +71 -0
- package/README.md +1 -0
- package/SKILL.md +23 -13
- 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/review-ledger.md +28 -0
- package/references/modes/set-autonomy.md +29 -0
- package/references/modes/upgrade.md +8 -2
- package/references/modes/velocity.md +13 -0
- package/tools/atomic-write.mjs +59 -21
- package/tools/autonomy-config.mjs +306 -0
- package/tools/autonomy-write.mjs +27 -0
- package/tools/bridge-settings-read.mjs +221 -0
- package/tools/bridge-settings.mjs +288 -0
- package/tools/commands.mjs +21 -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/review-ledger-write.mjs +257 -0
- package/tools/review-ledger.mjs +508 -0
- package/tools/seed-gates.mjs +45 -4
- package/tools/set-autonomy.mjs +195 -0
- package/tools/setup-backends.mjs +107 -9
- package/tools/velocity-profile.mjs +468 -5
- 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.36.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.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
### Mode: review-ledger
|
|
2
|
+
|
|
3
|
+
The review-round **LEDGER** (DEBT-REVIEW-CAP / AD-045) — it turns the prose review-loop crossover-stop (`planning.md` §9 "cap ≤2 / crossover-stop / fold-at-altitude"; `procedures.md` "{round N · finding-origin tally · per-backend verdict} … computed signal, not a remembered rule") into a **COMPUTED** signal. The orchestrator records **one round per review round** to a JSONL ledger **inside the git dir** (`<git dir>/agent-workflow-review-ledger.jsonl` — never committable by construction; `AW_REVIEW_LEDGER` overrides), and a read-only checker computes the stop decision from the recorded rounds + triage classifications, never from a remembered rule. It is the read-only sibling of `review-state` (presence vs convergence are distinct axes) — the read module **never imports the writer** (an import-split test pins it).
|
|
4
|
+
|
|
5
|
+
**Two tools — the read/write split (Decision 3):**
|
|
6
|
+
|
|
7
|
+
1. **Read-only checker** — `node ${CLAUDE_SKILL_DIR}/tools/review-ledger.mjs [--check | --status | --json]`:
|
|
8
|
+
- `--status` (default) → the human report: resolved `plan-execution.review` recipe, plan-in-flight, per-round tally with `findings[]`, per-backend counts + verdict, the `decideStop` verdict. **This printed render REPLACES the remembered per-round `{round N · finding-origin tally · per-backend verdict}` emission** — run it each round instead of composing the tally by hand.
|
|
9
|
+
- **`--check`** → the gate exit code. The **normative exit contract lives in the tool header** (the single home — do not re-enumerate it). It enforces the **plan-execution (code) loop** (the loop that produces the committable artifact), filtered to `activity==="plan-execution"` AND the in-flight plan's filename stem: exit 0 for solo / no plan in flight / a clean tree / a non-git cwd / a `converged` or `resolved-residual` loop; exit 1 for a dirty non-converged loop (`triage-required`, `continue`, or **no round/receipt recorded at all** — a dirty active plan with an empty/stale ledger is a FAILURE, not a fail-open pass), more than one plan in flight (ambiguous loop id), a recorded 0/0 coexisting with a non-ship receipt verdict, or a non-degraded recorded backend missing its receipt. **Fail-CLOSED** on a detector failure — the only detector-independent green is an EXPLICIT configured solo.
|
|
10
|
+
- `--json` → the structured state + decision.
|
|
11
|
+
|
|
12
|
+
2. **Writer** — `node ${CLAUDE_SKILL_DIR}/tools/review-ledger-write.mjs record|classify --json '<payload>'` (the SOLE writer, over the shared `atomic-write` core):
|
|
13
|
+
- `record` appends one round — `{ loop, round, origins, backends, findings }` (activity defaults to `plan-execution`). **The teeth (Decision 5):** it REFUSES (typed STOP) to append a round WHILE `decideStop` on the existing records is `triage-required` (an UNCLASSIFIED surviving blocking finding at/after the cap), and refuses ANY round beyond the **hard-max ceiling of 3** unconditionally. **Integrity binding (Decision 7):** each NON-degraded backend needs a grounded **code** receipt (from `codex-review code` / `agy-review code --facts @f`) for the current tree — a round cannot be recorded for a tree no bridge reviewed.
|
|
14
|
+
- `classify` appends one triage — each surviving blocking finding classified `fixable-bug` / `inherent-layer-residual` / `escalate`. **This is what BREAKS the deadlock:** once every surviving blocking finding is classified, `record` permits the next round (a `fixable-bug` classification lets the fix round run — no deadlock).
|
|
15
|
+
|
|
16
|
+
**The computed stop (`decideStop`, precedence `converged > resolved-residual > triage-required > continue`).** Machine fields only (counts, `class`, `accepted`, `fingerprint`), never free-text:
|
|
17
|
+
- **converged** — every recipe-named backend present, **non-degraded, at 0 blockers + 0 majors** in the latest round, at the current tree fingerprint. **"Surviving blocking finding" = a blocker OR a major** (a minor never forces triage).
|
|
18
|
+
- **resolved-residual** — at/after the cap (or a recurrence auto-trip), every surviving blocking finding classified `inherent-layer-residual` (document + raise to an acceptance criterion → NEVER folded again) OR `escalate` with `accepted:true`, and the triage's own fingerprint equals the current tree (a doc edit after the triage → a fresh round).
|
|
19
|
+
- **triage-required** (the writer HARD STOP) — a surviving blocking finding is UNCLASSIFIED at/after the cap, or a blocking finding recurred in ≥2 rounds still unclassified (even under the cap). `fixable-bug` → fold ONCE, re-review; `escalate` needs an accepted maintainer outcome.
|
|
20
|
+
- **continue** — the catch-all: under the cap, or a classified-but-not-resolved loop (fold the `fixable-bug` / obtain the `escalate` outcome, then a fresh round).
|
|
21
|
+
|
|
22
|
+
**"Every named backend" = "every NON-degraded named backend" (Decision 4).** A backend recorded `degraded:true` (agy is Issue-001-degraded on large diffs) is EXCLUDED from the 0/0 convergence requirement AND from the receipt cross-check (it ran no real review, mints no receipt), but is RECORDED (verdict `degraded`, a required `reason`) — it can never FAKE convergence. A recipe-named backend with NO entry is `missing` (≠ degraded) and BLOCKS convergence. Because the `degraded` mark is self-reported, `--check` surfaces every degraded backend loudly.
|
|
23
|
+
|
|
24
|
+
**Wire `--check` as a gate by hand OR via the explicit-consent seeder — never without consent.** The candidate line for your own `docs/ai/gates.json`: `{ "id": "review-ledger", "title": "Review-round ledger: the in-flight loop is converged or accepted-residual", "cmd": "node <path-to-this-skill>/tools/review-ledger.mjs --check" }` — the path your project reaches the kit by, QUOTED. The consent-gated seeder offers exactly this entry ONLY when `docs/ai/orchestration.json` declares `reviewed`/`council` on `plan-execution.review`.
|
|
25
|
+
|
|
26
|
+
**Human residual (stated, accepted — like `review-state`'s):** the ledger attests a review occurred and its ship-class is consistent; it does NOT prove the recorded COUNTS are truthful, nor that a self-reported `degraded:true` is real (`git commit --no-verify`, ledger-file editing, and forged counts remain possible). The ledger lives in the git dir (never committable) — a self-discipline mechanism against silent process drift, not a security boundary.
|
|
27
|
+
|
|
28
|
+
**Invariants:** the read module is read-only · never commits · never runs a subscription CLI · spawns read-only `git` queries only · the SOLE writer is `review-ledger-write.mjs`, which the read module NEVER imports (structural read/write split, import-split test) · `hardMax` is a writer-only ceiling, never a `decideStop` input.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
### Mode: set-autonomy
|
|
2
|
+
|
|
3
|
+
The **policy writer** for `docs/ai/autonomy.json` — the answer to *"set my autonomy policy without hand-editing JSON."* **Division of labor:** YOU turn the user's plain language into explicit ops; the KIT does the deterministic validate → merge → preview → write. It **previews by default** (writes nothing); `--write` applies. It **never renders enforcement** (that is the separate velocity autonomy mode — this writer touches only the policy file), **never runs a backend, and never commits**. Hand-editing `docs/ai/autonomy.json` stays fully supported — an offered convenience, never a lock.
|
|
4
|
+
|
|
5
|
+
**The policy** has two parts: **red-lines** (always hold, segment-independent) + a **per-activity autonomy level**.
|
|
6
|
+
|
|
7
|
+
- **red-lines** (`redlines.<key>` = `ask` | `deny`): the outward/irreversible actions. `commit` / `push` / `publish` (default **ask** — commit stays the human checkpoint); `network` / `credentials` / `fs_outside_repo` (default **deny** — the conservative floor).
|
|
8
|
+
- **autonomy** (`<activity>.autonomy` = `sandbox` | `prompt`) per activity (`plan-authoring`, `plan-execution`): `sandbox` ⇒ auto-allow confined commands + accept edits; `prompt` ⇒ conservative prompting (the sandbox still confines). An absent activity floors at `prompt`.
|
|
9
|
+
|
|
10
|
+
**Map the user's plain language → explicit ops** (the kit ships no NL parser; it performs no `all`-magic, so you expand scope explicitly, asking when unclear — localize the interpretation to the user's language at narration time, but keep the ops exact):
|
|
11
|
+
|
|
12
|
+
| user intent | op |
|
|
13
|
+
|---|---|
|
|
14
|
+
| more autonomous when executing | `--set plan-execution.autonomy=sandbox` |
|
|
15
|
+
| prompt while planning | `--set plan-authoring.autonomy=prompt` |
|
|
16
|
+
| always ask before commit | `--set redlines.commit=ask` |
|
|
17
|
+
| block the network | `--set redlines.network=deny` |
|
|
18
|
+
| revert / do it as before | `--unset <section>.<key>` (→ its computed default) |
|
|
19
|
+
|
|
20
|
+
Run **`node ${CLAUDE_SKILL_DIR}/tools/set-autonomy.mjs [--set <section>.<key>=<value>]… [--unset <section>.<key>]… [--write] [--json]`**:
|
|
21
|
+
|
|
22
|
+
1. **Grammar — always fully-qualified `<section>.<key>`** (the kit never guesses the section; a bare `commit=ask` is rejected). Sections/keys: `redlines.{commit,push,publish,network,credentials,fs_outside_repo}` (each `ask|deny`); `plan-authoring.autonomy`, `plan-execution.autonomy` (each `sandbox|prompt`).
|
|
23
|
+
2. **Preview by default** — prints `current → proposed` for the **changed** keys only, plus each key's **effective value** (an `--unset` shows its computed default). It writes **nothing**. Re-run with **`--write`** to apply. A no-op `--set` (value already equals) writes nothing and never re-seeds the onboarding note.
|
|
24
|
+
3. **`--write`** applies via a hardened, atomic write (deployment-gated — refuses to scatter a policy into a repo with no `docs/ai`; exclusive-create temp + rename; symlink/TOCTOU-safe; last-writer-wins). It preserves the onboarding note + every untouched key, normalizing to canonical 2-space JSON. After a write it points at the **velocity autonomy render** as the next step (rendering the policy into `.claude/settings.json` is a separate, previewed step — this writer never touches settings).
|
|
25
|
+
4. **Exit codes:** `0` success; `2` usage (a bare/duplicate op, or `--write` with no ops); `1` config error (malformed/unreadable policy — the file is left **untouched**, never clobbered) or a write STOP (no deployment / a symlinked policy). A `1`/`2` failure is loud; on a malformed policy, offer to show the parse error so you can help the user fix the JSON.
|
|
26
|
+
|
|
27
|
+
Output is **English/structured** — **localize it to the user's conversational language** when you narrate.
|
|
28
|
+
|
|
29
|
+
**Invariants:** writer (writes only `docs/ai/autonomy.json`) · never renders enforcement · never runs a backend · never commits · previews by default · hand-edit stays first-class.
|
|
@@ -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**.
|
|
@@ -31,3 +31,16 @@ Honesty notes: tier entries get **NO PreToolUse-hook residual coverage** — the
|
|
|
31
31
|
**Invariants:** creates `.claude/` if absent and writes **only** `.claude/settings.json` (no other file); **never** allowlists commit/push/publish; **never** writes `settings.local.json`; never commits; opt-in `acceptEdits`, never silent.
|
|
32
32
|
|
|
33
33
|
**Exit codes:** `0` done / dry-run; `1` a precondition STOP (stamp not current, unsafe mode, malformed settings, symlinked `.claude` / non-regular target); `2` bad arguments.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## The `--autonomy` render (separate mode — AD-044)
|
|
38
|
+
|
|
39
|
+
`--autonomy` is a **separate mode** from the allowlist seeding above: it renders the per-project autonomy policy (`docs/ai/autonomy.json`, written by `set-autonomy` — `${CLAUDE_SKILL_DIR}/references/modes/set-autonomy.md`) into the `.claude/settings.json` blocks it **owns** — the `sandbox` block, `permissions.ask`/`permissions.deny` red-lines, and `permissions.defaultMode`. It is **policy-only**: it never seeds the read-only allowlist and leaves `permissions.allow` untouched as a value. Run **`node ${CLAUDE_SKILL_DIR}/tools/velocity-profile.mjs --autonomy [--apply] [--cwd <dir>]`**:
|
|
40
|
+
|
|
41
|
+
1. **Preview by default** (writes nothing); **`--apply`** merges the render-owned blocks into `.claude/settings.json` (merge-don't-clobber — foreign top-level keys, existing `permissions.allow`, and foreign ask/deny entries preserved; a policy flip MOVES a red-line rule between ask/deny rather than duplicating it). It **refuses an absent policy** loudly (seed one first with `set-autonomy`). It **never writes `settings.local.json`**; a `settings.local.json` `defaultMode` that would mask the render is reported (local > project).
|
|
42
|
+
2. **`--autonomy --check`** is a **read-only drift gate**: it recomputes the render and compares against the live render-owned blocks — exit `0` in sync, exit `1` on drift (naming the exact key). A hand-edit OUTSIDE those blocks never flags.
|
|
43
|
+
3. **Version-sensitive (characterized against the real claude 2.1.185).** Sandbox keys are `sandbox.enabled` + `sandbox.autoAllowBashIfSandboxed`; `sandbox` level ⇒ auto-allow + `defaultMode: acceptEdits`, `prompt` level ⇒ auto-allow OFF + `defaultMode: default` (the sandbox stays enabled as a confine floor either way). Red-lines use the argument-matching `:*` wildcard (`Bash(git commit:*)` / `Bash(git push:*)` / `Bash(npm publish:*)`). Where 2.1.185 cannot express a red-line distinctly — a **network hard-block** (regular settings only prompt on egress), **credential denial** (`sandbox.credentials` is 2.1.187+ / mask 2.1.199+), or a **prompt-on-outside-write** — the render **DEGRADES LOUDLY** (never a silent allow, never pretend-security). A missing Linux dependency (`socat`/`bwrap`) or an unsupported platform degrades the WHOLE sandbox to unsandboxed — the render still lands the red-lines + `defaultMode` and caveats that ad-hoc scripts will still prompt.
|
|
44
|
+
4. **`--autonomy` cannot combine** with `--accept-edits` or `--kit-tools` (allowlist-mode flags) — a loud usage error.
|
|
45
|
+
|
|
46
|
+
**Autonomy invariants:** policy-only (never seeds the allowlist, leaves `permissions.allow` untouched) · writes only `.claude/settings.json` (never `settings.local.json`) · refuses an absent policy · merge-don't-clobber · degrades loudly where 2.1.185 can't enforce · previews by default · never commits.
|
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
|
+
};
|