refactor(gates): the autonomy gate is the --gates flag parser
lib/util/autonomy-gate.mjs keeps only gatesValue + parseGatesArgs and the
CLI ({"ok":true,"gates":true|false}; exact usage error on a bad value;
the old --state/--event arguments are a usage error). The four commands
run 'autonomy-gate.mjs --gates [value]' and read gates. Prose (README,
operations, architecture, sdlc-playbook-gap) and the S24, gates-flag
and [#1] tests follow (R9).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
651be3468b
commit
308b149e70
12 changed files with 165 additions and 355 deletions
|
|
@ -74,7 +74,7 @@ No cloud dependency. No GitHub requirement. Works on **Mac, Linux, and Windows**
|
|||
|
||||
All four pipeline commands accept `--gates [true|false|closed|open]`. A bare `--gates`, `true` or `closed` turns gating on; `false`, `open` or no flag (the default) leaves it off. Any other next token is not the flag's value, so `/trekplan --gates --project <dir>` is a bare `--gates`. With gating on, `/trekbrief`, `/trekresearch` and `/trekplan` pause at their own boundaries; `/trekexecute` accepts the flag and pauses on nothing, because its one stop is the merge into main, which always asks. Details: [`docs/operations.md`](docs/operations.md) § Autonomy mode.
|
||||
|
||||
Under the hood, `lib/util/autonomy-gate.mjs` runs a small state machine (`idle → gates_on | auto_running → paused_for_gate → completed`), and `lib/stats/event-emit.mjs` records named lifecycle events (the main-merge gate and its answer, among others) to `trekexecute-stats.jsonl` in the plugin data directory. The new `hooks/scripts/post-compact-flush.mjs` PostCompact hook re-injects `.session-state.local.json` after context compaction so multi-session work survives a compaction boundary.
|
||||
Under the hood, `lib/util/autonomy-gate.mjs` maps the `--gates` value to on/off for the four commands, and `lib/stats/event-emit.mjs` records named lifecycle events (the main-merge gate and its answer, among others) to `trekexecute-stats.jsonl` in the plugin data directory. The new `hooks/scripts/post-compact-flush.mjs` PostCompact hook re-injects `.session-state.local.json` after context compaction so multi-session work survives a compaction boundary.
|
||||
|
||||
## Quick start
|
||||
|
||||
|
|
|
|||
|
|
@ -47,11 +47,11 @@ Parse `$ARGUMENTS`:
|
|||
there is no value:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state idle --event start --gates [value]
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --gates [value]
|
||||
```
|
||||
|
||||
`next_state: gates_on` sets `gates_mode = true`, `auto_running` sets it
|
||||
false, and nothing else reads the state. On exit 1 or no JSON (a broken
|
||||
`gates: true` sets `gates_mode = true`, `false` sets it false.
|
||||
On exit 1 or no JSON (a broken
|
||||
shim; the four values cannot cause it), print the error in one line and
|
||||
map in prose instead: bare, `true`, `closed` → true; `false`, `open` →
|
||||
false. The run goes on either way. `gates_mode = true` adds one pause,
|
||||
|
|
|
|||
|
|
@ -78,11 +78,11 @@ Parse `$ARGUMENTS` for mode flags:
|
|||
once to map the value, bare when there is no value:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state idle --event start --gates [value]
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --gates [value]
|
||||
```
|
||||
|
||||
`next_state: gates_on` sets `gates_mode = true`, `auto_running` sets it
|
||||
false, and nothing else reads the state. On exit 1 or no JSON (a broken
|
||||
`gates: true` sets `gates_mode = true`, `false` sets it false.
|
||||
On exit 1 or no JSON (a broken
|
||||
shim; the four values cannot cause it), print the error in one line and
|
||||
map in prose instead: bare, `true`, `closed` → true; `false`, `open` →
|
||||
false. The run goes on either way. Default `gates_mode = false`.
|
||||
|
|
|
|||
|
|
@ -130,11 +130,11 @@ Parse `$ARGUMENTS` for mode flags. Order of precedence:
|
|||
once to map the value, bare when there is no value:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state idle --event start --gates [value]
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --gates [value]
|
||||
```
|
||||
|
||||
`next_state: gates_on` sets `gates_mode = true`, `auto_running` sets it
|
||||
false, and nothing else reads the state. On exit 1 or no JSON (a broken
|
||||
`gates: true` sets `gates_mode = true`, `false` sets it false.
|
||||
On exit 1 or no JSON (a broken
|
||||
shim; the four values cannot cause it), print the error in one line and
|
||||
map in prose instead: bare, `true`, `closed` → true; `false`, `open` →
|
||||
false. The run goes on either way. With `gates_mode = true`, pause for
|
||||
|
|
|
|||
|
|
@ -78,11 +78,11 @@ Supported flags:
|
|||
once to map the value, bare when there is no value:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --state idle --event start --gates [value]
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --gates [value]
|
||||
```
|
||||
|
||||
`next_state: gates_on` sets `gates_mode = true`, `auto_running` sets it
|
||||
false, and nothing else reads the state. On exit 1 or no JSON (a broken
|
||||
`gates: true` sets `gates_mode = true`, `false` sets it false.
|
||||
On exit 1 or no JSON (a broken
|
||||
shim; the four values cannot cause it), print the error in one line and
|
||||
map in prose instead: bare, `true`, `closed` → true; `false`, `open` →
|
||||
false. The run goes on either way. With `gates_mode = true`, the
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ Imported from `CLAUDE.md` via pointer.
|
|||
|
||||
`lib/` contains zero-dep validators, parsers, and autonomy primitives wired into the four commands:
|
||||
|
||||
- `lib/util/{frontmatter,result,atomic-write,autonomy-gate}.mjs` — shared YAML-frontmatter parser + Result helpers + `atomicWriteJson(path, obj)` for tmp+rename writes + autonomy-gate state machine (v3.4.0)
|
||||
- `lib/util/{frontmatter,result,atomic-write,autonomy-gate}.mjs` — shared YAML-frontmatter parser + Result helpers + `atomicWriteJson(path, obj)` for tmp+rename writes + `--gates` flag parser (v3.4.0)
|
||||
- `lib/parsers/{plan-schema,manifest-yaml,project-discovery,arg-parser,bash-normalize,jaccard,finding-id}.mjs` — pure parsers (no I/O), unit-tested. `manifest-yaml` extended in v3.4.0 with additive `skip_commit_check` + `memory_write` flags (forward-compat: unknown keys ignored)
|
||||
- `lib/review/{rule-catalogue,plan-review-dedup}.mjs` — version-pinned rule catalogue (17 keys) + Phase 9 inline dedup helpers (v3.4.0)
|
||||
- `lib/review/{verdict-gate,engine-findings,recurrence}.mjs` — the review gate (K7: 0 ALLOW, or WARN with every counting MAJOR answered · 1 BLOCK · 2 never green), Claude Code `/code-review` + `/security-review` findings → reviewer payload, recurring finding classes → operator-approved project-memory lines (review_version 1.2); `lib/review/fill-finding-ids.mjs` computes the coordinator's `@@ID_<n>@@` placeholders over `(file, line, rule_key)`; `lib/validators/brief-review-stamp.mjs` lets `/trekplan` skip a brief review `/trekbrief` already ran on the same text
|
||||
- `lib/stats/event-emit.mjs` — single-source stats event emitter for autonomy-gate transitions and main-merge-gate (v3.4.0)
|
||||
- `lib/stats/event-emit.mjs` — single-source stats event emitter for named lifecycle events (`brief-approved`, the main-merge gate and its answer, `user_input`) (v3.4.0)
|
||||
- `lib/validators/{brief,research,plan,progress,session-state}-validator.mjs` — schema validators with CLI shims (`node lib/validators/X.mjs --json <path>`)
|
||||
- `lib/validators/architecture-discovery.mjs` — drift-WARN external-contract discovery for `architecture/overview.md`
|
||||
- `lib/util/research-loop-cap.mjs` — stateful, **default-off** turn budget for the `/trekresearch` bounded conversation loop. `allowTurn()` derives the used-turn count from its own append-only JSONL ledger; it never asks the caller how many turns it has spent, because a cap that does is not a cap. Each grant first claims a turn **slot** with `O_EXCL` under `trekresearch-loop-claims/`, so the bound survives several callers deciding at once — counting the ledger and then appending is read-then-write, and Phase 4.5/5 can spawn several agents in one message. Budget = `TREKRESEARCH_MAX_CONV_TURNS` (default `3`, invalid values fall back to `3`) × `maxDimensions` (8, `settings.json:16`). **Default-off:** grants 0 unless `VOYAGE_STORM_ENABLED=1`, which is also the second condition on Phase 4.5's skip-guard (that phase does not call this module): unset, **both** STORM phases are inert. `resolveDataRoot()` is the single root for everything the loop writes — `CLAUDE_PLUGIN_DATA` when the harness sets it, `~/.claude/voyage` when it does not (it is empty in the Bash tool's process env, which is where the loop actually runs); the cap hook resolves through the same function, so writer and reader cannot disagree. A ledger that cannot be **written** denies the turn, and one that exists but cannot be **read** denies it too — only `ENOENT` counts as zero turns spent, that being the legitimate first-turn state (fail-closed — the opposite of `event-emit.mjs`, which is telemetry and must never block). The exported `readLedger()` is the single counting rule; the cap hook calls it rather than keeping a private copy. `checkDimensionCeiling()` is the reader for the OTHER axis of the bounded-cost NFR — the size of the whole dimension list after Phase 4.5 discovery — against the same `MAX_TOTAL_DIMENSIONS`, so the two axes cannot enforce different numbers for one `settings.json:16` value; an unreadable count is rejected, not waved through. CLI shim, two modes: `--run-id ID --dimension D --effort E` (budget gate) and `--check-dimensions N` (ceiling, no run id/effort/flag required since Phase 4.5 never calls the budget gate)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Imported from `CLAUDE.md` via pointer.
|
|||
|
||||
All four pipeline commands accept `--gates [true|false|closed|open]`. A bare `--gates`, `--gates true` or `--gates closed` turns gating **on**; `--gates false`, `--gates open` or no flag (the default) leaves it off. The next token is the flag's value only when it is one of those four; any other token stays the command's own argument, so `/trekplan --gates --project <dir>` and `/trekexecute --gates plan.md` are a bare `--gates`. Each command maps the value once through `lib/util/autonomy-gate.mjs`; a shim that fails falls back to the same mapping in prose, and the run goes on. With gating on, `/trekbrief` pauses before its auto chain, `/trekresearch` after each topic, and `/trekplan` after exploration, synthesis and adversarial review. `/trekexecute` accepts the flag and pauses on nothing: its one stop is the merge into main, which always asks (below).
|
||||
|
||||
Under the hood: `lib/util/autonomy-gate.mjs` runs the state machine `idle → gates_on | auto_running → paused_for_gate → completed`. `start` routes to `gates_on` when `--gates` is bare, `true` or `closed`, else `auto_running`; a `phase_boundary` from `gates_on` pauses at `paused_for_gate` (awaiting `resume`); `finish` reaches the terminal `completed`. The module is pure data with no I/O; the commands run only its `start` transition, to map the flag. Separately, `lib/stats/event-emit.mjs` records named lifecycle events (`brief-approved`, `main-merge-gate`, `main-merge-approved`, `main-merge-declined`, `user_input`) to `trekexecute-stats.jsonl` in the plugin data directory (`CLAUDE_PLUGIN_DATA`, else `~/.claude/plugins/data/voyage-ktg-plugin-marketplace/`, the directory `scripts/yardstick.mjs` reads); a write that fails is reported, never skipped. The main-merge gate is the final autonomy boundary before HEAD lands on `main`.
|
||||
Under the hood: `lib/util/autonomy-gate.mjs` is the `--gates` flag parser. It prints `{"ok":true,"gates":true}` when `--gates` is bare, `true` or `closed`, `{"ok":true,"gates":false}` for `false`, `open` or no flag, and a usage error (exit 1) for anything else. The module is pure data with no I/O. Separately, `lib/stats/event-emit.mjs` records named lifecycle events (`brief-approved`, `main-merge-gate`, `main-merge-approved`, `main-merge-declined`, `user_input`) to `trekexecute-stats.jsonl` in the plugin data directory (`CLAUDE_PLUGIN_DATA`, else `~/.claude/plugins/data/voyage-ktg-plugin-marketplace/`, the directory `scripts/yardstick.mjs` reads); a write that fails is reported, never skipped. The main-merge gate is the final autonomy boundary before HEAD lands on `main`.
|
||||
|
||||
**The main-merge gate is a lock, and the guard asks the one question** (operator decision 2026-10-08). The gate opens only when the run is `completed` and the review's verdict gate (`lib/review/verdict-gate.mjs`, K7) exits 0; BLOCK (1) and "could not decide" (2) keep it closed. When it opens, `/trekexecute` runs the merge into main itself and `pre-bash-executor` answers "ask" before it runs, in auto mode too, so that question is the only one. In the permission mode where Claude Code shows no prompts, the guard refuses the merge instead, and every other call it would ask about (push, tag or publish, destructive git), since no one can be asked (operator decisions 2026-10-09). The executor no longer asks "ready to merge?" first, and `--gates` no longer adds a pause before the wave merge-back: the guard asks there as well, once per session, because the push and the merge go in one call. Before this, one merge cost two questions in three places: the `--gates` pause before the wave merge-back, the gate's own question after the waves were merged, and the gate's question on the single-session path followed by the guard's at the merge itself.
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ By stage — Plan 0/1/0 · Design 0/0/1 · Build 2/3/1 · Test 0/2/0 · Deploy 0
|
|||
| P1 | Capture intent as a committed artifact (Plan) | partial | `commands/trekbrief.md` Phase 2.5 + `## Intent`; `lib/validators/brief-validator.mjs` | The brief requires an `## Intent` section and a `framing:` declaration with no skip path, enforced as a BLOCKER — but the artifact lands under `.claude/projects/` (gitignored), the pipeline never commits it, and no approval marker exists |
|
||||
| P2 | Requirements and design as a separate spec (Design) | none | — | No artifact between brief and plan; none of the 7 handovers in `docs/HANDOVER-CONTRACTS.md` is a spec, and no organisational policy is applied while writing |
|
||||
| P3 | Plan mode as the default start (Build) | partial | `commands/trekplan.md` Phase 9; `commands/trekexecute.md` Phase 2 (`plan-validator.mjs --strict`) | The `plan_version 1.7` manifest gate is real code that stops the run — but plan-critic is prose ("if blockers are found: revise the plan"), the plan is never committed by the pipeline, and execution starts on the word `execute` with no approved-plan marker |
|
||||
| P4 | Auto mode and longer autonomous sessions (Build) | full | `lib/util/autonomy-gate.mjs`; `commands/trekexecute.md` (waves, `--resume`) | Auto mode, headless worktree sessions and after-the-fact artifact review all exist. Note: `--gates` is a boolean, not a three-way mode |
|
||||
| P4 | Auto mode and longer autonomous sessions (Build) | full | `lib/util/autonomy-gate.mjs` (`--gates` flag parser); `commands/trekexecute.md` (waves, `--resume`) | Auto mode, headless worktree sessions and after-the-fact artifact review all exist. Note: `--gates` is a boolean, not a three-way mode |
|
||||
| P5 | `CLAUDE.md` as institutional memory (Build) | partial | `agents/convention-scanner.md`; `agents/brief-reviewer.md` dim. 6 | Conventions and operator memory are read; nothing in the pipeline ever writes to a memory file in any repo, so the "wrong twice → into the file" loop is absent |
|
||||
| P6 | Skills as institutional knowledge (Build) | none | — | `find . -iname 'SKILL.md'` and `find . -type d -name skills` are both empty; the plugin ships 7 commands and 23 agent files and zero skills |
|
||||
| P7 | Hooks as build-time guardrails (Build) | partial | `hooks/hooks.json` (7 wirings/6 events); `hooks/scripts/pre-write-executor.mjs`, `pre-bash-executor.mjs` | Path protection and credential blocks exist as PreToolUse hooks; there is no PostToolUse Edit/Write hook at all, so "formatter/linter after edit" is missing |
|
||||
|
|
@ -31,7 +31,7 @@ By stage — Plan 0/1/0 · Design 0/0/1 · Build 2/3/1 · Test 0/2/0 · Deploy 0
|
|||
| P9 | Give the agent a feedback loop (Test) | partial | `lib/verification/criteria-runner.mjs`; `commands/trekexecute.md` Phase 7 / 7.5 / 7.6 | Exit-code-as-truth and an independent fresh-context manifest audit are real code — but "failing test first, protected from rewriting" is explicitly off: `Test first:` is optional and the file says "do not block on test-first failures" |
|
||||
| P10 | Continuous evals in CI (Test) | partial | `tests/lib/doc-consistency.test.mjs`, `tests/lib/agent-frontmatter.test.mjs`; `tests/lib/gold-eval.test.mjs` + `lib/review/gold-scorer.mjs` | A suite does regression-test the agent configuration (agent count, frontmatter, model pin, command table, hook count — derived from the source, so a config change fells it), and a scored gold eval exists. What is missing: any CI at all (`.forgejo/workflows` and `.github` both absent), automatic triggering on config change, a merge gate on pass rate, and any live-agent eval |
|
||||
| P11 | AI in the PR review loop (Deploy) | partial | `commands/trekreview.md`; `lib/review/rule-catalogue.mjs`; `lib/review/coordinator-contract.mjs` | Independent reviewers, a judge, a versioned rule catalogue and a four-level severity taxonomy are strongly implemented — but the input is a SHA-range diff, not a PR: no forge integration, no code-owner approval, and the verdict blocks nothing |
|
||||
| P12 | Hooks as approval gates (Deploy) | partial | `lib/util/autonomy-gate.mjs`; `commands/trekexecute.md` main-merge gate | Human gates exist and the main-merge gate always pauses — but they are `AskUserQuestion` prose in command files; no hook returns an "ask" decision, and the hooks are binary (exit 0 / exit 2) |
|
||||
| P12 | Hooks as approval gates (Deploy) | partial | `hooks/scripts/pre-bash-executor.mjs` (asks before merge into main and push); `commands/trekexecute.md` main-merge gate | Human gates exist and the main-merge gate always pauses — but they are `AskUserQuestion` prose in command files; no hook returns an "ask" decision, and the hooks are binary (exit 0 / exit 2) |
|
||||
| P13 | CI/CD integration and deployment (Deploy) | none | — | No CI workflows, no deploy/status/rollback tooling, no per-environment autonomy ladder |
|
||||
| P14 | Close the loop from maintenance (Maintain) | none | `hooks/scripts/otel-export.mjs`, `lib/exporters/`, `lib/stats/` | Observability exists but measures Voyage's own runs, not a product in production; no control bands, and the one feedback path (review → plan) is started by a human |
|
||||
| P15 | Recurring codebase scans (Maintain) | none | `hooks/scripts/pre-bash-executor.mjs` | No scheduler and no periodic entry point; the only `cron` hits are the rule that blocks crontab persistence |
|
||||
|
|
|
|||
|
|
@ -1,160 +1,71 @@
|
|||
// lib/util/autonomy-gate.mjs
|
||||
// Autonomy-gate state machine for /trekexecute + /trekplan
|
||||
// (plan-v2 Step 4 — drives the --gates flag).
|
||||
// The --gates flag parser for /trekbrief, /trekresearch, /trekplan and
|
||||
// /trekexecute: maps the flag's value to gates on or off.
|
||||
//
|
||||
// States:
|
||||
// idle — not yet started
|
||||
// gates_on — gates enabled, between phases
|
||||
// auto_running — running phases continuously without pausing
|
||||
// paused_for_gate — stopped at a phase boundary; awaiting `resume`
|
||||
// completed — terminal
|
||||
// CLI:
|
||||
// node lib/util/autonomy-gate.mjs [--gates [true|false|closed|open]]
|
||||
// (bare --gates = on; true|closed = on, false|open = off; no flag = off.
|
||||
// Any other value is a usage error, exit 1; case-sensitive. One token
|
||||
// --gates=<value> is read as --gates <value>; an empty value is the same
|
||||
// usage error. Any other argument is a usage error.)
|
||||
// → JSON: { ok: true, gates: true|false } (exit 0)
|
||||
// → JSON: { ok: false, error: "usage: ..." } (exit 1)
|
||||
//
|
||||
// Events:
|
||||
// start — begin a run (gates flag chooses route)
|
||||
// phase_boundary — a phase finished
|
||||
// resume — operator confirmed; leave the gate
|
||||
// finish — pipeline reached its end
|
||||
//
|
||||
// CLI shim:
|
||||
// node lib/util/autonomy-gate.mjs --state X --event Y [--gates [true|false|closed|open]]
|
||||
// (bare --gates = on; true|closed = on, false|open = off; any other value
|
||||
// is a usage error, exit 1; case-sensitive. One token --gates=<value> is
|
||||
// read as --gates <value>; an empty value is the same usage error)
|
||||
// → JSON: { ok: true, next_state: "..." } (success)
|
||||
// → JSON: { ok: false, error: "..." } (invalid transition; exit 1)
|
||||
//
|
||||
// Pure data; no I/O. Re-entry to `completed` is idempotent.
|
||||
// Pure data; no I/O beyond the CLI's stdout.
|
||||
|
||||
import { isMain } from './is-main.mjs';
|
||||
|
||||
export const STATES = Object.freeze({
|
||||
IDLE: 'idle',
|
||||
GATES_ON: 'gates_on',
|
||||
AUTO_RUNNING: 'auto_running',
|
||||
PAUSED_FOR_GATE: 'paused_for_gate',
|
||||
COMPLETED: 'completed',
|
||||
});
|
||||
|
||||
export const EVENTS = Object.freeze({
|
||||
START: 'start',
|
||||
PHASE_BOUNDARY: 'phase_boundary',
|
||||
RESUME: 'resume',
|
||||
FINISH: 'finish',
|
||||
});
|
||||
|
||||
const STATE_SET = new Set(Object.values(STATES));
|
||||
const EVENT_SET = new Set(Object.values(EVENTS));
|
||||
|
||||
/**
|
||||
* Compute the next state given the current state, event, and (optional)
|
||||
* gates-flag intent (only consulted on `start` from `idle`).
|
||||
* A --gates value: true|closed = on, false|open = off, else undefined.
|
||||
*
|
||||
* @param {string} state
|
||||
* @param {string} event
|
||||
* @param {{ gates?: boolean }} [opts]
|
||||
* @returns {{ ok: true, next_state: string } | { ok: false, error: string }}
|
||||
* @param {string|undefined} v
|
||||
* @returns {boolean|undefined}
|
||||
*/
|
||||
export function transition(state, event, opts = {}) {
|
||||
if (!STATE_SET.has(state)) {
|
||||
return { ok: false, error: `unknown state: ${state}` };
|
||||
}
|
||||
if (!EVENT_SET.has(event)) {
|
||||
return { ok: false, error: `unknown event: ${event}` };
|
||||
}
|
||||
|
||||
// completed is terminal & idempotent
|
||||
if (state === STATES.COMPLETED) {
|
||||
return { ok: true, next_state: STATES.COMPLETED };
|
||||
}
|
||||
|
||||
if (state === STATES.IDLE) {
|
||||
if (event === EVENTS.START) {
|
||||
const gates = opts.gates === true;
|
||||
return { ok: true, next_state: gates ? STATES.GATES_ON : STATES.AUTO_RUNNING };
|
||||
}
|
||||
return { ok: false, error: `invalid transition: idle + ${event} (only \`start\` allowed from idle)` };
|
||||
}
|
||||
|
||||
if (state === STATES.GATES_ON) {
|
||||
if (event === EVENTS.PHASE_BOUNDARY) return { ok: true, next_state: STATES.PAUSED_FOR_GATE };
|
||||
if (event === EVENTS.FINISH) return { ok: true, next_state: STATES.COMPLETED };
|
||||
return { ok: false, error: `invalid transition: gates_on + ${event}` };
|
||||
}
|
||||
|
||||
if (state === STATES.AUTO_RUNNING) {
|
||||
if (event === EVENTS.PHASE_BOUNDARY) return { ok: true, next_state: STATES.AUTO_RUNNING };
|
||||
if (event === EVENTS.FINISH) return { ok: true, next_state: STATES.COMPLETED };
|
||||
return { ok: false, error: `invalid transition: auto_running + ${event}` };
|
||||
}
|
||||
|
||||
if (state === STATES.PAUSED_FOR_GATE) {
|
||||
if (event === EVENTS.RESUME) return { ok: true, next_state: STATES.GATES_ON };
|
||||
if (event === EVENTS.FINISH) return { ok: true, next_state: STATES.COMPLETED };
|
||||
return { ok: false, error: `invalid transition: paused_for_gate + ${event}` };
|
||||
}
|
||||
|
||||
return { ok: false, error: `unhandled state: ${state}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: is this state terminal?
|
||||
*/
|
||||
export function isTerminal(state) {
|
||||
return state === STATES.COMPLETED;
|
||||
}
|
||||
|
||||
// ---- CLI shim ----------------------------------------------------------------
|
||||
|
||||
/** A --gates value: true|closed = on, false|open = off, else undefined. */
|
||||
function gatesValue(v) {
|
||||
export function gatesValue(v) {
|
||||
if (v === 'true' || v === 'closed') return true;
|
||||
if (v === 'false' || v === 'open') return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const GATES_USAGE = 'usage: --gates takes true, false, closed or open';
|
||||
const USAGE = 'usage: autonomy-gate.mjs [--gates [true|false|closed|open]]';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
const setGates = (v) => {
|
||||
const g = gatesValue(v);
|
||||
if (g === undefined) out.error = GATES_USAGE;
|
||||
else out.gates = g;
|
||||
};
|
||||
/**
|
||||
* Parse the CLI arguments.
|
||||
*
|
||||
* @param {string[]} argv
|
||||
* @returns {{ ok: true, gates: boolean } | { ok: false, error: string }}
|
||||
*/
|
||||
export function parseGatesArgs(argv) {
|
||||
let gates = false;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--state') out.state = argv[++i];
|
||||
else if (a === '--event') out.event = argv[++i];
|
||||
else if (a.startsWith('--gates=')) {
|
||||
let v;
|
||||
if (a.startsWith('--gates=')) {
|
||||
// The one-token form, read as `--gates <value>`; an empty value is no value.
|
||||
setGates(a.slice('--gates='.length));
|
||||
v = a.slice('--gates='.length);
|
||||
} else if (a === '--gates') {
|
||||
// Bare `--gates` (last, or followed by another flag) means on and
|
||||
// consumes nothing. A value is case-sensitive: true|closed = on,
|
||||
// false|open = off; anything else is a usage error.
|
||||
const v = argv[i + 1];
|
||||
if (v === undefined || v.startsWith('--')) {
|
||||
out.gates = true;
|
||||
} else {
|
||||
setGates(v);
|
||||
i++;
|
||||
// consumes nothing.
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined || next.startsWith('--')) {
|
||||
gates = true;
|
||||
continue;
|
||||
}
|
||||
v = next;
|
||||
i++;
|
||||
} else {
|
||||
return { ok: false, error: USAGE };
|
||||
}
|
||||
const g = gatesValue(v);
|
||||
if (g === undefined) return { ok: false, error: GATES_USAGE };
|
||||
gates = g;
|
||||
}
|
||||
return out;
|
||||
return { ok: true, gates };
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.error || !args.state || !args.event) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
ok: false,
|
||||
error: args.error
|
||||
|| 'usage: autonomy-gate.mjs --state <state> --event <event> [--gates [true|false|closed|open]]',
|
||||
}) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
const result = transition(args.state, args.event, { gates: args.gates });
|
||||
const result = parseGatesArgs(process.argv.slice(2));
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
// tests/lib/autonomy-gate.test.mjs
|
||||
// Cover the autonomy-gate state machine (lib/util/autonomy-gate.mjs):
|
||||
// every legal transition + every invalid-transition error + idempotent
|
||||
// re-entry to `completed` + CLI-shim JSON-on-stdout contract.
|
||||
// Cover the --gates flag parser (lib/util/autonomy-gate.mjs): the exported
|
||||
// parser function, the CLI's JSON-on-stdout contract, and the one CLI line
|
||||
// each of the four commands runs.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { transition, isTerminal, STATES, EVENTS } from '../../lib/util/autonomy-gate.mjs';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SHIM = join(HERE, '..', '..', 'lib', 'util', 'autonomy-gate.mjs');
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const SHIM = join(ROOT, 'lib', 'util', 'autonomy-gate.mjs');
|
||||
const GATES_USAGE = 'usage: --gates takes true, false, closed or open';
|
||||
|
||||
function runShim(args) {
|
||||
try {
|
||||
|
|
@ -26,200 +27,99 @@ function runShim(args) {
|
|||
}
|
||||
}
|
||||
|
||||
test('idle + start + gates=true → gates_on', () => {
|
||||
const r = transition(STATES.IDLE, EVENTS.START, { gates: true });
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.next_state, STATES.GATES_ON);
|
||||
});
|
||||
|
||||
test('idle + start + gates=false → auto_running', () => {
|
||||
const r = transition(STATES.IDLE, EVENTS.START, { gates: false });
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.next_state, STATES.AUTO_RUNNING);
|
||||
});
|
||||
|
||||
test('idle + start + gates omitted defaults to auto_running', () => {
|
||||
const r = transition(STATES.IDLE, EVENTS.START);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.next_state, STATES.AUTO_RUNNING);
|
||||
});
|
||||
|
||||
test('gates_on + phase_boundary → paused_for_gate', () => {
|
||||
const r = transition(STATES.GATES_ON, EVENTS.PHASE_BOUNDARY);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.next_state, STATES.PAUSED_FOR_GATE);
|
||||
});
|
||||
|
||||
test('gates_on + finish → completed', () => {
|
||||
const r = transition(STATES.GATES_ON, EVENTS.FINISH);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.next_state, STATES.COMPLETED);
|
||||
});
|
||||
|
||||
test('auto_running + phase_boundary → auto_running (no pause)', () => {
|
||||
const r = transition(STATES.AUTO_RUNNING, EVENTS.PHASE_BOUNDARY);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.next_state, STATES.AUTO_RUNNING);
|
||||
});
|
||||
|
||||
test('auto_running + finish → completed', () => {
|
||||
const r = transition(STATES.AUTO_RUNNING, EVENTS.FINISH);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.next_state, STATES.COMPLETED);
|
||||
});
|
||||
|
||||
test('paused_for_gate + resume → gates_on', () => {
|
||||
const r = transition(STATES.PAUSED_FOR_GATE, EVENTS.RESUME);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.next_state, STATES.GATES_ON);
|
||||
});
|
||||
|
||||
test('paused_for_gate + finish → completed', () => {
|
||||
const r = transition(STATES.PAUSED_FOR_GATE, EVENTS.FINISH);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.next_state, STATES.COMPLETED);
|
||||
});
|
||||
|
||||
test('completed + any event → completed (idempotent re-entry)', () => {
|
||||
for (const ev of Object.values(EVENTS)) {
|
||||
const r = transition(STATES.COMPLETED, ev);
|
||||
assert.equal(r.ok, true, `event ${ev} should be tolerated from completed`);
|
||||
assert.equal(r.next_state, STATES.COMPLETED, `event ${ev} broke idempotency`);
|
||||
}
|
||||
});
|
||||
|
||||
test('idle + non-start event → invalid transition error', () => {
|
||||
const r = transition(STATES.IDLE, EVENTS.PHASE_BOUNDARY);
|
||||
assert.equal(r.ok, false);
|
||||
assert.match(r.error, /invalid transition.*idle/);
|
||||
});
|
||||
|
||||
test('gates_on + resume → invalid (resume is only valid from paused_for_gate)', () => {
|
||||
const r = transition(STATES.GATES_ON, EVENTS.RESUME);
|
||||
assert.equal(r.ok, false);
|
||||
});
|
||||
|
||||
test('auto_running + resume → invalid (auto-mode never pauses)', () => {
|
||||
const r = transition(STATES.AUTO_RUNNING, EVENTS.RESUME);
|
||||
assert.equal(r.ok, false);
|
||||
});
|
||||
|
||||
test('unknown state rejected with descriptive error', () => {
|
||||
const r = transition('zombie', EVENTS.START);
|
||||
assert.equal(r.ok, false);
|
||||
assert.match(r.error, /unknown state/);
|
||||
});
|
||||
|
||||
test('unknown event rejected with descriptive error', () => {
|
||||
const r = transition(STATES.IDLE, 'snooze');
|
||||
assert.equal(r.ok, false);
|
||||
assert.match(r.error, /unknown event/);
|
||||
});
|
||||
|
||||
test('isTerminal — only completed is terminal', () => {
|
||||
assert.equal(isTerminal(STATES.COMPLETED), true);
|
||||
for (const s of [STATES.IDLE, STATES.GATES_ON, STATES.AUTO_RUNNING, STATES.PAUSED_FOR_GATE]) {
|
||||
assert.equal(isTerminal(s), false, `${s} should not be terminal`);
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI shim returns valid JSON on success (exit 0)', () => {
|
||||
const r = runShim(['--state', 'idle', '--event', 'start', '--gates', 'true']);
|
||||
assert.equal(r.code, 0);
|
||||
const parsed = JSON.parse(r.out.trim());
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.next_state, 'gates_on');
|
||||
});
|
||||
|
||||
test('CLI shim returns JSON error on invalid transition (exit 1)', () => {
|
||||
const r = runShim(['--state', 'idle', '--event', 'phase_boundary']);
|
||||
assert.equal(r.code, 1);
|
||||
const parsed = JSON.parse(r.out.trim());
|
||||
assert.equal(parsed.ok, false);
|
||||
assert.match(parsed.error, /invalid transition/);
|
||||
});
|
||||
|
||||
test('CLI shim missing required args returns usage error (exit 1)', () => {
|
||||
const r = runShim(['--state', 'idle']);
|
||||
assert.equal(r.code, 1);
|
||||
const parsed = JSON.parse(r.out.trim());
|
||||
assert.equal(parsed.ok, false);
|
||||
assert.match(parsed.error, /usage:/);
|
||||
});
|
||||
|
||||
// ---- --gates value rule (fix round 4.5a, #1) ---------------------------------
|
||||
// The shim is tested only through its CLI; the exit code is asserted before
|
||||
// any JSON.parse, so a crash or a usage error never reads as a wrong value.
|
||||
|
||||
function shimState(args) {
|
||||
// The exit code is asserted before any JSON.parse, so a crash or a usage
|
||||
// error never reads as a wrong value.
|
||||
function shimGates(args) {
|
||||
const r = runShim(args);
|
||||
assert.equal(r.code, 0, `expected exit 0 for ${JSON.stringify(args)}, got ${r.code}: ${r.out}`);
|
||||
const parsed = JSON.parse(r.out.trim());
|
||||
assert.equal(parsed.ok, true);
|
||||
return parsed.next_state;
|
||||
assert.deepEqual(parsed, { ok: true, gates: parsed.gates });
|
||||
assert.equal(typeof parsed.gates, 'boolean', `gates must be a boolean: ${r.out}`);
|
||||
return parsed.gates;
|
||||
}
|
||||
|
||||
test('[#1] bare --gates as the last argument → gates_on', () => {
|
||||
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates']), 'gates_on');
|
||||
});
|
||||
|
||||
test('[#1] bare --gates before other flags swallows none of them → exit 0, gates_on', () => {
|
||||
assert.equal(shimState(['--gates', '--state', 'idle', '--event', 'start']), 'gates_on');
|
||||
});
|
||||
|
||||
test('[#1] --gates closed → gates_on', () => {
|
||||
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates', 'closed']), 'gates_on');
|
||||
});
|
||||
|
||||
for (const bad of ['TRUE', 'yes', 'adaptive']) {
|
||||
test(`[#1] --gates ${bad} → usage error (exit 1)`, () => {
|
||||
const r = runShim(['--state', 'idle', '--event', 'start', '--gates', bad]);
|
||||
assert.equal(r.code, 1, `expected exit 1 for --gates ${bad}, got ${r.code}: ${r.out}`);
|
||||
const parsed = JSON.parse(r.out.trim());
|
||||
assert.equal(parsed.ok, false);
|
||||
assert.match(parsed.error, /usage/);
|
||||
});
|
||||
function shimUsage(args) {
|
||||
const r = runShim(args);
|
||||
assert.equal(r.code, 1, `expected exit 1 for ${JSON.stringify(args)}, got ${r.code}: ${r.out}`);
|
||||
const parsed = JSON.parse(r.out.trim());
|
||||
assert.equal(parsed.ok, false);
|
||||
assert.equal(Object.keys(parsed).sort().join(','), 'error,ok');
|
||||
return parsed.error;
|
||||
}
|
||||
|
||||
test('--gates true → gates_on', () => {
|
||||
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates', 'true']), 'gates_on');
|
||||
// ---- the exported parser ----------------------------------------------------
|
||||
|
||||
test('gatesValue maps true|closed to on, false|open to off, anything else to undefined', async () => {
|
||||
const mod = await import(pathToFileURL(SHIM).href);
|
||||
assert.equal(typeof mod.gatesValue, 'function', 'autonomy-gate.mjs must export gatesValue');
|
||||
for (const v of ['true', 'closed']) assert.equal(mod.gatesValue(v), true, v);
|
||||
for (const v of ['false', 'open']) assert.equal(mod.gatesValue(v), false, v);
|
||||
for (const v of ['TRUE', 'yes', 'adaptive', '', undefined]) assert.equal(mod.gatesValue(v), undefined, String(v));
|
||||
});
|
||||
|
||||
test('--gates false → auto_running', () => {
|
||||
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates', 'false']), 'auto_running');
|
||||
});
|
||||
|
||||
test('--gates open → auto_running', () => {
|
||||
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates', 'open']), 'auto_running');
|
||||
});
|
||||
|
||||
// The `=` form reaches the shim whole as one token; it is read as `--gates <value>`.
|
||||
test('[#1] sibling: --gates=<value> is read as --gates <value>; an unknown or empty value is the usage error', () => {
|
||||
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates=true']), 'gates_on');
|
||||
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates=closed']), 'gates_on');
|
||||
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates=false']), 'auto_running');
|
||||
assert.equal(shimState(['--state', 'idle', '--event', 'start', '--gates=open']), 'auto_running');
|
||||
for (const bad of ['--gates=maybe', '--gates=']) {
|
||||
const r = runShim(['--state', 'idle', '--event', 'start', bad]);
|
||||
assert.equal(r.code, 1, `expected exit 1 for ${bad}, got ${r.code}: ${r.out}`);
|
||||
const parsed = JSON.parse(r.out.trim());
|
||||
assert.equal(parsed.ok, false);
|
||||
assert.match(parsed.error, /usage: --gates takes true, false, closed or open/);
|
||||
test('the module is the flag parser only: no state machine is exported', async () => {
|
||||
const mod = await import(pathToFileURL(SHIM).href);
|
||||
for (const gone of ['STATES', 'EVENTS', 'transition', 'isTerminal']) {
|
||||
assert.equal(mod[gone], undefined, `autonomy-gate.mjs still exports ${gone}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- the four commands' --gates rule and shim line ----------------------------
|
||||
// Each of the four commands holds one rule: the next token is the flag's value
|
||||
// only when it is true, false, closed or open; the shim maps it, run once; a
|
||||
// broken shim falls back to the same mapping in prose and the run goes on.
|
||||
// ---- the CLI ----------------------------------------------------------------
|
||||
|
||||
test('CLI: bare --gates → {"ok":true,"gates":true}, exit 0', () => {
|
||||
assert.equal(shimGates(['--gates']), true);
|
||||
});
|
||||
|
||||
test('CLI: no flag → {"ok":true,"gates":false}, exit 0', () => {
|
||||
assert.equal(shimGates([]), false);
|
||||
});
|
||||
|
||||
test('CLI: --gates true and --gates closed → gates true', () => {
|
||||
assert.equal(shimGates(['--gates', 'true']), true);
|
||||
assert.equal(shimGates(['--gates', 'closed']), true);
|
||||
});
|
||||
|
||||
test('CLI: --gates false and --gates open → gates false', () => {
|
||||
assert.equal(shimGates(['--gates', 'false']), false);
|
||||
assert.equal(shimGates(['--gates', 'open']), false);
|
||||
});
|
||||
|
||||
test('CLI: the one-token --gates=<value> form is read as --gates <value>', () => {
|
||||
assert.equal(shimGates(['--gates=true']), true);
|
||||
assert.equal(shimGates(['--gates=closed']), true);
|
||||
assert.equal(shimGates(['--gates=false']), false);
|
||||
assert.equal(shimGates(['--gates=open']), false);
|
||||
});
|
||||
|
||||
for (const bad of [['--gates', 'TRUE'], ['--gates', 'yes'], ['--gates', 'adaptive'], ['--gates=maybe'], ['--gates=']]) {
|
||||
test(`CLI: ${bad.join(' ')} → the exact usage error, exit 1`, () => {
|
||||
assert.equal(shimUsage(bad), GATES_USAGE);
|
||||
});
|
||||
}
|
||||
|
||||
for (const old of [
|
||||
['--state', 'idle', '--event', 'start', '--gates', 'true'],
|
||||
['--state', 'idle', '--event', 'start'],
|
||||
['--gates', '--state', 'idle'],
|
||||
['--event', 'start'],
|
||||
]) {
|
||||
test(`CLI: the old state-machine arguments are a usage error (${old.join(' ')}), exit 1`, () => {
|
||||
assert.match(shimUsage(old), /^usage:/);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the four commands' --gates rule and CLI line ----------------------------
|
||||
// Each of the four commands holds one rule: the next token is the flag's value
|
||||
// only when it is true, false, closed or open; the CLI maps it, run once; a
|
||||
// broken CLI falls back to the same mapping in prose and the run goes on.
|
||||
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const GATES_COMMANDS = ['trekbrief', 'trekresearch', 'trekplan', 'trekexecute'];
|
||||
const GATES_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/util/autonomy-gate.mjs --gates [value]';
|
||||
|
||||
const GATES_RULE = [
|
||||
'`--gates` takes the next token as its value only when it is `true`, `false`, `closed` or `open` (strip both); any other next token is not its value (strip only the flag).',
|
||||
'Run the shim once to map the value, bare when there is no value:',
|
||||
'`next_state: gates_on` sets `gates_mode = true`, `auto_running` sets it false, and nothing else reads the state.',
|
||||
'`gates: true` sets `gates_mode = true`, `false` sets it false.',
|
||||
'On exit 1 or no JSON (a broken shim; the four values cannot cause it), print the error in one line and map in prose instead: bare, `true`, `closed` → true; `false`, `open` → false. The run goes on either way.',
|
||||
];
|
||||
|
||||
|
|
@ -228,44 +128,46 @@ const commandText = (cmd) => readFileSync(join(ROOT, 'commands', `${cmd}.md`), '
|
|||
/** Whitespace (line breaks too) collapsed to one space. */
|
||||
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
||||
|
||||
/** The command's one shim line, `[value]` filled in with `value` (left out when ''). */
|
||||
function gatesShim(cmd, value) {
|
||||
/** The command's one CLI line, `[value]` filled in with `value` (left out when ''). */
|
||||
function gatesLine(cmd, value) {
|
||||
const hits = commandText(cmd).split('\n')
|
||||
.filter((l) => /^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/util\/autonomy-gate\.mjs\b/.test(l) && l.includes('--event start'));
|
||||
assert.equal(hits.length, 1, `${cmd}: expected exactly one autonomy-gate.mjs --event start line; found ${hits.length}`);
|
||||
.filter((l) => /^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/util\/autonomy-gate\.mjs\b/.test(l));
|
||||
assert.equal(hits.length, 1, `${cmd}: expected exactly one autonomy-gate.mjs line; found ${hits.length}`);
|
||||
const l = hits[0].trim();
|
||||
assert.match(l, / --state idle --event start --gates \[value\]$/, `${cmd}: the shim line must end "--gates [value]"`);
|
||||
assert.equal(l, GATES_LINE, `${cmd}: the line must read "${GATES_LINE}"`);
|
||||
return l.replace(' [value]', value ? ` ${value}` : '');
|
||||
}
|
||||
|
||||
const shimCache = new Map();
|
||||
const lineCache = new Map();
|
||||
|
||||
/** Run a shim line from the prose with the plugin root filled in; its `next_state`. Each distinct line runs once. */
|
||||
function shimNext(cmdLine) {
|
||||
if (!shimCache.has(cmdLine)) {
|
||||
/** Run a CLI line from the prose with the plugin root filled in; its `gates`. Each distinct line runs once. */
|
||||
function lineGates(cmdLine) {
|
||||
if (!lineCache.has(cmdLine)) {
|
||||
const r = spawnSync('bash', ['-c', cmdLine.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT)], { cwd: ROOT, encoding: 'utf8' });
|
||||
assert.equal(r.status, 0, `shim exited ${r.status}: ${r.stdout}${r.stderr}`);
|
||||
shimCache.set(cmdLine, JSON.parse(r.stdout).next_state);
|
||||
assert.equal(r.status, 0, `CLI exited ${r.status}: ${r.stdout}${r.stderr}`);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
assert.equal(parsed.ok, true);
|
||||
lineCache.set(cmdLine, parsed.gates);
|
||||
}
|
||||
return shimCache.get(cmdLine);
|
||||
return lineCache.get(cmdLine);
|
||||
}
|
||||
|
||||
for (const cmd of GATES_COMMANDS) {
|
||||
test(`[#1] commands/${cmd}.md holds the shared --gates rule and a shim line that maps closed to gates_on`, () => {
|
||||
test(`[#1] commands/${cmd}.md holds the shared --gates rule and a CLI line that maps closed to gates true`, () => {
|
||||
const text = commandText(cmd);
|
||||
const f = flat(text);
|
||||
for (const s of GATES_RULE) assert.ok(f.includes(s), `${cmd}: missing the shared --gates sentence: ${s}`);
|
||||
assert.doesNotMatch(text, /--state X --event Y/, `${cmd}: the placeholder shim line is still there`);
|
||||
assert.doesNotMatch(f, /consumed by the autonomy-gate/, `${cmd}: still says the flag is consumed by the state machine`);
|
||||
assert.equal(shimNext(gatesShim(cmd, 'closed')), 'gates_on');
|
||||
assert.doesNotMatch(text, /--state \w+ --event \w+/, `${cmd}: the state-machine line is still there`);
|
||||
assert.doesNotMatch(f, /next_state|auto_running|gates_on/, `${cmd}: still reads a state of the old state machine`);
|
||||
assert.equal(lineGates(gatesLine(cmd, 'closed')), true);
|
||||
});
|
||||
}
|
||||
|
||||
// `/trekplan --gates --project d` and `/trekexecute --gates plan.md` are a bare
|
||||
// --gates (the next token is not a value), so the shim runs bare.
|
||||
test('every command\'s shim line maps a bare --gates to gates_on and open to auto_running', () => {
|
||||
// --gates (the next token is not a value), so the line runs bare.
|
||||
test('every command\'s CLI line maps a bare --gates to true and open to false', () => {
|
||||
for (const cmd of GATES_COMMANDS) {
|
||||
assert.equal(shimNext(gatesShim(cmd, '')), 'gates_on', `${cmd}: bare --gates`);
|
||||
assert.equal(shimNext(gatesShim(cmd, 'open')), 'auto_running', `${cmd}: --gates open`);
|
||||
assert.equal(lineGates(gatesLine(cmd, '')), true, `${cmd}: bare --gates`);
|
||||
assert.equal(lineGates(gatesLine(cmd, 'open')), false, `${cmd}: --gates open`);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import { join, dirname } from 'node:path';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDocument } from '../../lib/util/frontmatter.mjs';
|
||||
import { resolveProfile, loadProfile } from '../../lib/profiles/resolver.mjs';
|
||||
import { STATES } from '../../lib/util/autonomy-gate.mjs';
|
||||
import { TREKRESEARCH_ALLOWED } from '../../lib/exporters/field-allowlist.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -395,11 +394,11 @@ test('operations.md custom-profile prose matches findProfilePath resolution orde
|
|||
|
||||
// --- S24 — anti-false-claim: operations.md + command-modes.md autonomy-gate truth-pass ---
|
||||
//
|
||||
// autonomy-gate.mjs is a BOOLEAN gate: --gates [true|false|closed|open] → gates_on|auto_running.
|
||||
// State machine: idle → gates_on|auto_running → paused_for_gate → completed.
|
||||
// autonomy-gate.mjs is the --gates flag parser: --gates [true|false|closed|open]
|
||||
// → {"ok":true,"gates":true|false}. It has no state machine (R9).
|
||||
// The old prose (operations.md:15 / command-modes.md gates rows) (1) fabricated a
|
||||
// state machine `idle → approved → executing → merge-pending → main-merged` — only
|
||||
// `idle` exists in autonomy-gate.mjs STATES; (2) claimed event-emit.mjs "records
|
||||
// state machine `idle → approved → executing → merge-pending → main-merged`, and
|
||||
// later named the removed one (gates_on, auto_running, paused_for_gate); (2) claimed event-emit.mjs "records
|
||||
// each transition" — it emits 3 named lifecycle events and is decoupled from the
|
||||
// (pure, no-I/O) autonomy-gate; (3) advertised `--gates {open|closed|adaptive}` —
|
||||
// there is no strictness policy and no `adaptive` value: the flag is boolean, and
|
||||
|
|
@ -409,21 +408,19 @@ test('operations.md + command-modes.md describe the real autonomy-gate, not a fa
|
|||
const ops = read('docs/operations.md');
|
||||
const modes = read('docs/command-modes.md');
|
||||
|
||||
// F1 — fabricated state names (merge-pending/main-merged are unique to the false claim)
|
||||
for (const bad of ['merge-pending', 'main-merged']) {
|
||||
// F1 — state names: fabricated (merge-pending/main-merged) or removed with the state machine (R9)
|
||||
for (const bad of ['merge-pending', 'main-merged', 'gates_on', 'auto_running', 'paused_for_gate']) {
|
||||
assert.ok(
|
||||
!ops.includes(bad),
|
||||
`docs/operations.md: fabricated autonomy state "${bad}" — autonomy-gate.mjs has no such state; fix the prose, not this test`,
|
||||
);
|
||||
}
|
||||
|
||||
// F1 — the real transient states (derived from autonomy-gate.mjs STATES) must be documented
|
||||
for (const real of [STATES.GATES_ON, STATES.AUTO_RUNNING, STATES.PAUSED_FOR_GATE]) {
|
||||
assert.ok(
|
||||
ops.includes(real),
|
||||
`docs/operations.md must name the real autonomy-gate state "${real}" (from autonomy-gate.mjs STATES)`,
|
||||
);
|
||||
}
|
||||
// F1 — the module is documented as what it is: the flag parser
|
||||
assert.ok(
|
||||
ops.includes('`lib/util/autonomy-gate.mjs` is the `--gates` flag parser'),
|
||||
'docs/operations.md must describe lib/util/autonomy-gate.mjs as the --gates flag parser',
|
||||
);
|
||||
|
||||
// F2 — event-emit does NOT record each autonomy-gate transition
|
||||
assert.ok(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// tests/lib/gates-flag-coverage.test.mjs
|
||||
// Step 11 (plan-v2) — pin that all four pipeline commands document the
|
||||
// --gates autonomy-control flag and consume the autonomy-gate state
|
||||
// machine via the lib/util/autonomy-gate.mjs CLI shim.
|
||||
// --gates autonomy-control flag and map its value through the
|
||||
// lib/util/autonomy-gate.mjs flag parser.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
|
|
@ -34,7 +34,7 @@ for (const cmdPath of COMMANDS) {
|
|||
const text = read(cmdPath);
|
||||
assert.ok(
|
||||
text.includes('autonomy-gate.mjs'),
|
||||
`${cmdPath} should reference lib/util/autonomy-gate.mjs as the state-machine implementation`,
|
||||
`${cmdPath} should reference lib/util/autonomy-gate.mjs, the --gates flag parser`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -43,6 +43,6 @@ test('commands/trekexecute.md mentions MAIN_MERGE_GATE', () => {
|
|||
const text = read('commands/trekexecute.md');
|
||||
assert.ok(
|
||||
text.includes('MAIN_MERGE_GATE'),
|
||||
'commands/trekexecute.md should name MAIN_MERGE_GATE — the only boundary that always pauses regardless of --gates',
|
||||
'commands/trekexecute.md should name MAIN_MERGE_GATE — the lock on the review verdict before the merge into main; the guard is what asks before merge and push, whatever --gates says',
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue