create-agent-rig 0.6.0 → 0.6.2
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 +78 -0
- package/README.md +1 -1
- package/package.json +3 -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 +46 -10
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +10 -5
- package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +3 -7
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +20 -23
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +6 -8
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +3 -7
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +100 -31
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +75 -72
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +3 -7
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +3 -6
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +11 -1
- package/templates/agent-os/universal/.claude/hooks/lib/hook-input.mjs +55 -0
- 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 +21 -8
- 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 +269 -58
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +46 -10
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/hooks.json +16 -16
- package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +15 -0
- package/templates/agent-os/universal/layers.json +1 -0
- package/templates/hash-history.json +158 -43
- package/templates/release-ledger.json +3 -1
- package/templates/skeleton/aws-serverless/gitignore +2 -0
- package/templates/skeleton/node-service/gitignore +2 -0
|
@@ -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
|
|
@@ -285,22 +286,32 @@ const retryDelayMs = (response, attempt) => {
|
|
|
285
286
|
* - a timeout (`timeoutMs`, default 20 s) through an AbortController — a stalled
|
|
286
287
|
* connection used to block selection forever, and a loop that cannot read its
|
|
287
288
|
* queue must stop, not hang;
|
|
288
|
-
* -
|
|
289
|
-
*
|
|
290
|
-
*
|
|
289
|
+
* - safe reads retry 429/502/503/504 at most `MAX_ATTEMPTS`, honouring
|
|
290
|
+
* `Retry-After`; mutating calls do not retry an ambiguous response, because
|
|
291
|
+
* Jira may have applied the write before a proxy returned the error. The
|
|
292
|
+
* search endpoint opts in explicitly: it is a POST at the transport layer and
|
|
293
|
+
* a read at the operation layer;
|
|
291
294
|
* - `retry.sleep` injectable, so a test measures the delay it would have waited
|
|
292
295
|
* instead of waiting it.
|
|
293
296
|
*
|
|
294
297
|
* Pinned in the generator's `test/template/queue-jira.test.ts` (absent in a
|
|
295
298
|
* generated rig) › "hands fetch an AbortSignal", › "rejects naming the timeout
|
|
296
|
-
* and the route when fetch never resolves", › "
|
|
297
|
-
*
|
|
299
|
+
* and the route when fetch never resolves", › "retries a semantically read-only
|
|
300
|
+
* search POST after a 429", › "keeps bounded retry for a safe issue GET", ›
|
|
301
|
+
* "does not retry %s", › "sleeps for the Retry-After the 429 carried, in
|
|
298
302
|
* milliseconds", › "gives up after four consecutive 503s, naming the status and
|
|
299
303
|
* the attempts" and › "does not retry a 401 — a bad credential is not transient".
|
|
300
304
|
*/
|
|
301
305
|
const request = async (
|
|
302
306
|
route,
|
|
303
|
-
{
|
|
307
|
+
{
|
|
308
|
+
method = 'GET',
|
|
309
|
+
body = null,
|
|
310
|
+
env = process.env,
|
|
311
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
312
|
+
retry = {},
|
|
313
|
+
retryTransient = method === 'GET',
|
|
314
|
+
} = {},
|
|
304
315
|
) => {
|
|
305
316
|
const { baseUrl, email, token } = requireCredentials(env);
|
|
306
317
|
const sleep = retry.sleep ?? defaultSleep;
|
|
@@ -333,13 +344,14 @@ const request = async (
|
|
|
333
344
|
clearTimeout(timer);
|
|
334
345
|
}
|
|
335
346
|
if (response.ok) return payload;
|
|
336
|
-
|
|
347
|
+
const retryable = retryTransient && TRANSIENT.has(response.status);
|
|
348
|
+
if (retryable && attempt < MAX_ATTEMPTS) {
|
|
337
349
|
await sleep(retryDelayMs(response, attempt));
|
|
338
350
|
continue;
|
|
339
351
|
}
|
|
340
352
|
// The status alone; never echo the response body, which can carry the token
|
|
341
353
|
// back in an error envelope.
|
|
342
|
-
const attempts =
|
|
354
|
+
const attempts = retryable ? ` after ${attempt} attempts` : '';
|
|
343
355
|
throw new Error(`jira ${method} ${route} failed: ${response.status} ${response.statusText}${attempts}`);
|
|
344
356
|
}
|
|
345
357
|
};
|
|
@@ -442,6 +454,7 @@ export const search = async ({
|
|
|
442
454
|
pages += 1;
|
|
443
455
|
const page = await request('/rest/api/3/search/jql', {
|
|
444
456
|
method: 'POST',
|
|
457
|
+
retryTransient: true,
|
|
445
458
|
body: {
|
|
446
459
|
jql: query,
|
|
447
460
|
maxResults: limit,
|