@xhulz/nina 0.70.0 → 0.71.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 (140) hide show
  1. package/package.json +1 -1
  2. package/releases/0.71.0/core/GAPS.md +125 -0
  3. package/releases/0.71.0/core/answered.json +89 -0
  4. package/releases/0.71.0/core/defaults/tree/.claude/agents/architect.md +1 -0
  5. package/releases/0.71.0/core/defaults/tree/.claude/agents/dba.md +3 -0
  6. package/releases/0.71.0/core/defaults/tree/.claude/agents/devops.md +1 -0
  7. package/releases/0.71.0/core/defaults/tree/.claude/agents/implementer.md +1 -0
  8. package/releases/0.71.0/core/defaults/tree/.claude/agents/integration-tester.md +3 -0
  9. package/releases/0.71.0/core/defaults/tree/.claude/agents/qa.md +7 -0
  10. package/releases/0.71.0/core/defaults/tree/.claude/agents/reviewer.md +3 -0
  11. package/releases/0.71.0/core/defaults/tree/.claude/agents/secops.md +3 -0
  12. package/releases/0.71.0/core/defaults/tree/.claude/agents/solidity-auditor.md +3 -0
  13. package/releases/0.71.0/core/defaults/tree/.claude/agents/solidity-dev.md +3 -0
  14. package/releases/0.71.0/core/tree/.claude/agents/architect.md +135 -0
  15. package/releases/0.71.0/core/tree/.claude/agents/dba.md +119 -0
  16. package/releases/0.71.0/core/tree/.claude/agents/design-reviewer.md +78 -0
  17. package/releases/0.71.0/core/tree/.claude/agents/devops.md +118 -0
  18. package/releases/0.71.0/core/tree/.claude/agents/implementer.md +178 -0
  19. package/releases/0.71.0/core/tree/.claude/agents/integration-tester.md +170 -0
  20. package/releases/0.71.0/core/tree/.claude/agents/planner.md +132 -0
  21. package/releases/0.71.0/core/tree/.claude/agents/qa.md +150 -0
  22. package/releases/0.71.0/core/tree/.claude/agents/reviewer.md +187 -0
  23. package/releases/0.71.0/core/tree/.claude/agents/secops.md +120 -0
  24. package/releases/0.71.0/core/tree/.claude/agents/solidity-auditor.md +126 -0
  25. package/releases/0.71.0/core/tree/.claude/agents/solidity-dev.md +123 -0
  26. package/releases/0.71.0/core/tree/.claude/agents-overview.md +65 -0
  27. package/releases/0.71.0/core/tree/.claude/frontend.md +10 -0
  28. package/releases/0.71.0/core/tree/.claude/graph.md +79 -0
  29. package/releases/0.71.0/core/tree/.claude/patterns.md +252 -0
  30. package/releases/0.71.0/core/tree/.claude/pills/README.md +150 -0
  31. package/releases/0.71.0/core/tree/.claude/pipeline.md +101 -0
  32. package/releases/0.71.0/core/tree/.claude/retrieval.md +103 -0
  33. package/releases/0.71.0/core/tree/.claude/router.md +221 -0
  34. package/releases/0.71.0/core/tree/.claude/templates/integration.md +48 -0
  35. package/releases/0.71.0/core/tree/CLAUDE.md +167 -0
  36. package/releases/0.71.0/core/tree/scripts/cost-watch.mjs +27 -0
  37. package/releases/0.71.0/core/tree/scripts/edit-guard.mjs +24 -0
  38. package/releases/0.71.0/core/tree/scripts/harness-check.mjs +82 -0
  39. package/releases/0.71.0/core/tree/scripts/loop-gate.mjs +19 -0
  40. package/releases/0.71.0/core/vocabulary.json +15 -0
  41. package/releases/0.71.0/surfaces/blockchain/tree/.claude/agents/architect.md +5 -0
  42. package/releases/0.71.0/surfaces/blockchain/tree/.claude/agents/devops.md +5 -0
  43. package/releases/0.71.0/surfaces/blockchain/tree/.claude/agents/reviewer.md +5 -0
  44. package/releases/0.71.0/surfaces/blockchain/tree/.claude/agents/solidity-auditor.md +18 -0
  45. package/releases/0.71.0/surfaces/blockchain/tree/.claude/agents/solidity-dev.md +16 -0
  46. package/releases/0.71.0/surfaces/blockchain/tree/.claude/agents-overview.md +6 -0
  47. package/releases/0.71.0/surfaces/blockchain/tree/.claude/graph.md +11 -0
  48. package/releases/0.71.0/surfaces/blockchain/tree/.claude/router.md +6 -0
  49. package/releases/0.71.0/surfaces/blockchain/tree/CLAUDE.md +12 -0
  50. package/releases/0.71.0/surfaces/d1/tree/.claude/agents/dba.md +15 -0
  51. package/releases/0.71.0/surfaces/d1/tree/.claude/agents/devops.md +2 -0
  52. package/releases/0.71.0/surfaces/d1/tree/.claude/agents/implementer.md +2 -0
  53. package/releases/0.71.0/surfaces/d1/tree/.claude/patterns.md +8 -0
  54. package/releases/0.71.0/surfaces/d1/tree/CLAUDE.md +2 -0
  55. package/releases/0.71.0/surfaces/db/tree/.claude/agents/architect.md +5 -0
  56. package/releases/0.71.0/surfaces/db/tree/.claude/agents/dba.md +54 -0
  57. package/releases/0.71.0/surfaces/db/tree/.claude/agents/devops.md +5 -0
  58. package/releases/0.71.0/surfaces/db/tree/.claude/agents/implementer.md +2 -0
  59. package/releases/0.71.0/surfaces/db/tree/.claude/agents/integration-tester.md +10 -0
  60. package/releases/0.71.0/surfaces/db/tree/.claude/agents/planner.md +2 -0
  61. package/releases/0.71.0/surfaces/db/tree/.claude/agents/reviewer.md +22 -0
  62. package/releases/0.71.0/surfaces/db/tree/.claude/agents/secops.md +2 -0
  63. package/releases/0.71.0/surfaces/db/tree/.claude/agents-overview.md +5 -0
  64. package/releases/0.71.0/surfaces/db/tree/.claude/graph.md +8 -0
  65. package/releases/0.71.0/surfaces/db/tree/.claude/patterns.md +22 -0
  66. package/releases/0.71.0/surfaces/db/tree/.claude/pipeline.md +13 -0
  67. package/releases/0.71.0/surfaces/db/tree/.claude/retrieval.md +5 -0
  68. package/releases/0.71.0/surfaces/db/tree/.claude/router.md +4 -0
  69. package/releases/0.71.0/surfaces/db/tree/CLAUDE.md +17 -0
  70. package/releases/0.71.0/surfaces/drizzle/tree/.claude/agents/dba.md +9 -0
  71. package/releases/0.71.0/surfaces/drizzle/tree/.claude/agents/devops.md +5 -0
  72. package/releases/0.71.0/surfaces/drizzle/tree/.claude/agents/implementer.md +2 -0
  73. package/releases/0.71.0/surfaces/drizzle/tree/.claude/patterns.md +7 -0
  74. package/releases/0.71.0/surfaces/edge-cf/tree/.claude/agents/architect.md +14 -0
  75. package/releases/0.71.0/surfaces/edge-cf/tree/.claude/agents/dba.md +2 -0
  76. package/releases/0.71.0/surfaces/edge-cf/tree/.claude/agents/devops.md +29 -0
  77. package/releases/0.71.0/surfaces/edge-cf/tree/.claude/agents/implementer.md +14 -0
  78. package/releases/0.71.0/surfaces/edge-cf/tree/.claude/agents/integration-tester.md +11 -0
  79. package/releases/0.71.0/surfaces/edge-cf/tree/.claude/agents/qa.md +3 -0
  80. package/releases/0.71.0/surfaces/edge-cf/tree/.claude/agents/reviewer.md +8 -0
  81. package/releases/0.71.0/surfaces/edge-cf/tree/.claude/agents/secops.md +8 -0
  82. package/releases/0.71.0/surfaces/edge-cf/tree/.claude/patterns.md +17 -0
  83. package/releases/0.71.0/surfaces/edge-cf/tree/.claude/retrieval.md +8 -0
  84. package/releases/0.71.0/surfaces/edge-cf/tree/CLAUDE.md +29 -0
  85. package/releases/0.71.0/surfaces/frontend/tree/.claude/agents/architect.md +20 -0
  86. package/releases/0.71.0/surfaces/frontend/tree/.claude/agents/devops.md +28 -0
  87. package/releases/0.71.0/surfaces/frontend/tree/.claude/agents/implementer.md +25 -0
  88. package/releases/0.71.0/surfaces/frontend/tree/.claude/agents/qa.md +17 -0
  89. package/releases/0.71.0/surfaces/frontend/tree/.claude/agents/reviewer.md +56 -0
  90. package/releases/0.71.0/surfaces/frontend/tree/.claude/patterns.md +8 -0
  91. package/releases/0.71.0/surfaces/frontend/tree/.claude/retrieval.md +2 -0
  92. package/releases/0.71.0/surfaces/frontend/tree/.claude/router.md +14 -0
  93. package/releases/0.71.0/surfaces/frontend/tree/CLAUDE.md +5 -0
  94. package/releases/0.71.0/surfaces/integrations/tree/.claude/agents/architect.md +16 -0
  95. package/releases/0.71.0/surfaces/integrations/tree/.claude/agents/implementer.md +12 -0
  96. package/releases/0.71.0/surfaces/integrations/tree/.claude/agents/integration-tester.md +20 -0
  97. package/releases/0.71.0/surfaces/integrations/tree/.claude/agents/planner.md +2 -0
  98. package/releases/0.71.0/surfaces/integrations/tree/.claude/agents/reviewer.md +21 -0
  99. package/releases/0.71.0/surfaces/integrations/tree/.claude/agents/secops.md +2 -0
  100. package/releases/0.71.0/surfaces/integrations/tree/.claude/agents-overview.md +8 -0
  101. package/releases/0.71.0/surfaces/integrations/tree/.claude/graph.md +10 -0
  102. package/releases/0.71.0/surfaces/integrations/tree/.claude/patterns.md +38 -0
  103. package/releases/0.71.0/surfaces/integrations/tree/.claude/pipeline.md +16 -0
  104. package/releases/0.71.0/surfaces/integrations/tree/.claude/retrieval.md +16 -0
  105. package/releases/0.71.0/surfaces/integrations/tree/.claude/router.md +6 -0
  106. package/releases/0.71.0/surfaces/integrations/tree/CLAUDE.md +17 -0
  107. package/releases/0.71.0/surfaces/money/tree/.claude/agents/architect.md +12 -0
  108. package/releases/0.71.0/surfaces/money/tree/.claude/agents/dba.md +14 -0
  109. package/releases/0.71.0/surfaces/money/tree/.claude/agents/implementer.md +11 -0
  110. package/releases/0.71.0/surfaces/money/tree/.claude/agents/integration-tester.md +3 -0
  111. package/releases/0.71.0/surfaces/money/tree/.claude/agents/reviewer.md +23 -0
  112. package/releases/0.71.0/surfaces/money/tree/.claude/agents/secops.md +5 -0
  113. package/releases/0.71.0/surfaces/money/tree/.claude/agents/solidity-auditor.md +4 -0
  114. package/releases/0.71.0/surfaces/money/tree/.claude/agents/solidity-dev.md +4 -0
  115. package/releases/0.71.0/surfaces/money/tree/.claude/graph.md +2 -0
  116. package/releases/0.71.0/surfaces/money/tree/.claude/patterns.md +49 -0
  117. package/releases/0.71.0/surfaces/money/tree/.claude/pipeline.md +2 -0
  118. package/releases/0.71.0/surfaces/money/tree/.claude/retrieval.md +2 -0
  119. package/releases/0.71.0/surfaces/money/tree/CLAUDE.md +11 -0
  120. package/releases/0.71.0/surfaces/pii/tree/.claude/agents/dba.md +2 -0
  121. package/releases/0.71.0/surfaces/pii/tree/.claude/agents/devops.md +8 -0
  122. package/releases/0.71.0/surfaces/pii/tree/.claude/agents/reviewer.md +8 -0
  123. package/releases/0.71.0/surfaces/pii/tree/.claude/agents/secops.md +2 -0
  124. package/releases/0.71.0/surfaces/pii/tree/.claude/agents/solidity-auditor.md +5 -0
  125. package/releases/0.71.0/surfaces/pii/tree/CLAUDE.md +5 -0
  126. package/releases/0.71.0/surfaces/prisma/tree/.claude/agents/architect.md +2 -0
  127. package/releases/0.71.0/surfaces/prisma/tree/.claude/agents/dba.md +42 -0
  128. package/releases/0.71.0/surfaces/prisma/tree/.claude/agents/devops.md +11 -0
  129. package/releases/0.71.0/surfaces/prisma/tree/.claude/agents/implementer.md +2 -0
  130. package/releases/0.71.0/surfaces/prisma/tree/.claude/agents/integration-tester.md +2 -0
  131. package/releases/0.71.0/surfaces/prisma/tree/.claude/agents/reviewer.md +2 -0
  132. package/releases/0.71.0/surfaces/prisma/tree/.claude/patterns.md +15 -0
  133. package/releases/0.71.0/surfaces/prisma/tree/CLAUDE.md +5 -0
  134. package/src/commands/stats.mjs +57 -1
  135. package/src/commands/upgrade.mjs +2 -2
  136. package/src/cost.mjs +174 -19
  137. package/src/dashboard.html +3 -1
  138. package/src/langfuse.mjs +5 -2
  139. package/src/transcripts.mjs +45 -7
  140. package/src/wiring.mjs +33 -5
