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.
Files changed (38) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -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 +46 -10
  7. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +10 -5
  8. package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +3 -7
  9. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +20 -23
  10. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +6 -8
  11. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +3 -7
  12. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +100 -31
  13. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +75 -72
  14. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +3 -7
  15. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +3 -6
  16. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +11 -1
  17. package/templates/agent-os/universal/.claude/hooks/lib/hook-input.mjs +55 -0
  18. package/templates/agent-os/universal/.claude/rules/autonomy.md +7 -5
  19. package/templates/agent-os/universal/.claude/rules/invariants.md +14 -16
  20. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -0
  21. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +4 -1
  22. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
  23. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +1 -0
  24. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
  25. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +144 -4
  26. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +21 -8
  27. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +1 -0
  28. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +1 -0
  29. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +269 -58
  30. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +46 -10
  31. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
  32. package/templates/agent-os/universal/.codex/hooks.json +16 -16
  33. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +15 -0
  34. package/templates/agent-os/universal/layers.json +1 -0
  35. package/templates/hash-history.json +158 -43
  36. package/templates/release-ledger.json +3 -1
  37. package/templates/skeleton/aws-serverless/gitignore +2 -0
  38. 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. 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
@@ -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
- * - a retry on 429/502/503/504, at most `MAX_ATTEMPTS`, honouring `Retry-After`;
289
- * 401/403/404 and every other status fail at once a bad credential is not
290
- * transient, and retrying it only delays the diagnosis;
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", › "a 429 followed by a 200 yields
297
- * the 200 body", › "sleeps for the Retry-After the 429 carried, in
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
- { method = 'GET', body = null, env = process.env, timeoutMs = DEFAULT_TIMEOUT_MS, retry = {} } = {},
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
- if (TRANSIENT.has(response.status) && attempt < MAX_ATTEMPTS) {
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 = TRANSIENT.has(response.status) ? ` after ${attempt} 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,
@@ -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).