@xhulz/nina 0.31.0 → 0.32.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 (137) hide show
  1. package/README.md +2 -2
  2. package/bin/nina.mjs +4 -0
  3. package/package.json +2 -1
  4. package/releases/0.32.0/core/GAPS.md +125 -0
  5. package/releases/0.32.0/core/answered.json +29 -0
  6. package/releases/0.32.0/core/defaults/tree/.claude/agents/architect.md +1 -0
  7. package/releases/0.32.0/core/defaults/tree/.claude/agents/dba.md +3 -0
  8. package/releases/0.32.0/core/defaults/tree/.claude/agents/devops.md +1 -0
  9. package/releases/0.32.0/core/defaults/tree/.claude/agents/implementer.md +1 -0
  10. package/releases/0.32.0/core/defaults/tree/.claude/agents/integration-tester.md +3 -0
  11. package/releases/0.32.0/core/defaults/tree/.claude/agents/qa.md +2 -0
  12. package/releases/0.32.0/core/defaults/tree/.claude/agents/reviewer.md +3 -0
  13. package/releases/0.32.0/core/defaults/tree/.claude/agents/secops.md +3 -0
  14. package/releases/0.32.0/core/defaults/tree/.claude/agents/solidity-auditor.md +3 -0
  15. package/releases/0.32.0/core/defaults/tree/.claude/agents/solidity-dev.md +3 -0
  16. package/releases/0.32.0/core/tree/.claude/agents/architect.md +126 -0
  17. package/releases/0.32.0/core/tree/.claude/agents/dba.md +110 -0
  18. package/releases/0.32.0/core/tree/.claude/agents/devops.md +114 -0
  19. package/releases/0.32.0/core/tree/.claude/agents/implementer.md +155 -0
  20. package/releases/0.32.0/core/tree/.claude/agents/integration-tester.md +168 -0
  21. package/releases/0.32.0/core/tree/.claude/agents/planner.md +132 -0
  22. package/releases/0.32.0/core/tree/.claude/agents/qa.md +148 -0
  23. package/releases/0.32.0/core/tree/.claude/agents/reviewer.md +183 -0
  24. package/releases/0.32.0/core/tree/.claude/agents/secops.md +121 -0
  25. package/releases/0.32.0/core/tree/.claude/agents/solidity-auditor.md +126 -0
  26. package/releases/0.32.0/core/tree/.claude/agents/solidity-dev.md +123 -0
  27. package/releases/0.32.0/core/tree/.claude/agents-overview.md +64 -0
  28. package/releases/0.32.0/core/tree/.claude/frontend.md +10 -0
  29. package/releases/0.32.0/core/tree/.claude/graph.md +75 -0
  30. package/releases/0.32.0/core/tree/.claude/patterns.md +213 -0
  31. package/releases/0.32.0/core/tree/.claude/pills/README.md +146 -0
  32. package/releases/0.32.0/core/tree/.claude/pipeline.md +101 -0
  33. package/releases/0.32.0/core/tree/.claude/retrieval.md +103 -0
  34. package/releases/0.32.0/core/tree/.claude/router.md +195 -0
  35. package/releases/0.32.0/core/tree/.claude/templates/integration.md +45 -0
  36. package/releases/0.32.0/core/tree/CLAUDE.md +160 -0
  37. package/releases/0.32.0/core/tree/scripts/cost-watch.mjs +18 -0
  38. package/releases/0.32.0/core/tree/scripts/edit-guard.mjs +16 -0
  39. package/releases/0.32.0/core/tree/scripts/harness-check.mjs +71 -0
  40. package/releases/0.32.0/core/tree/scripts/loop-gate.mjs +19 -0
  41. package/releases/0.32.0/core/vocabulary.json +13 -0
  42. package/releases/0.32.0/surfaces/blockchain/tree/.claude/agents/architect.md +5 -0
  43. package/releases/0.32.0/surfaces/blockchain/tree/.claude/agents/devops.md +5 -0
  44. package/releases/0.32.0/surfaces/blockchain/tree/.claude/agents/reviewer.md +5 -0
  45. package/releases/0.32.0/surfaces/blockchain/tree/.claude/agents/solidity-auditor.md +18 -0
  46. package/releases/0.32.0/surfaces/blockchain/tree/.claude/agents/solidity-dev.md +16 -0
  47. package/releases/0.32.0/surfaces/blockchain/tree/.claude/agents-overview.md +6 -0
  48. package/releases/0.32.0/surfaces/blockchain/tree/.claude/graph.md +11 -0
  49. package/releases/0.32.0/surfaces/blockchain/tree/.claude/router.md +6 -0
  50. package/releases/0.32.0/surfaces/blockchain/tree/CLAUDE.md +12 -0
  51. package/releases/0.32.0/surfaces/db/tree/.claude/agents/architect.md +5 -0
  52. package/releases/0.32.0/surfaces/db/tree/.claude/agents/dba.md +50 -0
  53. package/releases/0.32.0/surfaces/db/tree/.claude/agents/devops.md +5 -0
  54. package/releases/0.32.0/surfaces/db/tree/.claude/agents/implementer.md +2 -0
  55. package/releases/0.32.0/surfaces/db/tree/.claude/agents/integration-tester.md +10 -0
  56. package/releases/0.32.0/surfaces/db/tree/.claude/agents/planner.md +2 -0
  57. package/releases/0.32.0/surfaces/db/tree/.claude/agents/reviewer.md +22 -0
  58. package/releases/0.32.0/surfaces/db/tree/.claude/agents/secops.md +2 -0
  59. package/releases/0.32.0/surfaces/db/tree/.claude/agents-overview.md +5 -0
  60. package/releases/0.32.0/surfaces/db/tree/.claude/graph.md +8 -0
  61. package/releases/0.32.0/surfaces/db/tree/.claude/patterns.md +19 -0
  62. package/releases/0.32.0/surfaces/db/tree/.claude/pipeline.md +13 -0
  63. package/releases/0.32.0/surfaces/db/tree/.claude/retrieval.md +5 -0
  64. package/releases/0.32.0/surfaces/db/tree/.claude/router.md +4 -0
  65. package/releases/0.32.0/surfaces/db/tree/CLAUDE.md +17 -0
  66. package/releases/0.32.0/surfaces/edge-cf/tree/.claude/agents/architect.md +14 -0
  67. package/releases/0.32.0/surfaces/edge-cf/tree/.claude/agents/dba.md +2 -0
  68. package/releases/0.32.0/surfaces/edge-cf/tree/.claude/agents/devops.md +32 -0
  69. package/releases/0.32.0/surfaces/edge-cf/tree/.claude/agents/implementer.md +14 -0
  70. package/releases/0.32.0/surfaces/edge-cf/tree/.claude/agents/integration-tester.md +11 -0
  71. package/releases/0.32.0/surfaces/edge-cf/tree/.claude/agents/qa.md +3 -0
  72. package/releases/0.32.0/surfaces/edge-cf/tree/.claude/agents/reviewer.md +8 -0
  73. package/releases/0.32.0/surfaces/edge-cf/tree/.claude/agents/secops.md +8 -0
  74. package/releases/0.32.0/surfaces/edge-cf/tree/.claude/patterns.md +17 -0
  75. package/releases/0.32.0/surfaces/edge-cf/tree/.claude/retrieval.md +8 -0
  76. package/releases/0.32.0/surfaces/edge-cf/tree/CLAUDE.md +27 -0
  77. package/releases/0.32.0/surfaces/frontend/tree/.claude/agents/architect.md +20 -0
  78. package/releases/0.32.0/surfaces/frontend/tree/.claude/agents/devops.md +28 -0
  79. package/releases/0.32.0/surfaces/frontend/tree/.claude/agents/implementer.md +25 -0
  80. package/releases/0.32.0/surfaces/frontend/tree/.claude/agents/qa.md +17 -0
  81. package/releases/0.32.0/surfaces/frontend/tree/.claude/agents/reviewer.md +56 -0
  82. package/releases/0.32.0/surfaces/frontend/tree/.claude/patterns.md +8 -0
  83. package/releases/0.32.0/surfaces/frontend/tree/.claude/retrieval.md +2 -0
  84. package/releases/0.32.0/surfaces/frontend/tree/.claude/router.md +14 -0
  85. package/releases/0.32.0/surfaces/frontend/tree/CLAUDE.md +5 -0
  86. package/releases/0.32.0/surfaces/integrations/tree/.claude/agents/architect.md +16 -0
  87. package/releases/0.32.0/surfaces/integrations/tree/.claude/agents/implementer.md +12 -0
  88. package/releases/0.32.0/surfaces/integrations/tree/.claude/agents/integration-tester.md +20 -0
  89. package/releases/0.32.0/surfaces/integrations/tree/.claude/agents/planner.md +2 -0
  90. package/releases/0.32.0/surfaces/integrations/tree/.claude/agents/reviewer.md +21 -0
  91. package/releases/0.32.0/surfaces/integrations/tree/.claude/agents/secops.md +2 -0
  92. package/releases/0.32.0/surfaces/integrations/tree/.claude/agents-overview.md +8 -0
  93. package/releases/0.32.0/surfaces/integrations/tree/.claude/graph.md +10 -0
  94. package/releases/0.32.0/surfaces/integrations/tree/.claude/patterns.md +38 -0
  95. package/releases/0.32.0/surfaces/integrations/tree/.claude/pipeline.md +16 -0
  96. package/releases/0.32.0/surfaces/integrations/tree/.claude/retrieval.md +16 -0
  97. package/releases/0.32.0/surfaces/integrations/tree/.claude/router.md +6 -0
  98. package/releases/0.32.0/surfaces/integrations/tree/CLAUDE.md +17 -0
  99. package/releases/0.32.0/surfaces/money/tree/.claude/agents/architect.md +12 -0
  100. package/releases/0.32.0/surfaces/money/tree/.claude/agents/dba.md +14 -0
  101. package/releases/0.32.0/surfaces/money/tree/.claude/agents/implementer.md +11 -0
  102. package/releases/0.32.0/surfaces/money/tree/.claude/agents/integration-tester.md +3 -0
  103. package/releases/0.32.0/surfaces/money/tree/.claude/agents/reviewer.md +23 -0
  104. package/releases/0.32.0/surfaces/money/tree/.claude/agents/secops.md +5 -0
  105. package/releases/0.32.0/surfaces/money/tree/.claude/agents/solidity-auditor.md +4 -0
  106. package/releases/0.32.0/surfaces/money/tree/.claude/agents/solidity-dev.md +4 -0
  107. package/releases/0.32.0/surfaces/money/tree/.claude/graph.md +2 -0
  108. package/releases/0.32.0/surfaces/money/tree/.claude/patterns.md +49 -0
  109. package/releases/0.32.0/surfaces/money/tree/.claude/pipeline.md +2 -0
  110. package/releases/0.32.0/surfaces/money/tree/.claude/retrieval.md +2 -0
  111. package/releases/0.32.0/surfaces/money/tree/CLAUDE.md +11 -0
  112. package/releases/0.32.0/surfaces/pii/tree/.claude/agents/dba.md +2 -0
  113. package/releases/0.32.0/surfaces/pii/tree/.claude/agents/devops.md +8 -0
  114. package/releases/0.32.0/surfaces/pii/tree/.claude/agents/reviewer.md +8 -0
  115. package/releases/0.32.0/surfaces/pii/tree/.claude/agents/secops.md +2 -0
  116. package/releases/0.32.0/surfaces/pii/tree/.claude/agents/solidity-auditor.md +5 -0
  117. package/releases/0.32.0/surfaces/pii/tree/CLAUDE.md +5 -0
  118. package/releases/0.32.0/surfaces/prisma/tree/.claude/agents/architect.md +2 -0
  119. package/releases/0.32.0/surfaces/prisma/tree/.claude/agents/dba.md +39 -0
  120. package/releases/0.32.0/surfaces/prisma/tree/.claude/agents/devops.md +11 -0
  121. package/releases/0.32.0/surfaces/prisma/tree/.claude/agents/implementer.md +2 -0
  122. package/releases/0.32.0/surfaces/prisma/tree/.claude/agents/integration-tester.md +2 -0
  123. package/releases/0.32.0/surfaces/prisma/tree/.claude/agents/reviewer.md +2 -0
  124. package/releases/0.32.0/surfaces/prisma/tree/.claude/patterns.md +15 -0
  125. package/releases/0.32.0/surfaces/prisma/tree/CLAUDE.md +5 -0
  126. package/src/commands/check.mjs +36 -6
  127. package/src/commands/compose.mjs +5 -0
  128. package/src/commands/learn.mjs +27 -7
  129. package/src/commands/pills.mjs +54 -2
  130. package/src/commands/runs.mjs +124 -0
  131. package/src/commands/snapshot.mjs +80 -30
  132. package/src/commands/stats.mjs +35 -21
  133. package/src/commands/status.mjs +150 -0
  134. package/src/commands/upgrade.mjs +6 -1
  135. package/src/cost.mjs +141 -0
  136. package/src/detectors.mjs +84 -14
  137. package/src/wiring.mjs +3 -0
