create-agent-rig 0.6.0 → 0.6.1

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/templates/agent-os/init/AGENTS.md +4 -2
  5. package/templates/agent-os/init/CLAUDE.md +4 -2
  6. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +42 -10
  7. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +10 -5
  8. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +17 -17
  9. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +2 -1
  10. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +85 -25
  11. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +72 -65
  12. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +11 -1
  13. package/templates/agent-os/universal/.claude/rules/autonomy.md +7 -5
  14. package/templates/agent-os/universal/.claude/rules/invariants.md +14 -16
  15. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -0
  16. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +4 -1
  17. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
  18. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +1 -0
  19. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
  20. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +144 -4
  21. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +1 -0
  22. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +1 -0
  23. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +1 -0
  24. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +247 -50
  25. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +42 -10
  26. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
  27. package/templates/hash-history.json +132 -39
  28. package/templates/release-ledger.json +2 -1
  29. package/templates/skeleton/aws-serverless/gitignore +2 -0
  30. package/templates/skeleton/node-service/gitignore +2 -0
@@ -22,18 +22,25 @@
22
22
  // so plainly where none does — a limits comment nothing checks drifts into
23
23
  // overstatement, which is the direction that gets a reader hurt. ⚠ Those tests live in the GENERATOR this rig came from, not here;
24
24
  // `.claude/rules/invariants.md` ("About the hooks you were given") says the same
25
- // of this hook's own tests, and the moment you edit it they are yours.
25
+ // of this hook's own tests. A manifest-backed generator upgrade remains inherited
26
+ // while `.claude/.rig-manifest.json` matches; once the hash differs, the local test
27
+ // is yours.
26
28
  //
27
- // There are FOUR:
29
+ // There are FIVE:
28
30
  //
29
31
  // - It sees ONE edit fragment, not the resulting file. A credential assembled
30
- // across two edits is not seen — see guard-secret-file.test.ts › "does not
32
+ // across two edits is not seen — see guard-secret-file.test.ts (absent in a generated rig) › "does not
31
33
  // see a credential split across two edits, because it is shown one fragment
32
34
  // at a time". This is the same limit every guard in this directory has,
33
35
  // stated in full in `.claude/rules/invariants.md`, "What the enforcement
34
36
  // actually is — stated exactly".
37
+ // - A `MultiEdit` is capped at 256 fragments and REFUSES before mapping a
38
+ // longer list, so the tail is never silently dropped — see
39
+ // guard-secret-file.test.ts (absent in a generated rig) › "refuses a
40
+ // MultiEdit beyond the fragment cap instead of silently dropping the tail".
35
41
  // - It sees only what the AGENT writes. The `toolName` branch in `main` below
36
- // names the complete surface: `Write`, `Edit`, and `apply_patch`; every other
42
+ // names the complete surface: `Write`, `Edit`, `MultiEdit`, `NotebookEdit`,
43
+ // and `apply_patch`; every other
37
44
  // tool returns before inspection. A human editing the file, or a
38
45
  // `git commit` of something already on disk, never reaches a PreToolUse hook
39
46
  // at all, and no test here can show that: it is a property of the harness,
@@ -51,15 +58,16 @@
51
58
  // `findSecretValues` applies by default so a fail-open guard cannot be made
52
59
  // to hang. A credential past that point is not seen. ⚠ No test here pins
53
60
  // this one: the case is pinned one layer down, on the module, by
54
- // secrets-lib.test.ts › "has a limit even when the caller names none". The CI sweep lifts the
61
+ // secrets-lib.test.ts (absent in a generated rig) › "has a limit even when the caller names none". The CI sweep lifts the
55
62
  // cap; this hook cannot, and that asymmetry is the point.
56
63
  // - It FAILS OPEN on what it cannot understand — see guard-secret-file.test.ts
64
+ // (absent in a generated rig)
57
65
  // › "allows a payload that is not JSON at all" and its neighbours. An
