@sabaiway/agent-workflow-kit 1.35.0 → 1.37.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.
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ // set-autonomy.mjs — the WRITER for docs/ai/autonomy.json (the per-project autonomy policy). Mirrors
3
+ // set-recipe.mjs (AD-044): the AGENT turns plain language into explicit `--set <section>.<key>=<value>`
4
+ // / `--unset <section>.<key>` ops; the KIT does the deterministic validate → merge → preview → write.
5
+ // No NL parser (stays dependency-free + deterministic), no `all`-magic — the agent expands plain
6
+ // language into explicit per-key ops (asking if scope is unclear). Ops route through the SAME
7
+ // accept/reject grammar as validateAutonomy (autonomy-config.mjs), so the writer and the config
8
+ // validator can never disagree.
9
+ //
10
+ // Posture: PREVIEW BY DEFAULT (dry-run); `--write` applies. It NEVER commits, NEVER runs a backend, and
11
+ // NEVER renders enforcement — it writes ONLY docs/ai/autonomy.json (the render into .claude/settings.json
12
+ // is the separate velocity autonomy mode). It writes only via the hardened writeAutonomy (deployment
13
+ // gate; exclusive-create tmp + rename; symlink/TOCTOU-safe; last-writer-wins). A no-op set never writes
14
+ // and never spuriously seeds the _README. `--unset` returns a key to its computed default, so reverting
15
+ // needs no hand-edit either. Hand-edit stays first-class — this is an OFFERED convenience, never a lock.
16
+ //
17
+ // Output is ENGLISH/structured (repo-artifact Hard Constraint); the agent localizes when narrating.
18
+ // Exit codes: 0 success; 2 usage (bad/duplicate op, --write with zero ops); 1 config error
19
+ // (malformed/unreadable policy) or a write STOP (no deployment / symlinked leaf). main(argv, ctx) →
20
+ // { code, stdout, stderr }; cwd / fs are injectable for host-independent tests.
21
+ //
22
+ // Dependency-free, Node >= 18. No side effects on import (the isDirectRun idiom).
23
+
24
+ import { readFileSync, lstatSync } from 'node:fs';
25
+ import { pathToFileURL } from 'node:url';
26
+ import {
27
+ AUTONOMY_REL,
28
+ fail,
29
+ loadAutonomy,
30
+ validateAutonomy,
31
+ parseAutonomyOp,
32
+ applyAutonomyOps,
33
+ serializeAutonomy,
34
+ resolveAutonomy,
35
+ AUTONOMY_README,
36
+ } from './autonomy-config.mjs';
37
+ import { writeAutonomy as writeAutonomyFs } from './autonomy-write.mjs';
38
+
39
+ // ── argument parsing (usage errors → exit 2) ────────────────────────────────────────
40
+
41
+ // Parse argv → { ops, write, json }. `--set`/`--unset` take a fully-qualified token (parseAutonomyOp
42
+ // validates it). A duplicate op for the same section.key, a `--write` with zero ops, an unknown flag, or
43
+ // a bad token → exit 2. `--set=<tok>` / `--unset=<tok>` inline forms are accepted too.
44
+ const parseArgs = (argv) => {
45
+ const ops = [];
46
+ const seen = new Set();
47
+ let write = false;
48
+ let json = false;
49
+ const takeOp = (kind, tok) => {
50
+ if (tok === undefined || tok.startsWith('--')) throw fail(2, `--${kind} requires <section>.<key>${kind === 'set' ? '=<value>' : ''}`);
51
+ const op = parseAutonomyOp(kind, tok);
52
+ const key = `${op.section}.${op.key}`;
53
+ if (seen.has(key)) throw fail(2, `duplicate op for "${key}" — name each section.key at most once`);
54
+ seen.add(key);
55
+ ops.push(op);
56
+ };
57
+ for (let i = 0; i < argv.length; i += 1) {
58
+ const a = argv[i];
59
+ if (a === '--json') json = true;
60
+ else if (a === '--write') write = true;
61
+ else if (a === '--set') { takeOp('set', argv[i + 1]); i += 1; }
62
+ else if (a === '--unset') { takeOp('unset', argv[i + 1]); i += 1; }
63
+ else if (a.startsWith('--set=')) takeOp('set', a.slice('--set='.length));
64
+ else if (a.startsWith('--unset=')) takeOp('unset', a.slice('--unset='.length));
65
+ else if (a.startsWith('-')) throw fail(2, `unknown flag: ${a}`);
66
+ else throw fail(2, `unexpected argument: ${a}`);
67
+ }
68
+ if (write && ops.length === 0) throw fail(2, 'nothing to write — pass at least one --set/--unset (a bare --write is a no-op)');
69
+ return { ops, write, json };
70
+ };
71
+
72
+ // ── per-op before/after + resolved effective value ──────────────────────────────────
73
+
74
+ // The resolved effective value for a section.key against a resolved policy (computed defaults filled).
75
+ const resolvedValueFor = (resolved, section, key) =>
76
+ section === 'redlines' ? resolved.redlines[key] : resolved.activities[section]?.[key];
77
+
78
+ // A single op's before/after value + the effective value it resolves to (the computed default shows for
79
+ // an unset). `to` is null for an unset (falls to the computed default).
80
+ const resolveOp = (op, current, after) => {
81
+ const from = current?.[op.section]?.[op.key] ?? null;
82
+ const to = after?.[op.section]?.[op.key] ?? null;
83
+ const effective = resolvedValueFor(resolveAutonomy(after), op.section, op.key);
84
+ return { section: op.section, key: op.key, from, to, effective };
85
+ };
86
+
87
+ // ── rendering (ENGLISH; the agent localizes) ────────────────────────────────────────
88
+
89
+ const valueLabel = (v) => (v == null ? '(computed default)' : v);
90
+
91
+ const APPLY_HINT =
92
+ 'Next: render this policy into .claude/settings.json with the velocity autonomy mode (previews first) — the writer changes only the policy file, never the settings.';
93
+
94
+ const formatHuman = ({ changed, unchanged, wrote, fileBody }) => {
95
+ const lines = [];
96
+ if (wrote) lines.push(`wrote ${AUTONOMY_REL}`);
97
+ else if (changed.length) lines.push('set-autonomy — preview (nothing written; re-run with --write to apply)');
98
+ for (const e of changed) {
99
+ lines.push(` ${e.section}.${e.key}: ${valueLabel(e.from)} → ${valueLabel(e.to)}`);
100
+ lines.push(` ↳ effective: ${e.effective}`);
101
+ }
102
+ for (const e of unchanged) lines.push(` ${e.section}.${e.key}: already ${valueLabel(e.from)} (no change)`);
103
+ if (wrote && fileBody) lines.push('', `${AUTONOMY_REL} now reads:`, fileBody.replace(/\n$/, ''), '', APPLY_HINT);
104
+ if (!wrote) {
105
+ if (!changed.length) lines.push(' no changes — nothing to write.');
106
+ else lines.push('', `would write ${AUTONOMY_REL} — re-run with --write to apply.`);
107
+ }
108
+ return lines.join('\n');
109
+ };
110
+
111
+ const buildJson = ({ changed, unchanged, writtenPath, noop }) => ({
112
+ changed: changed.map((e) => ({ section: e.section, key: e.key, from: e.from, to: e.to, effective: e.effective })),
113
+ unchanged: unchanged.map((e) => ({ section: e.section, key: e.key, value: e.from })),
114
+ writtenPath: writtenPath ?? null,
115
+ noop,
116
+ });
117
+
118
+ const HELP = `set-autonomy — write the per-project autonomy policy (docs/ai/autonomy.json).
119
+
120
+ Usage:
121
+ node set-autonomy.mjs [--set <section>.<key>=<value>]... [--unset <section>.<key>]... [--write] [--json]
122
+
123
+ --set <section>.<key>=<value> pin a policy value (fully-qualified; e.g. plan-execution.autonomy=sandbox)
124
+ --unset <section>.<key> return a key to its computed default
125
+ --write apply the change (default: preview only — writes nothing)
126
+ --json machine-readable output
127
+ --help, -h this help
128
+
129
+ Sections/keys: redlines → commit|push|publish|network|credentials|fs_outside_repo (each ask|deny);
130
+ plan-authoring.autonomy, plan-execution.autonomy (each sandbox|prompt)
131
+
132
+ Previews by default; --write applies via an atomic, symlink/TOCTOU-safe write behind a deployment gate.
133
+ Policy writer only: it NEVER renders enforcement (that is the velocity autonomy mode), NEVER runs a
134
+ backend, and NEVER commits. Hand-editing the file stays fully supported.
135
+
136
+ Exit codes: 0 success; 2 usage (bad/duplicate op, or --write with no ops);
137
+ 1 config error (malformed/unreadable policy) or a write STOP (no deployment / symlinked leaf).`;
138
+
139
+ // ── main ────────────────────────────────────────────────────────────────────────────
140
+
141
+ export const main = (argv, ctx = {}) => {
142
+ const cwd = ctx.cwd ?? process.cwd();
143
+ const readFile = ctx.readFileSync ?? readFileSync;
144
+ const lstat = ctx.lstatSync ?? lstatSync;
145
+ const writeAutonomy = ctx.writeAutonomy ?? writeAutonomyFs;
146
+ try {
147
+ if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
148
+ const { ops, write, json } = parseArgs(argv);
149
+
150
+ // Load the current policy first (loadAutonomy throws fail(1) loud on malformed/unreadable — a write
151
+ // never clobbers an unparseable file; the message points the agent at the parse error to help fix it).
152
+ const { config: current, source } = loadAutonomy(cwd, readFile, lstat);
153
+
154
+ // No ops + no --write → show the current policy + a hint (read-only; nothing changes).
155
+ if (ops.length === 0) {
156
+ if (json) {
157
+ return { code: 0, stdout: JSON.stringify(buildJson({ changed: [], unchanged: [], writtenPath: null, noop: true }), null, 2), stderr: '' };
158
+ }
159
+ const shown = current == null ? `(no ${AUTONOMY_REL} yet — computed defaults apply)` : serializeAutonomy(current).replace(/\n$/, '');
160
+ const hint = `\nPass --set <section>.<key>=<value> (preview) then --write to apply. Sections: redlines, plan-authoring.autonomy, plan-execution.autonomy.`;
161
+ return { code: 0, stdout: `${source === 'none' ? '' : `${AUTONOMY_REL}:\n`}${shown}${hint}`, stderr: '' };
162
+ }
163
+
164
+ const after = applyAutonomyOps(current, ops, { seedReadme: AUTONOMY_README });
165
+ const resolved = ops.map((op) => resolveOp(op, current, after));
166
+ const changed = resolved.filter((e) => e.from !== e.to);
167
+ const unchanged = resolved.filter((e) => e.from === e.to);
168
+ const noop = changed.length === 0;
169
+
170
+ if (!write || noop) {
171
+ const stdout = json
172
+ ? JSON.stringify(buildJson({ changed, unchanged, writtenPath: null, noop }), null, 2)
173
+ : formatHuman({ changed, unchanged, wrote: false });
174
+ return { code: 0, stdout, stderr: '' };
175
+ }
176
+
177
+ validateAutonomy(after); // defensive re-validate immediately before the write
178
+ const { writtenPath } = writeAutonomy(cwd, after, ctx);
179
+ const fileBody = serializeAutonomy(after);
180
+ const stdout = json
181
+ ? JSON.stringify(buildJson({ changed, unchanged, writtenPath, noop: false }), null, 2)
182
+ : formatHuman({ changed, unchanged, wrote: true, fileBody });
183
+ return { code: 0, stdout, stderr: '' };
184
+ } catch (err) {
185
+ return { code: err.exitCode ?? 1, stdout: '', stderr: `set-autonomy: ${err.message}` };
186
+ }
187
+ };
188
+
189
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
190
+ if (isDirectRun) {
191
+ const r = main(process.argv.slice(2));
192
+ if (r.stdout) console.log(r.stdout);
193
+ if (r.stderr) console.error(r.stderr);
194
+ process.exit(r.code);
195
+ }