create-agent-rig 0.9.0 → 0.9.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 CHANGED
@@ -14,6 +14,53 @@ second recorded departure; its own entry states the direction and the reason,
14
14
  and this paragraph deliberately does not restate them — a numbering rule with
15
15
  two copies of its exceptions is the shape 0.8.0 exists to remove.
16
16
 
17
+ ## 0.9.1
18
+
19
+ **A patch, numbered by the owner.** Every entry corrects existing behaviour;
20
+ two of them (RP-182, RP-59) do it by recording or reporting something new.
21
+ Nothing is added to what `create`, `init` or `upgrade` install beyond the
22
+ corrected files, the contract version stays `1.0`, and the application
23
+ skeletons remain deprecated and scheduled for removal in 0.10.0 exactly as
24
+ 0.9.0 announced.
25
+
26
+ ### Fixed
27
+
28
+ - **`init` records the files it kept.** When `init` finds a payload path
29
+ already present and leaves it alone, the manifest now records that file's
30
+ hash under `kept`, so a later `upgrade` can say whether the file is
31
+ unchanged or edited since `init` found it instead of reporting it with no
32
+ history (RP-182). A manifest with nothing kept is written exactly as
33
+ before.
34
+ - **`memory load` passes Memory a deadline of its own.** A `load` that names
35
+ no `--timeout-ms` gets `--timeout-ms 45000` appended, below the rig's 60 s
36
+ kill. A well-formed `--timeout-ms <value>` pair raises that kill to the
37
+ value plus 15 s when that is above 60 s, capped at Node's timer limit of
38
+ 2 147 483 647 ms; any other spelling passes through unchanged. `doctor`'s
39
+ arguments are untouched. The README example adds `--cwd .`, which the Memory
40
+ owner names as part of Memory's own contract (RP-183).
41
+ - **`reconcile-external-prs.mjs` works on a `gh` that does not serve
42
+ `authorAssociation`** from `gh pr list`: the association is fetched
43
+ separately and kept only when it answers for the same pull request, and a
44
+ failed lookup leaves those pull requests untrusted rather than failing the
45
+ sweep. A failure now names the one cause the evidence shows, with terminal
46
+ control sequences and credential shapes removed from the detail (RP-99).
47
+ - **The queue reports a Blocks link the ticket body says was removed but the
48
+ tracker still carries** — a body line of the form
49
+ `The Blocks link A -> B is removed` — as a `link-contradicted-by-body`
50
+ hygiene finding (RP-59).
51
+ - **A Jira triage proposal with a long `change` is filed, not refused by the
52
+ tracker.** Jira rejects a summary over 255 characters; the summary is now
53
+ cut to fit and the full text stays in the description (RP-121).
54
+
55
+ ### Generator CI (not a rig-facing change)
56
+
57
+ Test-fixture cleanup goes through one bounded-retry helper, and an audit holds
58
+ every other recursive removal and every in-repository fixture to a written
59
+ reason. The four child-process time bounds are measured inside the child, and
60
+ the package-manager CLI-start cases carry their own measured budget. The e2e
61
+ installs run in a vitest group of their own, so they no longer compete with
62
+ the template tests on the Windows full-suite runner (RP-158).
63
+
17
64
  ## 0.9.0
18
65
 
19
66
  **The harness ↔ Memory boundary is executable, and the rig is a consumer of
package/README.md CHANGED
@@ -65,7 +65,13 @@ it does not. Each conflict names the file, why it was kept, and the path to the
65
65
  new version so you can diff it yourself.
66
66
 
67
67
  How it knows: `create` and `init` write `.claude/.rig-manifest.json` — the rig