58
66
  // unparseable payload, a missing field, or an internal throw all allow the
59
67
  // edit; a crashed guard that blocks everything gets deleted within the hour.
60
68
  //
61
69
  // ⚠ **An `apply_patch` command that is PRESENT and is not a shape this guard
62
- // reads is the other case, and it now REFUSES** — see codex.test.ts ›
70
+ // reads is the other case, and it now REFUSES** — see codex.test.ts (absent in a generated rig) ›
63
71
  // "refuses, rather than failing open, when apply_patch command is supplied
64
72
  // as %s". The line between them is whether the guard can tell: an absent
65
73
  // field is a payload it does not understand, a container it detects and
@@ -92,40 +100,27 @@ function main() {
92
100
  return 0; // unparseable payload: not ours to judge
93
101
  }
94
102
 
95
- const toolName = input?.tool_name;
96
- if (toolName !== 'Write' && toolName !== 'Edit' && toolName !== 'apply_patch') return 0;
103
+ const editTools = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
104
+ if (!editTools.has(input?.tool_name)) return 0;
97
105
 
98
- if (toolName === 'apply_patch') {
99
- let refused = false;
100
- for (const { filePath, fragment, inspectionRefusal, remedy, appliesToAll } of editFragments(input)) {
101
- if (inspectionRefusal) {
102
- refused = true;
103
- process.stderr.write(
104
- `BLOCKED — cannot safely inspect this edit: ${inspectionRefusal}\n` +
105
- // The remedy has to match the refusal: splitting cannot change a
106
- // container shape, and a fixed line sent the agent into a retry loop
107
- // on the one path it could not retry out of.
108
- `${remedy ?? 'Split it into a smaller patch and retry.'}\n`,
109
- );
110
- continue;
111
- }
112
- if (isCredentialPath(filePath)) {
113
- refused = true;
114
- process.stderr.write(`BLOCKED — "${filePath}" is a credential file, and this repository never carries one.\n${WHERE_CREDENTIALS_BELONG}\n`);
115
- continue;
116
- }
117
- const findings = findSecretValues(fragment);
118
- if (findings.length > 0 || appliesToAll) {
119
- refused = true;
120
- if (findings.length > 0) process.stderr.write(`BLOCKED — this edit writes a credential value into "${filePath}".\n${WHERE_CREDENTIALS_BELONG}\n`);
121
- }
122
- }
123
- return refused ? 2 : 0;
106
+ const fragments = editFragments(input);
107
+ const globalRefusal = fragments.find(
108
+ ({ inspectionRefusal, appliesToAll }) => appliesToAll && inspectionRefusal,
109
+ );
110
+ if (globalRefusal) {
111
+ const fallbackRemedy = input?.tool_name === 'apply_patch'
112
+ ? 'Split it into a smaller patch and retry.'
113
+ : 'Split it into a smaller edit and retry.';
114
+ process.stderr.write(
115
+ `BLOCKED — cannot safely inspect this edit: ${globalRefusal.inspectionRefusal}\n` +
116
+ `${globalRefusal.remedy ?? fallbackRemedy}\n`,
117
+ );
118
+ return 2;
124
119
  }
125
120
 
126
- const toolInput = input?.tool_input ?? {};
127
- const filePath = String(toolInput.file_path ?? '').replaceAll('\\', '/');
128
- if (filePath === '') return 0; // nothing to judge; fail open
121
+ if (fragments.length === 0 || fragments.every(({ filePath }) => filePath === '')) {
122
+ return 0; // nothing to judge; fail open
123
+ }
129
124
 
130
125
  // The tool sends an absolute path. Judge the repo-relative tail so a checkout
131
126
  // living under a directory literally called `secrets` does not make every edit
@@ -134,41 +129,53 @@ function main() {
134
129
  // below never matches, every path stays absolute, and a checkout that happens
135
130
  // to live under a directory called `secrets` has EVERY edit refused. That is
136
131
  // the "deleted within the hour" outcome `.claude/rules/invariants.md` warns
137
- // about — see guard-secret-file.test.ts › "judges the repo-relative path even
138
- // when the project directory is given with a trailing slash".
132
+ // about — see guard-secret-file.test.ts (absent in a generated rig) ›
133
+ // "judges the repo-relative path even when the project directory is given %s".
139
134
  const projectDir = String(process.env.CLAUDE_PROJECT_DIR ?? '')
140
135
  .replaceAll('\\', '/')
141
136
  .replace(/\/+$/, '');
142
- const relativePath =
143
- projectDir !== '' && filePath.startsWith(`${projectDir}/`)
144
- ? filePath.slice(projectDir.length + 1)
145
- : filePath;
137
+ let refused = false;
138
+ for (const { filePath, fragment, inspectionRefusal } of fragments) {
139
+ const relativePath =
140
+ projectDir !== '' && filePath.startsWith(`${projectDir}/`)
141
+ ? filePath.slice(projectDir.length + 1)
142
+ : filePath;
143
+ if (relativePath === '') continue;
146
144
 
147
- if (isCredentialPath(relativePath)) {
145
+ if (inspectionRefusal) {
146
+ refused = true;
147
+ process.stderr.write(
148
+ `BLOCKED — cannot safely inspect this edit to "${relativePath}": ${inspectionRefusal}\n` +
149
+ 'Split it into a smaller edit and retry.\n',
150
+ );
151
+ continue;
152
+ }
153
+ if (isCredentialPath(relativePath)) {
154
+ refused = true;
155
+ process.stderr.write(
156
+ `BLOCKED — "${relativePath}" is a credential file, and this repository never carries one.\n` +
157
+ `${WHERE_CREDENTIALS_BELONG}\n` +
158
+ `If this file is a documented placeholder, name it .env.example — that form stays committable.\n`,
159
+ );
160
+ continue;
161
+ }
162
+
163
+ const findings = findSecretValues(fragment);
164
+ if (findings.length === 0) continue;
165
+ refused = true;
148
166
  process.stderr.write(
149
- `BLOCKED — "${relativePath}" is a credential file, and this repository never carries one.\n` +
150
- `${WHERE_CREDENTIALS_BELONG}\n` +
151
- `If this file is a documented placeholder, name it .env.example — that form stays committable.\n`,
167
+ `BLOCKED — this edit writes a credential value into "${relativePath}":\n` +
168
+ findings
169
+ .map((finding) => ` - ${finding.id} on line ${finding.line} of the text being written`)
170
+ .join('\n') +
171
+ `\n${WHERE_CREDENTIALS_BELONG}\n` +
172
+ // Deliberately NOT the matched text. A guard that prints what it found has
173
+ // copied the credential into a hook transcript and a terminal scrollback —
174
+ // it has leaked the secret in the act of refusing it.
175
+ `The matched value is deliberately not shown; open the line above to see it.\n`,
152
176
  );
153
- return 2;
154
177
  }
155
-
156
- const fragment = String((toolName === 'Write' ? toolInput.content : toolInput.new_string) ?? '');
157
- const findings = findSecretValues(fragment);
158
- if (findings.length === 0) return 0;
159
-
160
- process.stderr.write(
161
- `BLOCKED — this edit writes a credential value into "${relativePath}":\n` +
162
- findings
163
- .map((finding) => ` - ${finding.id} on line ${finding.line} of the text being written`)
164
- .join('\n') +
165
- `\n${WHERE_CREDENTIALS_BELONG}\n` +
166
- // Deliberately NOT the matched text. A guard that prints what it found has
167
- // copied the credential into a hook transcript and a terminal scrollback —
168
- // it has leaked the secret in the act of refusing it.
169
- `The matched value is deliberately not shown; open the line above to see it.\n`,
170
- );
171
- return 2;
178
+ return refused ? 2 : 0;
172
179
  }
173
180
 
174
181
  let status;
@@ -70,7 +70,17 @@ export function editFragments(input) {
70
70
  if (toolName === 'MultiEdit') {
71
71
  if (!Array.isArray(toolInput.edits)) return [];
72
72
  const filePath = normalisePath(toolInput.file_path);
73
- return toolInput.edits.slice(0, MAX_MULTI_EDITS).map((edit) => ({
73
+ if (toolInput.edits.length > MAX_MULTI_EDITS) {
74
+ return [
75
+ {
76
+ filePath,
77
+ fragment: '',
78
+ inspectionRefusal: `MultiEdit carries more than the ${MAX_MULTI_EDITS}-fragment inspection limit`,
79
+ appliesToAll: true,
80
+ },
81
+ ];
82
+ }
83
+ return toolInput.edits.map((edit) => ({
74
84
  filePath,
75
85
  fragment: String(edit?.new_string ?? ''),
76
86
  }));
@@ -95,10 +95,10 @@ own cost figures are read next to the lane they do not cover.
95
95
  - bypass pre-commit (`--no-verify` is hook-blocked anyway)
96
96
  - force-push a shared branch
97
97
  - put secrets in code, config, logs, or fixtures. One part of this is
98
- **mechanical**: `guard-secret-file` refuses an edit through `Write`, `Edit`, or
99
- `apply_patch` that names a credential file or carries a credential value,
98
+ **mechanical**: `guard-secret-file` refuses an edit through `Write`, `Edit`,
99
+ `MultiEdit`, `NotebookEdit`, or `apply_patch` that names a credential file or carries a credential value,
100
100
  reading its vocabulary from `.claude/scripts/lib/secrets.mjs`. ⚠ **Only that
101
- part.** The hook sees what an agent writes through those three tools and
101
+ part.** The hook sees what an agent writes through those five tools and
102
102
  nothing else — its own header states the four blind spots — so whether a
103
103
  credential typed by a human, or committed from disk, is also refused depends
104
104
  on whether this project has a commit-time check. Look at `.husky/` and the CI
@@ -109,8 +109,10 @@ own cost figures are read next to the lane they do not cover.
109
109
  a leak.
110
110
  - touch production data outside a reviewed migration
111
111
  - edit the rulebook from an **unattended** run outside the item's allow-list — `guard-rulebook` refuses it.
112
- The rulebook: the hooks, their wiring, `.claude/queue.json`, the queue
113
- adapters, the router, the gate sweep, the rules, `CLAUDE.md`. Mechanical:
112
+ The rulebook is both harnesses' instruction, agent, skill, script and hook
113
+ trees (`CLAUDE.md`, `AGENTS.md`, `.claude/{agents,hooks,rules,scripts,skills}`,
114
+ `.agents/`, `.codex/`), plus `.claude/settings.json`, the queue config and its
115
+ always-refused board selector, and the integrity manifest. Mechanical:
114
116
  the hook refuses the edit while the unattended flag the `loop` skill writes
115
117
  at claim time is on disk (`.claude/scripts/unattended-flag.mjs`), and does
116
118
  nothing in an attended session. ⚠ It sees edit tool calls only — a
@@ -216,27 +216,25 @@ are guessing, and a guessed invariant is the one that will fire on honest work.
216
216
 
217
217
  ## About the hooks you were given
218
218
 
219
- They arrive with their tests **in the generator that produced this project**, not
220
- in this repository — so by the rule above, as they sit here, they are checks
221
- without tests. That is deliberate and it has a boundary: it holds only while they
222
- are untouched.
223
-
224
- This is the one narrow exception for a generator-authored hook: it may cite the
225
- generator's upstream tests **only while unchanged downstream**, and its hook
226
- header must identify the upstream generator tests as absent locally. That pointer
227
- records the evidence used to author the inherited snapshot; it does not turn the
228
- absent test into a local check.
229
-
230
- **The moment you edit one, its test is yours.** A guard whose behaviour has
231
- changed and whose test lives somewhere else is precisely the "quietly stopped
232
- matching" case this rule names, and nothing here would catch it. The same applies
233
- if you keep a hook whose invariant you have re-scoped.
219
+ Generator-authored rulebook artifacts — rules, hooks, skills, scripts and agent
220
+ specs — arrive with their tests **in the generator that produced this project**,
221
+ not in this repository. There is one narrow inherited-snapshot exception: such
222
+ an artifact may cite the generator's upstream tests, which are absent locally,
223
+ only when the pointer says they are absent and `.claude/.rig-manifest.json`
224
+ proves the current artifact's hash matches the installed manifest.
225
+
226
+ A manifest-backed upgrade remains an inherited, generator-owned artifact even
227
+ though the upgrade diff changes its bytes. The exception applies **only while the
228
+ manifest hash matches**. A hash mismatch, missing manifest, or no evidence ends
229
+ the exception and the local test is yours; an owned guard whose behaviour changed
230
+ while its test lives elsewhere is precisely the "quietly stopped matching" case
231
+ this rule names.
234
232
 
235
233
  If a hook matters enough to keep, it is worth ten minutes to copy the shape from
236
234
  `.claude/skills/new-invariant/guard-invariant.example.test.mjs` and pin the
237
235
  behaviour you actually rely on.
238
236
 
239
- **That boundary is audited, not remembered.** `node .claude/scripts/doctor.mjs`
237
+ **For hooks, that ownership boundary is audited, not remembered.** `node .claude/scripts/doctor.mjs`
240
238
  reads `.claude/.rig-manifest.json` and asks of every hook in `.claude/hooks/` (and
241
239
  `.husky/`, when it exists) whether the project owns it — the bytes differ from
242
240
  what the generator installed, or the manifest has no entry — and, if so, whether
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The decision router — which gate does this change deserve, and what does that
3
3
  * cost.
4
+ * All upstream test pointers in this script name the generator suite, absent in a generated rig.
4
5
  *
5
6
  * `pr-ship` is the merge-time gate and it always runs the expensive path: the
6
7
  * full suite, then `code-reviewer` on every diff. That is right for a change
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // All upstream test pointers in this script name the generator suite, absent in a generated rig.
2
3
  // doctor — the harness audits itself (AR-5).
3
4
  //
4
5
  // node .claude/scripts/doctor.mjs # the report, ready to paste
@@ -10,7 +11,9 @@
10
11
  // `.claude/rules/invariants.md` — a stated rule, a mechanical check, a test for
11
12
  // the check — is decoration with any part missing, and the part a rig loses
12
13
  // first is the third: the shipped hooks arrive with their tests in the generator
13
- // that produced them, and the moment one is edited its test is the rig's own.
14
+ // that produced them. A manifest-backed generator upgrade remains inherited;
15
+ // the test becomes the rig's own when the current hash no longer matches the
16
+ // manifest evidence below.
14
17
  //
15
18
  // Ownership is read from `.claude/.rig-manifest.json`, the install manifest the
16
19
  // generator writes (its `files` map is install-relative path → sha256 of the
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * The environment a child `git` should run with — one implementation, imported.
3
+ * All upstream test pointers in this script name the generator suite, absent in a generated rig.
3
4
  *
4
5
  * 🔴 **Why this is not inlined at each call site.** A process started under a
5
6
  * git hook inherits `GIT_DIR` and `GIT_INDEX_FILE`, and any child then answers
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * The revalidation points — ONE spelling of the fact (AR-137).
3
+ * All upstream test pointers in this script name the generator suite, absent in a generated rig.
3
4
  *
4
5
  * `queue/index.mjs next` writes the SELECT record; `revalidate.mjs` writes the
5
6
  * other two and answers outcomes at all three; `revalidation-report.mjs`
@@ -1,5 +1,6 @@
1
1
  // The credential vocabulary, decided once, so that everything which refuses a
2
2
  // credential refuses the same set.
3
+ // All upstream test pointers in this script name the generator suite, absent in a generated rig.
3
4
  //
4
5
  // Every layer that refuses one reads THIS module — which layers exist is a
5
6
  // question about the project, not about this file. A freshly generated rig has
@@ -18,7 +19,9 @@
18
19
  // GENERATOR this rig came from, not in this repository — the same arrangement
19
20
  // `.claude/rules/invariants.md` describes for the hooks themselves under "About
20
21
  // the hooks you were given". They are where a claim is proven, not cover you
21
- // have here. The moment you edit this module, its tests are yours.
22
+ // have here. A manifest-backed generator upgrade remains inherited while
23
+ // `.claude/.rig-manifest.json` matches; once the hash differs, the local test is
24
+ // yours.
22
25
  //
23
26
  // It answers two different questions, and keeping them apart matters:
24
27
  //
@@ -6,12 +6,13 @@
6
6
  // node .claude/scripts/queue/index.mjs list # every item, with skip reasons
7
7
  // node .claude/scripts/queue/index.mjs hygiene # stale labels, link anomalies, overtaken proposals
8
8
  // node .claude/scripts/queue/index.mjs gate-round --branch <b> # count a gate round
9
+ // node .claude/scripts/queue/index.mjs board [<name>] # the active board; or switch this checkout to <name>
9
10
  //
10
11
  // The adapter comes from `.claude/queue.json` (`{"adapter": "plan-md"}`) and
11
12
  // defaults to `plan-md`, which is the only adapter that works in a freshly
12
13
  // generated project. An unknown adapter is a hard error, never a fallback: a loop
13
14
  // that silently reads the wrong queue is worse than one that refuses to start.
14
- import { readFileSync, realpathSync } from 'node:fs';
15
+ import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
15
16
  import { fileURLToPath } from 'node:url';
16
17
  import { basename, dirname, join } from 'node:path';
17
18
  import {
@@ -48,7 +49,7 @@ export const resolveAdapter = async (adapterName) => {
48
49
  return import(new URL(modulePath, import.meta.url).href);
49
50
  };
50
51
 
51
- export const COMMANDS = ['next', 'list', 'hygiene', 'gate-round'];
52
+ export const COMMANDS = ['next', 'list', 'hygiene', 'gate-round', 'board'];
52
53
 
53
54
  /**
54
55
  * A missing config is the normal state of a fresh project. A config that exists
@@ -63,8 +64,9 @@ export const loadConfig = (configPath) => {
63
64
  } catch {
64
65
  return {};
65
66
  }
67
+ let parsed;
66
68
  try {
67
- return JSON.parse(raw);
69
+ parsed = JSON.parse(raw);
68
70
  } catch (error) {
69
71
  throw new Error(
70
72
  `${configPath} exists but is not valid JSON, so the configured queue cannot be ` +
@@ -73,6 +75,83 @@ export const loadConfig = (configPath) => {
73
75
  { cause: error },
74
76
  );
75
77
  }
78
+ return resolveBoard(parsed, configPath);
79
+ };
80
+
81
+ /**
82
+ * The selector that travels with a config: `<name>.json` → `<name>.board`.
83
+ *
84
+ * A plain-text file holding one board name. Same class as the state file — a
85
+ * per-checkout runtime value that must never be committed, because the config it
86
+ * sits beside is composed and tracked. Derived from the config path for the same
87
+ * reason `statePathFor` is: a run pointed at a temp config must not switch on
88
+ * this checkout's real selector.
89
+ */
90
+ export const boardPathFor = (configPath) => configPath.replace(/(\.json)?$/, '.board');
91
+
92
+ const isTerminalControl = (char) => {
93
+ const code = char.codePointAt(0);
94
+ return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
95
+ };
96
+
97
+ const assertSafeBoardName = (name, source) => {
98
+ if (typeof name === 'string' && [...name].some(isTerminalControl)) {
99
+ throw new Error(`${source}: board names must not contain terminal control characters.`);
100
+ }
101
+ };
102
+
103
+ const boardNamesOf = (boards, configPath) => {
104
+ const names = Object.keys(boards);
105
+ for (const name of names) assertSafeBoardName(name, configPath);
106
+ return names;
107
+ };
108
+
109
+ /**
110
+ * A config may declare several boards and one default:
111
+ *
112
+ * { "adapter": "jira", "board": "AR",
113
+ * "boards": { "AR": { "project": "AR", "owner": "x" }, "RP": { … } },
114
+ * "options": { "maxGateRounds": 3 } }
115
+ *
116
+ * The active board is the selector file if present, else `board`; its entry is
117
+ * laid over `options`, so a key every board shares stays in `options` and only
118
+ * what differs is per board. A config with no `boards` is returned exactly as it
119
+ * was. A name nobody declared — in the selector or as the default — is refused,
120
+ * never read as "no board": the loop would otherwise run on the shared options
121
+ * alone, and for `jira` that is a different (or no) project.
122
+ */
123
+ export const resolveBoard = (config, configPath) => {
124
+ if (config?.boards === undefined) return config;
125
+ const boards = config.boards;
126
+ if (boards === null || typeof boards !== 'object' || Array.isArray(boards)) {
127
+ throw new Error(`${configPath}: "boards" must be an object of <name> → options.`);
128
+ }
129
+ const known = boardNamesOf(boards, configPath);
130
+ let selected = null;
131
+ let source = 'the "board" key';
132
+ try {
133
+ selected = readFileSync(boardPathFor(configPath), 'utf8').trim();
134
+ source = boardPathFor(configPath);
135
+ } catch (error) {
136
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error;
137
+ }
138
+ // A selector that exists but is empty is refused, not read as "no selector":
139
+ // a truncated write would otherwise switch the run to the default board while
140
+ // the file still looks like a choice somebody made.
141
+ const active = selected === null ? config.board : selected;
142
+ assertSafeBoardName(active, source);
143
+ if (!active || !known.includes(active)) {
144
+ throw new Error(
145
+ `${source} names board ${JSON.stringify(active ?? null)}, which ${configPath} does not ` +
146
+ `declare. Declared boards: ${known.join(', ')}. Refusing rather than running on the ` +
147
+ 'shared options alone — that would be a different queue than the one configured.',
148
+ );
149
+ }
150
+ const entry = boards[active];
151
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
152
+ throw new Error(`${configPath}: boards.${active} must be an object of adapter options.`);
153
+ }
154
+ return { ...config, board: active, options: { ...(config.options ?? {}), ...entry } };
76
155
  };
77
156
 
78
157
  /**
@@ -222,11 +301,12 @@ export const loadState = (statePath) => {
222
301
  };
223
302
 
224
303
  const parseArgs = (argv) => {
225
- const args = { command: argv[0] ?? 'next', json: false, config: null, branch: null };
304
+ const args = { command: argv[0] ?? 'next', json: false, config: null, branch: null, name: null };
226
305
  for (let i = 1; i < argv.length; i += 1) {
227
306
  if (argv[i] === '--json') args.json = true;
228
307
  else if (argv[i] === '--config') args.config = argv[++i];
229
308
  else if (argv[i] === '--branch') args.branch = argv[++i];
309
+ else if (!argv[i].startsWith('--') && args.name === null) args.name = argv[i];
230
310
  }
231
311
  return args;
232
312
  };
@@ -288,6 +368,66 @@ if (invokedDirectly()) {
288
368
  process.exit(1);
289
369
  }
290
370
 
371
+ // `board` is local too: it reads the config and writes the selector beside it,
372
+ // and never touches the tracker. Switching is refused on a config that declares
373
+ // no boards — the selector would then be a file nothing reads.
374
+ if (args.command === 'board') {
375
+ try {
376
+ // Resolve before deriving either the checkout root or the selector path.
377
+ // Otherwise a symlinked `.claude` lets the caller authorize one checkout
378
+ // while the write follows the link into another unattended checkout.
379
+ const configPath = realpathSync(args.config ?? join(projectRoot, '.claude', 'queue.json'));
380
+ const raw = JSON.parse(readFileSync(configPath, 'utf8'));
381
+ if (raw?.boards === undefined) {
382
+ throw new Error(`${configPath} declares no boards, so there is nothing to switch between.`);
383
+ }
384
+ if (raw.boards === null || typeof raw.boards !== 'object' || Array.isArray(raw.boards)) {
385
+ throw new Error(`${configPath}: "boards" must be an object of <name> → options. Nothing was written.`);
386
+ }
387
+ const boardNames = boardNamesOf(raw.boards, configPath);
388
+ if (args.name !== null) {
389
+ assertSafeBoardName(args.name, 'the requested board');
390
+ if (!boardNames.includes(args.name)) {
391
+ throw new Error(
392
+ `${JSON.stringify(args.name)} is not a declared board. Declared: ` +
393
+ `${boardNames.join(', ')}. Nothing was written.`,
394
+ );
395
+ }
396
+ // Loaded only on the mutation path. `next`, `list` and `hygiene` also
397
+ // run in partial-install diagnostics where this sibling is deliberately
398
+ // absent; a static import would replace their own actionable refusal
399
+ // with ERR_MODULE_NOT_FOUND before the command could start.
400
+ const { readUnattended } = await import('../unattended-flag.mjs');
401
+ const callerRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
402
+ const targetRoot = projectRootOfConfig(configPath) ?? callerRoot;
403
+ const guardedRoots = [...new Set([callerRoot, targetRoot])];
404
+ const unattended = guardedRoots
405
+ .map((root) => ({ root, mode: readUnattended({ ...process.env, CLAUDE_PROJECT_DIR: root }) }))
406
+ .find(({ mode }) => mode.on);
407
+ if (unattended) {
408
+ throw new Error(
409
+ `board switching is refused while checkout ${unattended.root} is unattended. ` +
410
+ 'The read-only `board` report remains available; disarm the loop before re-aiming its queue.',
411
+ );
412
+ }
413
+ writeFileSync(boardPathFor(configPath), `${args.name}\n`);
414
+ }
415
+ const config = loadConfig(configPath);
416
+ const report = { board: config.board, boards: boardNames, options: config.options };
417
+ process.stdout.write(
418
+ args.json
419
+ ? `${JSON.stringify(report)}\n`
420
+ : `board: ${report.board}${args.name !== null ? ' (switched)' : ''}\n` +
421
+ ` declared: ${report.boards.join(', ')}\n` +
422
+ ` selector: ${boardPathFor(configPath)}\n`,
423
+ );
424
+ process.exit(0);
425
+ } catch (error) {
426
+ process.stderr.write(`board could not run: ${error.message}\n`);
427
+ process.exit(1);
428
+ }
429
+ }
430
+
291
431
  // 🔴 **`gate-round` returns before the tracker is ever touched, and that is
292
432
  // load-bearing rather than an optimisation.** `pr-ship` calls this once per
293
433
  // round, and every other command below reaches the adapter — so routing this one
@@ -1,4 +1,5 @@
1
1
  // Queue adapter: Jira issues, via the REST API.
2
+ // All upstream test pointers in this script name the generator suite, absent in a generated rig.
2
3
  //
3
4
  // The second adapter exists to prove the seam holds: everything about *selection*
4
5
  // lives in `core.mjs` and is imported, not re-derived. An adapter that answers
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // All upstream test pointers in this script name the generator suite, absent in a generated rig.
2
3
  /**
3
4
  * Revalidation at BEFORE_PR — is the branch about to ship still the branch the
4
5
  * run took up?
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // All upstream test pointers in this script name the generator suite, absent in a generated rig.
2
3
  /**
3
4
  * The Revalidation Experiment's report — what the evidence log says, over the
4
5
  * run directories of this rig since a date (AR-136).