@@ -0,0 +1,150 @@
1
+ /**
2
+ * `nina status` — one screen for one project: what it pins and what is installed, whether a newer release
3
+ * is out, whether its hooks are wired and its gate holds, what `check` says, how fresh its measured
4
+ * history is and what the last week cost, and where its learning stands.
5
+ *
6
+ * Each of these had its own command, and the question a person brings — is this project's harness in order,
7
+ * and is it current — needed six of them. Every line here reads what those commands read, or asks them, so
8
+ * the screen cannot disagree with them.
9
+ *
10
+ * nina status [--project <dir>] [--offline]
11
+ */
12
+
13
+ import { spawnSync } from 'node:child_process';
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { createRequire } from 'node:module';
16
+ import { join, resolve } from 'node:path';
17
+ import { HARNESS } from '../paths.mjs';
18
+ import { costOf } from '../prices.mjs';
19
+ import { runOf } from '../transcripts.mjs';
20
+ import { missingWiring, shippedScripts } from '../wiring.mjs';
21
+ import { amber, bold, dim, green, pink } from '../look.mjs';
22
+ import { byVersion, layerRootFor } from './compose.mjs';
23
+ import { filedRequests } from './learn.mjs';
24
+ import { frontmatter, pillFiles } from './pills.mjs';
25
+ import { storedRecords } from './snapshot.mjs';
26
+
27
+ /** How long the registry gets to say which version is newest; a status is not worth waiting on. */
28
+ const REGISTRY_MS = 3000;
29
+
30
+ /**
31
+ * The version of NINA the project has installed, or null.
32
+ *
33
+ * @param {string} target
34
+ * @returns {string|null}
35
+ */
36
+ function installedVersion(target) {
37
+ try {
38
+ const path = createRequire(join(target, 'package.json')).resolve('@xhulz/nina/package.json');
39
+ return JSON.parse(readFileSync(path, 'utf8')).version ?? null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * The newest version on the registry, or null when it cannot be asked.
47
+ *
48
+ * @returns {Promise<string|null>}
49
+ */
50
+ async function latestVersion() {
51
+ try {
52
+ const answer = await fetch('https://registry.npmjs.org/@xhulz%2Fnina/latest', { signal: AbortSignal.timeout(REGISTRY_MS) });
53
+ return answer.ok ? ((await answer.json()).version ?? null) : null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The last line a command printed, and whether it passed: what `check` and `gate --selftest` conclude.
61
+ *
62
+ * @param {string} bin - This install's entry point.
63
+ * @param {string[]} args
64
+ * @returns {{ok: boolean, line: string}}
65
+ */
66
+ function verdictOf(bin, args) {
67
+ const run = spawnSync(process.execPath, [bin, ...args, '--quiet'], { encoding: 'utf8', timeout: 120_000 });
68
+ const lines = `${run.stdout ?? ''}`.split('\n').map((l) => l.trim()).filter(Boolean);
69
+ return { ok: run.status === 0, line: lines.at(-1) ?? `exit ${run.status}` };
70
+ }
71
+
72
+ /** How long ago a moment was, in words. */
73
+ function ago(iso, now) {
74
+ const ms = now - Date.parse(iso);
75
+ if (!Number.isFinite(ms)) return 'at an unknown time';
76
+ const h = ms / 3_600_000;
77
+ return h < 1 ? `${Math.max(1, Math.round(ms / 60_000))} min ago` : h < 48 ? `${Math.round(h)}h ago` : `${Math.round(h / 24)} days ago`;
78
+ }
79
+
80
+ /**
81
+ * Runs the command.
82
+ *
83
+ * @param {string[]} argv
84
+ * @param {{root: string}} ctx
85
+ * @returns {Promise<number>}
86
+ */
87
+ export async function status(argv, ctx) {
88
+ const target = resolve(argv.includes('--project') ? argv[argv.indexOf('--project') + 1] : '.');
89
+ const profilePath = join(target, HARNESS, 'profile.json');
90
+ if (!existsSync(profilePath)) {
91
+ console.error(` no ${HARNESS}/profile.json under ${target} — run it inside a project, or name one with --project\n`);
92
+ return 1;
93
+ }
94
+ const profile = JSON.parse(readFileSync(profilePath, 'utf8'));
95
+ const bin = join(ctx.root, 'bin', 'nina.mjs');
96
+ const now = Date.now();
97
+ const row = (label, text) => console.log(` ${pink(label.padEnd(10))} ${text}`);
98
+ let attention = 0;
99
+ const warn = (text) => {
100
+ attention += 1;
101
+ return amber(text);
102
+ };
103
+
104
+ const installed = installedVersion(target);
105
+ const latest = argv.includes('--offline') ? null : await latestVersion();
106
+ const pinned = profile.core;
107
+ const behind = latest && pinned !== 'dev' && byVersion(pinned, latest) < 0;
108
+ row(
109
+ 'version',
110
+ `pins ${bold(pinned)} · installed ${installed ? bold(installed) : warn('none — pnpm add -D -E @xhulz/nina')}` +
111
+ (latest ? ` · newest ${bold(latest)}${behind ? warn(` — \`nina upgrade --to ${latest}\` says what the move costs`) : green(' — current')}` : dim(' · newest unknown (offline)')),
112
+ );
113
+
114
+ const layers = layerRootFor(ctx.root, pinned);
115
+ if (!layers.error) {
116
+ const unwired = await missingWiring(target, await shippedScripts(layers.dir, profile.surfaces ?? []));
117
+ row('hooks', unwired.length === 0 ? green('every hook its release needs is wired') : warn(`${unwired.length} missing — \`nina wire --apply\` merges them`));
118
+ } else {
119
+ row('hooks', warn(layers.error));
120
+ }
121
+ const gate = verdictOf(bin, ['gate', '--selftest', '--project', target]);
122
+ row('gate', gate.ok ? green(gate.line) : warn(`${gate.line} — \`nina gate --selftest\` says what`));
123
+ const check = verdictOf(bin, ['check', '--project', target]);
124
+ row('check', check.ok ? green(check.line) : warn(`${check.line} — \`nina check\` lists them`));
125
+
126
+ const records = storedRecords(target) ?? [];
127
+ if (records.length === 0) {
128
+ row('measured', dim('nothing yet — `nina snapshot` captures it'));
129
+ } else {
130
+ const latestAt = records.map((r) => String(r.result_ts ?? r.ts)).sort().at(-1);
131
+ const week = new Date(now - 7 * 86_400_000).toISOString();
132
+ const recent = records.filter((r) => String(r.ts) >= week);
133
+ const cost = recent.reduce((a, r) => a + ((r.tokens && costOf(r.tokens, r.usage_model)) || 0), 0);
134
+ row(
135
+ 'measured',
136
+ `${new Set(records.map(runOf)).size} runs in ${records.length} rounds, the latest ${ago(latestAt, now)} · last 7 days: ` +
137
+ `${new Set(recent.map(runOf)).size} runs, ${bold(`$${cost.toFixed(cost >= 100 ? 0 : 2)}`)} at API list prices — \`nina runs\` itemizes it`,
138
+ );
139
+ }
140
+
141
+ const pillsDir = join(target, '.claude', 'pills');
142
+ const pills = existsSync(pillsDir) ? await pillFiles(pillsDir) : [];
143
+ let active = 0;
144
+ for (const p of pills) if (frontmatter(readFileSync(p.path, 'utf8'))?.status !== 'retired') active += 1;
145
+ const open = (await filedRequests(target)).filter((r) => r.status === 'open').length;
146
+ row('learning', `${pills.length} pill(s), ${active} active · ${open} request(s) open with the harness${open ? dim(' — `nina upgrade` to the release that answers one closes it') : ''}`);
147
+
148
+ console.log(attention === 0 ? `\n status: ${green('in order')}\n` : `\n status: ${attention} thing(s) to look at\n`);
149
+ return attention === 0 ? 0 : 1;
150
+ }
@@ -20,6 +20,7 @@ import { NEEDS, detected } from '../surfaces.mjs';
20
20
  import { filledSlots, projectSlots, referencedVocabulary } from './check.mjs';
21
21
  import { EXPECT_ENV } from '../expected.mjs';
22
22
  import { closeAnswered } from './learn.mjs';
23
+ import { tidyRetired } from './pills.mjs';
23
24
  import { missingFragment, missingWiring, shippedScripts } from '../wiring.mjs';
24
25
  import { runSteps } from '../steps.mjs';
25
26
  import { defaultVocabulary } from '../vocabulary.mjs';
@@ -235,7 +236,8 @@ export async function upgrade(argv, ctx) {
235
236
  const declared = profile.vocabulary ?? {};
236
237
  // A name the new release supplies a default for is not owed: it composes as the default until declared.
237
238
  const defaultsAfter = defaultVocabulary(onto.dir);
238
- const needed = [...vocabAfter].filter((v) => (!(v in declared) && !(v in defaultsAfter)) || declared[v] === null).sort();
239
+ const waits = (key) => typeof profile.deferred?.[key] === 'string' && profile.deferred[key].trim() !== '';
240
+ const needed = [...vocabAfter].filter((v) => ((!(v in declared) && !(v in defaultsAfter)) || declared[v] === null) && !waits(v)).sort();
239
241
  const unused = Object.keys(declared).filter((v) => !vocabAfter.has(v)).sort();
240
242
  // A slot the new release fills itself is not asked of the project: it composes as the release wrote it.
241
243
  const defaultedAfter = await defaultedSlots(onto.dir);
@@ -499,6 +501,9 @@ export async function upgrade(argv, ctx) {
499
501
  }
500
502
  }
501
503
  }
