@xhulz/nina 0.62.0 → 0.63.0

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 (136) hide show
  1. package/bin/nina.mjs +7 -0
  2. package/package.json +1 -1
  3. package/releases/0.63.0/core/GAPS.md +125 -0
  4. package/releases/0.63.0/core/answered.json +77 -0
  5. package/releases/0.63.0/core/defaults/tree/.claude/agents/architect.md +1 -0
  6. package/releases/0.63.0/core/defaults/tree/.claude/agents/dba.md +3 -0
  7. package/releases/0.63.0/core/defaults/tree/.claude/agents/devops.md +1 -0
  8. package/releases/0.63.0/core/defaults/tree/.claude/agents/implementer.md +1 -0
  9. package/releases/0.63.0/core/defaults/tree/.claude/agents/integration-tester.md +3 -0
  10. package/releases/0.63.0/core/defaults/tree/.claude/agents/qa.md +7 -0
  11. package/releases/0.63.0/core/defaults/tree/.claude/agents/reviewer.md +3 -0
  12. package/releases/0.63.0/core/defaults/tree/.claude/agents/secops.md +3 -0
  13. package/releases/0.63.0/core/defaults/tree/.claude/agents/solidity-auditor.md +3 -0
  14. package/releases/0.63.0/core/defaults/tree/.claude/agents/solidity-dev.md +3 -0
  15. package/releases/0.63.0/core/tree/.claude/agents/architect.md +133 -0
  16. package/releases/0.63.0/core/tree/.claude/agents/dba.md +119 -0
  17. package/releases/0.63.0/core/tree/.claude/agents/design-reviewer.md +76 -0
  18. package/releases/0.63.0/core/tree/.claude/agents/devops.md +118 -0
  19. package/releases/0.63.0/core/tree/.claude/agents/implementer.md +164 -0
  20. package/releases/0.63.0/core/tree/.claude/agents/integration-tester.md +170 -0
  21. package/releases/0.63.0/core/tree/.claude/agents/planner.md +132 -0
  22. package/releases/0.63.0/core/tree/.claude/agents/qa.md +150 -0
  23. package/releases/0.63.0/core/tree/.claude/agents/reviewer.md +187 -0
  24. package/releases/0.63.0/core/tree/.claude/agents/secops.md +120 -0
  25. package/releases/0.63.0/core/tree/.claude/agents/solidity-auditor.md +126 -0
  26. package/releases/0.63.0/core/tree/.claude/agents/solidity-dev.md +123 -0
  27. package/releases/0.63.0/core/tree/.claude/agents-overview.md +65 -0
  28. package/releases/0.63.0/core/tree/.claude/frontend.md +10 -0
  29. package/releases/0.63.0/core/tree/.claude/graph.md +79 -0
  30. package/releases/0.63.0/core/tree/.claude/patterns.md +251 -0
  31. package/releases/0.63.0/core/tree/.claude/pills/README.md +150 -0
  32. package/releases/0.63.0/core/tree/.claude/pipeline.md +101 -0
  33. package/releases/0.63.0/core/tree/.claude/retrieval.md +103 -0
  34. package/releases/0.63.0/core/tree/.claude/router.md +211 -0
  35. package/releases/0.63.0/core/tree/.claude/templates/integration.md +48 -0
  36. package/releases/0.63.0/core/tree/CLAUDE.md +166 -0
  37. package/releases/0.63.0/core/tree/scripts/cost-watch.mjs +25 -0
  38. package/releases/0.63.0/core/tree/scripts/edit-guard.mjs +24 -0
  39. package/releases/0.63.0/core/tree/scripts/harness-check.mjs +80 -0
  40. package/releases/0.63.0/core/tree/scripts/loop-gate.mjs +19 -0
  41. package/releases/0.63.0/core/vocabulary.json +14 -0
  42. package/releases/0.63.0/surfaces/blockchain/tree/.claude/agents/architect.md +5 -0
  43. package/releases/0.63.0/surfaces/blockchain/tree/.claude/agents/devops.md +5 -0
  44. package/releases/0.63.0/surfaces/blockchain/tree/.claude/agents/reviewer.md +5 -0
  45. package/releases/0.63.0/surfaces/blockchain/tree/.claude/agents/solidity-auditor.md +18 -0
  46. package/releases/0.63.0/surfaces/blockchain/tree/.claude/agents/solidity-dev.md +16 -0
  47. package/releases/0.63.0/surfaces/blockchain/tree/.claude/agents-overview.md +6 -0
  48. package/releases/0.63.0/surfaces/blockchain/tree/.claude/graph.md +11 -0
  49. package/releases/0.63.0/surfaces/blockchain/tree/.claude/router.md +6 -0
  50. package/releases/0.63.0/surfaces/blockchain/tree/CLAUDE.md +12 -0
  51. package/releases/0.63.0/surfaces/d1/tree/.claude/agents/dba.md +15 -0
  52. package/releases/0.63.0/surfaces/d1/tree/.claude/agents/devops.md +2 -0
  53. package/releases/0.63.0/surfaces/d1/tree/.claude/agents/implementer.md +2 -0
  54. package/releases/0.63.0/surfaces/d1/tree/.claude/patterns.md +8 -0
  55. package/releases/0.63.0/surfaces/d1/tree/CLAUDE.md +2 -0
  56. package/releases/0.63.0/surfaces/db/tree/.claude/agents/architect.md +5 -0
  57. package/releases/0.63.0/surfaces/db/tree/.claude/agents/dba.md +54 -0
  58. package/releases/0.63.0/surfaces/db/tree/.claude/agents/devops.md +5 -0
  59. package/releases/0.63.0/surfaces/db/tree/.claude/agents/implementer.md +2 -0
  60. package/releases/0.63.0/surfaces/db/tree/.claude/agents/integration-tester.md +10 -0
  61. package/releases/0.63.0/surfaces/db/tree/.claude/agents/planner.md +2 -0
  62. package/releases/0.63.0/surfaces/db/tree/.claude/agents/reviewer.md +22 -0
  63. package/releases/0.63.0/surfaces/db/tree/.claude/agents/secops.md +2 -0
  64. package/releases/0.63.0/surfaces/db/tree/.claude/agents-overview.md +5 -0
  65. package/releases/0.63.0/surfaces/db/tree/.claude/graph.md +8 -0
  66. package/releases/0.63.0/surfaces/db/tree/.claude/patterns.md +22 -0
  67. package/releases/0.63.0/surfaces/db/tree/.claude/pipeline.md +13 -0
  68. package/releases/0.63.0/surfaces/db/tree/.claude/retrieval.md +5 -0
  69. package/releases/0.63.0/surfaces/db/tree/.claude/router.md +4 -0
  70. package/releases/0.63.0/surfaces/db/tree/CLAUDE.md +17 -0
  71. package/releases/0.63.0/surfaces/drizzle/tree/.claude/agents/dba.md +9 -0
  72. package/releases/0.63.0/surfaces/drizzle/tree/.claude/agents/devops.md +5 -0
  73. package/releases/0.63.0/surfaces/drizzle/tree/.claude/agents/implementer.md +2 -0
  74. package/releases/0.63.0/surfaces/drizzle/tree/.claude/patterns.md +7 -0
  75. package/releases/0.63.0/surfaces/edge-cf/tree/.claude/agents/architect.md +14 -0
  76. package/releases/0.63.0/surfaces/edge-cf/tree/.claude/agents/dba.md +2 -0
  77. package/releases/0.63.0/surfaces/edge-cf/tree/.claude/agents/devops.md +29 -0
  78. package/releases/0.63.0/surfaces/edge-cf/tree/.claude/agents/implementer.md +14 -0
  79. package/releases/0.63.0/surfaces/edge-cf/tree/.claude/agents/integration-tester.md +11 -0
  80. package/releases/0.63.0/surfaces/edge-cf/tree/.claude/agents/qa.md +3 -0
  81. package/releases/0.63.0/surfaces/edge-cf/tree/.claude/agents/reviewer.md +8 -0
  82. package/releases/0.63.0/surfaces/edge-cf/tree/.claude/agents/secops.md +8 -0
  83. package/releases/0.63.0/surfaces/edge-cf/tree/.claude/patterns.md +17 -0
  84. package/releases/0.63.0/surfaces/edge-cf/tree/.claude/retrieval.md +8 -0
  85. package/releases/0.63.0/surfaces/edge-cf/tree/CLAUDE.md +27 -0
  86. package/releases/0.63.0/surfaces/frontend/tree/.claude/agents/architect.md +20 -0
  87. package/releases/0.63.0/surfaces/frontend/tree/.claude/agents/devops.md +28 -0
  88. package/releases/0.63.0/surfaces/frontend/tree/.claude/agents/implementer.md +25 -0
  89. package/releases/0.63.0/surfaces/frontend/tree/.claude/agents/qa.md +17 -0
  90. package/releases/0.63.0/surfaces/frontend/tree/.claude/agents/reviewer.md +56 -0
  91. package/releases/0.63.0/surfaces/frontend/tree/.claude/patterns.md +8 -0
  92. package/releases/0.63.0/surfaces/frontend/tree/.claude/retrieval.md +2 -0
  93. package/releases/0.63.0/surfaces/frontend/tree/.claude/router.md +14 -0
  94. package/releases/0.63.0/surfaces/frontend/tree/CLAUDE.md +5 -0
  95. package/releases/0.63.0/surfaces/integrations/tree/.claude/agents/architect.md +16 -0
  96. package/releases/0.63.0/surfaces/integrations/tree/.claude/agents/implementer.md +12 -0
  97. package/releases/0.63.0/surfaces/integrations/tree/.claude/agents/integration-tester.md +20 -0
  98. package/releases/0.63.0/surfaces/integrations/tree/.claude/agents/planner.md +2 -0
  99. package/releases/0.63.0/surfaces/integrations/tree/.claude/agents/reviewer.md +21 -0
  100. package/releases/0.63.0/surfaces/integrations/tree/.claude/agents/secops.md +2 -0
  101. package/releases/0.63.0/surfaces/integrations/tree/.claude/agents-overview.md +8 -0
  102. package/releases/0.63.0/surfaces/integrations/tree/.claude/graph.md +10 -0
  103. package/releases/0.63.0/surfaces/integrations/tree/.claude/patterns.md +38 -0
  104. package/releases/0.63.0/surfaces/integrations/tree/.claude/pipeline.md +16 -0
  105. package/releases/0.63.0/surfaces/integrations/tree/.claude/retrieval.md +16 -0
  106. package/releases/0.63.0/surfaces/integrations/tree/.claude/router.md +6 -0
  107. package/releases/0.63.0/surfaces/integrations/tree/CLAUDE.md +17 -0
  108. package/releases/0.63.0/surfaces/money/tree/.claude/agents/architect.md +12 -0
  109. package/releases/0.63.0/surfaces/money/tree/.claude/agents/dba.md +14 -0
  110. package/releases/0.63.0/surfaces/money/tree/.claude/agents/implementer.md +11 -0
  111. package/releases/0.63.0/surfaces/money/tree/.claude/agents/integration-tester.md +3 -0
  112. package/releases/0.63.0/surfaces/money/tree/.claude/agents/reviewer.md +23 -0
  113. package/releases/0.63.0/surfaces/money/tree/.claude/agents/secops.md +5 -0
  114. package/releases/0.63.0/surfaces/money/tree/.claude/agents/solidity-auditor.md +4 -0
  115. package/releases/0.63.0/surfaces/money/tree/.claude/agents/solidity-dev.md +4 -0
  116. package/releases/0.63.0/surfaces/money/tree/.claude/graph.md +2 -0
  117. package/releases/0.63.0/surfaces/money/tree/.claude/patterns.md +49 -0
  118. package/releases/0.63.0/surfaces/money/tree/.claude/pipeline.md +2 -0
  119. package/releases/0.63.0/surfaces/money/tree/.claude/retrieval.md +2 -0
  120. package/releases/0.63.0/surfaces/money/tree/CLAUDE.md +11 -0
  121. package/releases/0.63.0/surfaces/pii/tree/.claude/agents/dba.md +2 -0
  122. package/releases/0.63.0/surfaces/pii/tree/.claude/agents/devops.md +8 -0
  123. package/releases/0.63.0/surfaces/pii/tree/.claude/agents/reviewer.md +8 -0
  124. package/releases/0.63.0/surfaces/pii/tree/.claude/agents/secops.md +2 -0
  125. package/releases/0.63.0/surfaces/pii/tree/.claude/agents/solidity-auditor.md +5 -0
  126. package/releases/0.63.0/surfaces/pii/tree/CLAUDE.md +5 -0
  127. package/releases/0.63.0/surfaces/prisma/tree/.claude/agents/architect.md +2 -0
  128. package/releases/0.63.0/surfaces/prisma/tree/.claude/agents/dba.md +42 -0
  129. package/releases/0.63.0/surfaces/prisma/tree/.claude/agents/devops.md +11 -0
  130. package/releases/0.63.0/surfaces/prisma/tree/.claude/agents/implementer.md +2 -0
  131. package/releases/0.63.0/surfaces/prisma/tree/.claude/agents/integration-tester.md +2 -0
  132. package/releases/0.63.0/surfaces/prisma/tree/.claude/agents/reviewer.md +2 -0
  133. package/releases/0.63.0/surfaces/prisma/tree/.claude/patterns.md +15 -0
  134. package/releases/0.63.0/surfaces/prisma/tree/CLAUDE.md +5 -0
  135. package/src/commands/sizes.mjs +350 -0
  136. package/src/gate.mjs +138 -4
