@sabaiway/agent-workflow-kit 1.33.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 +99 -0
- package/README.md +2 -1
- package/SKILL.md +6 -2
- package/bin/install.mjs +37 -1
- 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/launchers/windsurf-workflow.md +3 -1
- package/package.json +1 -1
- package/references/contracts.md +3 -2
- package/references/modes/bootstrap.md +11 -6
- package/references/modes/bridge-settings.md +29 -0
- package/references/modes/gates.md +4 -2
- package/references/modes/review-state.md +1 -1
- package/references/modes/status.md +2 -0
- package/references/modes/upgrade.md +11 -5
- package/references/shared/deploy-tail.md +1 -1
- package/references/shared/report-footer.md +11 -4
- package/tools/atomic-write.mjs +144 -0
- package/tools/bridge-settings-read.mjs +221 -0
- package/tools/bridge-settings.mjs +288 -0
- package/tools/commands.mjs +22 -1
- package/tools/family-registry.mjs +45 -2
- package/tools/manifest/schema.md +31 -0
- package/tools/manifest/validate.mjs +85 -0
- package/tools/orchestration-write.mjs +8 -78
- package/tools/presentation.mjs +1 -0
- package/tools/procedures.mjs +26 -4
- package/tools/recipes.mjs +16 -6
- package/tools/renderers.mjs +17 -2
- package/tools/review-state.mjs +4 -2
- package/tools/seed-gates.mjs +413 -0
- package/tools/setup-backends.mjs +107 -9
- package/tools/velocity-profile.mjs +4 -0
- package/tools/view-model.mjs +20 -0
package/tools/commands.mjs
CHANGED
|
@@ -96,7 +96,7 @@ const CATALOG = [
|
|
|
96
96
|
invocation: invocationOf('gates'),
|
|
97
97
|
group: 'Inspect',
|
|
98
98
|
kind: PROJECT_EXEC,
|
|
99
|
-
oneLine: 'Run the project’s own declared gate commands (docs/ai/gates.json) as one batch — a PASS/FAIL table, one summary line.',
|
|
99
|
+
oneLine: 'Run the project’s own declared gate commands (docs/ai/gates.json) as one batch — a PASS/FAIL table, one summary line. The mode itself writes nothing; a separate consent-gated seeder can propose entries from your project’s own scripts (preview first, written only on your yes).',
|
|
100
100
|
},
|
|
101
101
|
{
|
|
102
102
|
key: 'setup',
|
|
@@ -126,6 +126,13 @@ const CATALOG = [
|
|
|
126
126
|
kind: WRITER,
|
|
127
127
|
oneLine: 'Auto-approve your own declared gate commands (docs/ai/gates.json) via a Claude Code hook — exact matches only, previews first (opt-in).',
|
|
128
128
|
},
|
|
129
|
+
{
|
|
130
|
+
key: 'bridge-settings',
|
|
131
|
+
invocation: invocationOf('bridge-settings'),
|
|
132
|
+
group: 'Configure',
|
|
133
|
+
kind: GUARDED,
|
|
134
|
+
oneLine: 'Read or change the host-level bridge settings (e.g. the codex Fast tier) — a KEY=VALUE file that survives kit upgrades; previews first, and the Fast tier carries its extra-cost caveat.',
|
|
135
|
+
},
|
|
129
136
|
{
|
|
130
137
|
key: 'recipes',
|
|
131
138
|
invocation: invocationOf('recipes'),
|
|
@@ -212,6 +219,19 @@ const KIND_TAG = {
|
|
|
212
219
|
};
|
|
213
220
|
const pad = (s, n) => (s.length >= n ? s : s + ' '.repeat(n - s.length));
|
|
214
221
|
|
|
222
|
+
// The "Tune" tail — the opt-in accelerator funnel, rendered AFTER the catalog groups. NOT a new
|
|
223
|
+
// catalog key or mode (the frozen CATALOG + GROUP_ORDER stay; the router SKILL.md is untouched):
|
|
224
|
+
// the same four entries the bootstrap accelerators block presents, one line of why each.
|
|
225
|
+
const TUNE_TAIL = Object.freeze([
|
|
226
|
+
'',
|
|
227
|
+
'Tune — opt-in accelerators (consent-first: every writer previews before writing; nothing runs without your yes)',
|
|
228
|
+
` ${BARE_INVOCATION} velocity routine read-only commands stop prompting (incl. the --kit-tools tier for the kit's own read-only tools)`,
|
|
229
|
+
` ${BARE_INVOCATION} agents cheap-model subagents take the mechanical work (sweeps, changelog skeletons, gate triage)`,
|
|
230
|
+
` ${BARE_INVOCATION} gates run your declared gates (docs/ai/gates.json) as one batch; its guide also offers the consent-gated seeding preview — writes only on your yes`,
|
|
231
|
+
` ${BARE_INVOCATION} hook auto-approve exactly your declared gate commands (byte-exact matches only)`,
|
|
232
|
+
` ${BARE_INVOCATION} set-recipe put a ready review backend to work on plans and diffs`,
|
|
233
|
+
]);
|
|
234
|
+
|
|
215
235
|
// formatHelp() → the grouped, kind-tagged human index. Deterministic; groups in GROUP_ORDER, modes in
|
|
216
236
|
// catalog order within each group. The header states the index itself is read-only.
|
|
217
237
|
export const formatHelp = () => {
|
|
@@ -229,6 +249,7 @@ export const formatHelp = () => {
|
|
|
229
249
|
lines.push(` ${pad(c.invocation, invocWidth)} ${KIND_TAG[c.kind] ?? c.kind} ${c.oneLine}`);
|
|
230
250
|
}
|
|
231
251
|
}
|
|
252
|
+
lines.push(...TUNE_TAIL);
|
|
232
253
|
return lines.join('\n');
|
|
233
254
|
};
|
|
234
255
|
|
|
@@ -47,7 +47,13 @@ import {
|
|
|
47
47
|
// The gate-hook writer's own wired-detection + placed path — reused by the settings survey (one
|
|
48
48
|
// implementation; gate-hook imports only node builtins + velocity-profile, so no cycle).
|
|
49
49
|
import { HOOK_FILE_REL as GATE_HOOK_FILE_REL, isHookWired } from './gate-hook.mjs';
|
|
50
|
-
|
|
50
|
+
// The host-level bridge-settings snapshot (fact-only, best-effort). The READ-ONLY core (never the
|
|
51
|
+
// writer, which pulls in the atomic-write core) so the status survey stays a pure reader.
|
|
52
|
+
import { settingsSnapshot } from './bridge-settings-read.mjs';
|
|
53
|
+
import { GATES_REL, loadDeclaration } from './run-gates.mjs';
|
|
54
|
+
// The cheap-agents writer's own bundle reader + placement planner — reused by the settings survey
|
|
55
|
+
// (one implementation, never a drifting copy; cheap-agents imports only node builtins, no cycle).
|
|
56
|
+
import { readBundledAgents, planPlacement } from './cheap-agents.mjs';
|
|
51
57
|
// The status vocabulary (manifestState constants, internal→public maps, display names, the no-leak
|
|
52
58
|
// forbidden set) lives in the frozen labels.mjs LEAF (Plan §4.2 B1) so the import graph is acyclic —
|
|
53
59
|
// nothing imports family-registry for vocabulary. Imported here for internal use; the public subset is
|
|
@@ -456,21 +462,50 @@ export const surveyGateHook = (dir, deps = {}) => {
|
|
|
456
462
|
const exists = deps.exists ?? existsSync;
|
|
457
463
|
const project = readSettingsFile(join(d, SETTINGS_FILE), { ...deps, cwd: d });
|
|
458
464
|
const local = readSettingsFile(join(d, SETTINGS_LOCAL_FILE), { ...deps, cwd: d });
|
|
465
|
+
// How many gates are actually DECLARED — the welcome-mat hook rung keys on a non-empty
|
|
466
|
+
// declaration (a fresh bootstrap seeds gates.json EMPTY, so file presence alone would misfire).
|
|
467
|
+
// 0 = absent or empty list; null = present but unreadable/malformed (unknown — never a false
|
|
468
|
+
// count, and never an area-wide error: the wired/placed probes still report; the validation
|
|
469
|
+
// REASON is preserved beside the null so status never renders a bare question mark).
|
|
470
|
+
const declaration = (() => {
|
|
471
|
+
try {
|
|
472
|
+
const res = loadDeclaration(d, deps);
|
|
473
|
+
return { declaredGates: res.outcome === 'loaded' ? res.gates.length : 0 };
|
|
474
|
+
} catch (err) {
|
|
475
|
+
return { declaredGates: null, declarationError: localizeError(err) };
|
|
476
|
+
}
|
|
477
|
+
})();
|
|
459
478
|
return {
|
|
460
479
|
wired: isHookWired(project.data) || isHookWired(local.data),
|
|
461
480
|
filePlaced: Boolean(exists(join(d, GATE_HOOK_FILE_REL))),
|
|
462
481
|
declarationPresent: Boolean(exists(join(d, GATES_REL))),
|
|
482
|
+
...declaration,
|
|
463
483
|
};
|
|
464
484
|
} catch (err) {
|
|
465
485
|
return { error: localizeError(err) };
|
|
466
486
|
}
|
|
467
487
|
};
|
|
468
488
|
|
|
489
|
+
// cheap agents: the kit-placed .claude/agents/ vehicles (Mode: agents) — how many of the bundled
|
|
490
|
+
// cheap-lane definitions are present in the project. A customized copy counts as PLACED (it exists;
|
|
491
|
+
// the writer preserves it) — the welcome-mat agents rung keys on zero placed. Read-only; REUSES the
|
|
492
|
+
// writer's own bundle reader + placement planner (cheap-agents.mjs — one implementation).
|
|
493
|
+
export const surveyCheapAgents = (dir, deps = {}) => {
|
|
494
|
+
try {
|
|
495
|
+
const templates = readBundledAgents(deps);
|
|
496
|
+
const plan = planPlacement(templates, resolve(dir), deps);
|
|
497
|
+
return { bundled: templates.length, placed: plan.filter((p) => p.action !== 'place').length };
|
|
498
|
+
} catch (err) {
|
|
499
|
+
return { error: localizeError(err) };
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
|
|
469
503
|
// the project-scoped settings survey (needs a project dir). Each area is independently localized-on-error.
|
|
470
504
|
export const surveySettings = (dir, deps = {}) => ({
|
|
471
505
|
recipes: surveyRecipes(dir, deps),
|
|
472
506
|
attribution: surveyAttribution(dir, deps),
|
|
473
507
|
velocity: surveyVelocity(dir, deps),
|
|
508
|
+
agents: surveyCheapAgents(dir, deps),
|
|
474
509
|
hook: surveyGateHook(dir, deps),
|
|
475
510
|
});
|
|
476
511
|
|
|
@@ -481,12 +516,20 @@ export const surveySettings = (dir, deps = {}) => ({
|
|
|
481
516
|
export const surveyBridges = (deps = {}) => {
|
|
482
517
|
const { detection } = detectSafe(deps); // a detector failure → every readiness reads 'unknown' (honest)
|
|
483
518
|
const probe = deps.findOnPath ?? findOnPath;
|
|
519
|
+
// Host-level bridge settings, read ONCE (best-effort — never throws; a corrupt bundle/fs error → error).
|
|
520
|
+
const snapshot = settingsSnapshot(deps);
|
|
484
521
|
return FAMILY_MEMBERS.filter((m) => m.kind === 'execution-backend').map((m) => {
|
|
485
522
|
const det = detection.find((d) => d.name === m.name);
|
|
486
523
|
// Preserve findOnPath's THREE-state result (present | missing | unknown): an `unknown` (EACCES,
|
|
487
524
|
// "cannot confirm") must stay distinct from a real `missing` — never flattened to a false boolean.
|
|
488
525
|
const wrappers = m.wrapperCmds.map((cmd) => ({ cmd, state: probe(cmd, deps).state }));
|
|
489
|
-
|
|
526
|
+
// Fact-only: the settings knobs ACTIVE (env/file, non-default) for THIS bridge. Model/effort are
|
|
527
|
+
// structurally absent from the registry, so this can NEVER carry a model claim (the survey's
|
|
528
|
+
// no-default-model-claim invariant). Localized-on-error like every other survey.
|
|
529
|
+
const settings = snapshot.error
|
|
530
|
+
? { error: snapshot.error }
|
|
531
|
+
: { active: snapshot.active.filter((a) => a.bridge === m.name) };
|
|
532
|
+
return { member: m.name, display: displayOf(m.name), readiness: det?.readiness ?? 'unknown', wrappers, settings };
|
|
490
533
|
});
|
|
491
534
|
};
|
|
492
535
|
|
package/tools/manifest/schema.md
CHANGED
|
@@ -17,6 +17,7 @@ expansion), never via the shell. The validator is [`validate.mjs`](./validate.mj
|
|
|
17
17
|
| `provides` | string[] | yes | subset of the **role vocabulary** |
|
|
18
18
|
| `roles` | object | yes | keys ⊆ `provides`; see *Roles* |
|
|
19
19
|
| `detect` | object | no | `installed` (skill on the machine) + `deployed` (substrate set up in cwd) |
|
|
20
|
+
| `settings` | array | no | the bridge's **settings-file surface** (typed; see *Settings*). Unlike `contract`, a malformed entry **fails** `--strict`. |
|
|
20
21
|
| `install` / `uninstall` | object | no | `install.npm` is a package name, not a path |
|
|
21
22
|
| `cost` / `quota` / `provenance` | misc | no | informational |
|
|
22
23
|
| `available` | boolean | no | `false` = a declared-but-not-installed stub; skips fs/version checks |
|
|
@@ -54,6 +55,36 @@ Each `roles.<role>` is an object:
|
|
|
54
55
|
`{ policy: "guarded", blocked: string[], probeRelaxed: string[] }`, matching the wrapper's
|
|
55
56
|
real case-arm patterns (pinned by the source-level reverse-guard test).
|
|
56
57
|
|
|
58
|
+
## Settings (bridges 2.3.0, D6 — manifest-as-source)
|
|
59
|
+
|
|
60
|
+
`settings` declares the bridge's host-level settings-file surface
|
|
61
|
+
(`${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf`). It is an **array** of
|
|
62
|
+
typed entries — a JSON object would silently dedupe duplicate keys under `JSON.parse` — and,
|
|
63
|
+
unlike `contract`, it is validated by `validate.mjs` itself: a malformed entry **fails**
|
|
64
|
+
`--strict` (the kit writer, the status renderers, and the wrapper shell constants all consume
|
|
65
|
+
this block). Each entry:
|
|
66
|
+
|
|
67
|
+
- `key` (string, required) — UPPER_SNAKE_CASE env-var name; unique across the array.
|
|
68
|
+
- `kind` (string, required) — `enum | integer | duration | boolean`.
|
|
69
|
+
- `enum` → `values` (string[], non-empty, unique).
|
|
70
|
+
- `integer` → `min` / `max` (safe integers, `min <= max`); values are decimal strings.
|
|
71
|
+
- `duration` → the wrappers' shell duration grammar (`5m`, `30m`, `90s`): a unit suffix
|
|
72
|
+
(`s|m|h|d`) is **required** — a bare integer is invalid — and **zero durations are
|
|
73
|
+
rejected** (`timeout 0` would silently DISABLE a hard cap).
|
|
74
|
+
- `boolean` → the pinned wire format `"0" | "1"` (exactly what the wrappers' env vars accept).
|
|
75
|
+
- `default` (string|null, **required property** — a missing key fails validation) — `null` = no
|
|
76
|
+
file default, the wrapper built-ins apply (state them in `effect`); a non-null default must
|
|
77
|
+
itself pass the kind's validation.
|
|
78
|
+
- `appliesTo` (string[], required) — the wrapper `cmd` names (from `roles.*.cmd` of THIS
|
|
79
|
+
manifest) that APPLY the key. Every wrapper still RECOGNIZES the whole family registry (the
|
|
80
|
+
union of both bridges' `settings` keys) and skips other wrappers' keys silently.
|
|
81
|
+
- `effect` (string, required) — what the knob does, incl. built-in defaults and any spend/risk
|
|
82
|
+
caveat (the credit-rate caveat rides here for the tier knob).
|
|
83
|
+
|
|
84
|
+
Wrappers never parse JSON at run time: each carries its own shell registry/validation constants,
|
|
85
|
+
drift-guarded set-equal to this block by the bridge `bin/*.test.mjs` suites (help section keys,
|
|
86
|
+
`aw_settings_known` registry, `AW_SETTINGS_APPLIED` subset, `aw_settings_valid` typed arms).
|
|
87
|
+
|
|
57
88
|
## Path-field rules (Windows-safe, traversal-safe)
|
|
58
89
|
|
|
59
90
|
- **No absolute paths**, **no `..` traversal**, **no unresolved placeholders** (`{{…}}` / `${…}`)
|
|
@@ -36,6 +36,30 @@ export const VALID = 'valid';
|
|
|
36
36
|
export const UNSUPPORTED = 'unsupported';
|
|
37
37
|
export const INVALID = 'invalid';
|
|
38
38
|
|
|
39
|
+
// Typed settings-value grammar (D6, bridges 2.3.0) — the ONE predicate the manifest validator (for a
|
|
40
|
+
// declared `default`), the kit-side bridge-settings writer (for a user-supplied value), and — mirrored
|
|
41
|
+
// in shell as aw_settings_valid — the wrappers all use, so a value the writer accepts is exactly a
|
|
42
|
+
// value a wrapper honors. A value is always compared as a STRING (the settings-file wire format).
|
|
43
|
+
export const SETTING_KINDS = new Set(['enum', 'integer', 'duration', 'boolean']);
|
|
44
|
+
// The wrappers' shell duration grammar: a unit suffix is REQUIRED (a bare integer is invalid), and
|
|
45
|
+
// zero durations are rejected — `timeout 0` DISABLES a hard cap, so a persistent settings line could
|
|
46
|
+
// otherwise silently remove the stall guard.
|
|
47
|
+
export const DURATION_RE = /^[0-9]+(\.[0-9]+)?[smhd]$/;
|
|
48
|
+
export const ZERO_DURATION_RE = /^0+(\.0+)?[smhd]$/;
|
|
49
|
+
export const settingValueValid = (entry, value) => {
|
|
50
|
+
switch (entry.kind) {
|
|
51
|
+
case 'enum': return Array.isArray(entry.values) && entry.values.includes(value);
|
|
52
|
+
case 'integer': {
|
|
53
|
+
if (!/^[0-9]+$/.test(value)) return false;
|
|
54
|
+
const n = Number(value);
|
|
55
|
+
return Number.isSafeInteger(n) && n >= entry.min && n <= entry.max;
|
|
56
|
+
}
|
|
57
|
+
case 'duration': return DURATION_RE.test(value) && !ZERO_DURATION_RE.test(value);
|
|
58
|
+
case 'boolean': return value === '0' || value === '1';
|
|
59
|
+
default: return false;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
39
63
|
const hasTraversal = (p) => p.split(/[\\/]/).includes('..');
|
|
40
64
|
const isUnresolved = (s) => /\{\{|\}\}|\$\{/.test(s);
|
|
41
65
|
|
|
@@ -228,6 +252,67 @@ export const validateManifest = (skillDir) => {
|
|
|
228
252
|
if (role.template != null) checkInSkillPath(`role "${key}".template`, role.template, !isStub);
|
|
229
253
|
}
|
|
230
254
|
|
|
255
|
+
// Typed `settings` block (bridges 2.3.0, D6 manifest-as-source): the per-bridge settings-file
|
|
256
|
+
// surface — an ARRAY of typed entries (a JSON object would silently dedupe duplicate keys under
|
|
257
|
+
// JSON.parse). Unlike the AD-033 `contract` block (validator-tolerated, externally
|
|
258
|
+
// drift-guarded), a malformed `settings` entry FAILS validation: the kit writer, the status
|
|
259
|
+
// renderers, and the wrapper shell constants all consume this block, so a bad entry would
|
|
260
|
+
// corrupt a host-level config surface.
|
|
261
|
+
const settings = manifest.settings;
|
|
262
|
+
if (settings != null) {
|
|
263
|
+
if (!Array.isArray(settings)) {
|
|
264
|
+
errors.push('`settings` must be an array of setting entries');
|
|
265
|
+
} else {
|
|
266
|
+
const cmds = new Set(Object.values(roles)
|
|
267
|
+
.map((r) => (r && typeof r === 'object' && !Array.isArray(r) ? r.cmd : null))
|
|
268
|
+
.filter((c) => typeof c === 'string' && c));
|
|
269
|
+
const seenKeys = new Set();
|
|
270
|
+
settings.forEach((entry, i) => {
|
|
271
|
+
const at = `\`settings[${i}]\``;
|
|
272
|
+
if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
273
|
+
errors.push(`${at} must be an object`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (typeof entry.key !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(entry.key)) {
|
|
277
|
+
errors.push(`${at}.key must be an UPPER_SNAKE_CASE string`);
|
|
278
|
+
} else if (seenKeys.has(entry.key)) {
|
|
279
|
+
errors.push(`duplicate settings key "${entry.key}" (${at})`);
|
|
280
|
+
} else {
|
|
281
|
+
seenKeys.add(entry.key);
|
|
282
|
+
}
|
|
283
|
+
if (typeof entry.effect !== 'string' || !entry.effect) errors.push(`${at}.effect must be a non-empty string`);
|
|
284
|
+
if (!Array.isArray(entry.appliesTo) || entry.appliesTo.length === 0
|
|
285
|
+
|| !entry.appliesTo.every((c) => typeof c === 'string' && c)) {
|
|
286
|
+
errors.push(`${at}.appliesTo must be a non-empty array of wrapper cmd names`);
|
|
287
|
+
} else {
|
|
288
|
+
for (const c of entry.appliesTo) {
|
|
289
|
+
if (!cmds.has(c)) errors.push(`${at}.appliesTo names "${c}" which is no roles.*.cmd of this manifest`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (!SETTING_KINDS.has(entry.kind)) {
|
|
293
|
+
errors.push(`${at}.kind must be one of enum|integer|duration|boolean`);
|
|
294
|
+
return; // the typed checks below are meaningless without a kind
|
|
295
|
+
}
|
|
296
|
+
if (entry.kind === 'enum'
|
|
297
|
+
&& (!Array.isArray(entry.values) || entry.values.length === 0
|
|
298
|
+
|| !entry.values.every((v) => typeof v === 'string' && v)
|
|
299
|
+
|| new Set(entry.values).size !== entry.values.length)) {
|
|
300
|
+
errors.push(`${at}.values must be a non-empty array of unique non-empty strings (enum kind)`);
|
|
301
|
+
}
|
|
302
|
+
if (entry.kind === 'integer'
|
|
303
|
+
&& (!Number.isSafeInteger(entry.min) || !Number.isSafeInteger(entry.max) || entry.min > entry.max)) {
|
|
304
|
+
errors.push(`${at}.min/.max must be integers with min <= max (integer kind)`);
|
|
305
|
+
}
|
|
306
|
+
if (!Object.hasOwn(entry, 'default')) {
|
|
307
|
+
errors.push(`${at}.default is required (null = the wrapper built-ins apply)`);
|
|
308
|
+
} else if (entry.default != null
|
|
309
|
+
&& (typeof entry.default !== 'string' || !settingValueValid(entry, entry.default))) {
|
|
310
|
+
errors.push(`${at}.default must be null or a string value that passes the ${entry.kind} validation`);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
231
316
|
if (!isStub) {
|
|
232
317
|
const auth = readAuthoritativeVersion(skillDir);
|
|
233
318
|
if (auth.version == null) errors.push(`could not resolve an authoritative version (${auth.from})`);
|
|
@@ -4,94 +4,24 @@
|
|
|
4
4
|
// can never reach a writer" is a STRUCTURAL invariant (an import-split test pins it), not just an
|
|
5
5
|
// assertion. Splitting the writer out of the schema/read module keeps the read surface fs-write-free.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// - guard the dst + the tmp sibling with assertContainedRealPath (fs-safe) — refuses a symlinked
|
|
13
|
-
// docs/ or docs/ai/ PARENT, not just the leaf, and refuses any escape outside cwd.
|
|
14
|
-
// - atomic: write a UNIQUE *.json.<rand>.tmp opened EXCLUSIVE-CREATE (wx), then rename over the dst.
|
|
15
|
-
// - RE-CHECK the parent chain + the leaf immediately before the rename (TOCTOU).
|
|
16
|
-
// - LAST-WRITER-WINS: local, single-user; no cross-process lock (documented, not silently assumed).
|
|
7
|
+
// The hardened write flow (deployment gate → symlink STOPs → containment guard → exclusive-create
|
|
8
|
+
// tmp + rename with a TOCTOU re-check → tmp cleanup; LAST-WRITER-WINS, documented) lives in the
|
|
9
|
+
// shared atomic-write core (tools/atomic-write.mjs, extracted from this module — AD-042) and is
|
|
10
|
+
// consumed here with this module's own STOP identity, so the public API + error contract are
|
|
11
|
+
// unchanged (this file's tests pin them, characterize-first).
|
|
17
12
|
//
|
|
18
13
|
// Dependency-free, Node >= 18. Every fs primitive is injectable (deps.*) so the guards are unit-testable.
|
|
19
14
|
|
|
20
|
-
import { lstatSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
|
21
|
-
import { randomBytes } from 'node:crypto';
|
|
22
|
-
import { join } from 'node:path';
|
|
23
|
-
import { assertContainedRealPath } from './fs-safe.mjs';
|
|
24
15
|
import { CONFIG_REL, serializeConfig } from './orchestration-config.mjs';
|
|
16
|
+
import { writeDocsAiFileAtomic } from './atomic-write.mjs';
|
|
25
17
|
|
|
26
18
|
// A typed STOP — a deliberate refusal we surface (deployment gate / symlinked leaf), distinct from a
|
|
27
19
|
// native fs error. `Object.assign(new Error(), { code })`, the codebase's typed-error idiom (no classes).
|
|
28
20
|
export const ORCH_WRITE_STOP = 'ORCH_WRITE_STOP';
|
|
29
21
|
const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'OrchWriteStop', code: ORCH_WRITE_STOP });
|
|
30
22
|
|
|
31
|
-
// lstat without following symlinks; null when absent. A non-ENOENT error propagates (never fail open).
|
|
32
|
-
const lstatNoFollow = (path, lstat) => {
|
|
33
|
-
try {
|
|
34
|
-
return lstat(path);
|
|
35
|
-
} catch (err) {
|
|
36
|
-
if (err && err.code === 'ENOENT') return null;
|
|
37
|
-
throw err;
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
|
|
41
23
|
// writeConfig(cwd, config, deps) → { writtenPath } on success; THROWS a typed STOP (no deployment /
|
|
42
24
|
// symlinked leaf) or a native fs error otherwise. The tmp is cleaned up on any failure after its
|
|
43
25
|
// creation. config is serialized canonically (serializeConfig: 2-space, _README-first, trailing NL).
|
|
44
|
-
export const writeConfig = (cwd, config, deps = {}) =>
|
|
45
|
-
|
|
46
|
-
const writeFile = deps.writeFile ?? writeFileSync;
|
|
47
|
-
const rename = deps.rename ?? renameSync;
|
|
48
|
-
const rm = deps.rm ?? ((p) => rmSync(p, { force: true }));
|
|
49
|
-
const rand = deps.rand ?? (() => randomBytes(6).toString('hex'));
|
|
50
|
-
const guard = (target) => assertContainedRealPath(cwd, target, { lstat });
|
|
51
|
-
|
|
52
|
-
// Deployment gate — never scatter a config into a non-deployed repo (mirror velocity / setup). lstat,
|
|
53
|
-
// NOT existsSync: existsSync FOLLOWS a symlink, so a DANGLING `docs/ai` symlink would read as "absent"
|
|
54
|
-
// and mislabel a broken/symlinked deployment as "no deployment". lstat the leaf instead: a true ENOENT
|
|
55
|
-
// → no deployment (STOP, run init); a symlink or non-directory → STOP loud (never write through it).
|
|
56
|
-
const docsAi = join(cwd, 'docs', 'ai');
|
|
57
|
-
const docsAiStat = lstatNoFollow(docsAi, lstat);
|
|
58
|
-
if (docsAiStat === null) {
|
|
59
|
-
throw stop(`no agent-workflow deployment here (docs/ai is absent) — run init/bootstrap before writing ${CONFIG_REL}`);
|
|
60
|
-
}
|
|
61
|
-
if (docsAiStat.isSymbolicLink()) {
|
|
62
|
-
throw stop(`docs/ai is a symlink — refusing to write a config through it (run init/bootstrap in a real deployment)`);
|
|
63
|
-
}
|
|
64
|
-
if (!docsAiStat.isDirectory()) {
|
|
65
|
-
throw stop(`docs/ai exists but is not a directory — refusing to write ${CONFIG_REL}`);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const dst = join(cwd, CONFIG_REL);
|
|
69
|
-
// Refuse a symlinked leaf with a CLEAR message before the generic traversal guard fires (a rename
|
|
70
|
-
// would silently replace the link rather than the file the user thinks they are editing).
|
|
71
|
-
const leaf = lstatNoFollow(dst, lstat);
|
|
72
|
-
if (leaf && leaf.isSymbolicLink()) {
|
|
73
|
-
throw stop(`${CONFIG_REL} is a symlink — refusing to replace it (a write would clobber the link target)`);
|
|
74
|
-
}
|
|
75
|
-
// Guard the dst + a unique tmp SIBLING: refuses a symlinked docs/ or docs/ai/ parent, and any escape.
|
|
76
|
-
guard(dst);
|
|
77
|
-
const tmp = `${dst}.${rand()}.tmp`;
|
|
78
|
-
guard(tmp);
|
|
79
|
-
|
|
80
|
-
const body = serializeConfig(config);
|
|
81
|
-
// Exclusive-create (wx): never clobber a leftover tmp (a stray collision is surfaced, not silently
|
|
82
|
-
// overwritten). The random suffix makes a collision effectively impossible; wx makes it impossible-loud.
|
|
83
|
-
writeFile(tmp, body, { encoding: 'utf8', flag: 'wx' });
|
|
84
|
-
try {
|
|
85
|
-
// TOCTOU re-check: the parent chain + the leaf may have changed since the pre-checks above.
|
|
86
|
-
guard(dst);
|
|
87
|
-
const leafAgain = lstatNoFollow(dst, lstat);
|
|
88
|
-
if (leafAgain && leafAgain.isSymbolicLink()) {
|
|
89
|
-
throw stop(`${CONFIG_REL} became a symlink — refusing to replace it`);
|
|
90
|
-
}
|
|
91
|
-
rename(tmp, dst);
|
|
92
|
-
} catch (err) {
|
|
93
|
-
rm(tmp); // never leave a temp file behind on failure
|
|
94
|
-
throw err;
|
|
95
|
-
}
|
|
96
|
-
return { writtenPath: CONFIG_REL };
|
|
97
|
-
};
|
|
26
|
+
export const writeConfig = (cwd, config, deps = {}) =>
|
|
27
|
+
writeDocsAiFileAtomic(cwd, CONFIG_REL, serializeConfig(config), deps, { stop, noun: 'a config' });
|
package/tools/presentation.mjs
CHANGED
package/tools/procedures.mjs
CHANGED
|
@@ -21,6 +21,9 @@ import { homedir } from 'node:os';
|
|
|
21
21
|
import { join, dirname } from 'node:path';
|
|
22
22
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
23
23
|
import { detectBackends, wrapperCmdFor, wrapperContractFor } from './detect-backends.mjs';
|
|
24
|
+
// The host-level bridge-settings registry (manifest-as-source) + its allowed-value labels. READ-ONLY
|
|
25
|
+
// core only — never the writer — so this read-only advisor never imports the atomic-write core.
|
|
26
|
+
import { loadRegistry, allowedLabel } from './bridge-settings-read.mjs';
|
|
24
27
|
import { ACTIVITIES, resolveActivityRecipe, planRecipe } from './recipes.mjs';
|
|
25
28
|
import { resolveEngineDir, readEngineFragment, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
|
|
26
29
|
// The plan-in-flight detector (AD-038) — imported from the READ-ONLY checker (review-state.mjs
|
|
@@ -130,8 +133,19 @@ export const extractSection = (text, activity) => {
|
|
|
130
133
|
|
|
131
134
|
// ── resolution + rendering ─────────────────────────────────────────────────────────
|
|
132
135
|
|
|
133
|
-
const resolveAllSlots = ({ activity, config, detection, overrides }) =>
|
|
134
|
-
|
|
136
|
+
const resolveAllSlots = ({ activity, config, detection, overrides }) => {
|
|
137
|
+
// The host-level settings knobs (manifest-as-source), best-effort: a corrupt bundle degrades to none
|
|
138
|
+
// and the advisor never crashes. Attached per wrapper cmd via each knob's `appliesTo`. Fact-only —
|
|
139
|
+
// model/effort are not knobs, so no model claim can ride here.
|
|
140
|
+
const registry = (() => {
|
|
141
|
+
try {
|
|
142
|
+
return loadRegistry({});
|
|
143
|
+
} catch {
|
|
144
|
+
return new Map();
|
|
145
|
+
}
|
|
146
|
+
})();
|
|
147
|
+
const knobsFor = (cmd) => [...registry.values()].filter((k) => (k.appliesTo ?? []).includes(cmd));
|
|
148
|
+
return Object.keys(ACTIVITIES[activity].slots).map((slot) => {
|
|
135
149
|
const resolved = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity, slot, override: overrides[slot] });
|
|
136
150
|
// The concrete wrapper set this slot's EFFECTIVE recipe dispatches (empty for solo). Reuse
|
|
137
151
|
// planRecipe's drift-guarded dispatch for WHICH backends, then resolve each (backend, role) to its
|
|
@@ -144,9 +158,11 @@ const resolveAllSlots = ({ activity, config, detection, overrides }) =>
|
|
|
144
158
|
// is NEVER gated by REVIEW_RECIPES (that set gates only the review-loop economics block).
|
|
145
159
|
const contracts = dispatch
|
|
146
160
|
.map((d) => ({ backend: d.backend, role: d.role, cmd: wrapperCmdFor(d.backend, d.role), contract: wrapperContractFor(d.backend, d.role) }))
|
|
147
|
-
.filter((c) => c.cmd && c.contract)
|
|
161
|
+
.filter((c) => c.cmd && c.contract)
|
|
162
|
+
.map((c) => ({ ...c, settings: knobsFor(c.cmd).map((k) => ({ key: k.key, allowed: allowedLabel(k) })) }));
|
|
148
163
|
return { slot, ...resolved, backends, contracts };
|
|
149
164
|
});
|
|
165
|
+
};
|
|
150
166
|
|
|
151
167
|
// An unsatisfiable EXPLICIT override is the only "warning" (loud, flagged for the agent to relay). A
|
|
152
168
|
// graceful config/default degradation is reported as a per-slot reason, not a warning.
|
|
@@ -250,7 +266,7 @@ const costLanesAdvice = () => [
|
|
|
250
266
|
// roles[role].contract (drift-guarded), never re-derived or re-worded here. Rendered for EVERY
|
|
251
267
|
// dispatched backend of EVERY slot (review AND execute=delegated); each wrapper's --help prints the
|
|
252
268
|
// same contract, so the agent never needs to open the wrapper source.
|
|
253
|
-
const contractLines = ({ cmd, contract }) => {
|
|
269
|
+
const contractLines = ({ cmd, contract, settings }) => {
|
|
254
270
|
const lines = [` ${cmd} — driving contract (as bundled with this kit; copy-paste — \`${cmd} --help\` prints the same):`];
|
|
255
271
|
for (const inv of contract.invocations) lines.push(` ${inv}`);
|
|
256
272
|
for (const f of contract.flags ?? []) lines.push(` ${f}`);
|
|
@@ -263,6 +279,12 @@ const contractLines = ({ cmd, contract }) => {
|
|
|
263
279
|
if (contract.passthrough) {
|
|
264
280
|
lines.push(` passthrough after '--' is ${contract.passthrough.policy}: blocked always: ${contract.passthrough.blocked.join(' ')}; relaxed only under CODEX_PROBE=1: ${contract.passthrough.probeRelaxed.join(' ')}`);
|
|
265
281
|
}
|
|
282
|
+
// Host-level settings knobs this wrapper honors (fact-only, from the bundled manifests; explicit
|
|
283
|
+
// branch — contractLines drops any contract key it does not name, so this must be enumerated here).
|
|
284
|
+
if ((settings ?? []).length) {
|
|
285
|
+
lines.push(' host settings (survive kit upgrades — set via /agent-workflow-kit bridge-settings):');
|
|
286
|
+
for (const s of settings) lines.push(` ${s.key} — ${s.allowed}`);
|
|
287
|
+
}
|
|
266
288
|
return lines;
|
|
267
289
|
};
|
|
268
290
|
|
package/tools/recipes.mjs
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
// a backend is advisory or delegated, never autonomous. Dependency-free, Node >= 18.
|
|
16
16
|
|
|
17
17
|
import { pathToFileURL } from 'node:url';
|
|
18
|
+
// The host-level bridge-settings snapshot (fact-only, best-effort). READ-ONLY core only — never the
|
|
19
|
+
// writer — so this read-only advisor never pulls in the atomic-write core.
|
|
20
|
+
import { settingsSnapshot } from './bridge-settings-read.mjs';
|
|
18
21
|
import {
|
|
19
22
|
detectBackends,
|
|
20
23
|
wrapperCmdFor,
|
|
@@ -293,12 +296,19 @@ export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, s
|
|
|
293
296
|
// DISPLAY_ALIASES — the ONE alias table the recommendation clause already uses; ordering is the
|
|
294
297
|
// deterministic BACKEND_PRIORITY (codex before agy), independent of detection emission order.
|
|
295
298
|
// Always exactly one line: no part may carry a newline (pinned by tests).
|
|
296
|
-
export const composeStatusLine = (detection, recommendation) => {
|
|
299
|
+
export const composeStatusLine = (detection, recommendation, settings = null) => {
|
|
297
300
|
const backends = [...detection]
|
|
298
301
|
.sort((a, b) => priorityIndex(a.name) - priorityIndex(b.name))
|
|
299
302
|
.map((b) => `${DISPLAY_ALIASES[b.name] ?? b.name} ${b.readiness === READY ? '✓' : '✗'} ${b.readiness}`)
|
|
300
303
|
.join(' · ');
|
|
301
|
-
|
|
304
|
+
const base = `backends: ${backends} — run /agent-workflow-kit backends · recipes: ${recommendation.clause} — see /agent-workflow-kit recipes`;
|
|
305
|
+
// Fact-only suffix, ONLY when a bridge knob is actively set (env/file, non-default). Omitted otherwise,
|
|
306
|
+
// so the default line is byte-identical to before. A raw env value may (D3) carry newlines/control
|
|
307
|
+
// chars — collapse them to a single space so the "exactly one line" backend-status contract holds.
|
|
308
|
+
const oneLine = (s) => String(s).replace(/[\s]+/g, ' ').trim();
|
|
309
|
+
const active = settings?.active ?? [];
|
|
310
|
+
const suffix = active.length ? ` · settings: ${active.map((s) => `${oneLine(s.key)}=${oneLine(s.value)}`).join(' · ')}` : '';
|
|
311
|
+
return base + suffix;
|
|
302
312
|
};
|
|
303
313
|
|
|
304
314
|
// ── the one-line ACTIVE-recipe line (the discovery line — configured, never recommended) ───────────
|
|
@@ -340,7 +350,7 @@ export const composeActiveRecipeLine = ({ config, source } = {}, detection) => {
|
|
|
340
350
|
|
|
341
351
|
// The structured report behind `--json` — the recipes, the recommendation, a plan per recipe, and
|
|
342
352
|
// (additive) the pasteable one-line backend status composed from the same detection.
|
|
343
|
-
export const buildReport = (detection) => {
|
|
353
|
+
export const buildReport = (detection, settings = null) => {
|
|
344
354
|
const recommendation = recommendRecipe(detection);
|
|
345
355
|
return {
|
|
346
356
|
recipes: RECIPES.map(({ id, title, role, minBackends, degradesTo, summary }) => ({
|
|
@@ -353,7 +363,7 @@ export const buildReport = (detection) => {
|
|
|
353
363
|
})),
|
|
354
364
|
recommendation,
|
|
355
365
|
plans: RECIPES.map((r) => planRecipe(r.id, detection)),
|
|
356
|
-
statusLine: composeStatusLine(detection, recommendation),
|
|
366
|
+
statusLine: composeStatusLine(detection, recommendation, settings),
|
|
357
367
|
};
|
|
358
368
|
};
|
|
359
369
|
|
|
@@ -423,8 +433,8 @@ exclusive. Detection only — never writes, never commits, never runs a subscrip
|
|
|
423
433
|
console.error(`[agent-workflow-kit] ${err.message}`);
|
|
424
434
|
return err.exitCode ?? 1;
|
|
425
435
|
}
|
|
426
|
-
} else if (argv.includes('--status-line')) console.log(composeStatusLine(detection, recommendRecipe(detection)));
|
|
427
|
-
else if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection), null, 2));
|
|
436
|
+
} else if (argv.includes('--status-line')) console.log(composeStatusLine(detection, recommendRecipe(detection), settingsSnapshot()));
|
|
437
|
+
else if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection, settingsSnapshot()), null, 2));
|
|
428
438
|
else console.log(formatRecipes(detection));
|
|
429
439
|
return 0;
|
|
430
440
|
};
|
package/tools/renderers.mjs
CHANGED
|
@@ -56,6 +56,13 @@ const renderBridges = (vm, { glyph, color }) => {
|
|
|
56
56
|
for (const b of vm.bridges) {
|
|
57
57
|
const wrappers = b.wrappers.map((w) => `${w.cmd} ${glyph[w.state] ?? glyph.unknown}`).join(', ') || '—';
|
|
58
58
|
lines.push(` ${pad(b.display, MEMBER_COL)}${pad(b.readiness, READINESS_COL)}wrappers: ${wrappers}`);
|
|
59
|
+
// Fact-only host-level settings sub-line: the active knobs for this bridge, or a localized error.
|
|
60
|
+
// Absent when no knob is active, so the block stays byte-identical to before when nothing is set.
|
|
61
|
+
if (b.settings?.error) lines.push(` ${pad('', MEMBER_COL)}${glyph.note} couldn't read bridge settings (${b.settings.error})`);
|
|
62
|
+
else if (b.settings?.active?.length) {
|
|
63
|
+
const active = b.settings.active.map((s) => `${s.key}=${s.value} [${s.source}]`).join(' · ');
|
|
64
|
+
lines.push(` ${pad('', MEMBER_COL)}settings: ${active}`);
|
|
65
|
+
}
|
|
59
66
|
}
|
|
60
67
|
return lines;
|
|
61
68
|
};
|
|
@@ -101,11 +108,19 @@ const renderSettings = (vm, { color, glyph }) => {
|
|
|
101
108
|
else if (s.velocity) {
|
|
102
109
|
lines.push(` ${pad(SETTINGS_LABELS.velocity, SETTINGS_COL)}defaultMode=${String(s.velocity.defaultMode)} · allow project/local=${s.velocity.allow.project}/${s.velocity.allow.local}`);
|
|
103
110
|
}
|
|
104
|
-
//
|
|
111
|
+
// cheap agents — the kit-placed .claude/agents/ vehicles: placed count vs the bundle.
|
|
112
|
+
if (s.agents?.error) lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}error: ${s.agents.error}`);
|
|
113
|
+
else if (s.agents) {
|
|
114
|
+
lines.push(` ${pad(SETTINGS_LABELS.agents, SETTINGS_COL)}placed=${s.agents.placed}/${s.agents.bundled}`);
|
|
115
|
+
}
|
|
116
|
+
// gate hook — the opt-in PreToolUse gate-approval hook: wired / file placed / declaration present /
|
|
117
|
+
// declared gate count (null → '?' — unknown is shown as unknown, never as a number).
|
|
105
118
|
if (s.hook?.error) lines.push(` ${pad(SETTINGS_LABELS.hook, SETTINGS_COL)}error: ${s.hook.error}`);
|
|
106
119
|
else if (s.hook) {
|
|
107
120
|
const yn = (b) => (b ? 'yes' : 'no');
|
|
108
|
-
|
|
121
|
+
// A null count carries its preserved validation reason — never a silent bare "?".
|
|
122
|
+
const declared = s.hook.declaredGates ?? (s.hook.declarationError ? `? (${s.hook.declarationError})` : '?');
|
|
123
|
+
lines.push(` ${pad(SETTINGS_LABELS.hook, SETTINGS_COL)}wired=${yn(s.hook.wired)} · file=${yn(s.hook.filePlaced)} · gates.json=${yn(s.hook.declarationPresent)} · declared=${declared}`);
|
|
109
124
|
}
|
|
110
125
|
return lines;
|
|
111
126
|
};
|
package/tools/review-state.mjs
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// resolves the effective `plan-execution.review` recipe (the advisor's single-source readers),
|
|
6
6
|
// recomputes the CURRENT canonical uncommitted-state fingerprint, and reports — per recipe-named
|
|
7
7
|
// backend — whether a FRESH, grounded, current-fingerprint receipt exists. `--check` turns the
|
|
8
|
-
// report into a gate exit code (declare it in docs/ai/gates.json — by hand
|
|
8
|
+
// report into a gate exit code (declare it in docs/ai/gates.json — by hand OR via the
|
|
9
|
+
// explicit-consent seeder, tools/seed-gates.mjs — never without consent, AD-021/AD-042).
|
|
9
10
|
//
|
|
10
11
|
// Normative `--check` exit contract (the single home of this list — SKILL.md points here):
|
|
11
12
|
// exit 0 when the resolved plan-execution.review recipe is solo (configured, or degraded there —
|
|
@@ -353,7 +354,8 @@ presence + verdict + grounding for the CURRENT tree. Plan/diff-mode receipts and
|
|
|
353
354
|
--check exits 0/1 per the normative contract in the tool header: 0 for solo / no plan in flight /
|
|
354
355
|
a clean tree / not-a-git-tree / all recipe-named backends receipted-current-and-grounded; 1 when a
|
|
355
356
|
recipe-named backend is missing, stale (edited after review), or grounded:false under
|
|
356
|
-
reviewed/council. Declare it as a project gate by hand (docs/ai/gates.json)
|
|
357
|
+
reviewed/council. Declare it as a project gate by hand (docs/ai/gates.json) or via the
|
|
358
|
+
explicit-consent seeder (tools/seed-gates.mjs) — never without consent.
|
|
357
359
|
|
|
358
360
|
Read-only: never writes, never commits, never runs a subscription CLI; spawns read-only git queries.
|
|
359
361
|
Human residual: git commit --no-verify and receipt-file deletion remain possible — this is a
|