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.
- package/CHANGELOG.md +30 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/templates/agent-os/init/AGENTS.md +4 -2
- package/templates/agent-os/init/CLAUDE.md +4 -2
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +42 -10
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +10 -5
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +17 -17
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +2 -1
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +85 -25
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +72 -65
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +11 -1
- package/templates/agent-os/universal/.claude/rules/autonomy.md +7 -5
- package/templates/agent-os/universal/.claude/rules/invariants.md +14 -16
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +4 -1
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +144 -4
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +247 -50
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +42 -10
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
- package/templates/hash-history.json +132 -39
- package/templates/release-ledger.json +2 -1
- package/templates/skeleton/aws-serverless/gitignore +2 -0
- 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
|
|
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
|
|
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`,
|
|
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
|
|
96
|
-
if (
|
|
103
|
+
const editTools = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
|
|
104
|
+
if (!editTools.has(input?.tool_name)) return 0;
|
|
97
105
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
138
|
-
// when the project directory is given
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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 —
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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`,
|
|
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
|
|
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
|
|
113
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
are
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
**
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|