@@ -0,0 +1,350 @@
1
+ /**
2
+ * `nina sizes` — each step's forecast against what it landed: the `Size:` line its step file carried before it was
3
+ * built, beside the lines of code its commits added, the rate that measures, and the steps still to land forecast
4
+ * again at it.
5
+ *
6
+ * The architect forecasts each step (`Size: ~N lines in M files`) and splits past the step limit, and until this
7
+ * nothing compared a forecast with what landed. In the project that asked for it, two steps landed about 1.5× their
8
+ * forecasts before a reviewer worked the factor out by hand, and a whole architect run — 28 minutes, 375k tokens —
9
+ * then re-derived the rates from `git diff --numstat` to re-forecast six steps. Measured at each commit, that is a
10
+ * line; and as a detector (`--check`) it speaks when the newest step overran and the steps after it still carry the
11
+ * forecasts made before it landed, so the re-forecast is set off by the data rather than noticed.
12
+ *
13
+ * A step is its file, `.claude/plans/specs/<spec>.step-<n>.md`; its commits are those whose message names the spec and
14
+ * `step <n>`, as the router asks a commit to; what it landed is the lines they added outside the harness's own files.
15
+ *
16
+ * nina sizes [--project <dir>] [--check]
17
+ */
18
+
19
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
20
+ import { spawnSync } from 'node:child_process';
21
+ import { dirname, join, relative, resolve } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { HARNESS } from '../paths.mjs';
24
+ import { defaultVocabulary } from '../vocabulary.mjs';
25
+ import { amber, bold, dim, pink } from '../look.mjs';
26
+
27
+ const PACKAGE = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
28
+
29
+ /** Where the architect writes a spec's steps. */
30
+ const SPECS = join('.claude', 'plans', 'specs');
31
+
32
+ /** A landed step past its forecast by more than this sets off the detector. */
33
+ export const OVERRUN = 1.25;
34
+
35
+ /** How many of a spec's newest landed steps its rate is measured over. */
36
+ const RATE_OVER = 5;
37
+
38
+ /** Files a step's commit carries that are not the step's code: the harness's own, and lockfiles. */
39
+ const NOT_CODE = /^(\.claude\/|\.nina\/|CLAUDE\.md$)|(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?)$/;
40
+
41
+ /**
42
+ * The forecast a step file states, from its `Size:` line.
43
+ *
44
+ * @param {string} text - A step file.
45
+ * @returns {{lines: number, files: number|null}|null}
46
+ */
47
+ export function forecastIn(text) {
48
+ const m = /\bSize:[*_\s]*~?\s*(\d[\d,]*)\s*lines?\b([^\n]*)/i.exec(text);
49
+ if (!m) return null;
50
+ const files = /^[^.;]*?\bin\s+(\d+)\s+files?\b/i.exec(m[2]);
51
+ return { lines: Number(m[1].replace(/,/g, '')), files: files ? Number(files[1]) : null };
52
+ }
53
+
54
+ /** Runs git in the project, or says it could not. */
55
+ function git(dir, args) {
56
+ const r = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
57
+ return r.status === 0 ? r.stdout : null;
58
+ }
59
+
60
+ /**
61
+ * Many files at many revisions, read by one git process: `<rev>:<path>` → its text, or null where it did not exist.
62
+ * The detector runs on every turn, and a process a version came to most of its second.
63
+ *
64
+ * @param {string} dir
65
+ * @param {string[]} wanted
66
+ * @returns {Map<string, string|null>}
67
+ */
68
+ function blobs(dir, wanted) {
69
+ const keys = [...new Set(wanted)];
70
+ const out = new Map(keys.map((k) => [k, null]));
71
+ if (keys.length === 0) return out;
72
+ const r = spawnSync('git', ['-C', dir, 'cat-file', '--batch'], { input: `${keys.join('\n')}\n`, maxBuffer: 256 * 1024 * 1024 });
73
+ if (r.status !== 0) return out;
74
+ const buffer = r.stdout;
75
+ let at = 0;
76
+ for (const key of keys) {
77
+ const end = buffer.indexOf(10, at);
78
+ if (end === -1) break;
79
+ const header = /^\S+ (\S+) (\d+)$/.exec(buffer.toString('utf8', at, end));
80
+ at = end + 1;
81
+ // `<rev>:<path> missing` carries no body.
82
+ if (!header) continue;
83
+ const size = Number(header[2]);
84
+ if (header[1] === 'blob') out.set(key, buffer.toString('utf8', at, at + size));
85
+ at += size + 1;
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /** Each commit's code: the lines it added to each file that is not the harness's own, read by one git process. */
91
+ function codeOf(dir, hashes) {
92
+ const out = new Map(hashes.map((h) => [h, new Map()]));
93
+ if (hashes.length === 0) return out;
94
+ for (const block of (git(dir, ['show', '--no-renames', '--numstat', '--format=%x00%H', ...hashes]) ?? '').split('\0').filter((b) => b.trim())) {
95
+ const [hash, ...rows] = block.split('\n');
96
+ const code = out.get(hash.trim());
97
+ if (!code) continue;
98
+ for (const row of rows) {
99
+ const [added, , path] = row.split('\t');
100
+ if (!path || added === '-' || NOT_CODE.test(path)) continue;
101
+ code.set(path, (code.get(path) ?? 0) + Number(added));
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /** The commits that changed each of these files, newest first, read by one git process. */
108
+ function changesTo(dir, paths) {
109
+ const out = new Map(paths.map((p) => [p, []]));
110
+ if (paths.length === 0) return out;
111
+ for (const block of (git(dir, ['log', '--no-renames', '--format=%x00%H%x09%ct', '--name-only', 'HEAD', '--', ...paths]) ?? '').split('\0').filter((b) => b.trim())) {
112
+ const [head, ...names] = block.split('\n');
113
+ const [hash, seconds] = head.split('\t');
114
+ for (const name of names) out.get(name.trim())?.push({ hash, t: Number(seconds) * 1000 });
115
+ }
116
+ return out;
117
+ }
118
+
119
+ /** A text as a pattern that matches it literally. */
120
+ const literal = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
121
+
122
+ /**
123
+ * Whether a commit message names this step of this spec: `step 10e2` names neither `step 10e2a` nor `step 10`, and
124
+ * `1.6-spec-c` is not `1.6-spec-c2`.
125
+ */
126
+ export function names(subject, spec, step) {
127
+ const whole = '(?![\\w.-]*\\w)';
128
+ return (
129
+ new RegExp(`(^|[^\\w.-])${literal(spec)}${whole}`, 'i').test(subject) && new RegExp(`\\bstep[\\s-]+${literal(step)}${whole}`, 'i').test(subject)
130
+ );
131
+ }
132
+
133
+ /** The step files in the working tree, as `{path, spec, step}`, in their natural order. */
134
+ function stepFiles(dir) {
135
+ const root = join(dir, SPECS);
136
+ if (!existsSync(root)) return [];
137
+ const out = [];
138
+ for (const entry of readdirSync(root, { recursive: true })) {
139
+ const m = /^(.*)\.step-([^/\\]+)\.md$/.exec(String(entry).split(/[/\\]/).at(-1));
140
+ if (!m) continue;
141
+ out.push({ path: relative(dir, join(root, String(entry))).split(/[/\\]/).join('/'), spec: m[1], step: m[2] });
142
+ }
143
+ return out.sort((a, b) => a.spec.localeCompare(b.spec) || a.step.localeCompare(b.step, undefined, { numeric: true }));
144
+ }
145
+
146
+ /** The middle value. */
147
+ const median = (xs) => {
148
+ const s = [...xs].sort((a, b) => a - b);
149
+ const m = Math.floor(s.length / 2);
150
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
151
+ };
152
+
153
+ /**
154
+ * Every step with a forecast, landed or not: what it was forecast before it was built, and what its commits added.
155
+ *
156
+ * @param {string} dir - The project.
157
+ * @returns {{steps: object[], limit: number}|null} Null outside a git repository.
158
+ */
159
+ export function measure(dir) {
160
+ const profile = (() => {
161
+ try {
162
+ return JSON.parse(readFileSync(join(dir, HARNESS, 'profile.json'), 'utf8'));
163
+ } catch {
164
+ return {};
165
+ }
166
+ })();
167
+ const limit = Number(profile?.vocabulary?.STEP_LINES) || Number(defaultVocabulary(PACKAGE).STEP_LINES) || 400;
168
+ // It runs every turn: a project with no step file does not pay for its history.
169
+ const files = stepFiles(dir);
170
+ if (files.length === 0) return git(dir, ['rev-parse', '--verify', '--quiet', 'HEAD']) === null ? null : { steps: [], limit };
171
+ const log = git(dir, ['log', '--format=%H%x09%ct%x09%cI%x09%s', 'HEAD']);
172
+ if (log === null) return null;
173
+ // Times as instants: an ISO time carries its zone, and two zones compared as text order wrongly.
174
+ const commits = log
175
+ .split('\n')
176
+ .filter(Boolean)
177
+ .map((row) => {
178
+ const [hash, seconds, at, ...rest] = row.split('\t');
179
+ return { hash, t: Number(seconds) * 1000, at, subject: rest.join('\t') };
180
+ })
181
+ .reverse();
182
+ const order = new Map(commits.map((c, i) => [c.hash, i]));
183
+ const named = new Map(files.map((f) => [f.path, commits.filter((c) => names(c.subject, f.spec, f.step))]));
184
+ const code = codeOf(dir, [...new Set([...named.values()].flat().map((c) => c.hash))]);
185
+ const changes = changesTo(dir, files.map((f) => f.path));
186
+ const wanted = [];
187
+ for (const f of files) {
188
+ wanted.push(`HEAD:${f.path}`);
189
+ for (const c of changes.get(f.path)) wanted.push(`${c.hash}:${f.path}`);
190
+ for (const c of named.get(f.path)) wanted.push(`${c.hash}^:${f.path}`, `${c.hash}:${f.path}`);
191
+ }
192
+ const text = blobs(dir, wanted);
193
+ const at = (rev, path) => forecastIn(text.get(`${rev}:${path}`) ?? '');
194
+ // When a forecast was written: the oldest commit of the newest run, before a point in history, that carried it. An
195
+ // edit to the rest of the step file is not a forecast. Null where no commit is found to carry it.
196
+ const writtenAt = (path, before, lines) => {
197
+ let when = null;
198
+ for (const c of changes.get(path)) {
199
+ if ((order.get(c.hash) ?? -1) >= before) continue;
200
+ if (at(c.hash, path)?.lines !== lines) break;
201
+ when = c.t;
202
+ }
203
+ return when;
204
+ };
205
+ const steps = [];
206
+ for (const file of files) {
207
+ const now = forecastIn(readFileSync(join(dir, file.path), 'utf8'));
208
+ const landings = named.get(file.path).filter((c) => code.get(c.hash)?.size > 0);
209
+ if (landings.length === 0) {
210
+ if (!now) continue;
211
+ // Forecast again in the working tree, it is newer than every commit.
212
+ const since = at('HEAD', file.path)?.lines === now.lines ? writtenAt(file.path, Infinity, now.lines) : Infinity;
213
+ steps.push({ ...file, landed: false, forecast: now, since });
214
+ continue;
215
+ }
216
+ // The forecast the step was built to: its file before its first commit, or as that commit wrote it.
217
+ const first = landings[0];
218
+ const before = at(`${first.hash}^`, file.path);
219
+ const forecast = before ?? at(first.hash, file.path);
220
+ if (!forecast) continue;
221
+ const since = (before ? writtenAt(file.path, order.get(first.hash), forecast.lines) : null) ?? first.t;
222
+ const lines = new Map();
223
+ for (const c of landings) for (const [path, added] of code.get(c.hash)) lines.set(path, (lines.get(path) ?? 0) + added);
224
+ const total = [...lines.values()].reduce((a, n) => a + n, 0);
225
+ const last = landings.at(-1);
226
+ // A forecast changed while the step was built — a fix round's spec — is shown, and the step measured against the first.
227
+ const revised = at(last.hash, file.path);
228
+ steps.push({
229
+ ...file,
230
+ landed: true,
231
+ forecast,
232
+ revised: revised && revised.lines !== forecast.lines ? revised : null,
233
+ since,
234
+ lines: total,
235
+ files: lines.size,
236
+ ratio: total / forecast.lines,
237
+ t: last.t,
238
+ at: last.at,
239
+ hash: last.hash,
240
+ });
241
+ }
242
+ return { steps, limit };
243
+ }
244
+
245
+ /**
246
+ * The rate to forecast a step again at: the median actual over forecast of its spec's newest landed steps forecast no
247
+ * earlier than it was. A step forecast before the last re-forecast was measured against the old rates, which that
248
+ * re-forecast already corrected for, and counting it would correct for them twice.
249
+ *
250
+ * @param {object[]} steps
251
+ * @param {{spec: string, since?: string}} step - Its spec, and when its own forecast was written.
252
+ * @returns {{rate: number, over: number}|null}
253
+ */
254
+ export function rateOf(steps, { spec, since }) {
255
+ // A forecast not known to be written when is measured by nothing, rather than by everything.
256
+ if (since === null || since === undefined) return null;
257
+ const landed = steps
258
+ .filter((s) => s.landed && s.spec === spec && s.since >= since)
259
+ .sort((a, b) => a.t - b.t)
260
+ .slice(-RATE_OVER);
261
+ return landed.length ? { rate: median(landed.map((s) => s.ratio)), over: landed.length } : null;
262
+ }
263
+
264
+ /**
265
+ * What the detector says: the newest landed step, when it overran, and the steps of its spec still forecast as they
266
+ * were when it landed.
267
+ *
268
+ * @param {string} dir
269
+ * @param {{steps: object[], limit: number}} measured
270
+ * @returns {{step: object, rate: {rate: number, over: number}, stale: object[]}|null}
271
+ */
272
+ export function overrun({ steps }) {
273
+ const newest = steps.filter((s) => s.landed).sort((a, b) => a.t - b.t).at(-1);
274
+ if (!newest || newest.ratio <= OVERRUN) return null;
275
+ // Forecast no later than the overrun step was, so its rate measures them: forecast again since, they are not stale.
276
+ const stale = steps.filter((s) => !s.landed && s.spec === newest.spec && s.since !== null && s.since <= newest.since);
277
+ return stale.length ? { step: newest, rate: rateOf(steps, stale[0]), stale } : null;
278
+ }
279
+
280
+ const times = (x) => `×${x.toFixed(2)}`;
281
+
282
+ /**
283
+ * `nina sizes`.
284
+ *
285
+ * @param {string[]} argv
286
+ * @returns {Promise<number>}
287
+ */
288
+ export async function sizes(argv) {
289
+ const arg = (name) => (argv.includes(name) ? argv[argv.indexOf(name) + 1] : null);
290
+ const dir = resolve(arg('--project') ?? '.');
291
+ const check = argv.includes('--check');
292
+ if (!existsSync(join(dir, HARNESS, 'profile.json'))) {
293
+ console.error(` no ${HARNESS}/profile.json under ${dir} — run it inside a project, or name one with --project\n`);
294
+ return check ? 0 : 1;
295
+ }
296
+ const measured = measure(dir);
297
+ if (measured === null) {
298
+ if (check) console.log('sizes: current');
299
+ else console.error(' not a git repository, or one with no commit yet: a step is measured by its commits\n');
300
+ return check ? 0 : 1;
301
+ }
302
+ const { steps, limit } = measured;
303
+ if (check) {
304
+ const found = overrun(measured);
305
+ if (!found) {
306
+ console.log('sizes: current');
307
+ return 0;
308
+ }
309
+ const { step, rate, stale } = found;
310
+ const again = stale.slice(0, 4).map((s) => {
311
+ const next = Math.round(s.forecast.lines * rate.rate);
312
+ return `${s.step} ~${s.forecast.lines} → ~${next}${next > limit ? ` (past ${limit}: split)` : ''}`;
313
+ });
314
+ console.log(
315
+ `step ${step.step} of ${step.spec} landed ${step.lines} lines against ~${step.forecast.lines} (${times(step.ratio)}); ` +
316
+ `${stale.length} step(s) forecast when it was have not been forecast again — at ${times(rate.rate)}, over ${rate.over} landed step(s) forecast the same way: ` +
317
+ `${again.join(', ')}${stale.length > again.length ? ', …' : ''}`,
318
+ );
319
+ return 1;
320
+ }
321
+ if (steps.length === 0) {
322
+ console.log(` no step file under ${SPECS} states a \`Size:\` line yet\n`);
323
+ return 0;
324
+ }
325
+ for (const spec of [...new Set(steps.map((s) => s.spec))]) {
326
+ const mine = steps.filter((s) => s.spec === spec);
327
+ console.log(`\n ${bold(pink(spec))}${dim(` landed against the forecast it was built to; limit ${limit} lines`)}`);
328
+ for (const s of mine.filter((x) => x.landed).sort((a, b) => a.t - b.t)) {
329
+ const mark = s.ratio > OVERRUN ? amber(times(s.ratio)) : times(s.ratio);
330
+ const files = s.forecast.files === null ? `${s.files} file(s)` : `${s.files} file(s) of ${s.forecast.files}`;
331
+ const revised = s.revised ? `, revised to ~${s.revised.lines} while built` : '';
332
+ console.log(` ${s.step.padEnd(10)} ~${String(s.forecast.lines).padEnd(5)} → ${String(s.lines).padEnd(5)} ${mark} ${dim(`${files}${revised} · ${s.hash.slice(0, 7)} ${s.at.slice(0, 10)}`)}`);
333
+ }
334
+ for (const s of mine.filter((x) => !x.landed)) {
335
+ const rate = rateOf(steps, s);
336
+ if (!rate) {
337
+ console.log(` ${s.step.padEnd(10)} ~${String(s.forecast.lines).padEnd(5)} ${dim('to land — forecast after every step that landed, so none measures it yet')}`);
338
+ continue;
339
+ }
340
+ const next = Math.round(s.forecast.lines * rate.rate);
341
+ console.log(
342
+ ` ${s.step.padEnd(10)} ~${String(s.forecast.lines).padEnd(5)} → ~${String(next).padEnd(5)} ` +
343
+ dim(`to land, at ${times(rate.rate)} over ${rate.over} step(s) forecast as it was`) +
344
+ (next > limit ? amber(` past ${limit}: split`) : ''),
345
+ );
346
+ }
347
+ }
348
+ console.log('');
349
+ return 0;
350
+ }
package/src/gate.mjs CHANGED
@@ -59,7 +59,7 @@
59
59
 
60
60
  import { spawnSync } from 'node:child_process';
61
61
  import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
62
- import { dirname, join } from 'node:path';
62
+ import { basename, dirname, join } from 'node:path';
63
63
  import { fileURLToPath } from 'node:url';
64
64
  import { TERMINALS, declaredTokens, parseGraph } from './graph.mjs';
65
65
  import { gateDir, layerRootFor, readProfile, slugFor } from './paths.mjs';
@@ -215,7 +215,7 @@ export function loadProject(root) {
215
215
  const spec = join(root, '.claude', 'agents', `${stage}.md`);
216
216
  if (existsSync(spec)) tokens.set(stage, declaredTokens(readFileSync(spec, 'utf8')));
217
217
  }
218
- return { graph, tokens, loops: loopEdges(graph), forward: forwardEdges(graph) };
218
+ return { root, graph, tokens, loops: loopEdges(graph), forward: forwardEdges(graph) };
219
219
  }
220
220
 
221
221
  /**
@@ -637,7 +637,138 @@ function onPost(input, project, path, at) {
637
637
  // The name an agent was launched with, if any: a later SendMessage may reach it by that name alone.
638
638
  const name = tool !== 'SendMessage' && typeof input.tool_input?.name === 'string' ? input.tool_input.name.slice(0, 80) : null;
639
639
  append(path, { k: 'dispatch', at, role, agent, via: tool, id: input.tool_use_id ?? null, ...describedAs(input), ...(name ? { name } : {}), ...(resumed === undefined ? {} : { resumed }) });
640
- return null;
640
+ // A stage run in the foreground has finished by now, and its report is this call's result. One launched in the
641
+ // background, or sent a message, has not: its report arrives later, as a prompt.
642
+ if (tool === 'SendMessage' || response.isAsync === true || String(response.status ?? '').startsWith('async') || input.tool_input?.run_in_background === true) return null;
643
+ return leftBehind(input, project, path, at, agent, 'PostToolUse');
644
+ }
645
+
646
+ /** Where a tool call names a path: a shell command, or a file tool's path. A file's contents are not read, and a long
647
+ * run of characters with no space — a base64 blob a stage wrote — is what made the pattern below take seconds. */
648
+ const PATH_KEYS = ['command', 'file_path', 'path', 'notebook_path'];
649
+
650
+ /** A text as a pattern that matches it literally. */
651
+ const literal = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
652
+
653
+ /** How much of a stage's transcript is read for its commands: a run's commands are seldom near this, a stray never is. */
654
+ const STRAY_TAIL = 32 * 1024 * 1024;
655
+
656
+ /**
657
+ * What a stage left at the root of the session's scratchpad (Hard Rule #18): each entry its commands named there, made
658
+ * or changed since it was dispatched, that is not a stage's directory, `<role>-<step>-XXXX`. On the first night the rule
659
+ * held it was read and still missed twice — a reviewer's log, an integration-tester's pointer file — and only the
660
+ * orchestrator, by chance, saw either. The root is the one the stage's commands used, written out or held in a
661
+ * variable, so nothing here assumes where Claude Code keeps it; an entry no command of the stage named, or one it only
662
+ * read, is not laid at its door. A `cd` into the root and a bare name are not followed.
663
+ *
664
+ * @param {string} transcript - The stage's own transcript.
665
+ * @param {Set<string>} roles - The pipeline's stages, whose directories the root may hold.
666
+ * @param {{since?: number, project?: string}} [options] - When the stage was dispatched, in ms; and the project, inside
667
+ * which a directory called `scratchpad` is the project's own, not the session's.
668
+ * @returns {string[]} The paths left there.
669
+ */
670
+ export function strays(transcript, roles, { since = 0, project = null } = {}) {
671
+ let text = '';
672
+ try {
673
+ const size = statSync(transcript).size;
674
+ const length = Math.min(size, STRAY_TAIL);
675
+ const buffer = Buffer.alloc(length);
676
+ const fd = openSync(transcript, 'r');
677
+ try {
678
+ readSync(fd, buffer, 0, length, size - length);
679
+ } finally {
680
+ closeSync(fd);
681
+ }
682
+ for (const line of buffer.toString('utf8').split('\n')) {
683
+ if (!line.includes('"tool_use"') || !line.includes('scratchpad')) continue;
684
+ let row;
685
+ try {
686
+ row = JSON.parse(line);
687
+ } catch {
688
+ continue;
689
+ }
690
+ if (!Array.isArray(row?.message?.content)) continue;
691
+ for (const block of row.message.content) {
692
+ if (block?.type !== 'tool_use' || !block.input || typeof block.input !== 'object') continue;
693
+ for (const key of PATH_KEYS) if (typeof block.input[key] === 'string') text += `${block.input[key]}\n`;
694
+ }
695
+ }
696
+ } catch {
697
+ return [];
698
+ }
699
+ if (!text.includes('/scratchpad')) return [];
700
+ const own = new RegExp(`^(${[...roles].map(literal).join('|')})-.+-[A-Za-z0-9]{4,}$`);
701
+ const inside = project ? `${realpathOr(project)}/` : null;
702
+ const out = [];
703
+ for (const root of new Set([...text.matchAll(/(\/[^\s"'`;|&<>()$\\]{0,400}\/scratchpad)(?![\w.-])/g)].map((m) => m[1]))) {
704
+ if (inside && `${realpathOr(root)}/`.startsWith(inside)) continue;
705
+ let entries;
706
+ try {
707
+ entries = readdirSync(root, { withFileTypes: true });
708
+ } catch {
709
+ continue;
710
+ }
711
+ // The root as its commands wrote it, and every variable they set to it.
712
+ const ways = new Set([root]);
713
+ for (const m of text.matchAll(new RegExp(`\\b([A-Za-z_]\\w*)=["']?${literal(root)}["']?(?=[\\s;&|]|$)`, 'gm'))) ways.add(`$${m[1]}`).add(`\${${m[1]}}`);
714
+ const named = new RegExp(`(?:${[...ways].map(literal).join('|')})/([^\\s"'\`;|&<>()/\\\\]{1,200})`, 'g');
715
+ const touched = new Set([...text.matchAll(named)].map((m) => m[1]));
716
+ for (const entry of entries) {
717
+ if (entry.isDirectory() && own.test(entry.name)) continue;
718
+ if (!touched.has(entry.name)) continue;
719
+ // Read, not made: a file the orchestrator left there and a reviewer read is not the reviewer's.
720
+ let changed = 0;
721
+ try {
722
+ const stat = statSync(join(root, entry.name));
723
+ changed = Math.max(stat.mtimeMs, stat.birthtimeMs || 0);
724
+ } catch {
725
+ continue;
726
+ }
727
+ if (changed >= since) out.push(join(root, entry.name));
728
+ }
729
+ }
730
+ return out.sort();
731
+ }
732
+
733
+ /** A path with its links resolved, where it can be. */
734
+ function realpathOr(path) {
735
+ try {
736
+ return realpathSync(path);
737
+ } catch {
738
+ return path;
739
+ }
740
+ }
741
+
742
+ /**
743
+ * A stage's report reached the orchestrator: say what it left at the scratchpad's root, once each, for the
744
+ * orchestrator to move or remove before a stage beside it overwrites or deletes it.
745
+ *
746
+ * @param {string|null} named - The agent, by its id or the name it was launched with.
747
+ * @returns {object|null} The hook's answer, its text for the orchestrator.
748
+ */
749
+ function leftBehind(input, project, path, at, named, event) {
750
+ if (!named || typeof input.transcript_path !== 'string' || typeof input.session_id !== 'string') return null;
751
+ const entries = readLedger(path);
752
+ const { agent, role } = recipient(entries, project, named);
753
+ if (!agent || !role || !project.graph.stages.has(role)) return null;
754
+ // From when it went out: a foreground dispatch is recorded as it comes back, so its start is its `pre`.
755
+ const launched = entries.findLast((e) => e.k === 'dispatch' && e.agent === agent);
756
+ const pre = launched?.id ? entries.find((e) => e.k === 'pre' && e.id === launched.id) : null;
757
+ const transcript = join(dirname(input.transcript_path), input.session_id, 'subagents', `agent-${agent}.jsonl`);
758
+ const told = new Set(entries.filter((e) => e.k === 'stray').flatMap((e) => e.paths ?? []));
759
+ const found = strays(transcript, project.graph.stages, { since: Date.parse(pre?.at ?? launched?.at ?? '') || 0, project: project.root }).filter((p) => !told.has(p));
760
+ if (found.length === 0) return null;
761
+ append(path, { k: 'stray', at, agent, role, paths: found });
762
+ const names = found.map((p) => `\`${basename(p)}\``).join(', ');
763
+ return {
764
+ hookSpecificOutput: {
765
+ hookEventName: event,
766
+ additionalContext:
767
+ `NINA, Hard Rule #18: the ${role} whose report this is worked in ${names} at the root of the session's scratchpad ` +
768
+ `(${dirname(found[0])}), outside any stage's \`<role>-<step>-XXXX\` directory, where a stage beside it can overwrite ` +
769
+ `or delete it. Move it into the directory of the stage that made it, or remove it, and say so in that stage's next dispatch.`,
770
+ },
771
+ };
641
772
  }
642
773
 
643
774
  /** How far a round is past what holds it: its issue's cap, or its edge's ceiling. Above zero asks. */
@@ -862,7 +993,10 @@ export function handle(input, { root, now = new Date(), pkg = PACKAGE }) {
862
993
  const at = now.toISOString();
863
994
  switch (input?.hook_event_name) {
864
995
  case 'UserPromptSubmit':
865
- if (typeof input.prompt === 'string' && DELIVERED.test(input.prompt.slice(0, DELIVERY_HEAD))) return null;
996
+ if (typeof input.prompt === 'string' && DELIVERED.test(input.prompt.slice(0, DELIVERY_HEAD))) {
997
+ const from = /<agent-message from="([^"]+)"/.exec(input.prompt)?.[1] ?? /<task-id>([^<\s]+)<\/task-id>/.exec(input.prompt)?.[1];
998
+ return leftBehind(input, project, path, at, from, 'UserPromptSubmit');
999
+ }
866
1000
  append(path, { k: 'reset', at, why: 'prompt' });
867
1001
  prune(root, now);
868
1002
  return null;