package/src/cost.mjs CHANGED
@@ -11,11 +11,19 @@
11
11
  *
12
12
  * It reads only what the run's transcript gained since its last call, keeps counts per message and
13
13
  * nothing else, under the project's gate directory, and lets every call through if anything goes wrong.
14
+ *
15
+ * Two things a run pays for on every turn after are settled at the call itself. A shell command's long
16
+ * output stays in the context and is re-read on every turn — outputs longer than a screen were a fifth of
17
+ * what two projects' stages re-read — so past `clipAt` characters it goes to a file beside the ones Claude
18
+ * Code keeps for the session, and the run sees its first and last lines and where the rest is. And a call
19
+ * that ran past the five minutes a subagent's cache lives makes the next turn write the whole context again,
20
+ * at twelve times a read's price — a quarter of what one project's stages spent — so the run is told then.
14
21
  */
15
22
 
16
23
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
17
24
  import { dirname, join } from 'node:path';
18
25
  import { projectGateDir } from './gate.mjs';
26
+ import { CLIP_MARK } from './transcripts.mjs';
19
27
 
20
28
  /** Where a run's counts are kept between calls, one file per agent. */
21
29
  const stateDir = (root) => join(projectGateDir(root), 'cost');
@@ -45,18 +53,168 @@ function gained(path, from) {
45
53
  return { text: buffer.subarray(0, end + 1).toString('utf8'), to: from + end + 1 };
46
54
  }
