@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.
- package/CHANGELOG.md +70 -0
- package/README.md +2 -0
- package/SKILL.md +24 -14
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/fold-completeness.md +22 -0
- package/references/modes/review-ledger.md +28 -0
- package/references/modes/set-autonomy.md +29 -0
- package/references/modes/velocity.md +13 -0
- package/tools/autonomy-config.mjs +306 -0
- package/tools/autonomy-write.mjs +27 -0
- package/tools/commands.mjs +21 -0
- package/tools/fold-completeness-run.mjs +526 -0
- package/tools/fold-completeness.mjs +364 -0
- package/tools/procedures.mjs +12 -3
- package/tools/review-ledger-write.mjs +261 -0
- package/tools/review-ledger.mjs +535 -0
- package/tools/seed-gates.mjs +45 -4
- package/tools/set-autonomy.mjs +195 -0
- package/tools/velocity-profile.mjs +468 -5
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// autonomy-config.mjs — the schema / read / pure-transform core for the per-project autonomy policy
|
|
3
|
+
// (docs/ai/autonomy.json). It is the SINGLE source of the policy contract, mirroring
|
|
4
|
+
// orchestration-config.mjs structurally (AD-044):
|
|
5
|
+
//
|
|
6
|
+
// loadAutonomy / validateAutonomy / AUTONOMY_REL — the strict-JSON reader the read-only surfaces
|
|
7
|
+
// share (the Claude render imports loadAutonomy).
|
|
8
|
+
// parseAutonomyOp / assertAutonomyAssignment — the TYPED op parser + the ONE section/key/value
|
|
9
|
+
// validity table the set-autonomy writer reuses
|
|
10
|
+
// (drift-guarded: one accept/reject grammar).
|
|
11
|
+
// applyAutonomyOps / serializeAutonomy — the PURE merge + the canonical (2-space,
|
|
12
|
+
// _README-first) serializer the writer commits.
|
|
13
|
+
// resolveAutonomy — the pure computed-defaults resolver (sparse →
|
|
14
|
+
// effective policy); the render + any future read
|
|
15
|
+
// surface share this ONE resolver.
|
|
16
|
+
//
|
|
17
|
+
// This module performs NO filesystem WRITES — only reads (loadAutonomy). The single fs-writer lives in
|
|
18
|
+
// autonomy-write.mjs, which no read-only module imports, so the read surface stays fs-write-free.
|
|
19
|
+
// Pure-where-possible (fs injectable), dependency-free, Node >= 18. No side effects on import.
|
|
20
|
+
|
|
21
|
+
import { readFileSync, lstatSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { ACTIVITIES } from './recipes.mjs';
|
|
24
|
+
|
|
25
|
+
// The hand-editable / agent-writable, per-project policy (strict JSON). cwd-relative — the error prefix
|
|
26
|
+
// uses this rel path so a user sees a path they can open, never an absolute temp/host path.
|
|
27
|
+
export const AUTONOMY_REL = 'docs/ai/autonomy.json';
|
|
28
|
+
|
|
29
|
+
// A tagged failure: a plain Error carrying the intended process exit code (2 usage / 1 config). Avoids
|
|
30
|
+
// a class (project rule) while letting a CLI main() map a throw to the right code in one place. Shared
|
|
31
|
+
// so set-autonomy.mjs raises identically-typed errors (mirrors orchestration-config.mjs `fail`).
|
|
32
|
+
export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
33
|
+
|
|
34
|
+
// ── the grammar tables (Decision 5) — the ONE shared accept/reject data ──────────────
|
|
35
|
+
// Both validateAutonomy and parseAutonomyOp read THESE tables (never a second hand-copy), so the
|
|
36
|
+
// accept/reject decision can never drift between config-validation and the writer's op parser (pinned
|
|
37
|
+
// by the full-matrix drift test, the orchestration recipeValidForSlot precedent).
|
|
38
|
+
|
|
39
|
+
// The six red-lines split into command red-lines (commit/push/publish) and non-command red-lines
|
|
40
|
+
// (network/credentials/fs_outside_repo). Both take `ask` or `deny`; only the Decision-4 DEFAULT differs.
|
|
41
|
+
export const COMMAND_REDLINES = Object.freeze(['commit', 'push', 'publish']);
|
|
42
|
+
export const NONCOMMAND_REDLINES = Object.freeze(['network', 'credentials', 'fs_outside_repo']);
|
|
43
|
+
export const REDLINE_KEYS = Object.freeze([...COMMAND_REDLINES, ...NONCOMMAND_REDLINES]);
|
|
44
|
+
export const REDLINE_VALUES = Object.freeze(['ask', 'deny']);
|
|
45
|
+
// Decision 4 defaults: command red-lines default to `ask` (commit stays the human checkpoint), the
|
|
46
|
+
// non-command red-lines default to `deny` (network/credentials/fs escape are the conservative floor).
|
|
47
|
+
export const REDLINE_DEFAULTS = Object.freeze({
|
|
48
|
+
commit: 'ask', push: 'ask', publish: 'ask',
|
|
49
|
+
network: 'deny', credentials: 'deny', fs_outside_repo: 'deny',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Per-activity autonomy level (Decision 2): `sandbox` ⇒ auto-allow + acceptEdits; `prompt` ⇒
|
|
53
|
+
// conservative prompting (sandbox still confines). An absent activity floors at `prompt` (Decision 5).
|
|
54
|
+
export const AUTONOMY_LEVELS = Object.freeze(['sandbox', 'prompt']);
|
|
55
|
+
export const DEFAULT_ACTIVITY_AUTONOMY = 'prompt';
|
|
56
|
+
export const ACTIVITY_KEY = 'autonomy';
|
|
57
|
+
export const REDLINES_SECTION = 'redlines';
|
|
58
|
+
|
|
59
|
+
const KNOWN_ACTIVITIES = () => Object.keys(ACTIVITIES).join(', ');
|
|
60
|
+
const KNOWN_SECTIONS = () => `${REDLINES_SECTION}, ${KNOWN_ACTIVITIES()}`;
|
|
61
|
+
const isJsonObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
62
|
+
|
|
63
|
+
// ── the ONE section/key/value validity grammar (shared accept/reject) ────────────────
|
|
64
|
+
// assignmentValid is the PURE predicate (mirror recipeValidForSlot) the full-matrix drift test pins;
|
|
65
|
+
// assertAutonomySlot / assertAutonomyAssignment are the LOUD assertions the op parser uses. All read
|
|
66
|
+
// the same tables above, so parseAutonomyOp and validateAutonomy can never disagree.
|
|
67
|
+
|
|
68
|
+
// True iff (section, key) is a known slot of the policy (redlines.<redline> | <activity>.autonomy).
|
|
69
|
+
export const slotValid = (section, key) => {
|
|
70
|
+
if (section === REDLINES_SECTION) return REDLINE_KEYS.includes(key);
|
|
71
|
+
if (ACTIVITIES[section]) return key === ACTIVITY_KEY;
|
|
72
|
+
return false;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Accepted values for a KNOWN slot: red-lines take ask|deny; an activity's autonomy takes sandbox|prompt.
|
|
76
|
+
export const acceptedValuesFor = (section) => (section === REDLINES_SECTION ? REDLINE_VALUES : AUTONOMY_LEVELS);
|
|
77
|
+
|
|
78
|
+
// True iff (section, key, value) is a fully-valid assignment. Pure predicate; throws nothing.
|
|
79
|
+
export const assignmentValid = (section, key, value) =>
|
|
80
|
+
slotValid(section, key) && acceptedValuesFor(section).includes(value);
|
|
81
|
+
|
|
82
|
+
// Assert (section, key) is a known slot; return its kind ('redline' | 'activity'). Loud (exit 2 by
|
|
83
|
+
// default) with the shared "unknown section" / "unknown slot" message the op parser emits.
|
|
84
|
+
export const assertAutonomySlot = (section, key, exitCode = 2) => {
|
|
85
|
+
if (section === REDLINES_SECTION) {
|
|
86
|
+
if (!REDLINE_KEYS.includes(key)) {
|
|
87
|
+
throw fail(exitCode, `unknown red-line "${key}" (known: ${REDLINE_KEYS.join(', ')})`);
|
|
88
|
+
}
|
|
89
|
+
return 'redline';
|
|
90
|
+
}
|
|
91
|
+
if (ACTIVITIES[section]) {
|
|
92
|
+
if (key !== ACTIVITY_KEY) {
|
|
93
|
+
throw fail(exitCode, `unknown key "${key}" for activity "${section}" (only: ${ACTIVITY_KEY})`);
|
|
94
|
+
}
|
|
95
|
+
return 'activity';
|
|
96
|
+
}
|
|
97
|
+
throw fail(exitCode, `unknown section "${section}" (known: ${KNOWN_SECTIONS()})`);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Assert (section, key, value) is a fully-valid assignment — unknown section/key OR bad value → loud.
|
|
101
|
+
// Used by the set-autonomy op parser AND (with exitCode 1) by validateAutonomy, so they accept/reject
|
|
102
|
+
// in lockstep on ONE grammar. Returns the slot kind.
|
|
103
|
+
export const assertAutonomyAssignment = (section, key, value, exitCode = 2) => {
|
|
104
|
+
const kind = assertAutonomySlot(section, key, exitCode);
|
|
105
|
+
const accepted = acceptedValuesFor(section);
|
|
106
|
+
if (typeof value !== 'string' || !accepted.includes(value)) {
|
|
107
|
+
throw fail(exitCode, `invalid value "${value}" for ${section}.${key} (accepts: ${accepted.join(', ')})`);
|
|
108
|
+
}
|
|
109
|
+
return kind;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// ── the typed op parser (usage errors → exit 2) ─────────────────────────────────────
|
|
113
|
+
// The grammar is ALWAYS fully-qualified `<section>.<key>` — the writer never guesses a section. A bare
|
|
114
|
+
// `commit=ask` is rejected (name the section). No `all`-magic; the agent expands plain language into
|
|
115
|
+
// explicit per-key ops (asking if scope is unclear) — the set-recipe division of labor.
|
|
116
|
+
|
|
117
|
+
const parseQualified = (lhs, flag) => {
|
|
118
|
+
const dot = lhs.indexOf('.');
|
|
119
|
+
if (dot <= 0 || dot === lhs.length - 1) {
|
|
120
|
+
throw fail(
|
|
121
|
+
2,
|
|
122
|
+
`${flag} must be fully-qualified <section>.<key> (got "${lhs}") — name the section, e.g. redlines.commit / plan-execution.autonomy`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return { section: lhs.slice(0, dot), key: lhs.slice(dot + 1) };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// parseAutonomyOp(kind, token) → a typed record:
|
|
129
|
+
// kind 'set' + token `<section>.<key>=<value>` → { kind:'set', section, key, value }
|
|
130
|
+
// kind 'unset' + token `<section>.<key>` → { kind:'unset', section, key }
|
|
131
|
+
// Every malformed token is a USAGE error (exit 2): a bare key (no section), an unknown section/key, a
|
|
132
|
+
// bad value, a missing value on --set, or a stray value on --unset.
|
|
133
|
+
export const parseAutonomyOp = (kind, token) => {
|
|
134
|
+
if (kind === 'set') {
|
|
135
|
+
const eq = token.indexOf('=');
|
|
136
|
+
if (eq <= 0) throw fail(2, `--set must be <section>.<key>=<value> (got "${token}")`);
|
|
137
|
+
const value = token.slice(eq + 1);
|
|
138
|
+
if (!value) throw fail(2, `--set must be <section>.<key>=<value> (got "${token}")`);
|
|
139
|
+
const { section, key } = parseQualified(token.slice(0, eq), '--set');
|
|
140
|
+
assertAutonomyAssignment(section, key, value);
|
|
141
|
+
return { kind: 'set', section, key, value };
|
|
142
|
+
}
|
|
143
|
+
if (token.includes('=')) throw fail(2, `--unset takes <section>.<key> without a value (got "${token}")`);
|
|
144
|
+
const { section, key } = parseQualified(token, '--unset');
|
|
145
|
+
assertAutonomySlot(section, key);
|
|
146
|
+
return { kind: 'unset', section, key };
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// ── policy validation (config errors → exit 1) ──────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
// Validate a parsed autonomy.json object against the Decision-5 grammar. Strict: an unknown top-level
|
|
152
|
+
// key, an unknown red-line, an unknown activity/key, or a bad value is an error. All keys optional
|
|
153
|
+
// (sparse). An optional "_README" string key is allowed + ignored. Never a silent fallback — every
|
|
154
|
+
// rejection is a loud `path: reason` (exit 1). Returns the config on success.
|
|
155
|
+
export const validateAutonomy = (config) => {
|
|
156
|
+
if (!isJsonObject(config)) {
|
|
157
|
+
throw fail(1, `${AUTONOMY_REL}: must be a JSON object (red-lines + per-activity autonomy)`);
|
|
158
|
+
}
|
|
159
|
+
for (const [key, val] of Object.entries(config)) {
|
|
160
|
+
if (key === '_README') {
|
|
161
|
+
if (typeof val !== 'string') throw fail(1, `${AUTONOMY_REL}: "_README" must be a string`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (key === REDLINES_SECTION) {
|
|
165
|
+
if (!isJsonObject(val)) throw fail(1, `${AUTONOMY_REL}: "${REDLINES_SECTION}" must be a JSON object of red-line → ask|deny`);
|
|
166
|
+
for (const [rk, rv] of Object.entries(val)) {
|
|
167
|
+
try {
|
|
168
|
+
assertAutonomyAssignment(REDLINES_SECTION, rk, rv, 1);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
throw fail(1, `${AUTONOMY_REL}: ${err.message}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (ACTIVITIES[key]) {
|
|
176
|
+
if (!isJsonObject(val)) throw fail(1, `${AUTONOMY_REL}: activity "${key}" must be a JSON object of { ${ACTIVITY_KEY}: sandbox|prompt }`);
|
|
177
|
+
for (const [ak, av] of Object.entries(val)) {
|
|
178
|
+
try {
|
|
179
|
+
assertAutonomyAssignment(key, ak, av, 1);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
throw fail(1, `${AUTONOMY_REL}: ${err.message}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
throw fail(1, `${AUTONOMY_REL}: unknown top-level key "${key}" (known: _README, ${KNOWN_SECTIONS()})`);
|
|
187
|
+
}
|
|
188
|
+
return config;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// ── policy IO (config errors → exit 1) ──────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
// Load + validate the policy from <cwd>/docs/ai/autonomy.json. Absent FILE → computed defaults (NOT an
|
|
194
|
+
// error): { config: null, source: 'none' }. Malformed JSON / schema-invalid / unreadable → loud
|
|
195
|
+
// `path: reason` (exit 1). lstat (NOT existsSync) so a DANGLING SYMLINK reads as present and its later
|
|
196
|
+
// read failure surfaces loudly — never silently treated as absent (no-silent-failures Hard Constraint).
|
|
197
|
+
export const loadAutonomy = (cwd, readFile = readFileSync, lstat = lstatSync) => {
|
|
198
|
+
const full = join(cwd, AUTONOMY_REL);
|
|
199
|
+
try {
|
|
200
|
+
lstat(full);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (err && err.code === 'ENOENT') return { config: null, source: 'none' };
|
|
203
|
+
throw fail(1, `${AUTONOMY_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
|
|
204
|
+
}
|
|
205
|
+
let raw;
|
|
206
|
+
try {
|
|
207
|
+
raw = readFile(full, 'utf8');
|
|
208
|
+
} catch (err) {
|
|
209
|
+
throw fail(1, `${AUTONOMY_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
|
|
210
|
+
}
|
|
211
|
+
let parsed;
|
|
212
|
+
try {
|
|
213
|
+
parsed = JSON.parse(raw);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
throw fail(1, `${AUTONOMY_REL}: malformed JSON (${err.message})`);
|
|
216
|
+
}
|
|
217
|
+
return { config: validateAutonomy(parsed), source: AUTONOMY_REL };
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// ── the pure computed-defaults resolver ─────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
// resolveAutonomy(config) → the effective policy: every red-line resolved to its config value or its
|
|
223
|
+
// Decision-4 default; every ACTIVITIES entry resolved to its config autonomy or `prompt`. The render +
|
|
224
|
+
// any future read surface share THIS resolver, so a sparse policy always yields one full effective
|
|
225
|
+
// policy (no key ever undefined). Pure; accepts null (absent file → all defaults).
|
|
226
|
+
export const resolveAutonomy = (config) => {
|
|
227
|
+
const cfg = isJsonObject(config) ? config : {};
|
|
228
|
+
const rl = isJsonObject(cfg[REDLINES_SECTION]) ? cfg[REDLINES_SECTION] : {};
|
|
229
|
+
const redlines = {};
|
|
230
|
+
for (const k of REDLINE_KEYS) redlines[k] = rl[k] ?? REDLINE_DEFAULTS[k];
|
|
231
|
+
const activities = {};
|
|
232
|
+
for (const a of Object.keys(ACTIVITIES)) {
|
|
233
|
+
const entry = isJsonObject(cfg[a]) ? cfg[a] : {};
|
|
234
|
+
activities[a] = { [ACTIVITY_KEY]: entry[ACTIVITY_KEY] ?? DEFAULT_ACTIVITY_AUTONOMY };
|
|
235
|
+
}
|
|
236
|
+
return { redlines, activities };
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// ── pure merge + canonical serialization ────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
// A pure deep-equal over the JSON-ish policy shape (plain objects + string values). Used only for the
|
|
242
|
+
// "did anything actually change?" decision (no-op detection + seed-on-change), never for output.
|
|
243
|
+
const deepEqual = (a, b) => {
|
|
244
|
+
if (a === b) return true;
|
|
245
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
|
|
246
|
+
const ak = Object.keys(a);
|
|
247
|
+
const bk = Object.keys(b);
|
|
248
|
+
if (ak.length !== bk.length) return false;
|
|
249
|
+
return ak.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// applyAutonomyOps(current, ops, { seedReadme }) → the merged policy. PURE: deep-clones `current` (or
|
|
253
|
+
// {}), applies each set/unset, preserves `_README` + every untouched section/key, drops a section that
|
|
254
|
+
// an unset empties (sparse), then re-runs validateAutonomy (loud on invalid — defensive; the op parser
|
|
255
|
+
// pre-validates). When (and ONLY when) the merge CHANGES the policy, `_README` is absent, and
|
|
256
|
+
// `seedReadme` is supplied, the canonical note is seeded — so a no-op set never spuriously seeds it.
|
|
257
|
+
export const applyAutonomyOps = (current, ops, { seedReadme = null } = {}) => {
|
|
258
|
+
const base = current == null ? {} : structuredClone(current);
|
|
259
|
+
const next = structuredClone(base);
|
|
260
|
+
for (const op of ops) {
|
|
261
|
+
if (op.kind === 'set') {
|
|
262
|
+
next[op.section] = { ...(next[op.section] ?? {}) };
|
|
263
|
+
next[op.section][op.key] = op.value;
|
|
264
|
+
} else if (next[op.section] && op.key in next[op.section]) {
|
|
265
|
+
const rest = { ...next[op.section] };
|
|
266
|
+
delete rest[op.key];
|
|
267
|
+
if (Object.keys(rest).length === 0) delete next[op.section];
|
|
268
|
+
else next[op.section] = rest;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
validateAutonomy(next);
|
|
272
|
+
const changed = !deepEqual(next, base);
|
|
273
|
+
if (changed && seedReadme != null && next._README === undefined) next._README = seedReadme;
|
|
274
|
+
return next;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// serializeAutonomy(config) → strict JSON, 2-space, trailing newline, `_README` FIRST (explicitly, so
|
|
278
|
+
// the onboarding note never sinks below the policy). This is the canonical on-disk form: a touched
|
|
279
|
+
// write normalizes to it (content-preserving, NOT byte-preserving of arbitrary hand-formatting).
|
|
280
|
+
export const serializeAutonomy = (config) => {
|
|
281
|
+
const ordered = {};
|
|
282
|
+
if (config._README !== undefined) ordered._README = config._README;
|
|
283
|
+
for (const [k, v] of Object.entries(config)) {
|
|
284
|
+
if (k !== '_README') ordered[k] = v;
|
|
285
|
+
}
|
|
286
|
+
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// ── the canonical seed (Decision 5) ──────────────────────────────────────────────────
|
|
290
|
+
// AUTONOMY_README is the onboarding note; SEED_AUTONOMY is the Decision-5 fixture the set-autonomy
|
|
291
|
+
// writer seeds and the config tests copy/validate verbatim. Plan 1 ships NO references/templates/
|
|
292
|
+
// autonomy.json and NO template-parity test (both Plan 4) — SEED_AUTONOMY's Plan-1 consumers are the
|
|
293
|
+
// autonomy-config tests and the render's "seed one first" guidance.
|
|
294
|
+
export const AUTONOMY_README =
|
|
295
|
+
'Per-project autonomy policy: red-lines (always) + per-activity autonomy level. Hand-editable; or ' +
|
|
296
|
+
'use the set-autonomy writer (previews, then writes valid JSON). Strict JSON — no comments.';
|
|
297
|
+
|
|
298
|
+
export const SEED_AUTONOMY = {
|
|
299
|
+
_README: AUTONOMY_README,
|
|
300
|
+
redlines: {
|
|
301
|
+
commit: 'ask', push: 'ask', publish: 'ask',
|
|
302
|
+
network: 'deny', credentials: 'deny', fs_outside_repo: 'deny',
|
|
303
|
+
},
|
|
304
|
+
'plan-authoring': { autonomy: 'sandbox' },
|
|
305
|
+
'plan-execution': { autonomy: 'sandbox' },
|
|
306
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// autonomy-write.mjs — the ONLY filesystem WRITER for docs/ai/autonomy.json. It is imported by the
|
|
3
|
+
// set-autonomy writer alone; no read-only module (autonomy-config.mjs, velocity-profile.mjs's read
|
|
4
|
+
// path) imports it, so "a read-only surface can never reach a writer" is a STRUCTURAL invariant (an
|
|
5
|
+
// import-split test pins it), not just an assertion. Splitting the writer out of the schema/read module
|
|
6
|
+
// keeps the read surface fs-write-free — the orchestration-write.mjs precedent (AD-044).
|
|
7
|
+
//
|
|
8
|
+
// The hardened write flow (deployment gate → symlink STOPs → containment guard → exclusive-create tmp
|
|
9
|
+
// + rename with a TOCTOU re-check → tmp cleanup; LAST-WRITER-WINS) lives in the shared atomic-write
|
|
10
|
+
// core (tools/atomic-write.mjs, AD-042) and is consumed here with this module's own STOP identity, so
|
|
11
|
+
// the public API + error contract are its own (this file's tests pin them).
|
|
12
|
+
//
|
|
13
|
+
// Dependency-free, Node >= 18. Every fs primitive is injectable (deps.*) so the guards are unit-testable.
|
|
14
|
+
|
|
15
|
+
import { AUTONOMY_REL, serializeAutonomy } from './autonomy-config.mjs';
|
|
16
|
+
import { writeDocsAiFileAtomic } from './atomic-write.mjs';
|
|
17
|
+
|
|
18
|
+
// A typed STOP — a deliberate refusal we surface (deployment gate / symlinked leaf), distinct from a
|
|
19
|
+
// native fs error. `Object.assign(new Error(), { code })`, the codebase's typed-error idiom (no classes).
|
|
20
|
+
export const AUTONOMY_WRITE_STOP = 'AUTONOMY_WRITE_STOP';
|
|
21
|
+
const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'AutonomyWriteStop', code: AUTONOMY_WRITE_STOP });
|
|
22
|
+
|
|
23
|
+
// writeAutonomy(cwd, config, deps) → { writtenPath } on success; THROWS a typed STOP (no deployment /
|
|
24
|
+
// symlinked leaf) or a native fs error otherwise. The tmp is cleaned up on any failure after its
|
|
25
|
+
// creation. config is serialized canonically (serializeAutonomy: 2-space, _README-first, trailing NL).
|
|
26
|
+
export const writeAutonomy = (cwd, config, deps = {}) =>
|
|
27
|
+
writeDocsAiFileAtomic(cwd, AUTONOMY_REL, serializeAutonomy(config), deps, { stop, noun: 'a policy' });
|
package/tools/commands.mjs
CHANGED
|
@@ -154,6 +154,13 @@ const CATALOG = [
|
|
|
154
154
|
kind: WRITER,
|
|
155
155
|
oneLine: 'Set the orchestration recipe for an activity from plain language — previews the change, then writes the config when you confirm.',
|
|
156
156
|
},
|
|
157
|
+
{
|
|
158
|
+
key: 'set-autonomy',
|
|
159
|
+
invocation: invocationOf('set-autonomy'),
|
|
160
|
+
group: 'Orchestrate',
|
|
161
|
+
kind: WRITER,
|
|
162
|
+
oneLine: 'Set the per-project autonomy policy from plain language — which actions always ask (commit/push/publish/network) and how autonomously each activity runs; previews the change, then writes the policy when you confirm.',
|
|
163
|
+
},
|
|
157
164
|
{
|
|
158
165
|
key: 'review-state',
|
|
159
166
|
invocation: invocationOf('review-state'),
|
|
@@ -168,6 +175,20 @@ const CATALOG = [
|
|
|
168
175
|
kind: WRITER,
|
|
169
176
|
oneLine: 'Assemble the verified-facts payload a grounded review runs against — the entry-point Hard Constraints plus a plan’s decision sections; prints it, or writes ONE scratch file with --out.',
|
|
170
177
|
},
|
|
178
|
+
{
|
|
179
|
+
key: 'review-ledger',
|
|
180
|
+
invocation: invocationOf('review-ledger'),
|
|
181
|
+
group: 'Orchestrate',
|
|
182
|
+
kind: WRITER,
|
|
183
|
+
oneLine: 'Record each review round and read the computed crossover-stop for the plan-execution loop (converged / accepted-residual / triage-required); --check turns it into a gate exit code and forces a triage before over-running the review.',
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
key: 'fold-completeness',
|
|
187
|
+
invocation: invocationOf('fold-completeness'),
|
|
188
|
+
group: 'Orchestrate',
|
|
189
|
+
kind: WRITER,
|
|
190
|
+
oneLine: 'Verify the review loop’s folded fixes are pinned by tests — one recorded coverage run proves every changed executable line is executed and each bound test starts green; --check turns the result into a gate exit code.',
|
|
191
|
+
},
|
|
171
192
|
];
|
|
172
193
|
|
|
173
194
|
// Deep-freeze: freeze the array AND every entry, so the catalog is genuinely immutable at runtime
|