68
- version plus a hash per installed file. **Commit it**; without it in the
68
+ version plus a hash per installed file. A file `init` found already in place and
69
+ left alone is recorded separately, under `kept`, with the hash of the bytes it
70
+ found, and never as the rig's (`packages/cli/test/init.test.ts` › "records what
71
+ it kept, with the sha256 of the bytes actually on disk — never in `files`"). An
72
+ `upgrade` conflict on such a file says it was kept by init and whether it was
73
+ edited since (`packages/cli/test/upgrade.test.ts` › "says "edited since"
74
+ instead, once the disk sha no longer matches what init recorded"). **Commit it**; without it in the
69
75
  repository the command is blind on CI and on a colleague's machine. Rigs
70
76
  installed before 0.4.0 have no manifest, so the package also carries the hashes
71
77
  of every release whose published commit is on record (0.2.0 onward — 0.1.0's
@@ -112,7 +118,7 @@ Both bins answer the same handshake, and the rig consumes Memory only through it
112
118
  ```sh
113
119
  npx create-agent-rig@latest --version --json # {"schemaVersion":1,"name":"create-agent-rig","version":"…","contractVersion":"1.0"}
114
120
  npx create-agent-rig@latest memory doctor --json # handshake first, then Memory's doctor, answer passed through unchanged
115
- npx create-agent-rig@latest memory load --json # same, for load; every argument after the verb goes to Memory verbatim
121
+ npx create-agent-rig@latest memory load --json --cwd . # same, for load (`--cwd`: Memory's own contract, per its owner — RP-183); arguments pass to Memory verbatim, plus `--timeout-ms 45000` when you name none
116
122
  ```
117
123
 
118
124
  `memory` reads the manifest above, runs Memory's `--version --json`, and only
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-agent-rig",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Scaffold a new project with an agent operating system (rules, gates, hooks) and a runnable code skeleton",
5
5
  "keywords": [
6
6
  "create",
@@ -1,4 +1,4 @@
1
- import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { access, lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { settingsForInstalledHooks } from '../lib/init-settings.js';
4
4
  import { mapConcurrent } from '../lib/copy-tree.js';
@@ -158,17 +158,40 @@ export async function initProject(repoDir, options) {
158
158
  const written = actions.filter(({ verdict }) => verdict === 'written').map(({ rel }) => rel);
159
159
  const skipped = actions.filter(({ verdict }) => verdict === 'skipped').map(({ rel }) => rel);
160
160
  if (!options.dryRun)
161
- await recordInstall(repoDir, written, contents);
161
+ await recordInstall(repoDir, written, skipped, contents);
162
162
  return { written, skipped, plannedCount };
163
163
  }
164
+ /**
165
+ * The text of a regular file, or `null` for anything else at that path — a
166
+ * directory, a symlink, a file this process cannot read. `kept` records only
167
+ * bytes that are the file itself: hashing through a link would put the hash of
168
+ * something outside the repository into a committed manifest, and a read that
169
+ * throws here would abort the install after every other file was written.
170
+ */
171
+ async function readRegularFile(abs) {
172
+ try {
173
+ if (!(await lstat(abs)).isFile())
174
+ return null;
175
+ return await readFile(abs, 'utf8');
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ }
164
181
  /**
165
182
  * Record what was installed, so a later `upgrade` can tell a file it wrote
166
183
  * from a file the user owns.
167
184
  *
168
- * Only files actually **written** are recorded. A file `init` kept is
169
- * somebody else's — claiming it here would let the next upgrade replace a
170
- * user's own document with the rig's. Earlier entries are preserved: a re-run
171
- * writes nothing and must not therefore un-remember everything.
185
+ * Only files actually **written** are recorded in `files`. A file `init` kept
186
+ * is somebody else's — claiming it there would let the next upgrade replace a
187
+ * user's own document with the rig's. It is recorded in `kept` instead, with
188
+ * the sha256 of the bytes found on disk (RP-182): not a claim of ownership, a
189
+ * record that the rig saw the file and left it — so the next upgrade can say
190
+ * "kept by init, unchanged since" or "edited since" rather than only "not a
191
+ * version this rig ever released". A path already in `files` is never moved to
192
+ * `kept` by a later run that skips it: the rig wrote those bytes. Earlier
193
+ * entries are preserved: a re-run writes nothing and must not therefore
194
+ * un-remember everything.
172
195
  *
173
196
  * 🔴 **`kind`, `project` and `stacks` are preserved, not rewritten.** Reached
174
197
  * inside a rig `create` produced, this used to stamp `kind: 'init'`,
@@ -196,12 +219,24 @@ export async function initProject(repoDir, options) {
196
219
  * leaves is a `create` rig whose `CLAUDE.md` was deleted, and this function is
197
220
  * what makes that case safe.
198
221
  */
199
- async function recordInstall(repoDir, written, contents) {
222
+ async function recordInstall(repoDir, written, skipped, contents) {
200
223
  const previous = await readManifest(repoDir);
201
224
  const name = projectNameFor(repoDir);
202
225
  const files = { ...(previous?.files ?? {}) };
203
226
  for (const rel of written)
204
227
  files[rel] = sha256(contents.get(rel) ?? '');
228
+ const kept = { ...(previous?.kept ?? {}) };
229
+ for (const rel of written)
230
+ delete kept[rel];
231
+ for (const rel of skipped) {
232
+ if (files[rel] !== undefined)
233
+ continue; // the rig wrote it once; still its bytes to vouch for
234
+ const found = await readRegularFile(path.join(repoDir, rel));
235
+ if (found === null)
236
+ delete kept[rel];
237
+ else
238
+ kept[rel] = sha256(found);
239
+ }
205
240
  const manifest = {
206
241
  version: await packageVersion(),
207
242
  kind: previous?.kind ?? 'init',
@@ -211,6 +246,7 @@ async function recordInstall(repoDir, written, contents) {
211
246
  project: previous?.project ?? { name, scope: name, region: '' },
212
247
  stacks: previous?.stacks ?? [],
213
248
  files,
249
+ ...(Object.keys(kept).length > 0 ? { kept } : {}),
214
250
  };
215
251
  await writeManifest(repoDir, manifest);
216
252
  }
@@ -3,7 +3,11 @@
3
3
  // resolves the executable from the machine subsystem manifest `setup` wrote
4
4
  // (RP-147), performs `--version --json` first, refuses a foreign contract
5
5
  // major with exit 4 before doctor/load ever run, and otherwise hands the verb
6
- // and its arguments to Memory verbatim and returns Memory's answer unchanged.
6
+ // and its arguments to Memory and returns Memory's answer unchanged. The one
7
+ // thing it adds to the arguments: a `load` that names no `--timeout-ms` gets
8
+ // Memory's default internal deadline appended (`deadlinesFor`, RP-183);
9
+ // everything the caller wrote passes through verbatim, and `doctor` is never
10
+ // touched.
7
11
  //
8
12
  // What it never does, by construction: import Memory code, read Memory's
9
13
  // storage tree, or reinterpret a doctor/load payload — the only Memory bytes
@@ -22,8 +26,62 @@
22
26
  import { execFileRunner } from './setup.js';
23
27
  import { MEMORY_CONTRACT_MAJOR, SubsystemsError, handshake, readSubsystemsManifest, subsystemsManifestPath, } from '../lib/subsystems.js';
24
28
  const MEMORY_VERBS = ['doctor', 'load'];
25
- /** doctor/load do real work; the caller-supplied `--timeout-ms` is Memory's own bound. */
29
+ /**
30
+ * The outer deadline: the rig kills the Memory child after this many ms. It is
31
+ * the consumer's bound, and it should not be the one that fires first — a kill
32
+ * leaves no JSON answer behind. So `load` always carries an INTERNAL deadline
33
+ * as well (RP-183): Memory's own `--timeout-ms`, which per the Memory owner
34
+ * (RP-183, Jira) it answers with a typed result instead of being killed.
35
+ * `doctor` accepts no such flag per the same source — its args are never
36
+ * touched. Pinned in packages/cli/test/memory.test.ts, "RP-183".
37
+ */
26
38
  const VERB_TIMEOUT_MS = 60_000;
39
+ /** Memory's default internal deadline when the caller names none. */
40
+ const DEFAULT_MEMORY_TIMEOUT_MS = 45_000;
41
+ /**
42
+ * Headroom the outer deadline keeps above an explicit internal one. Sized per
43
+ * the Memory owner (RP-183, Jira): Memory's Windows cleanup may return up to
44
+ * 10 s after its own deadline fired; the rest is spawn overhead. The rig does
45
+ * not measure that lag itself.
46
+ */
47
+ const OUTER_MARGIN_MS = 15_000;
48
+ /**
49
+ * The largest delay `execFile`'s timer can hold: Node clamps a larger
50
+ * `setTimeout` to ONE millisecond, which would make the outer kill fire first
51
+ * — the inversion this whole helper exists to prevent. A caller who asks for
52
+ * more than ~24.8 days gets this ceiling as the outer deadline.
53
+ */
54
+ const MAX_TIMER_MS = 2_147_483_647;
55
+ const TIMEOUT_FLAG = '--timeout-ms';
56
+ /** Memory's own grammar for the value: a positive integer, milliseconds. */
57
+ const TIMEOUT_VALUE = /^[1-9][0-9]*$/;
58
+ /**
59
+ * The verb's argument list and outer deadline. `load` with no `--timeout-ms`
60
+ * gets the default appended; a caller-owned flag — any spelling — passes
61
+ * through verbatim, and only a well-formed `--timeout-ms <value>` pair can
62
+ * raise the outer deadline above it. Memory owns the refusal of a malformed
63
+ * value, so the rig neither corrects nor drops one.
64
+ */
65
+ const deadlinesFor = (verb, args) => {
66
+ if (verb !== 'load')
67
+ return { args: [...args], timeoutMs: VERB_TIMEOUT_MS };
68
+ const at = args.findIndex((arg) => arg === TIMEOUT_FLAG || arg.startsWith(`${TIMEOUT_FLAG}=`));
69
+ if (at === -1) {
70
+ return {
71
+ args: [...args, TIMEOUT_FLAG, String(DEFAULT_MEMORY_TIMEOUT_MS)],
72
+ timeoutMs: VERB_TIMEOUT_MS,
73
+ };
74
+ }
75
+ const value = args[at] === TIMEOUT_FLAG ? args[at + 1] : undefined;
76
+ const internal = value !== undefined && TIMEOUT_VALUE.test(value) ? Number(value) : null;
77
+ // A digit string too long for a safe integer — or for a double at all —
78
+ // still lands on the ceiling: `Number` yields a huge float or Infinity, and
79
+ // `Math.min` with the ceiling holds either.
80
+ const timeoutMs = internal !== null
81
+ ? Math.min(Math.max(VERB_TIMEOUT_MS, internal + OUTER_MARGIN_MS), MAX_TIMER_MS)
82
+ : VERB_TIMEOUT_MS;
83
+ return { args: [...args], timeoutMs };
84
+ };
27
85
  const jsonLine = (payload) => `${JSON.stringify(payload)}\n`;
28
86
  const isVerb = (verb) => MEMORY_VERBS.includes(verb);
29
87
  export async function runMemory(options) {
@@ -106,8 +164,9 @@ export async function runMemory(options) {
106
164
  stderr: `memory: the handshake did not answer as the manifest promised (${result.reason}); ${options.verb} was not run\n`,
107
165
  };
108
166
  const [command, script] = entry.invocation;
109
- const answer = await run(command, [script, options.verb, ...options.args], {
110
- timeoutMs: VERB_TIMEOUT_MS,
167
+ const deadlines = deadlinesFor(options.verb, options.args);
168
+ const answer = await run(command, [script, options.verb, ...deadlines.args], {
169
+ timeoutMs: deadlines.timeoutMs,
111
170
  });
112
171
  if (answer.spawnError)
113
172
  return {
@@ -315,17 +315,34 @@ export async function planUpgrade(repoDir, options = {}) {
315
315
  nextFiles[file.rel] = recorded;
316
316
  }
317
317
  else {
318
+ // A path `init` found and left alone carries its provenance in `kept`
319
+ // (RP-182): the reason can then say what happened to the file since,
320
+ // instead of only that no release ever shipped these bytes. It names no
321
+ // version — `manifest.version` is rewritten by every upgrade, so it is
322
+ // not the version of the init that kept the file.
323
+ const kept = recorded === undefined ? manifest?.kept?.[file.rel] : undefined;
324
+ const keptReason = (since) => `kept by init (already here, not the rig's bytes), ${since} since — treated as yours`;
318
325
  actions.push({
319
326
  rel: file.rel,
320
327
  verdict: 'conflict',
321
- reason: recorded === undefined
322
- ? 'not a version this rig ever released — treated as yours'
323
- : 'edited since it was installed',
328
+ reason: kept !== undefined
329
+ ? keptReason(sha256(current) === kept ? 'unchanged' : 'edited')
330
+ : recorded === undefined
331
+ ? 'not a version this rig ever released — treated as yours'
332
+ : 'edited since it was installed',
324
333
  templatePath: file.source,
325
334
  });
326
- // deliberately NOT recorded: the rig does not own these bytes
335
+ // deliberately NOT recorded in `files`: the rig does not own these bytes
327
336
  }
328
337
  }
338
+ // `kept` travels forward untouched, minus every path the plan now vouches
339
+ // for in `files` — a kept file that turned out to be a released version, or
340
+ // is byte-identical to this release, has become the rig's to manage.
341
+ const nextKept = {};
342
+ for (const [rel, hash] of Object.entries(manifest?.kept ?? {})) {
343
+ if (nextFiles[rel] === undefined)
344
+ nextKept[rel] = hash;
345
+ }
329
346
  // With no manifest, "there is a rig here" has to be *recognised*, not
330
347
  // assumed from a file existing: `CLAUDE.md` and `.claude/settings.json` are
331
348
  // in the install set and in nearly every repository ever opened by an agent.
@@ -352,6 +369,7 @@ export async function planUpgrade(repoDir, options = {}) {
352
369
  project,
353
370
  stacks: [...stacks],
354
371
  files: nextFiles,
372
+ ...(Object.keys(nextKept).length > 0 ? { kept: nextKept } : {}),
355
373
  },
356
374
  };
357
375
  }
@@ -48,8 +48,10 @@ Also: create-agent-rig setup --memory-root <checkout> [--memory-ref <sha>] [--dr
48
48
  Also: create-agent-rig memory <doctor|load> [args…]
49
49
  Run a Memory verb through the registered executable: the --version --json
50
50
  handshake first (a foreign contract major exits 4 and the verb never runs),
51
- then the verb and its arguments verbatim, Memory's answer passed through
52
- unchanged. No manifest answers unsupported/absent (exit 0); an invalid
51
+ then the verb and its arguments, Memory's answer passed through unchanged.
52
+ Your arguments go to Memory as written; a load that names no --timeout-ms
53
+ gets --timeout-ms 45000 appended (Memory's own deadline, kept under the rig's
54
+ 60 s bound). No manifest answers unsupported/absent (exit 0); an invalid
53
55
  invocation exits 2.`;
54
56
  async function runSetup(rawArgs) {
55
57
  let values;
@@ -318,7 +320,8 @@ async function main() {
318
320
  }
319
321
  if (process.argv[2] === 'memory') {
320
322
  // The consumer path of the RP-19 handshake: manifest → `--version --json`
321
- // → exit 4 on a foreign major → doctor/load passed through verbatim.
323
+ // → exit 4 on a foreign major → doctor/load passed through (`load` gains a
324
+ // default `--timeout-ms` when the caller names none — commands/memory.ts).
322
325
  const [verb = '', ...args] = process.argv.slice(3);
323
326
  const result = await runMemory({ verb, args });
324
327
  process.stdout.write(result.stdout);
@@ -91,20 +91,38 @@ export function parseManifest(raw) {
91
91
  return null;
92
92
  if (!isStringRecord(m.files))
93
93
  return null;
94
+ // Present in a shape this reader does not accept voids the manifest, exactly
95
+ // as `files` does; absent is every manifest written before the field existed.
96
+ if (m.kept !== undefined && !isStringRecord(m.kept))
97
+ return null;
94
98
  return {
95
99
  version: m.version,
96
100
  kind: m.kind,
97
101
  project: { name: project.name, scope: project.scope, region: project.region },
98
102
  stacks: [...m.stacks],
99
103
  files: { ...m.files },
104
+ ...(m.kept !== undefined ? { kept: { ...m.kept } } : {}),
100
105
  };
101
106
  }
102
- /** Stable bytes: sorted paths, so a re-run produces no diff of its own. */
107
+ const sortedRecord = (record) => {
108
+ const sorted = {};
109
+ for (const rel of Object.keys(record).sort())
110
+ sorted[rel] = record[rel];
111
+ return sorted;
112
+ };
113
+ /**
114
+ * Stable bytes: sorted paths, so a re-run produces no diff of its own. An
115
+ * empty `kept` is omitted, not written as `{}` — a manifest that kept nothing
116
+ * must serialise byte-identical to one written before the key existed.
117
+ */
103
118
  export function serializeManifest(manifest) {
104
- const files = {};
105
- for (const rel of Object.keys(manifest.files).sort())
106
- files[rel] = manifest.files[rel];
107
- return `${JSON.stringify({ ...manifest, files }, null, 2)}\n`;
119
+ const { kept, ...rest } = manifest;
120
+ const body = {
121
+ ...rest,
122
+ files: sortedRecord(manifest.files),
123
+ ...(kept !== undefined && Object.keys(kept).length > 0 ? { kept: sortedRecord(kept) } : {}),
124
+ };
125
+ return `${JSON.stringify(body, null, 2)}\n`;
108
126
  }
109
127
  export async function readManifest(repoDir) {
110
128
  try {
@@ -485,6 +485,18 @@ export const hygieneOf = (ticket, { owner = null } = {}) => {
485
485
  };
486
486
  }
487
487
 
488
+ const removed = removedDependencyIn(body, ticket);
489
+ if (removed) {
490
+ return {
491
+ kind: 'link-contradicted-by-body',
492
+ id: ticket.id,
493
+ why:
494
+ `body line ${removed.line} declares "The Blocks link ${removed.source} -> ` +
495
+ `${removed.target} is removed", but that dependency is still carried in ` +
496
+ `${removed.direction}. Reconcile the declaration and link before relying on either`,
497
+ };
498
+ }
499
+
488
500
  return null;
489
501
  };
490
502
 
@@ -502,6 +514,37 @@ export const hygieneOf = (ticket, { owner = null } = {}) => {
502
514
  */
503
515
  const BLOCKER_IN_BODY = /^[-*\t ]{0,4}(?:blocked by|depends on|blocker)[ \t:]{0,8}[#A-Za-z0-9]/im;
504
516
 
517
+ // See test/template/queue.test.ts (absent in a generated rig) ›
518
+ // "reports its carried outgoing Blocks link that its own body says was removed".
519
+ const REMOVAL_ID = '(?:[A-Z][A-Z0-9_]{0,31}-[0-9]{1,16}|#[0-9]{1,16})';
520
+ const REMOVED_DEPENDENCY = new RegExp(
521
+ `^[ \\t]{0,4}(?:[-*][ \\t]{1,4})?The[ \\t]{1,8}Blocks[ \\t]{1,8}link[ \\t]{1,8}` +
522
+ `(${REMOVAL_ID})[ \\t]{1,8}->[ \\t]{1,8}(${REMOVAL_ID})[ \\t]{1,8}is[ \\t]{1,8}removed` +
523
+ '(?=[ \\t]{0,8}(?:$|[.;(]))',
524
+ 'i',
525
+ );
526
+ const dependencyId = (value) => String(value).replace(/^#/, '').toUpperCase();
527
+ const removedDependencyIn = (body, ticket) => {
528
+ const ownId = dependencyId(ticket.id);
529
+ const outgoingIds = new Set((ticket.blocks ?? []).map(dependencyId));
530
+ const incomingIds = new Set((ticket.blockedBy ?? []).map((link) => dependencyId(link.id)));
531
+ const lines = body.split(/\r?\n/);
532
+ for (let index = 0; index < lines.length; index += 1) {
533
+ const match = REMOVED_DEPENDENCY.exec(lines[index]);
534
+ if (!match) continue;
535
+ const [, source, target] = match;
536
+ const outgoing =
537
+ dependencyId(source) === ownId &&
538
+ outgoingIds.has(dependencyId(target));
539
+ const incoming =
540
+ dependencyId(target) === ownId &&
541
+ incomingIds.has(dependencyId(source));
542
+ if (outgoing || incoming)
543
+ return { source, target, line: index + 1, direction: outgoing ? 'blocks' : 'blockedBy' };
544
+ }
545
+ return null;
546
+ };
547
+
505
548
  /** The item saying, in its own words, that it was broken into other items. */
506
549
  const SPLIT_IN_BODY = /\b(?:split into|split up into|broken into|broken up into|superseded by|subtasks?:)/i;
507
550
 
@@ -680,6 +680,23 @@ export const escalate = async (ticket, diagnosis, { env = process.env } = {}) =>
680
680
  return { ok: true };
681
681
  };
682
682
 
683
+ /**
684
+ * Jira refuses a summary longer than this, and creates nothing: `400
685
+ * {"errors":{"summary":"Summary can't exceed 255 characters."}}` (RP-121). The
686
+ * summary is therefore a prefix; the full `change` stays in the description,
687
+ * where the fingerprint lives too, so the dedupe does not depend on the cut.
688
+ * The cut never leaves half of a surrogate pair behind. Pinned in
689
+ * `test/template/queue-jira.test.ts` › "bounds the POSTed summary so a
690
+ * 2186-character change still files, keeping the full text in the body".
691
+ */
692
+ const MAX_SUMMARY_LENGTH = 255;
693
+
694
+ const boundedSummary = (text) => {
695
+ if (text.length <= MAX_SUMMARY_LENGTH) return text;
696
+ const cut = text.slice(0, MAX_SUMMARY_LENGTH);
697
+ return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
698
+ };
699
+
683
700
  /**
684
701
  * 🔴 INVARIANT 2: the agent never creates its own work. A proposal is labelled
685
702
  * `triage`, which `buildJql` excludes explicitly, and it never receives a ready
@@ -689,7 +706,7 @@ export const triageItemFor = (proposal) => {
689
706
  validateProposal(proposal);
690
707
  const fingerprint = fingerprintOf(proposal);
691
708
  return {
692
- title: `proposal: ${proposal.change}`,
709
+ title: boundedSummary(`proposal: ${proposal.change}`),
693
710
  body: [
694
711
  `- finding — ${proposal.finding}`,
695
712
  `- part to change — ${proposal.part}`,
@@ -26,6 +26,9 @@ import { dirname, join } from 'node:path';
26
26
  // so this sweep sees exactly what the gate sweep sees — including the paths a
27
27
  // stack layer contributes for its own shape.
28
28
  import { elevatedPathsIn, laneOf, readDeclaredPaths } from './detect-missed-gate.mjs';
29
+ // The one credential vocabulary (`guard-secret-file`, the commit sweep and this
30
+ // diagnostic all read it): a second list of token shapes here would drift.
31
+ import { SECRET_VALUE_PATTERNS } from './lib/secrets.mjs';
29
32
 
30
33
  /**
31
34
  * The audit trail for work that already happened: a record born **closed**.
@@ -210,37 +213,261 @@ const parseArgs = (argv) => {
210
213
 
211
214
  const daysAgo = (n) => new Date(Date.now() - n * 86_400_000).toISOString().slice(0, 10);
212
215
 
213
- /** Same rule as the gate sweep: "could not look" must never render as "clean". */
214
- const fetchMergedPrs = (since) => {
215
- try {
216
- return JSON.parse(
217
- execFileSync(
218
- 'gh',
219
- [
220
- 'pr',
221
- 'list',
222
- '--state',
223
- 'merged',
224
- '--limit',
225
- '100',
226
- '--search',
227
- `merged:>=${since}`,
228
- '--json',
229
- 'number,title,body,headRefName,mergedAt,url,files,changedFiles,authorAssociation',
230
- ],
231
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
232
- ),
233
- );
234
- } catch (error) {
235
- process.stderr.write(
236
- 'lane reconciliation: could not list merged PRs the `gh` CLI is missing, ' +
237
- 'unauthenticated, or the API is unreachable. No lane was reconciled; do not ' +
238
- 'record an empty `external lane` block from this run. Use --input <file> to ' +
239
- 'work offline.\n' +
240
- ` ${String(error?.stderr ?? error?.message ?? error).trim().split('\n')[0]}\n`,
216
+ // --- Acquisition (RP-99) --------------------------------------------------------
217
+ //
218
+ // Two calls, not one. `gh pr list --json` does not know `authorAssociation` on
219
+ // every supported `gh` (2.71.2 answers `Unknown JSON field`), so the BASE
220
+ // projection asks only for fields every `gh` has, and the association — the
221
+ // trust signal — is fetched afterwards through one GraphQL query. When that
222
+ // second step fails for any reason the base list is returned WITHOUT the field
223
+ // and `reconcile()` treats those PRs as untrusted, and an answer is kept only
224
+ // when it names the same pull request URL the base list did — see the
225
+ // generator's test/template/reconcile-acquisition.test.ts (absent in a generated rig)
226
+ // › "reconcile() treats a PR that lost authorAssociation to a failed enrichment as untrusted when it crosses an elevated path"
227
+ // and › "drops an association whose answer names a different pull request URL — another host or repository never becomes trust".
228
+
229
+ /** The base projection: fields every supported `gh` serves. */
230
+ const BASE_FIELDS = ['number', 'title', 'body', 'headRefName', 'mergedAt', 'url', 'files', 'changedFiles'];
231
+ /** What GitHub returns for `authorAssociation`: an upper-case enum word. */
232
+ const ASSOCIATION_SHAPE = /^[A-Z_]{1,32}$/;
233
+ const MAX_PRS = 100;
234
+
235
+ /** The default runner: one `gh` child, stdout as text, a thrown error otherwise. */
236
+ const execGh = (file, args) =>
237
+ execFileSync(file, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
238
+
239
+ /** A base record the rest of this script can read: an object with a positive PR number. */
240
+ const isPrRecord = (row) =>
241
+ row !== null &&
242
+ typeof row === 'object' &&
243
+ !Array.isArray(row) &&
244
+ Number.isSafeInteger(row.number) &&
245
+ row.number > 0;
246
+
247
+ /**
248
+ * The merged PRs since `since`, plus the warnings the acquisition produced.
249
+ *
250
+ * Throws only from the BASE list — the caller classifies that with
251
+ * `classifyAcquisitionFailure`. A parsed base list that is not an array is
252
+ * thrown with `notAnArray: true` (JSON.parse succeeded, so a SyntaxError would
253
+ * misdescribe it). Enrichment never throws: it degrades to a warning.
254
+ */
255
+ export const fetchMergedPrs = (since, { exec = execGh } = {}) => {
256
+ const raw = exec('gh', [
257
+ 'pr',
258
+ 'list',
259
+ '--state',
260
+ 'merged',
261
+ '--limit',
262
+ String(MAX_PRS),
263
+ '--search',
264
+ `merged:>=${since}`,
265
+ '--json',
266
+ BASE_FIELDS.join(','),
267
+ ]);
268
+ const parsed = JSON.parse(raw);
269
+ if (!Array.isArray(parsed)) {
270
+ throw Object.assign(new Error('gh pr list answered with something other than a JSON array'), {
271
+ notAnArray: true,
272
+ });
273
+ }
274
+
275
+ const warnings = [];
276
+ const prs = [];
277
+ for (const row of parsed.slice(0, MAX_PRS)) {
278
+ if (!isPrRecord(row)) {
279
+ warnings.push('a merged-PR record without a positive numeric `number` was dropped from the sweep');
280
+ continue;
281
+ }
282
+ // Copy the base fields only; whatever else `gh` returned does not travel.
283
+ const record = {};
284
+ for (const field of BASE_FIELDS) if (row[field] !== undefined) record[field] = row[field];
285
+ prs.push(record);
286
+ }
287
+ if (prs.length === 0) return { prs, warnings };
288
+
289
+ const associations = fetchAuthorAssociations(prs, exec);
290
+ if (associations === null) {
291
+ warnings.push(
292
+ 'author association unavailable for this window — every external merge below is treated as untrusted origin',
241
293
  );
242
- process.exit(1);
294
+ return { prs, warnings };
295
+ }
296
+ for (const pr of prs) {
297
+ const answer = associations.get(pr.number);
298
+ if (answer === undefined) continue;
299
+ // `gh api graphql` asks its own default host, not the remote's: an answer
300
+ // about another pull request must never lend its association to this one.
301
+ if (typeof pr.url !== 'string' || answer.url !== pr.url) {
302
+ warnings.push(`PR #${pr.number}: an author association whose pull request URL does not match the listed one was dropped`);
303
+ continue;
304
+ }
305
+ const value = answer.authorAssociation;
306
+ if (typeof value === 'string' && ASSOCIATION_SHAPE.test(value)) pr.authorAssociation = value;
307
+ else warnings.push(`PR #${pr.number}: an author association of an unexpected shape was dropped`);
243
308
  }
309
+ return { prs, warnings };
310
+ };
311
+
312
+ /**
313
+ * `number → { url, authorAssociation }` for the listed PRs through one GraphQL
314
+ * query, or `null` when the answer cannot be trusted: a failed call, unusable
315
+ * JSON, a repository name of an unexpected shape. Never throws.
316
+ */
317
+ const fetchAuthorAssociations = (prs, exec) => {
318
+ try {
319
+ const repo = JSON.parse(exec('gh', ['repo', 'view', '--json', 'nameWithOwner']));
320
+ const nameWithOwner = typeof repo?.nameWithOwner === 'string' ? repo.nameWithOwner : '';
321
+ const parts = nameWithOwner.split('/');
322
+ if (parts.length !== 2 || !parts.every((part) => /^[A-Za-z0-9_.-]{1,100}$/.test(part))) return null;
323
+ const [owner, name] = parts;
324
+ const fields = prs
325
+ .map((pr) => `pr${pr.number}: pullRequest(number: ${pr.number}) { url authorAssociation }`)
326
+ .join(' ');
327
+ const query = `query { repository(owner: "${owner}", name: "${name}") { ${fields} } }`;
328
+ const answer = JSON.parse(exec('gh', ['api', 'graphql', '-f', `query=${query}`]));
329
+ const repository = answer?.data?.repository;
330
+ if (repository === null || typeof repository !== 'object') return null;
331
+ const out = new Map();
332
+ for (const pr of prs) {
333
+ const node = repository[`pr${pr.number}`];
334
+ if (node === null || typeof node !== 'object' || node.authorAssociation === undefined) continue;
335
+ out.set(pr.number, { url: node.url, authorAssociation: node.authorAssociation });
336
+ }
337
+ return out;
338
+ } catch {
339
+ return null;
340
+ }
341
+ };
342
+
343
+ // --- Failure diagnostics (RP-99) -----------------------------------------------
344
+
345
+ const CAUSES = Object.freeze({
346
+ 'gh-missing': 'the `gh` CLI could not be started (not installed, or not on PATH)',
347
+ 'gh-unauthenticated': 'the `gh` CLI is not authenticated for this host — run `gh auth login`',
348
+ 'api-unreachable': 'the GitHub API could not be reached',
349
+ 'unsupported-projection': 'this `gh` version does not serve a field the sweep asked for',
350
+ 'malformed-response': '`gh` answered, but not with the JSON array the sweep expects',
351
+ unknown: 'the cause is unknown',
352
+ });
353
+ const STDERR_UNAUTHENTICATED = /gh auth login|not logged in|HTTP 401|authentication required/i;
354
+ const STDERR_UNREACHABLE =
355
+ /dial tcp|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|HTTP 5\d\d|could not connect|connection refused/i;
356
+ const STDERR_UNSUPPORTED = /Unknown JSON field/i;
357
+ // ESC-led sequences (CSI, OSC and the single-character escapes); the
358
+ // whitespace controls, which become a space where they sit next to whitespace
359
+ // and a mark where they sit inside a run (so they cannot split a token into
360
+ // pieces too short to match); and every other character a terminal would not
361
+ // print as itself — the remaining C0/C1 controls except the line feed the next
362
+ // step splits on, the invisible format characters (zero-width, bidi,
363
+ // byte-order mark), the line and paragraph separators, and U+E000, the mark
364
+ // they are all replaced with.
365
+ // eslint-disable-next-line no-control-regex -- the pattern exists to remove these very bytes
366
+ const TERMINAL_SEQUENCES = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g;
367
+ const WHITESPACE_CONTROLS = /[\t\v\f\r]/g;
368
+ const WHITESPACE_CONTROLS_INSIDE_A_RUN = /(?<=\S)[\t\v\f\r]+(?=\S)/g;
369
+ // eslint-disable-next-line no-control-regex -- likewise
370
+ const HIDDEN_CHARACTERS = /[\x00-\x08\x0E-\x1F\x7F-\x9F\p{Cf}\p{Zl}\p{Zp}\uE000]/gu;
371
+ const MARK = '\uE000';
372
+ // No leading word boundary: a keyword glued to the word before must still be
373
+ // found, and over-redacting prose is the safe way to be wrong here.
374
+ const AUTHORIZATION_VALUE = /(?:Bearer|Basic|token)\s+[A-Za-z0-9._~+/=-]{8,}|Authorization:\s*[^\s]+(?:\s+[^\s]+)?/gi;
375
+ const DIAGNOSTIC_CAP = 200;
376
+ // Redaction runs on at most this much of the line, and the 200-character cut
377
+ // comes after it: cutting first can shorten a token below what its pattern
378
+ // needs and print the prefix. When the window itself cuts the line, the last
379
+ // whitespace-free run is dropped before redaction, so no token is ever
380
+ // shortened by a cut.
381
+ const REDACTION_WINDOW = 4096;
382
+ // A whitespace-free run that had a hidden character removed from it is masked
383
+ // whole once what is left is at least this long — the shortest value the
384
+ // vocabulary redacts, an authorization value. A removed character can glue a
385
+ // token to the word before it or split it into pieces too short to match, and
386
+ // neither can be told apart from honest text, so such a run is not trusted to
387
+ // the patterns at all.
388
+ const MARKED_RUN_MIN = 8;
389
+ // A masked run that ended in one of these keywords takes the next run with it:
390
+ // the value that followed the keyword can no longer be recognised on its own.
391
+ const AUTHORIZATION_KEYWORD_END = /(?:bearer|basic|token|authorization:)$/i;
392
+
393
+ /** `text` without its trailing whitespace-free run. One backward pass. */
394
+ const dropTrailingRun = (text) => {
395
+ let end = text.length;
396
+ while (end > 0 && !/\s/.test(text[end - 1])) end -= 1;
397
+ return text.slice(0, end);
398
+ };
399
+
400
+ const redactPatterns = (text) => {
401
+ let out = text.replace(AUTHORIZATION_VALUE, '[redacted]');
402
+ for (const { pattern } of SECRET_VALUE_PATTERNS) {
403
+ out = out.replace(new RegExp(pattern.source, `${pattern.flags.replace('g', '')}g`), '[redacted]');
404
+ }
405
+ return out;
406
+ };
407
+
408
+ /**
409
+ * One printable line of a subprocess's stderr: no terminal sequences, control
410
+ * or invisible format characters, the first non-empty line only, every run a
411
+ * hidden character was removed from masked when it is long enough to be a
412
+ * credential, the credential shapes the shared vocabulary knows — plus HTTP
413
+ * authorization values — replaced by `[redacted]`, and then at most 200
414
+ * characters.
415
+ */
416
+ export const sanitizeDiagnostic = (text) => {
417
+ const source = typeof text === 'string' ? text : String(text ?? '');
418
+ const marked = source
419
+ .replace(TERMINAL_SEQUENCES, MARK)
420
+ .replace(HIDDEN_CHARACTERS, MARK)
421
+ .replace(WHITESPACE_CONTROLS_INSIDE_A_RUN, MARK)
422
+ .replace(WHITESPACE_CONTROLS, ' ');
423
+ const line = marked.split('\n').find((candidate) => candidate.replaceAll(MARK, '').trim() !== '') ?? '';
424
+ const windowed = line.length > REDACTION_WINDOW ? dropTrailingRun(line.slice(0, REDACTION_WINDOW)) : line;
425
+ let maskNext = false;
426
+ const masked = windowed
427
+ .split(/(\s+)/)
428
+ .map((run) => {
429
+ if (run.trim() === '') return run;
430
+ const visible = run.replaceAll(MARK, '');
431
+ const hidden = run.includes(MARK) && visible.length >= MARKED_RUN_MIN;
432
+ const mask = hidden || maskNext;
433
+ maskNext = mask && AUTHORIZATION_KEYWORD_END.test(visible);
434
+ return mask ? '[redacted]' : visible;
435
+ })
436
+ .join('');
437
+ return redactPatterns(masked).trim().slice(0, DIAGNOSTIC_CAP);
438
+ };
439
+
440
+ /**
441
+ * The cause the evidence supports, and nothing more. `detail` is the sanitized
442
+ * first non-empty stderr line, whatever the cause, or `null` when there was none. An
443
+ * `unknown` failure with no stderr falls back to the error's own message; the
444
+ * other causes never do, because a parse error's message quotes `gh` output.
445
+ */
446
+ export const classifyAcquisitionFailure = (error) => {
447
+ const stderr = typeof error?.stderr === 'string' ? error.stderr : '';
448
+ const detail = stderr.trim() === '' ? null : sanitizeDiagnostic(stderr);
449
+ if (error?.code === 'ENOENT') return { cause: 'gh-missing', detail };
450
+ if (error instanceof SyntaxError || error?.notAnArray === true) {
451
+ return { cause: 'malformed-response', detail };
452
+ }
453
+ if (STDERR_UNSUPPORTED.test(stderr)) return { cause: 'unsupported-projection', detail };
454
+ if (STDERR_UNAUTHENTICATED.test(stderr)) return { cause: 'gh-unauthenticated', detail };
455
+ if (STDERR_UNREACHABLE.test(stderr)) return { cause: 'api-unreachable', detail };
456
+ if (detail === null && typeof error?.message === 'string' && error.message.trim() !== '') {
457
+ return { cause: 'unknown', detail: sanitizeDiagnostic(error.message) };
458
+ }
459
+ return { cause: 'unknown', detail };
460
+ };
461
+
462
+ /** The message the CLI prints for a failed acquisition: one cause, named. */
463
+ export const acquisitionFailureMessage = ({ cause, detail }) => {
464
+ const named = CAUSES[cause] ?? CAUSES.unknown;
465
+ return (
466
+ `lane reconciliation: could not list merged PRs — ${named}. ` +
467
+ 'No lane was reconciled; do not record an empty `external lane` block from this run. ' +
468
+ 'Use --input <file> to work offline.' +
469
+ (detail ? `\n ${detail}` : '')
470
+ );
244
471
  };
245
472
 
246
473
  /**
@@ -267,9 +494,19 @@ const invokedDirectly = () => {
267
494
  if (invokedDirectly()) {
268
495
  const args = parseArgs(process.argv.slice(2));
269
496
  const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
270
- const prs = args.input
271
- ? readInput(args.input, 'lane reconciliation')
272
- : fetchMergedPrs(args.since ?? daysAgo(7));
497
+ let prs;
498
+ let warnings = [];
499
+ if (args.input) {
500
+ prs = readInput(args.input, 'lane reconciliation');
501
+ } else {
502
+ try {
503
+ ({ prs, warnings } = fetchMergedPrs(args.since ?? daysAgo(7)));
504
+ } catch (error) {
505
+ process.stderr.write(`${acquisitionFailureMessage(classifyAcquisitionFailure(error))}\n`);
506
+ process.exit(1);
507
+ }
508
+ }
509
+ for (const warning of warnings) process.stderr.write(`lane reconciliation: ${warning}\n`);
273
510
  // Lane sorting still works without a declaration; only the elevated marks go
274
511
  // missing, and they render as "no elevated path crossed" rather than lying.
275
512
  const elevatedPaths = readDeclaredPaths(projectRoot) ?? [];
@@ -11,7 +11,8 @@
11
11
  "0.6.2",
12
12
  "0.7.0",
13
13
  "0.7.1",
14
- "0.8.0"
14
+ "0.8.0",
15
+ "0.9.0"
15
16
  ],
16
17
  "files": {
17
18
  ".agents/skills/check-premises/SKILL.md": {
@@ -29,7 +30,8 @@
29
30
  "57f6add0504db45e7577474f97705fe474310e4cd3310cdb348a2c64eabe88cd",
30
31
  "18f4300de4c4c7ea380d726fb109fa224da950ef0a4cea5015ab1d408b823be5",
31
32
  "0a1b6575f6df8139766c8e71ba7320e14d2a5a5ed8ab26b6b43f7a006153d37d",
32
- "a0d11ca1745095cc5b68777915161f16a6d1602c21f6d273f475d1d07c4b8546"
33
+ "a0d11ca1745095cc5b68777915161f16a6d1602c21f6d273f475d1d07c4b8546",
34
+ "b47bdf0d7a2f6c5b2034b8f6d683ad2b3c83bd65d15baf7de9b58cc4689d0ab1"
33
35
  ]
34
36
  },
35
37
  ".agents/skills/new-invariant/SKILL.md": {
@@ -83,7 +85,8 @@
83
85
  "hashes": [
84
86
  "2286fbc01c0342b9bac9909890876c079615db5b398e3499a0f2fddb1a334098",
85
87
  "4c6241da56060b44e77f9b5f7a8cde7d2923bd605b8b9bae70d00329784c35be",
86
- "03f87fbc976d8d65287b032ae3612511fc8f7e57772470b2282b660343fc32db"
88
+ "03f87fbc976d8d65287b032ae3612511fc8f7e57772470b2282b660343fc32db",
89
+ "5ec51bc08c9a934bfcbb2077fc7edce48c0e0cc3ed117d7dfc8a2895f2d9c01b"
87
90
  ]
88
91
  },
89
92
  ".claude/agents/code-reviewer.md": {
@@ -93,7 +96,8 @@
93
96
  "be3d2992d1f278dde46b555df1840b02f91403db1ec2fd4d489fb5efa940f019",
94
97
  "ec403373ef6bc0575d61c6ca11f82792817b272221fad8317ef6c45388b52628",
95
98
  "f125cfbce848cdaa91fd81e961808b4dbde452287959ec2cf10b905ee3ede789",
96
- "8ede9297480c889ec6d6caf8d076e7d69111fb472933dcf5a48b9a8425fe50c2"
99
+ "8ede9297480c889ec6d6caf8d076e7d69111fb472933dcf5a48b9a8425fe50c2",
100
+ "f12cb04871578eca3c9c36a1c2d60f5ae15a0e9094ecfc7c704c341756de4e3e"
97
101
  ]
98
102
  },
99
103
  ".claude/agents/prose-reviewer.md": {
@@ -102,7 +106,8 @@
102
106
  "8d5762849afd328fdc1aabf053ad207f2e306e013665e3ff934b06cbb1bbda38",
103
107
  "0e4307cd9cf8ef723d79e98b1af46a34c6912a6aa925e2179d939cb83c8f2a44",
104
108
  "7e8eaa2b4af9d65e1d10bf50c32c1d138fb2f8b9ba6e6d0880cade0524324d9d",
105
- "ed1c05d3938c0b4f277f1bf4af8641460def830569049ff0697c9bda14518b78"
109
+ "ed1c05d3938c0b4f277f1bf4af8641460def830569049ff0697c9bda14518b78",
110
+ "7aed3bfc955efc94bbeda3f866d25e2b253c88facaa2beed73939182a9f845b2"
106
111
  ]
107
112
  },
108
113
  ".claude/agents/security-scanner.md": {
@@ -110,13 +115,15 @@
110
115
  "hashes": [
111
116
  "43e5d59315483e369ad19f3070175f146e88deabd3a12dc09339ac4fac0f2aee",
112
117
  "e01467a38a0183be46ba45b05d44f663fbf4b139ceaecb91b5d096f3286a3fcd",
113
- "844e215af7d80bd85f62ca937c79bea59c8d8cd7c97353937ce62f7747eb9df7"
118
+ "844e215af7d80bd85f62ca937c79bea59c8d8cd7c97353937ce62f7747eb9df7",
119
+ "7c0bc189521955f2d126bac0ff97ab4fa252b05067692a5a3bb6cda232ddfaa3"
114
120
  ]
115
121
  },
116
122
  ".claude/agents/test-writer.md": {
117
123
  "since": "0.2.0",
118
124
  "hashes": [
119
- "333feef12383dea51791ae4923c44ba542438de793f0792fb51d96aa15b8b1b8"
125
+ "333feef12383dea51791ae4923c44ba542438de793f0792fb51d96aa15b8b1b8",
126
+ "fce976938fdd1154641e405a557b67161daa34cf84de9ad35fa14287d13b7f9c"
120
127
  ]
121
128
  },
122
129
  ".claude/hooks/block-no-verify.mjs": {
@@ -180,6 +187,12 @@
180
187
  "5bf2896ec5e5f94fab20b52cd22418a4392778b40744602a087e320eeca3f688"
181
188
  ]
182
189
  },
190
+ ".claude/hooks/guard-subagent-model.mjs": {
191
+ "since": "0.9.0",
192
+ "hashes": [
193
+ "345cbd5f43171adb01cd46ad485fb61299f7135c00ff4217765993551aca3b38"
194
+ ]
195
+ },
183
196
  ".claude/hooks/guard-web-boundary.mjs": {
184
197
  "since": "0.2.0",
185
198
  "hashes": [
@@ -202,7 +215,8 @@
202
215
  "hashes": [
203
216
  "4f836bc3ff75a868ffe035b179ac9e7dc1ad7715d520677ff9f6aa5bfdb200eb",
204
217
  "0c191d326a7bb0bbfc110e2210f44b4aa39276e8db3779aaa4545cffd4e11863",
205
- "572c671f8f2cad190fe2f144071954ac4d3b1357cab80a19bad584dd3b3768c8"
218
+ "572c671f8f2cad190fe2f144071954ac4d3b1357cab80a19bad584dd3b3768c8",
219
+ "6695698d2b38c79a12292601048dd0082a0cd18bf9e69c047d47679c2e6ecf67"
206
220
  ]
207
221
  },
208
222
  ".claude/hooks/lib/hook-input.mjs": {
@@ -212,6 +226,12 @@
212
226
  "3be3d4af4a25536d2a2bac0441a1370be7efafb5f8aa737b195bdce77db0091b"
213
227
  ]
214
228
  },
229
+ ".claude/hooks/warn-subagent-routing.mjs": {
230
+ "since": "0.9.0",
231
+ "hashes": [
232
+ "b95165243ebb60264c78b82ce01407669457fe33c0bf86ff2a5c117963c6bc6d"
233
+ ]
234
+ },
215
235
  ".claude/queue.json": {
216
236
  "since": "0.3.0",
217
237
  "hashes": [
@@ -267,7 +287,8 @@
267
287
  "hashes": [
268
288
  "dbe8025e10583e03fee5056e88aa6b73e4399453711d9156d82528a02d6e0402",
269
289
  "9e60d0300e856a9b3f81ab748bf2c0a9de27233ad0aa58a8c7141413fa0f3cae",
270
- "c8e9849a8fccd4e1e60b1ddc82937eb15aceb7f7c2afbd151b05e1bc966bfd2a"
290
+ "c8e9849a8fccd4e1e60b1ddc82937eb15aceb7f7c2afbd151b05e1bc966bfd2a",
291
+ "aa34a1eab572964b328ffa0044e96acb3e1c31491df0183460f06e5e6bba3b76"
271
292
  ]
272
293
  },
273
294
  ".claude/scripts/decision-router.mjs": {
@@ -352,7 +373,8 @@
352
373
  "ceb6d35fa028f9c5e5e2d5965c627095d5ba80c37191a584627b4bc645b6be15",
353
374
  "efa193dc543237d82dcb371fa1df6483ccd43c1db88d0e218f5d89769564974c",
354
375
  "bed44df40412a7edac96914de21edc796df604ddc275ff1a02764dfcc4d4ede7",
355
- "b3893c365de37ef6d9d04ab933296b9fcf09a0886197d7e12313fa63eacdbd89"
376
+ "b3893c365de37ef6d9d04ab933296b9fcf09a0886197d7e12313fa63eacdbd89",
377
+ "37eeb4bdb50d7f6b1427bd02ec9815f39ad9142f35691ffadd8c0b626c6cdb00"
356
378
  ]
357
379
  },
358
380
  ".claude/scripts/queue/as-of.mjs": {
@@ -381,7 +403,8 @@
381
403
  ".claude/scripts/queue/gate-rounds.mjs": {
382
404
  "since": "0.5.0",
383
405
  "hashes": [
384
- "7577674d5477cfc388594e4c301601e329d60741dfa0b2844be2cc94a3ab6283"
406
+ "7577674d5477cfc388594e4c301601e329d60741dfa0b2844be2cc94a3ab6283",
407
+ "deeb7a10772f906c7630bb3c295be1b61d7ebf0541b2a34debcb40c80e6d8f0e"
385
408
  ]
386
409
  },
387
410
  ".claude/scripts/queue/github-issues.mjs": {
@@ -401,7 +424,8 @@
401
424
  "7db3c17b781e6876b4822afd09e6affa97e46607b63d443975d987daf1f14464",
402
425
  "9edd70b5960f367958e8e7500150b4edd285c99869cca803e40e0537d2d347b0",
403
426
  "4cf917ac06708e1068dfe9f8bbebc9376c4748526bd669b9bea32929ba996d0a",
404
- "b4a0789be7ad4b401de872edb06665bca349048178b4dd6c5db848ca44f7c977"
427
+ "b4a0789be7ad4b401de872edb06665bca349048178b4dd6c5db848ca44f7c977",
428
+ "19a04cdbdfd936213cea3a9a00a583a060a4a8d0356d6a6922ae1c2f593f16f1"
405
429
  ]
406
430
  },
407
431
  ".claude/scripts/queue/jira.mjs": {
@@ -481,7 +505,8 @@
481
505
  "hashes": [
482
506
  "060b001927d8b471f1c1a8c8851fbb4266d3b2edd674cccbd6c16c44b57b91dd",
483
507
  "7721ee19fd1bcf6902be3d359def40742d79b88f5d83e7e3ca22ba45ca3211aa",
484
- "250292c9959916240bfc0fdf5987a864f6d54799de3d932dc6b85b776e62eaa9"
508
+ "250292c9959916240bfc0fdf5987a864f6d54799de3d932dc6b85b776e62eaa9",
509
+ "ad58b25f9ce30a1549b5047daa1e5b43c30352306aeceebacd638db86087e01f"
485
510
  ]
486
511
  },
487
512
  ".claude/scripts/verdict.mjs": {
@@ -498,7 +523,8 @@
498
523
  "55a6938b4d9967291a26e93301e34e5ffdef74a88f8c773a4d11b5d87d864fff",
499
524
  "713718fbb2cda132f05665b29b8f1108a6305729b5e7c789d2053d117c13e08e",
500
525
  "e588ab05bee707a099c80c9ea399008be31488e616f3db3c0598f66f3d1b3a52",
501
- "a180578d6e6de6a21900ff2f8a8ade2242170d16d2dd95574e5004435d870b9b"
526
+ "a180578d6e6de6a21900ff2f8a8ade2242170d16d2dd95574e5004435d870b9b",
527
+ "44695f83e445c7da3257556a3decc70e79ef46d19da394d4d5dd4c9268c0b429"
502
528
  ]
503
529
  },
504
530
  ".claude/skills/check-premises/SKILL.md": {
@@ -521,7 +547,8 @@
521
547
  "57f6add0504db45e7577474f97705fe474310e4cd3310cdb348a2c64eabe88cd",
522
548
  "18f4300de4c4c7ea380d726fb109fa224da950ef0a4cea5015ab1d408b823be5",
523
549
  "0a1b6575f6df8139766c8e71ba7320e14d2a5a5ed8ab26b6b43f7a006153d37d",
524
- "a0d11ca1745095cc5b68777915161f16a6d1602c21f6d273f475d1d07c4b8546"
550
+ "a0d11ca1745095cc5b68777915161f16a6d1602c21f6d273f475d1d07c4b8546",
551
+ "b47bdf0d7a2f6c5b2034b8f6d683ad2b3c83bd65d15baf7de9b58cc4689d0ab1"
525
552
  ]
526
553
  },
527
554
  ".claude/skills/new-invariant/SKILL.md": {
@@ -577,14 +604,16 @@
577
604
  "since": "0.5.0",
578
605
  "hashes": [
579
606
  "902dbace9532ec8e7a3c0cfd6ea0934227f087e345c0619923ec03512b26ba3e",
580
- "55778d7fb9f778a33213590cc0a2dcf8e01026a1a93eed1c0f811ba4f1070721"
607
+ "55778d7fb9f778a33213590cc0a2dcf8e01026a1a93eed1c0f811ba4f1070721",
608
+ "5fbbb1431833692d56cd0d15249f1d024e53bb0ab63349842910a9fc90547f6a"
581
609
  ]
582
610
  },
583
611
  ".codex/agents/code-reviewer.toml": {
584
612
  "since": "0.5.0",
585
613
  "hashes": [
586
614
  "e0e3475404c95402f33d77ebb14001e289d26bb429205a0a73bdcef99b4a62c7",
587
- "9ec92775791907f068d85ba58489236bd2bee69a8d769554ac530e8eadd5e374"
615
+ "9ec92775791907f068d85ba58489236bd2bee69a8d769554ac530e8eadd5e374",
616
+ "180c182cc909cc0b6c06cfa286f528f22b77189eee49cac067fdaaf2920f8bae"
588
617
  ]
589
618
  },
590
619
  ".codex/agents/prose-reviewer.toml": {
@@ -592,20 +621,29 @@
592
621
  "hashes": [
593
622
  "80b4631502d95c619ad060ce02cc8702b12574758ba7df8337ed63efa15d764d",
594
623
  "cfd516f72acdfd197c59ac3cbdd39d7b175976dbd3d9eef52825dfdc4f1e5b60",
595
- "8687eacf0ad707a4f887e0714e82fca23fd005fa9ec8824faedfe99ca46333e1"
624
+ "8687eacf0ad707a4f887e0714e82fca23fd005fa9ec8824faedfe99ca46333e1",
625
+ "7c37e4dbd94980bee16396d0d46d97c5079e295d3eed4b3ed8f98d701e57b10d"
596
626
  ]
597
627
  },
598
628
  ".codex/agents/security-scanner.toml": {
599
629
  "since": "0.5.0",
600
630
  "hashes": [
601
631
  "9ee408789f237a0b55aac6a2472682a08f8696e1d1ff4918ea72766ac8535499",
602
- "bc9aacca1ca3ed4ceac485f7cd1c6ff5a5405ac7be83419d6145499cc560ee91"
632
+ "bc9aacca1ca3ed4ceac485f7cd1c6ff5a5405ac7be83419d6145499cc560ee91",
633
+ "b529f02056948ff0ca179c96d9d47962c9c8064feccb68d3c970e7b1a2513fc9"
603
634
  ]
604
635
  },
605
636
  ".codex/agents/test-writer.toml": {
606
637
  "since": "0.5.0",
607
638
  "hashes": [
608
- "bb89d93f57d70885d98cfbe428caa524e9c00e1ce30da235ed3318c1ba3c9606"
639
+ "bb89d93f57d70885d98cfbe428caa524e9c00e1ce30da235ed3318c1ba3c9606",
640
+ "ec9e61b2c16c97ed26d5bdda2b0f715a41b80c0af549d0f2bbb96d6ca4c4d8d7"
641
+ ]
642
+ },
643
+ ".codex/config.toml": {
644
+ "since": "0.9.0",
645
+ "hashes": [
646
+ "c41e34ef5c3bb606852269adeaf5678ce172dc7a7937514ac879152bbddea7f2"
609
647
  ]
610
648
  },
611
649
  ".codex/hooks.json": {
@@ -659,7 +697,8 @@
659
697
  "since": "0.5.0",
660
698
  "hashes": [
661
699
  "30e55d866d9a2c7359e594181070c63b1974d9df36e000caf419229284303a2e",
662
- "ebf0ef61407944373c862d37e6a80d4965ece72304b931428c6b3fcc5d9527c4"
700
+ "ebf0ef61407944373c862d37e6a80d4965ece72304b931428c6b3fcc5d9527c4",
701
+ "46b68f9130d16a96278e03d0ddb14d99b678c5a239e4667aa79733e632e67505"
663
702
  ]
664
703
  },
665
704
  "docs/decisions/content-blind-revalidation.md": {
@@ -705,6 +744,12 @@
705
744
  "dc0c9f5988fc983eb5100a085e274691b1e88a3b54ab2797e00972c4d69d61c4"
706
745
  ]
707
746
  },
747
+ "docs/decisions/subagent-routing.md": {
748
+ "since": "0.9.0",
749
+ "hashes": [
750
+ "97bb882dd066a65a740780a836199ecef8887392c1ece02a92670ecf153988d3"
751
+ ]
752
+ },
708
753
  "docs/decisions/two-empty-endings.md": {
709
754
  "since": "0.5.0",
710
755
  "hashes": [
@@ -11,5 +11,6 @@
11
11
  "0.6.2": "2a1fc8e10cd2b65deb5c95d937fd7de39f2c92a2",
12
12
  "0.7.0": "6589db36e1daa63a99ec595191db1cccf7373196",
13
13
  "0.7.1": "52e879b6c103f6ba70493007b6a6466c57ea9824",
14
- "0.8.0": "870f9a3ecae2881908ece8ec3e2ac13f84f505f5"
14
+ "0.8.0": "870f9a3ecae2881908ece8ec3e2ac13f84f505f5",
15
+ "0.9.0": "c27f391e7395accaf09354f255a07e1b9e4710c1"
15
16
  }