47
55
 
56
+ /** How long a subagent's prompt cache lives without a turn: a call that runs past it is paid for again in full. */
57
+ const CACHE_MS = 5 * 60_000;
58
+
59
+ /**
60
+ * Whether a shell command reads a file or a diff rather than producing output: what it prints is what the
61
+ * run asked to read, so clipping it would only send the run to read the file it was cut to. What a command
62
+ * line prints last is its last command's, and of a pipe its last stage's, so that is the one judged:
63
+ * `sed -i … && node run` produces output, and `cd p && cat f` or `node run && tail -n 50 log` reads.
64
+ *
65
+ * @param {unknown} command - The Bash call's command.
66
+ * @returns {boolean}
67
+ */
68
+ export function readsByShell(command) {
69
+ if (typeof command !== 'string') return false;
70
+ // Read as words, never by a pattern that backtracks: the command is the model's, of any length.
71
+ const assignment = (word) => /^\w+=/.test(word);
72
+ // A subshell's parentheses are not part of the words they hold.
73
+ const unwrap = (word) => {
74
+ let from = 0;
75
+ let to = word.length;
76
+ while (from < to && word[from] === '(') from += 1;
77
+ while (to > from && word[to - 1] === ')') to -= 1;
78
+ return word.slice(from, to);
79
+ };
80
+ const segments = command
81
+ .split(/&&|\|\||;|\n/)
82
+ .map((segment) => segment.split(/\s+/).map(unwrap).filter(Boolean))
83
+ // A segment of assignments alone prints nothing.
84
+ .filter((words) => words.length && !words.every(assignment));
85
+ const piped = (segments.at(-1) ?? []).join(' ').split('|').at(-1);
86
+ const words = piped.split(/\s+/).filter(Boolean);
87
+ // Less what runs a command rather than being it: assignments, `export NAME=…`, `timeout N`, `nice`, `command`.
88
+ let i = 0;
89
+ while (i < words.length) {
90
+ if (assignment(words[i]) || words[i] === 'nice' || words[i] === 'command') i += 1;
91
+ else if ((words[i] === 'export' && assignment(words[i + 1] ?? '')) || (words[i] === 'timeout' && words[i + 1])) i += 2;
92
+ else break;
93
+ }
94
+ if (['cat', 'sed', 'head', 'tail', 'nl', 'awk', 'less', 'more'].includes(words[i])) return true;
95
+ if (words[i] !== 'git') return false;
96
+ // `git diff` and `git show`, past the options that come before a subcommand.
97
+ i += 1;
98
+ while (words[i] === '--no-pager' || words[i] === '-C' || words[i] === '-c') i += words[i] === '--no-pager' ? 1 : 2;
99
+ return words[i] === 'diff' || words[i] === 'show';
100
+ }
101
+
102
+ /**
103
+ * The first lines of a text up to a size, or its last ones; a single line longer than the size is cut.
104
+ *
105
+ * @param {string} text - The output.
106
+ * @param {number} size - Characters at most.
107
+ * @param {boolean} fromEnd - The last lines rather than the first.
108
+ * @returns {string}
109
+ */
110
+ function lines(text, size, fromEnd) {
111
+ const all = text.split('\n');
112
+ const kept = [];
113
+ let length = 0;
114
+ for (const line of fromEnd ? all.reverse() : all) {
115
+ if (length + line.length + 1 > size) break;
116
+ kept.push(line);
117
+ length += line.length + 1;
118
+ }
119
+ if (kept.length === 0) return fromEnd ? text.slice(-size) : text.slice(0, size);
120
+ return (fromEnd ? kept.reverse() : kept).join('\n');
121
+ }
122
+
123
+ /**
124
+ * A long Bash output clipped: the whole of it written to a file in the session's `tool-results` directory,
125
+ * where Claude Code keeps the outputs it moves out itself and a stage may read, and in its place the first
126
+ * lines, the last ones — where a test runner or a battery prints its summary — and where the rest is.
127
+ * Null when the output is short, the command reads a file, or the file cannot be written: the run then sees
128
+ * the output as it was.
129
+ *
130
+ * @param {object} input - The PostToolUse hook's input.
131
+ * @param {number} clipAt - The characters past which an output is clipped; 0 clips nothing.
132
+ * @returns {{updatedToolOutput: object, chars: number}|null}
133
+ */
134
+ export function clipOutput(input, clipAt) {
135
+ const response = input?.tool_response;
136
+ if (input?.tool_name !== 'Bash' || !(clipAt > 0) || response === null || typeof response !== 'object' || typeof response.stdout !== 'string') return null;
137
+ const stderr = typeof response.stderr === 'string' ? response.stderr : '';
138
+ const chars = response.stdout.length + stderr.length;
139
+ if (chars <= clipAt || response.isImage || readsByShell(input.tool_input?.command)) return null;
140
+ const id = /^[A-Za-z0-9_-]{1,80}$/.test(String(input.tool_use_id ?? '')) ? input.tool_use_id : `${Date.now()}`;
141
+ const file = join(dirname(input.transcript_path), input.session_id, 'tool-results', `nina-${id}.txt`);
142
+ const whole = stderr ? `${response.stdout}\n--- stderr ---\n${stderr}` : response.stdout;
143
+ try {
144
+ mkdirSync(dirname(file), { recursive: true });
145
+ writeFileSync(file, whole);
146
+ } catch {
147
+ return null;
148
+ }
149
+ const said =
150
+ `${CLIP_MARK}: ${whole.split('\n').length.toLocaleString('en-US')} lines and ${chars.toLocaleString('en-US')} characters, of which the first and last lines are shown. ` +
151
+ `The whole of it${stderr ? ', stderr after stdout,' : ''} is in ${file} — grep it, or read the range you need: read whole, it all comes back into your context and is re-read on every turn after.]`;
152
+ // A stdout short enough to show whole is shown once, the notice after it: only what is long is cut.
153
+ const head = Math.round(clipAt / 8);
154
+ const tail = Math.round((clipAt * 3) / 8);
155
+ const stdout = response.stdout.length > head + tail ? `${lines(response.stdout, head, false)}\n…\n${said}\n…\n${lines(response.stdout, tail, true)}` : `${response.stdout}\n${said}`;
156
+ return {
157
+ chars,
158
+ updatedToolOutput: { ...response, stdout, stderr: stderr ? lines(stderr, head, true) : stderr },
159
+ };
160
+ }
161
+
48
162
  /**
49
163
  * What one call of the watch says, or null: the counts are brought up to date from the run's transcript,
50
164
  * and a warning is due each time the total re-read crosses `warnAt`, then twice it, four times it, and on.
165
+ * A long Bash output is clipped to a file ({@link clipOutput}), and a call that outlived the cache is named.
51
166
  *
52
167
  * @param {object} input - The PostToolUse hook's input.
53
- * @param {{root: string, warnAt: number, now?: number}} options
168
+ * @param {{root: string, warnAt: number, clipAt?: number, now?: number}} options
54
169
  * @returns {object|null} The hook's answer.
55
170
  */
