@sabaiway/agent-workflow-kit 1.35.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 +33 -0
- package/SKILL.md +18 -12
- package/capability.json +1 -1
- package/package.json +1 -1
- 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 +14 -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/velocity-profile.mjs +468 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
|
|
|
4
4
|
is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
|
|
5
5
|
every `migrations/<version>-<slug>.md` newer than it, in semver order.
|
|
6
6
|
|
|
7
|
+
## 1.36.0 — Review-round ledger: the prose crossover-stop becomes a computed signal (AD-045)
|
|
8
|
+
|
|
9
|
+
A **feature** release (deployment-lineage head stays `1.3.0` — no migration). The review-loop
|
|
10
|
+
crossover-stop that `planning.md` §9 and `procedures.md` describe in prose — "cap ≤2 rounds", the
|
|
11
|
+
crossover, "fold-at-altitude vs residual", "{round N · finding-origin tally · per-backend verdict} …
|
|
12
|
+
a computed signal, not a remembered rule" — was **prose with no checker** and broke under load. This
|
|
13
|
+
ships it as a **mechanism** (DEBT-REVIEW-CAP): a review-round **LEDGER** that protects every consumer
|
|
14
|
+
project's `plan-execution` review loop.
|
|
15
|
+
|
|
16
|
+
- **`tools/review-ledger.mjs`** (read-only) — the record schema (two kinds, `round` / `triage`,
|
|
17
|
+
internally-consistency-validated), a tolerant reader (malformed lines counted + surfaced, never
|
|
18
|
+
dropped), the pure **`decideStop`** truth table returning exactly one state under a fixed precedence
|
|
19
|
+
(**converged > resolved-residual > triage-required > continue**) from machine fields only, the
|
|
20
|
+
integrity receipt cross-check, and the **`--check`** gate — **fail-CLOSED** on every unknown state
|
|
21
|
+
(detector failure · unreadable / malformed ledger · a corrupt round sequence · more than one plan
|
|
22
|
+
in flight).
|
|
23
|
+
- **`tools/review-ledger-write.mjs`** (the sole writer, over the hardened `atomic-write` core) —
|
|
24
|
+
`record` / `classify` with **the teeth**: it refuses a round while triage is required, beyond the
|
|
25
|
+
hard-max ceiling of 3, or without a grounded review receipt, and enforces round-sequence integrity +
|
|
26
|
+
round-bound classifications. The read/write split is pinned by an import-split test; the ledger lives
|
|
27
|
+
in the git dir (uncommittable by construction, mirroring the receipts precedent).
|
|
28
|
+
- **Command surface** — a `review-ledger` catalog entry + `### Mode: review-ledger` + its mode
|
|
29
|
+
reference; the conditional `seed-gates` candidate (offered only when `plan-execution.review` is
|
|
30
|
+
reviewed / council).
|
|
31
|
+
- **Honest residual** (stated in the tool header, like `review-state`'s): the ledger attests a review
|
|
32
|
+
occurred and its ship-class is consistent; it does not prove the recorded counts are truthful nor
|
|
33
|
+
that a self-reported `degraded` is real — a self-discipline mechanism, not a security boundary.
|
|
34
|
+
|
|
35
|
+
Self-arming dogfood: this release's own review loop was recorded through the ledger it builds
|
|
36
|
+
(`--check` exits 0 via the `converged` branch). The optional per-fold `testId` slot exists but stays
|
|
37
|
+
**unenforced** — enforcement + a fold-completeness signal + the canon-pointer mechanization are the
|
|
38
|
+
next plan (DEBT-TEST-COMPLETENESS).
|
|
39
|
+
|
|
7
40
|
## 1.35.0 — Host-level bridge settings surface + Codex Fast tier as configuration (AD-043)
|
|
8
41
|
|
|
9
42
|
A **feature** release (deployment-lineage head stays `1.3.0` — no migration). Bridge knobs like the
|
package/SKILL.md
CHANGED
|
@@ -3,12 +3,12 @@ name: agent-workflow-kit
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '1.
|
|
6
|
+
version: '1.36.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
|
10
10
|
|
|
11
|
-
Deploys a **portable AI-agent memory-and-workflow system** into a project, and upgrades it as the kernel evolves — so any future agent can reconstruct project context
|
|
11
|
+
Deploys a **portable AI-agent memory-and-workflow system** into a project, and upgrades it as the kernel evolves — so any future agent can reconstruct project context fast and avoid repeating past mistakes.
|
|
12
12
|
|
|
13
13
|
**Posture:** never leak kit internals — translate tool outcomes to plain language; Gotchas: `${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md`.
|
|
14
14
|
|
|
@@ -23,14 +23,13 @@ when it is present and healthy, and otherwise uses its **own bundled copy** (`re
|
|
|
23
23
|
`migrations/`) — so the existing one-command install keeps working with **no new dependency on the
|
|
24
24
|
memory substrate**. (The methodology slot is a separate axis: its fragment is read **live from the
|
|
25
25
|
installed `agent-workflow-engine`**, which `npx @sabaiway/agent-workflow-kit@latest init` installs — a
|
|
26
|
-
runtime dependency
|
|
26
|
+
runtime dependency read live; see `${CLAUDE_SKILL_DIR}/references/shared/composition-handoff.md`.)
|
|
27
27
|
`init` also **refreshes the installed memory substrate** (best-effort — a miss is a loud DEGRADED
|
|
28
|
-
success:
|
|
29
|
-
|
|
28
|
+
success: warn + the exact recovery command + exit 0, never silent, never the engine's hard STOP;
|
|
29
|
+
`--no-memory` skips it), so a returning `init` leaves **no stale core member**. The
|
|
30
30
|
execution-backend bridges are still never **placed** by `init` (placed on demand by `setup`, opt-in);
|
|
31
|
-
**once placed**, `init` **refreshes** them from the kit's own bundled copies (refresh-only,
|
|
32
|
-
|
|
33
|
-
bridge either.
|
|
31
|
+
**once placed**, `init` **refreshes** them from the kit's own bundled copies (refresh-only, never a
|
|
32
|
+
downgrade; `--no-bridges` skips it) — so a returning `init` leaves no stale placed bridge either.
|
|
34
33
|
|
|
35
34
|
**Detection (kit-owned, decided BEFORE any project write).** Run the kit's **own shipped**
|
|
36
35
|
validator — `node ${CLAUDE_SKILL_DIR}/tools/manifest/validate.mjs <memory-skill-dir>` — never a
|
|
@@ -38,9 +37,8 @@ validator shipped by the candidate (which could itself be broken). Delegate only
|
|
|
38
37
|
hold:
|
|
39
38
|
- result is **valid** and `kind` is `memory-substrate`;
|
|
40
39
|
- **every required asset is present** in the candidate, at its real path:
|
|
41
|
-
`references/templates/`, **`references/templates/orchestration.json`** (
|
|
42
|
-
|
|
43
|
-
falls back to the kit's own bundled substrate, which does), `references/contracts.md`,
|
|
40
|
+
`references/templates/`, **`references/templates/orchestration.json`** (a pre-`1.2.0` memory can't
|
|
41
|
+
ship it, so it falls back to the kit's bundled substrate), `references/contracts.md`,
|
|
44
42
|
`references/scripts/`, `scripts/stamp-takeover.mjs`, `migrations/`, `capability.json`. A partial
|
|
45
43
|
install (manifest + `SKILL.md` only) is treated as **invalid**.
|
|
46
44
|
|
|
@@ -81,7 +79,7 @@ Before acting, read `docs/ai/.workflow-version` (the project's stamp) to decide
|
|
|
81
79
|
**Two independent version axes — never conflate them:**
|
|
82
80
|
|
|
83
81
|
1. **Project deployment** — `docs/ai/.workflow-version` vs the lineage head (`1.3.0`). This is the **only** axis this skill compares.
|
|
84
|
-
2. **Kit freshness** — this skill's own files vs the published npm package. That is the **npx installer's** job: `npx @sabaiway/agent-workflow-kit@latest init` (it refuses a stale-cache downgrade by comparing the version on disk — **no network**). This skill never checks npm, and the package version
|
|
82
|
+
2. **Kit freshness** — this skill's own files vs the published npm package. That is the **npx installer's** job: `npx @sabaiway/agent-workflow-kit@latest init` (it refuses a stale-cache downgrade by comparing the version on disk — **no network**). This skill never checks npm, and the package version is **not** the lineage head.
|
|
85
83
|
|
|
86
84
|
**Refreshed the kit but nothing changed?** The skill you are running is whatever was on disk when the session started. After `npx @sabaiway/agent-workflow-kit@latest init` updates `~/.claude/skills/agent-workflow-kit/`, **restart the session** so the agent reloads the new skill files (the slash command + this `SKILL.md`).
|
|
87
85
|
|
|
@@ -125,6 +123,10 @@ read-only — read `${CLAUDE_SKILL_DIR}/references/modes/procedures.md` before a
|
|
|
125
123
|
|
|
126
124
|
writer — read `${CLAUDE_SKILL_DIR}/references/modes/set-recipe.md` before acting.
|
|
127
125
|
|
|
126
|
+
### Mode: set-autonomy
|
|
127
|
+
|
|
128
|
+
writer — read `${CLAUDE_SKILL_DIR}/references/modes/set-autonomy.md` before acting.
|
|
129
|
+
|
|
128
130
|
### Mode: uninstall
|
|
129
131
|
|
|
130
132
|
guarded — read `${CLAUDE_SKILL_DIR}/references/modes/uninstall.md` before acting.
|
|
@@ -153,6 +155,10 @@ read-only — read `${CLAUDE_SKILL_DIR}/references/modes/review-state.md` before
|
|
|
153
155
|
|
|
154
156
|
writer — read `${CLAUDE_SKILL_DIR}/references/modes/grounding.md` before acting.
|
|
155
157
|
|
|
158
|
+
### Mode: review-ledger
|
|
159
|
+
|
|
160
|
+
writer — read `${CLAUDE_SKILL_DIR}/references/modes/review-ledger.md` before acting.
|
|
161
|
+
|
|
156
162
|
---
|
|
157
163
|
|
|
158
164
|
## References
|
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,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.
|
|
@@ -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.
|
|
@@ -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,13 @@ 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
|
+
},
|
|
171
185
|
];
|
|
172
186
|
|
|
173
187
|
// Deep-freeze: freeze the array AND every entry, so the catalog is genuinely immutable at runtime
|