504
+ // Retired pills still where agents open them, from before they had a place of their own.
505
+ const shelved = await tidyRetired(target).catch(() => []);
506
+ if (shelved.length > 0) console.log(`\n moved ${shelved.length} retired pill(s) to .claude/pills/retired/, where no agent opens them: ${shelved.join(', ')}`);
502
507
  const lessons = nina(ctx, target, ['learn', '--project', target, '--check']);
503
508
  if (!lessons.ok && lessons.out.trim()) {
504
509
  console.log('\n the learning cycle has something to act on — not caused by this move, now visible:');
package/src/cost.mjs ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The cost watch: tells a subagent, while it runs, what its own context has come to cost.
3
+ *
4
+ * What a run spends is mostly its context read again: every turn re-reads everything the run has built,
5
+ * so its cost grows faster than its work. Over the first new project's first days, three quarters of the
6
+ * implementer's spend was cache read, a median 36M tokens a round and 159M at most, and nothing said so
7
+ * until `stats` read the transcripts afterwards. This runs on every tool call a subagent makes — Claude Code
8
+ * hands a PostToolUse hook's `additionalContext` to the subagent that made the call — and, each time the
9
+ * tokens its run has re-read cross a threshold and then each doubling of it, tells the run what each turn
10
+ * now costs and what to do about it, and tells the person in one line.
11
+ *
12
+ * It reads only what the run's transcript gained since its last call, keeps counts per message and
13
+ * nothing else, under the project's gate directory, and lets every call through if anything goes wrong.
14
+ */
15
+
16
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import { projectGateDir } from './gate.mjs';
19
+
20
+ /** Where a run's counts are kept between calls, one file per agent. */
21
+ const stateDir = (root) => join(projectGateDir(root), 'cost');
22
+
23
+ /** Runs' counts are dropped this long after their last call: a run does not come back after a week. */
24
+ const KEEP_MS = 7 * 86_400_000;
25
+
26
+ /**
27
+ * Reads what a file gained after an offset, up to its last complete line.
28
+ *
29
+ * @param {string} path - The transcript.
30
+ * @param {number} from - The offset already read.
31
+ * @returns {{text: string, to: number}}
32
+ */
33
+ function gained(path, from) {
34
+ const size = statSync(path).size;
35
+ if (size <= from) return { text: '', to: size < from ? 0 : from };
36
+ const buffer = Buffer.alloc(size - from);
37
+ const fd = openSync(path, 'r');
38
+ try {
39
+ readSync(fd, buffer, 0, buffer.length, from);
40
+ } finally {
41
+ closeSync(fd);
42
+ }
43
+ const end = buffer.lastIndexOf(0x0a);
44
+ if (end === -1) return { text: '', to: from };
45
+ return { text: buffer.subarray(0, end + 1).toString('utf8'), to: from + end + 1 };
46
+ }
47
+
48
+ /**
49
+ * What one call of the watch says, or null: the counts are brought up to date from the run's transcript,
50
+ * and a warning is due each time the total re-read crosses `warnAt`, then twice it, four times it, and on.
51
+ *
52
+ * @param {object} input - The PostToolUse hook's input.
53
+ * @param {{root: string, warnAt: number, now?: number}} options
54
+ * @returns {object|null} The hook's answer.
55
+ */
56
+ export function watch(input, { root, warnAt, now = Date.now() }) {
57
+ // Only inside a subagent, which is where `agent_id` is set; and an id that could not be a file name is not one.
58
+ if (input?.hook_event_name !== 'PostToolUse' || !/^[A-Za-z0-9_-]{1,64}$/.test(String(input.agent_id ?? ''))) return null;
59
+ if (typeof input.transcript_path !== 'string' || typeof input.session_id !== 'string' || !(warnAt > 0)) return null;
60
+ const transcript = join(dirname(input.transcript_path), input.session_id, 'subagents', `agent-${input.agent_id}.jsonl`);
61
+ if (!existsSync(transcript)) return null;
62
+ const dir = stateDir(root);
63
+ const file = join(dir, `${input.agent_id}.json`);
64
+ let state = { offset: 0, read: {}, warned: 0 };
65
+ try {
66
+ state = { ...state, ...JSON.parse(readFileSync(file, 'utf8')) };
67
+ } catch {
68
+ // A run's first call, or its counts lost: read its transcript from the start.
69
+ }
70
+ const { text, to } = gained(transcript, state.offset);
71
+ let last = null;
72
+ for (const line of text.split('\n')) {
73
+ if (!line.includes('"cache_read_input_tokens"')) continue;
74
+ try {
75
+ const message = JSON.parse(line)?.message;
76
+ const read = message?.usage?.cache_read_input_tokens;
77
+ // A streamed message is written once per content block under one id: its last copy counts.
78
+ if (message?.id && typeof read === 'number') {
79
+ state.read[message.id] = read;
80
+ last = read;
81
+ }
82
+ } catch {
83
+ // A torn line loses one message's count.
84
+ }
85
+ }
86
+ state.offset = to;
87
+ state.at = now;
88
+ const total = Object.values(state.read).reduce((a, b) => a + b, 0);
89
+ let crossed = state.warned;
90
+ while (total >= warnAt * 2 ** crossed) crossed += 1;
91
+ const due = crossed > state.warned;
92
+ state.warned = crossed;
93
+ mkdirSync(dir, { recursive: true });
94
+ writeFileSync(file, JSON.stringify(state));
95
+ if (!due) return null;
96
+ const m = (n) => `${Math.round(n / 1e6)}M`;
97
+ const size = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : `${Math.round(n / 1000)}k`);
98
+ const turn = last === null ? '' : `, and each turn now re-reads about ${size(last)} tokens more`;
99
+ return {
100
+ systemMessage: `NINA: ${input.agent_type ?? 'a subagent'} ${input.agent_id.slice(0, 8)} has re-read ${m(total)} tokens of its own context${turn}.`,
101
+ hookSpecificOutput: {
102
+ hookEventName: 'PostToolUse',
103
+ additionalContext:
104
+ `NINA cost watch: this run has re-read ${m(total)} tokens of its own context${turn}; every further turn costs more than the last. ` +
105
+ 'If what is left is more than one run should carry, stop where your work is whole, hand back what you have, and name what is left, so the orchestrator can send it as its own step. Otherwise finish without widening the work.',
106
+ },
107
+ };
108
+ }
109
+
110
+ /** Drops the counts of runs not heard from in a week. */
111
+ function prune(root, now) {
112
+ const dir = stateDir(root);
113
+ for (const name of existsSync(dir) ? readdirSync(dir) : []) {
114
+ try {
115
+ if (now - statSync(join(dir, name)).mtimeMs > KEEP_MS) rmSync(join(dir, name), { force: true });
116
+ } catch {
117
+ // Another call's, going as this one looks.
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * The hook: reads the event from stdin, answers on stdout when a warning is due, and exits 0 whatever
124
+ * happens — a hook that fails must not stop the run it watches.
125
+ *
126
+ * @param {{root: string, warnAt: number, stdin?: NodeJS.ReadableStream, stdout?: NodeJS.WritableStream}} options
127
+ * @returns {Promise<number>}
128
+ */
129
+ export async function runCostWatch({ root, warnAt, stdin = process.stdin, stdout = process.stdout }) {
130
+ try {
131
+ const chunks = [];
132
+ for await (const chunk of stdin) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
133
+ const now = Date.now();
134
+ const answer = watch(JSON.parse(Buffer.concat(chunks).toString('utf8')), { root, warnAt, now });
135
+ if (answer) stdout.write(`${JSON.stringify(answer)}\n`);
136
+ if (Math.random() < 0.01) prune(root, now);
137
+ } catch {
138
+ // Fails open: the run goes on unwatched.
139
+ }
140
+ return 0;
141
+ }
package/src/detectors.mjs CHANGED
@@ -24,7 +24,7 @@ import { spawnSync } from 'node:child_process';
24
24
  import { createHash } from 'node:crypto';
25
25
  import { hookStateDir, slugFor } from './paths.mjs';
26
26
  import { CHECK } from './wiring.mjs';
27
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
27
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from 'node:fs';
28
28
  import { join } from 'node:path';
29
29
 
30
30
  /**
@@ -157,20 +157,82 @@ const stopState = (root) => join(hookStateDir(), `${slugFor(root)}.stop.json`);
157
157
  /** Sessions remembered per project; the oldest is forgotten past this. */
158
158
  const SESSIONS_KEPT = 20;
159
159
 
160
+ /** What Claude Code wrote to the hook's stdin, read once: stdin can be read only once. */
161
+ let input;
162
+
163
+ /**
164
+ * The payload Claude Code writes to a hook's stdin, or an empty object run by hand.
165
+ *
166
+ * @returns {{session_id?: string, prompt?: string, transcript_path?: string}}
167
+ */
168
+ function hookInput() {
169
+ if (input === undefined) {
170
+ try {
171
+ input = process.stdin.isTTY ? {} : (JSON.parse(readFileSync(0, 'utf8')) ?? {});
172
+ } catch {
173
+ input = {};
174
+ }
175
+ }
176
+ return input;
177
+ }
178
+
160
179
  /**
161
- * The session a Stop hook runs in, from the payload Claude Code writes to its stdin. What was said is
162
- * kept per session: kept per project, a second session open on the same project found the finding
163
- * already told — to the first session's person — and told its own nothing.
180
+ * The session a hook runs in. What was said is kept per session: kept per project, a second session open
181
+ * on the same project found the finding already told — to the first session's person — and told its own
182
+ * nothing.
164
183
  *
165
184
  * @returns {string}
166
185
  */
167
- function hookSession() {
168
- if (process.stdin.isTTY) return '-';
186
+ const hookSession = () => String(hookInput().session_id ?? '-');
187
+
188
+ /**
189
+ * Whether the prompt the hook runs before is a subagent's completion notification rather than something a
190
+ * person said. Claude Code submits each as a prompt, and every one ran the detectors — a second or more
191
+ * before the model read it — and put their findings beside it: one session of the first new project did so
192
+ * 181 times over 23 of its owner's messages.
193
+ *
194
+ * @returns {boolean}
195
+ */
196
+ const notification = () => String(hookInput().prompt ?? '').trimStart().startsWith('<task-notification>');
197
+
198
+ /** How much of a transcript's end is read for a compaction since the last hand-over: it is the latest line. */
199
+ const TAIL = 1024 * 1024;
200
+
201
+ /**
202
+ * Whether the session was compacted after a moment: a compaction drops what the model was handed, so what it
203
+ * was handed is handed again.
204
+ *
205
+ * @param {unknown} path - The session's transcript.
206
+ * @param {unknown} since - An ISO time.
207
+ * @returns {boolean}
208
+ */
209
+ function compactedSince(path, since) {
210
+ if (typeof path !== 'string' || typeof since !== 'string' || !existsSync(path)) return false;
211
+ let text;
169
212
  try {
170
- return String(JSON.parse(readFileSync(0, 'utf8'))?.session_id ?? '-');
213
+ const size = statSync(path).size;
214
+ const length = Math.min(size, TAIL);
215
+ const buffer = Buffer.alloc(length);
216
+ const fd = openSync(path, 'r');
217
+ try {
218
+ readSync(fd, buffer, 0, length, size - length);
219
+ } finally {
220
+ closeSync(fd);
221
+ }
222
+ text = buffer.toString('utf8');
171
223
  } catch {
172
- return '-';
224
+ return false;
173
225
  }
226
+ for (const line of text.split('\n')) {
227
+ if (!line.includes('"compact_boundary"')) continue;
228
+ try {
229
+ const row = JSON.parse(line);
230
+ if (row?.subtype === 'compact_boundary' && String(row.timestamp) > since) return true;
231
+ } catch {
232
+ // A torn line at the head of the tail.
233
+ }
234
+ }
235
+ return false;
174
236
  }
175
237
 
176
238
  /** What was said, per session, for a project. */
@@ -237,8 +299,9 @@ function reportOf(drift, errored, missing, detectors) {
237
299
  /**
238
300
  * What the prompt hook hands the model, or null for nothing. A finding not handed over at the last
239
301
  * message goes in full, with the instruction to act on it or say it is pending. One that was, and has
240
- * not changed, goes as its summary, with the instruction not to say it again: told before every message
241
- * to say it was pending, the model closed every answer with the same line.
302
+ * not changed, goes as its summary beside a new one, with the instruction not to say it again — told
303
+ * before every message to say it was pending, the model closed every answer with the same line — and on
304
+ * its own not at all: the model has it from the first time, until a compaction drops it.
242
305
  *
243
306
  * @returns {string|null}
244
307
  */
@@ -317,6 +380,7 @@ function remember(root, session, change) {
317
380
  */
318
381
  export function runDetectors(detectors, options) {
319
382
  const { root, hook = false, context = false } = options;
383
+ if (context && notification()) return 0;
320
384
  const declared = declaredIn(root);
321
385
  const results = detectors.map((d) => runOne(d, root, declared, context ? 'context' : hook ? 'stop' : ''));
322
386
  const applicable = results.filter((r) => r.state !== 'n/a');
@@ -358,11 +422,17 @@ export function runDetectors(detectors, options) {
358
422
  // again: it has it already, and every copy stays in the conversation.
359
423
  if (context) {
360
424
  const session = hookSession();
361
- const before = readStops(root)[session]?.given;
362
- const handed = new Set(Array.isArray(before) ? before : []);
363
- remember(root, session, { given: drift.flatMap(findingKeys) });
425
+ const state = readStops(root)[session] ?? {};
426
+ // A compaction since the last hand-over dropped it from the model's context: everything goes again.
427
+ const compacted = compactedSince(hookInput().transcript_path, state.handed_at);
428
+ const handed = new Set(!compacted && Array.isArray(state.given) ? state.given : []);
364
429
  const known = drift.filter((r) => findingKeys(r).every((k) => handed.has(k)));
365
- const said = contextOf(drift.filter((r) => !known.includes(r)), known, errored, missing, detectors);
430
+ const fresh = drift.filter((r) => !known.includes(r));
431
+ remember(root, session, { given: drift.flatMap(findingKeys), ...(fresh.length > 0 ? { handed_at: new Date().toISOString() } : {}) });
432
+ // What the model was handed and has not changed is said only beside something new. Said on its own
433
+ // before every message, it went into the conversation on every prompt the session had.
434
+ const speaking = fresh.length > 0 || errored.length > 0 || missing.length > 0;
435
+ const said = contextOf(fresh, speaking ? known : [], errored, missing, detectors);
366
436
  if (said) {
367
437
  process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: said } })}\n`);
368
438
  }
package/src/wiring.mjs CHANGED
@@ -23,6 +23,7 @@ import { join } from 'node:path';
23
23
  export const CHECK = 'scripts/harness-check.mjs';
24
24
  export const GATE = 'scripts/loop-gate.mjs';
25
25
  export const GUARD = 'scripts/edit-guard.mjs';
26
+ export const COST = 'scripts/cost-watch.mjs';
26
27
 
27
28
  /** The npm scripts the detectors hang off: the reviewer runs one, the other is a detector itself. */
28
29
  export const SCRIPTS = {
@@ -135,6 +136,8 @@ export const HOOKS = [
135
136
  event: 'PreToolUse', matcher: 'Edit|Write|MultiEdit|NotebookEdit', script: GUARD, timeout: 10, why: 'a hand edit to a composed file is found only after the turn, as drift, and the next compose overwrites it',
136
137
  crash: (cannot) => ({ systemMessage: `NINA: the edit guard could not start, so composed files can be edited in place — ${cannot}` }),
137
138
  },
139
+ // Every tool, since what it watches is a subagent's every turn; a call outside a subagent returns at once.
140
+ { event: 'PostToolUse', script: COST, timeout: 10, why: 'a run that keeps re-reading its own context is told what it cost only afterwards, by `stats`' },
138
141
  ];
139
142
 
140
143
  /** Where a project's hooks can live: Claude Code reads both, so a hook kept in the local file counts. */