56
- export function watch(input, { root, warnAt, now = Date.now() }) {
171
+ export function watch(input, { root, warnAt, clipAt = 0, now = Date.now() }) {
57
172
  // 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
173
  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;
174
+ // The session's id becomes a directory name below, in the clip's path and the transcript's.
175
+ if (typeof input.transcript_path !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(String(input.session_id ?? ''))) return null;
176
+ const clipped = clipOutput(input, clipAt);
177
+ const counted = warnAt > 0 ? count(input, { root, warnAt, now }) : null;
178
+ const ran = typeof input.duration_ms === 'number' && input.duration_ms > CACHE_MS ? input.duration_ms : null;
179
+ if (!clipped && !counted?.due && ran === null) return null;
180
+ const size = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : `${Math.round(n / 1000)}k`);
181
+ const context = [];
182
+ const person = [];
183
+ if (counted?.due) {
184
+ context.push(
185
+ `NINA cost watch: this run has re-read ${counted.total} tokens of its own context${counted.turn}; every further turn costs more than the last. ` +
186
+ '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.',
187
+ );
188
+ person.push(`${input.agent_type ?? 'a subagent'} ${input.agent_id.slice(0, 8)} has re-read ${counted.total} tokens of its own context${counted.turn}.`);
189
+ }
190
+ if (ran !== null) {
191
+ const minutes = Math.round(ran / 60_000);
192
+ const whole = counted?.context ? `, about ${size(counted.context)} tokens,` : '';
193
+ context.push(
194
+ `NINA cost watch: that call ran ${minutes} minutes, past the five your context stays cached between turns, so your next turn writes all of it${whole} again — at twelve times what re-reading it costs. ` +
195
+ 'Wait in calls that each end within three minutes: run the rest in shares, or start it in the background and check on it no more often than every three minutes (Hard Rule #19).',
196
+ );
197
+ person.push(`${input.agent_type ?? 'a subagent'} ${input.agent_id.slice(0, 8)} waited ${minutes} min in one call; its next turn writes its whole context again.`);
198
+ }
199
+ return {
200
+ ...(person.length ? { systemMessage: `NINA: ${person.join(' ')}` } : {}),
201
+ hookSpecificOutput: {
202
+ hookEventName: 'PostToolUse',
203
+ ...(context.length ? { additionalContext: context.join('\n\n') } : {}),
204
+ ...(clipped ? { updatedToolOutput: clipped.updatedToolOutput } : {}),
205
+ },
206
+ };
207
+ }
208
+
209
+ /**
210
+ * The run's re-read brought up to date from its transcript, and whether a warning is due; null when the
211
+ * transcript is not there.
212
+ *
213
+ * @param {object} input - The PostToolUse hook's input.
214
+ * @param {{root: string, warnAt: number, now: number}} options
215
+ * @returns {{due: boolean, total: string, turn: string, context: number|null}|null}
216
+ */
217
+ function count(input, { root, warnAt, now }) {
60
218
  const transcript = join(dirname(input.transcript_path), input.session_id, 'subagents', `agent-${input.agent_id}.jsonl`);
61
219
  if (!existsSync(transcript)) return null;
62
220
  const dir = stateDir(root);
@@ -69,6 +227,7 @@ export function watch(input, { root, warnAt, now = Date.now() }) {
69
227
  }
70
228
  const { text, to } = gained(transcript, state.offset);
71
229
  let last = null;
230
+ const n = (v) => (typeof v === 'number' ? v : 0);
72
231
  for (const line of text.split('\n')) {
73
232
  if (!line.includes('"cache_read_input_tokens"')) continue;
74
233
  try {
@@ -78,6 +237,8 @@ export function watch(input, { root, warnAt, now = Date.now() }) {
78
237
  if (message?.id && typeof read === 'number') {
79
238
  state.read[message.id] = read;
80
239
  last = read;
240
+ // The whole context that turn held, which a turn after an expired cache writes again.
241
+ state.context = read + n(message.usage.cache_creation_input_tokens) + n(message.usage.input_tokens);
81
242
  }
82
243
  } catch {
83
244
  // A torn line loses one message's count.
@@ -92,18 +253,12 @@ export function watch(input, { root, warnAt, now = Date.now() }) {
92
253
  state.warned = crossed;
93
254
  mkdirSync(dir, { recursive: true });
94
255
  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`;
256
+ const size = (v) => (v >= 1e6 ? `${(v / 1e6).toFixed(1)}M` : `${Math.round(v / 1000)}k`);
99
257
  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
- },
258
+ due,
259
+ total: `${Math.round(total / 1e6)}M`,
260
+ turn: last === null ? '' : `, and each turn now re-reads about ${size(last)} tokens more`,
261
+ context: typeof state.context === 'number' ? state.context : null,
107
262
  };
108
263
  }
109
264
 
@@ -120,18 +275,18 @@ function prune(root, now) {
120
275
  }
121
276
 
122
277
  /**
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.
278
+ * The hook: reads the event from stdin, answers on stdout when a warning is due or an output is clipped,
279
+ * and exits 0 whatever happens — a hook that fails must not stop the run it watches.
125
280
  *
126
- * @param {{root: string, warnAt: number, stdin?: NodeJS.ReadableStream, stdout?: NodeJS.WritableStream}} options
281
+ * @param {{root: string, warnAt: number, clipAt?: number, stdin?: NodeJS.ReadableStream, stdout?: NodeJS.WritableStream}} options
127
282
  * @returns {Promise<number>}
128
283
  */
129
- export async function runCostWatch({ root, warnAt, stdin = process.stdin, stdout = process.stdout }) {
284
+ export async function runCostWatch({ root, warnAt, clipAt = 0, stdin = process.stdin, stdout = process.stdout }) {
130
285
  try {
131
286
  const chunks = [];
132
287
  for await (const chunk of stdin) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
133
288
  const now = Date.now();
134
- const answer = watch(JSON.parse(Buffer.concat(chunks).toString('utf8')), { root, warnAt, now });
289
+ const answer = watch(JSON.parse(Buffer.concat(chunks).toString('utf8')), { root, warnAt, clipAt, now });
135
290
  if (answer) stdout.write(`${JSON.stringify(answer)}\n`);
136
291
  if (Math.random() < 0.01) prune(root, now);
137
292
  } catch {
@@ -362,7 +362,9 @@ const RENDER = {
362
362
  table(['stage', 'runs', 'median', 'total', 'share', ''], d.cost.rows.map((r) => [el('span', { class: 'role' }, r.role), String(r.runs), money(r.median), money(r.total), pct(r.total, d.cost.total), bar([[r.total / d.cost.total, 'var(--pink)']])]), [1, 2, 3, 4]),
363
363
  d.cost.unpriced ? el('p', { class: 'note faint' }, `${d.cost.unpriced} run(s) on a model the price table does not know.`) : null] : null,
364
364
  d.context ? [el('h3', {}, 'context · every turn re-reads it all'),
365
- table(['stage', 'rounds', 'n', 'turns', 'calls/turn', 'starts at', 'peak', 'prompt'], d.context.rows.map((r, i, all) => [i > 0 && all[i - 1].role === r.role ? '' : el('span', { class: 'role' }, r.role), el('span', { class: 'faint' }, r.label), String(r.n), String(r.turns), r.callsPerTurn.toFixed(2), kTokens(r.start), kTokens(r.peak), r.prompt === null ? '—' : r.prompt.toLocaleString('en-US')]), [2, 3, 4, 5, 6, 7])] : null,
365
+ table(['stage', 'rounds', 'n', 'turns', 'calls/turn', 'starts at', 'peak', 'prompt'], d.context.rows.map((r, i, all) => [i > 0 && all[i - 1].role === r.role ? '' : el('span', { class: 'role' }, r.role), el('span', { class: 'faint' }, r.label), String(r.n), String(r.turns), r.callsPerTurn.toFixed(2), kTokens(r.start), kTokens(r.peak), r.prompt === null ? '—' : r.prompt.toLocaleString('en-US')]), [2, 3, 4, 5, 6, 7]),
366
+ d.context.reread?.length ? el('h3', {}, 'what the turns re-read') : null,
367
+ d.context.reread?.length ? table(['stage', 'rounds', 'opened with', 'rewrites', 'of spend', 'clipped'], d.context.reread.map((r) => [el('span', { class: 'role' }, r.role), String(r.n), r.fixed === null ? '—' : `${Math.round(r.fixed * 100)}%`, String(r.rewrites), r.rewriteShare === null ? '—' : `${Math.round(r.rewriteShare * 100)}%`, String(r.clipped)]), [1, 2, 3, 4, 5]) : null] : null,
366
368
  d.proportion ? [el('h3', {}, 'proportion · the size of a change against its chain'),
367
369
  table(['largest write', 'cycles', 'with a planner or architect', ''], d.proportion.rows.map((r) => [r.label, String(r.cycles), r.cycles ? `${r.designed} (${pct(r.designed, r.cycles)})` : '—', bar([[r.cycles ? r.designed / r.cycles : 0, 'var(--pink)']])]), [1, 2]),
368
370
  d.proportion.over ? el('p', { class: 'note warn' }, `${d.proportion.over.count} implementer round(s) wrote more files than one step may (${d.proportion.over.limit}); the largest wrote ${d.proportion.over.largest}.`) : null,
package/src/langfuse.mjs CHANGED
@@ -112,8 +112,11 @@ export function contextMode(setting) {
112
112
  return setting?.content === 'prompts' ? 'prompts' : setting?.content ? 'full' : null;
113
113
  }
114
114
 
115
- /** A round's shape, as metadata on its root: how many turns and calls it took, and how large its context and prompt were. */
116
- const SHAPE = ['turns', 'tool_calls', 'context_start', 'context_peak', 'prompt_chars'];
115
+ /**
116
+ * A round's shape, as metadata on its root: how many turns and calls it took, how large its context and prompt
117
+ * were, how often it wrote an expired cache again and how much, and how many outputs the cost watch clipped.
118
+ */
119
+ const SHAPE = ['turns', 'tool_calls', 'context_start', 'context_peak', 'prompt_chars', 'cache_rewrites', 'cache_rewritten', 'clipped'];
117
120
 
118
121
  /**
119
122
  * The OTLP span at the root of a dispatch's trace. Without its context, a run that spent tokens is a
@@ -860,7 +860,7 @@ async function attachAgentDetail(projectDir, dispatches) {
860
860
  if (
861
861
  run.agent_read_bytes === size &&
862
862
  !('resumes' in run) &&
863
- [...records, ...later].every((r) => settled(r) && 'round' in r && 'lessons_read' in r && 'tokens' in r && 'files_touched' in r && 'lines_written' in r && 'shell_writes' in r && 'effort' in r && 'turns' in r && 'prompt_chars' in r)
863
+ [...records, ...later].every((r) => settled(r) && 'round' in r && 'lessons_read' in r && 'tokens' in r && 'files_touched' in r && 'lines_written' in r && 'shell_writes' in r && 'effort' in r && 'turns' in r && 'prompt_chars' in r && 'cache_rewrites' in r && 'clipped' in r)
864
864
  ) {
865
865
  continue;
866
866
  }
@@ -899,6 +899,8 @@ async function attachAgentDetail(projectDir, dispatches) {
899
899
  calls: new Set(),
900
900
  /** How long the prompt that opened the round was, in characters; never the prompt. */
901
901
  promptChars: null,
902
+ /** The tool results the cost watch clipped to a file, so they were never re-read whole. */
903
+ clipped: 0,
902
904
  });
903
905
  const rounds = [fresh()];
904
906
  const roundOf = roundsOf();
@@ -942,6 +944,19 @@ async function attachAgentDetail(projectDir, dispatches) {
942
944
  // A torn line loses one edit, not the run's.
943
945
  }
944
946
  }
947
+ if (line.includes(CLIP_MARK) && line.includes('"tool_result"')) {
948
+ try {
949
+ const row = JSON.parse(line);
950
+ const content = row?.type === 'user' ? row.message?.content : null;
951
+ for (const block of Array.isArray(content) ? content : []) {
952
+ const said = block?.type === 'tool_result' ? block.content : null;
953
+ const text = typeof said === 'string' ? said : Array.isArray(said) ? said.map((x) => x?.text ?? '').join('') : '';
954
+ if (text.includes(CLIP_MARK)) round.clipped += 1;
955
+ }
956
+ } catch {
957
+ // A torn line loses one count.
958
+ }
959
+ }
945
960
  if (line.includes('"name":"Bash"')) {
946
961
  try {
947
962
  for (const block of JSON.parse(line)?.message?.content ?? []) {
@@ -1028,6 +1043,9 @@ async function attachAgentDetail(projectDir, dispatches) {
1028
1043
  record.tool_calls = round.calls.size;
1029
1044
  record.context_start = shape.start;
1030
1045
  record.context_peak = shape.peak;
1046
+ record.cache_rewrites = shape.rewrites;
1047
+ record.cache_rewritten = shape.rewritten;
1048
+ record.clipped = round.clipped;
1031
1049
  record.prompt_chars = round.promptChars;
1032
1050
  record.usage_model = spent.model;
1033
1051
  record.effort = spent.effort;
@@ -1078,20 +1096,40 @@ export function promptOf(row, round) {
1078
1096
  return row?.type === 'user' && (round > 1 || !row.isMeta) && typeof content === 'string' ? content : null;
1079
1097
  }
1080
1098
 
1099
+ /** The smallest context whose loss counts as a rewrite: below it, a turn writing most of it is the round beginning. */
1100
+ const REWRITE_FLOOR = 30_000;
1101
+
1081
1102
  /**
1082
- * How a round spent its context: how many turns it took, and how large the context each turn re-read was
1083
- * when the round began and at its largest. Every turn is billed the whole context again, so a round that
1084
- * begins where a long one ended — a run resumed for a fix — pays for everything before it on each turn.
1103
+ * How a round spent its context: how many turns it took, how large the context each turn re-read was
1104
+ * when the round began and at its largest, and how often its cache had expired. Every turn is billed the
1105
+ * whole context again, so a round that begins where a long one ended — a run resumed for a fix — pays for
1106
+ * everything before it on each turn.
1107
+ *
1108
+ * A turn reads its context from the cache only while the cache is alive — five minutes after the turn
1109
+ * before, for a subagent — and past that writes all of it again, at twelve times a read's price. So a turn
1110
+ * that wrote more than half of a context of at least {@link REWRITE_FLOOR} tokens is counted a rewrite.
1085
1111
  *
1086
1112
  * @param {Map<string, {usage: object}>} usage - Message id → its final usage, in the order the round sent them.
1087
- * @returns {{turns: number, start: number|null, peak: number|null}} Context sizes in tokens.
1113
+ * @returns {{turns: number, start: number|null, peak: number|null, rewrites: number, rewritten: number}} Context sizes in tokens.
1088
1114
  */
1089
1115
  export function contextOf(usage) {
1090
1116
  const n = (v) => (typeof v === 'number' ? v : 0);
1091
- const sizes = [...usage.values()].map(({ usage: u }) => n(u.input_tokens) + n(u.cache_read_input_tokens) + n(u.cache_creation_input_tokens));
1092
- return { turns: sizes.length, start: sizes[0] ?? null, peak: sizes.length > 0 ? Math.max(...sizes) : null };
1117
+ const turns = [...usage.values()].map(({ usage: u }) => ({ size: n(u.input_tokens) + n(u.cache_read_input_tokens) + n(u.cache_creation_input_tokens), written: n(u.cache_creation_input_tokens) }));
1118
+ const sizes = turns.map((t) => t.size);
1119
+ let rewrites = 0;
1120
+ let rewritten = 0;
1121
+ for (let i = 1; i < turns.length; i += 1) {
1122
+ if (sizes[i - 1] >= REWRITE_FLOOR && turns[i].written > sizes[i - 1] / 2) {
1123
+ rewrites += 1;
1124
+ rewritten += turns[i].written;
1125
+ }
1126
+ }
1127
+ return { turns: sizes.length, start: sizes[0] ?? null, peak: sizes.length > 0 ? Math.max(...sizes) : null, rewrites, rewritten };
1093
1128
  }
1094
1129
 
1130
+ /** How the cost watch opens an output it clipped (`src/cost.mjs`), which is how a snapshot counts them. */
1131
+ export const CLIP_MARK = '[NINA clipped this output';
1132
+
1095
1133
  /**
1096
1134
  * What a run spent, from each of its messages' final usage: tokens by kind, and the model and effort
1097
1135
  * level that most of its messages ran at. Metadata — counts, a model id and a level, never a message.
package/src/wiring.mjs CHANGED
@@ -146,6 +146,23 @@ const HOOKS = [
146
146
  /** Where a project's hooks can live: Claude Code reads both, so a hook kept in the local file counts. */
147
147
  const SETTINGS_FILES = ['.claude/settings.json', '.claude/settings.local.json'];
148
148
 
149
+ /**
150
+ * Settings a project's `.claude/settings.json` carries beside its hooks, each shipped with the script whose
151
+ * concern it is. `why` is what `check` says when one is missing. A key the owner already wrote, in either
152
+ * settings file and with whatever value, is theirs: `wire` adds a missing one and never changes one.
153
+ *
154
+ * The skill listing is every installed skill's description, sized at a hundredth of the model's context
155
+ * window, and every stage whose tools include `Skill` re-reads it on every turn: 32k characters in two
156
+ * projects, a twentieth of what their stages re-read. The skills a stage must use are named in its spec,
157
+ * and every skill's name stays listed however small the budget, so the descriptions buy a stage nothing.
158
+ */
159
+ const SETTINGS = [
160
+ {
161
+ key: 'skillListingBudgetFraction', value: 0.001, script: COST,
162
+ why: "every stage that may invoke a skill re-reads every installed skill's description on every turn, about 9k tokens",
163
+ },
164
+ ];
165
+
149
166
  /** The scripts some hook runs — `check` asks for these by hook, not by npm script. */
150
167
  export const HOOK_SCRIPTS = new Set(HOOKS.map((h) => h.script));
151
168
 
@@ -252,11 +269,13 @@ function covered(hooks, h) {
252
269
  *
253
270
  * @param {string} target - The project directory.
254
271
  * @param {(event: string) => boolean} [wanted] - Events whose malformed lists are worth a problem.
255
- * @returns {Promise<{merged: Record<string, object[]>, problems: string[]}>} Each merged group says `from` which file.
272
+ * @returns {Promise<{merged: Record<string, object[]>, problems: string[], keys: Set<string>}>} Each merged group says
273
+ * `from` which file; `keys` holds every top-level key either file sets.
256
274
  */
257
275
  async function projectHooks(target, wanted = () => false) {
258
276
  const merged = {};
259
277
  const problems = [];
278
+ const keys = new Set();
260
279
  for (const rel of SETTINGS_FILES) {
261
280
  const path = join(target, rel);
262
281
  if (!existsSync(path)) continue;
@@ -267,6 +286,7 @@ async function projectHooks(target, wanted = () => false) {
267
286
  problems.push(`${rel} is not valid JSON — ${error.message}`);
268
287
  continue;
269
288
  }
289
+ if (settings && typeof settings === 'object' && !Array.isArray(settings)) for (const key of Object.keys(settings)) keys.add(key);
270
290
  const shape = settings?.hooks;
271
291
  if (shape === undefined || shape === null) continue;
272
292
  if (typeof shape !== 'object' || Array.isArray(shape)) {
@@ -278,7 +298,7 @@ async function projectHooks(target, wanted = () => false) {
278
298
  else if (wanted(event)) problems.push(`${rel}'s hooks.${event} is not a list — no hook in it can run`);
279
299
  }
280
300
  }
281
- return { merged, problems };
301
+ return { merged, problems, keys };
282
302
  }
283
303
 
284
304
  /** The command settings run for a hook, when they run one — the selftest runs it the same way. */
@@ -298,7 +318,8 @@ export function hookCommand(settings, event, script) {
298
318
  * @returns {string}
299
319
  */
300
320
  export function settingsFile(shipped = HOOK_SCRIPTS) {
301
- return `${JSON.stringify({ hooks: settingsHooks(HOOKS.filter((h) => shipped.has(h.script))) }, null, 2)}\n`;
321
+ const values = Object.fromEntries(SETTINGS.filter((x) => shipped.has(x.script)).map((x) => [x.key, x.value]));
322
+ return `${JSON.stringify({ ...values, hooks: settingsHooks(HOOKS.filter((h) => shipped.has(h.script))) }, null, 2)}\n`;
302
323
  }
303
324
 
304
325
  /** Hooks as settings JSON, grouped by event. */
@@ -369,8 +390,11 @@ export async function missingWiring(target, shipped) {
369
390
  }
370
391
  const wanted = HOOKS.filter((h) => shipped.has(h.script));
371
392
  if (wanted.length === 0) return out;
372
- const { merged, problems } = await projectHooks(target, (event) => wanted.some((h) => h.event === event));
393
+ const { merged, problems, keys } = await projectHooks(target, (event) => wanted.some((h) => h.event === event));
373
394
  out.push(...problems);
395
+ for (const x of SETTINGS.filter((x) => shipped.has(x.script) && !keys.has(x.key))) {
396
+ out.push(`.claude/settings.json has no "${x.key}" — without it ${x.why}; \`nina wire --apply\` sets it to ${x.value}`);
397
+ }
374
398
  for (const h of wanted) {
375
399
  const running = (merged[h.event] ?? []).filter((g) => runs(g, h));
376
400
  if (doubled(running)) {
@@ -468,7 +492,11 @@ export async function applyWiring(target, shipped, { settings: editSettings = tr
468
492
  if (readable) {
469
493
  const hooks = settings.hooks ?? {};
470
494
  const added = [];
471
- const { merged } = await projectHooks(target);
495
+ const { merged, keys } = await projectHooks(target);
496
+ for (const x of SETTINGS.filter((x) => shipped.has(x.script) && !keys.has(x.key))) {
497
+ settings[x.key] = x.value;
498
+ added.push(`"${x.key}": ${x.value}`);
499
+ }
472
500
  for (const h of HOOKS.filter((h) => shipped.has(h.script))) {
473
501
  const running = (merged[h.event] ?? []).filter((g) => runs(g, h));
474
502
  if (covered(merged, h) && !doubled(running)) continue;