@xhulz/nina 0.28.18 → 0.28.19

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 (116) hide show
  1. package/package.json +1 -1
  2. package/releases/0.28.19/core/GAPS.md +125 -0
  3. package/releases/0.28.19/core/answered.json +29 -0
  4. package/releases/0.28.19/core/defaults/tree/.claude/agents/architect.md +1 -0
  5. package/releases/0.28.19/core/defaults/tree/.claude/agents/dba.md +3 -0
  6. package/releases/0.28.19/core/defaults/tree/.claude/agents/devops.md +1 -0
  7. package/releases/0.28.19/core/defaults/tree/.claude/agents/implementer.md +1 -0
  8. package/releases/0.28.19/core/defaults/tree/.claude/agents/integration-tester.md +3 -0
  9. package/releases/0.28.19/core/defaults/tree/.claude/agents/qa.md +2 -0
  10. package/releases/0.28.19/core/defaults/tree/.claude/agents/reviewer.md +3 -0
  11. package/releases/0.28.19/core/defaults/tree/.claude/agents/secops.md +3 -0
  12. package/releases/0.28.19/core/defaults/tree/.claude/agents/solidity-auditor.md +3 -0
  13. package/releases/0.28.19/core/defaults/tree/.claude/agents/solidity-dev.md +3 -0
  14. package/releases/0.28.19/core/tree/.claude/agents/architect.md +125 -0
  15. package/releases/0.28.19/core/tree/.claude/agents/dba.md +107 -0
  16. package/releases/0.28.19/core/tree/.claude/agents/devops.md +113 -0
  17. package/releases/0.28.19/core/tree/.claude/agents/implementer.md +155 -0
  18. package/releases/0.28.19/core/tree/.claude/agents/integration-tester.md +160 -0
  19. package/releases/0.28.19/core/tree/.claude/agents/planner.md +132 -0
  20. package/releases/0.28.19/core/tree/.claude/agents/qa.md +146 -0
  21. package/releases/0.28.19/core/tree/.claude/agents/reviewer.md +180 -0
  22. package/releases/0.28.19/core/tree/.claude/agents/secops.md +121 -0
  23. package/releases/0.28.19/core/tree/.claude/agents/solidity-auditor.md +126 -0
  24. package/releases/0.28.19/core/tree/.claude/agents/solidity-dev.md +123 -0
  25. package/releases/0.28.19/core/tree/.claude/agents-overview.md +64 -0
  26. package/releases/0.28.19/core/tree/.claude/graph.md +73 -0
  27. package/releases/0.28.19/core/tree/.claude/patterns.md +212 -0
  28. package/releases/0.28.19/core/tree/.claude/pills/README.md +145 -0
  29. package/releases/0.28.19/core/tree/.claude/pipeline.md +102 -0
  30. package/releases/0.28.19/core/tree/.claude/retrieval.md +104 -0
  31. package/releases/0.28.19/core/tree/.claude/router.md +201 -0
  32. package/releases/0.28.19/core/tree/.claude/templates/integration.md +45 -0
  33. package/releases/0.28.19/core/tree/CLAUDE.md +155 -0
  34. package/releases/0.28.19/core/tree/scripts/edit-guard.mjs +16 -0
  35. package/releases/0.28.19/core/tree/scripts/harness-check.mjs +71 -0
  36. package/releases/0.28.19/core/tree/scripts/loop-gate.mjs +19 -0
  37. package/releases/0.28.19/core/vocabulary.json +12 -0
  38. package/releases/0.28.19/surfaces/blockchain/tree/.claude/agents/architect.md +5 -0
  39. package/releases/0.28.19/surfaces/blockchain/tree/.claude/agents/devops.md +5 -0
  40. package/releases/0.28.19/surfaces/blockchain/tree/.claude/agents/reviewer.md +5 -0
  41. package/releases/0.28.19/surfaces/blockchain/tree/.claude/agents/solidity-auditor.md +18 -0
  42. package/releases/0.28.19/surfaces/blockchain/tree/.claude/agents/solidity-dev.md +16 -0
  43. package/releases/0.28.19/surfaces/blockchain/tree/.claude/agents-overview.md +6 -0
  44. package/releases/0.28.19/surfaces/blockchain/tree/.claude/graph.md +11 -0
  45. package/releases/0.28.19/surfaces/blockchain/tree/.claude/router.md +9 -0
  46. package/releases/0.28.19/surfaces/blockchain/tree/CLAUDE.md +12 -0
  47. package/releases/0.28.19/surfaces/db/tree/.claude/agents/architect.md +8 -0
  48. package/releases/0.28.19/surfaces/db/tree/.claude/agents/dba.md +84 -0
  49. package/releases/0.28.19/surfaces/db/tree/.claude/agents/devops.md +14 -0
  50. package/releases/0.28.19/surfaces/db/tree/.claude/agents/implementer.md +5 -0
  51. package/releases/0.28.19/surfaces/db/tree/.claude/agents/integration-tester.md +13 -0
  52. package/releases/0.28.19/surfaces/db/tree/.claude/agents/planner.md +2 -0
  53. package/releases/0.28.19/surfaces/db/tree/.claude/agents/reviewer.md +25 -0
  54. package/releases/0.28.19/surfaces/db/tree/.claude/agents/secops.md +2 -0
  55. package/releases/0.28.19/surfaces/db/tree/.claude/agents-overview.md +5 -0
  56. package/releases/0.28.19/surfaces/db/tree/.claude/graph.md +8 -0
  57. package/releases/0.28.19/surfaces/db/tree/.claude/patterns.md +29 -0
  58. package/releases/0.28.19/surfaces/db/tree/.claude/pipeline.md +13 -0
  59. package/releases/0.28.19/surfaces/db/tree/.claude/retrieval.md +5 -0
  60. package/releases/0.28.19/surfaces/db/tree/.claude/router.md +10 -0
  61. package/releases/0.28.19/surfaces/db/tree/CLAUDE.md +23 -0
  62. package/releases/0.28.19/surfaces/edge-cf/tree/.claude/agents/architect.md +14 -0
  63. package/releases/0.28.19/surfaces/edge-cf/tree/.claude/agents/dba.md +2 -0
  64. package/releases/0.28.19/surfaces/edge-cf/tree/.claude/agents/devops.md +31 -0
  65. package/releases/0.28.19/surfaces/edge-cf/tree/.claude/agents/implementer.md +14 -0
  66. package/releases/0.28.19/surfaces/edge-cf/tree/.claude/agents/integration-tester.md +11 -0
  67. package/releases/0.28.19/surfaces/edge-cf/tree/.claude/agents/qa.md +3 -0
  68. package/releases/0.28.19/surfaces/edge-cf/tree/.claude/agents/reviewer.md +8 -0
  69. package/releases/0.28.19/surfaces/edge-cf/tree/.claude/agents/secops.md +8 -0
  70. package/releases/0.28.19/surfaces/edge-cf/tree/.claude/patterns.md +17 -0
  71. package/releases/0.28.19/surfaces/edge-cf/tree/.claude/retrieval.md +8 -0
  72. package/releases/0.28.19/surfaces/edge-cf/tree/.claude/router.md +21 -0
  73. package/releases/0.28.19/surfaces/edge-cf/tree/CLAUDE.md +27 -0
  74. package/releases/0.28.19/surfaces/frontend/tree/.claude/agents/architect.md +20 -0
  75. package/releases/0.28.19/surfaces/frontend/tree/.claude/agents/devops.md +28 -0
  76. package/releases/0.28.19/surfaces/frontend/tree/.claude/agents/implementer.md +25 -0
  77. package/releases/0.28.19/surfaces/frontend/tree/.claude/agents/qa.md +17 -0
  78. package/releases/0.28.19/surfaces/frontend/tree/.claude/agents/reviewer.md +62 -0
  79. package/releases/0.28.19/surfaces/frontend/tree/.claude/patterns.md +15 -0
  80. package/releases/0.28.19/surfaces/frontend/tree/.claude/retrieval.md +5 -0
  81. package/releases/0.28.19/surfaces/frontend/tree/.claude/router.md +17 -0
  82. package/releases/0.28.19/surfaces/frontend/tree/CLAUDE.md +5 -0
  83. package/releases/0.28.19/surfaces/integrations/tree/.claude/agents/architect.md +16 -0
  84. package/releases/0.28.19/surfaces/integrations/tree/.claude/agents/implementer.md +12 -0
  85. package/releases/0.28.19/surfaces/integrations/tree/.claude/agents/integration-tester.md +20 -0
  86. package/releases/0.28.19/surfaces/integrations/tree/.claude/agents/planner.md +2 -0
  87. package/releases/0.28.19/surfaces/integrations/tree/.claude/agents/reviewer.md +21 -0
  88. package/releases/0.28.19/surfaces/integrations/tree/.claude/agents/secops.md +2 -0
  89. package/releases/0.28.19/surfaces/integrations/tree/.claude/agents-overview.md +8 -0
  90. package/releases/0.28.19/surfaces/integrations/tree/.claude/graph.md +9 -0
  91. package/releases/0.28.19/surfaces/integrations/tree/.claude/patterns.md +38 -0
  92. package/releases/0.28.19/surfaces/integrations/tree/.claude/pipeline.md +16 -0
  93. package/releases/0.28.19/surfaces/integrations/tree/.claude/retrieval.md +16 -0
  94. package/releases/0.28.19/surfaces/integrations/tree/.claude/router.md +6 -0
  95. package/releases/0.28.19/surfaces/integrations/tree/CLAUDE.md +17 -0
  96. package/releases/0.28.19/surfaces/money/tree/.claude/agents/architect.md +12 -0
  97. package/releases/0.28.19/surfaces/money/tree/.claude/agents/dba.md +14 -0
  98. package/releases/0.28.19/surfaces/money/tree/.claude/agents/implementer.md +11 -0
  99. package/releases/0.28.19/surfaces/money/tree/.claude/agents/integration-tester.md +3 -0
  100. package/releases/0.28.19/surfaces/money/tree/.claude/agents/reviewer.md +23 -0
  101. package/releases/0.28.19/surfaces/money/tree/.claude/agents/secops.md +5 -0
  102. package/releases/0.28.19/surfaces/money/tree/.claude/agents/solidity-auditor.md +4 -0
  103. package/releases/0.28.19/surfaces/money/tree/.claude/agents/solidity-dev.md +4 -0
  104. package/releases/0.28.19/surfaces/money/tree/.claude/graph.md +2 -0
  105. package/releases/0.28.19/surfaces/money/tree/.claude/patterns.md +49 -0
  106. package/releases/0.28.19/surfaces/money/tree/.claude/pipeline.md +2 -0
  107. package/releases/0.28.19/surfaces/money/tree/.claude/retrieval.md +2 -0
  108. package/releases/0.28.19/surfaces/money/tree/CLAUDE.md +11 -0
  109. package/releases/0.28.19/surfaces/pii/tree/.claude/agents/dba.md +2 -0
  110. package/releases/0.28.19/surfaces/pii/tree/.claude/agents/devops.md +8 -0
  111. package/releases/0.28.19/surfaces/pii/tree/.claude/agents/reviewer.md +8 -0
  112. package/releases/0.28.19/surfaces/pii/tree/.claude/agents/secops.md +2 -0
  113. package/releases/0.28.19/surfaces/pii/tree/.claude/agents/solidity-auditor.md +5 -0
  114. package/releases/0.28.19/surfaces/pii/tree/CLAUDE.md +5 -0
  115. package/src/commands/pipeline.mjs +190 -55
  116. package/src/graph.mjs +16 -1
@@ -1,17 +1,19 @@
1
1
  /**
2
2
  * `nina pipeline` — draws a project's agent chain: the line work moves along, the gates it passes on the
3
- * way, where each stage sends work back and how many rounds it may, each stage's model, effort and skills,
4
- * and which chain a task of each shape takes.
3
+ * way, the stages that run as several agents at once, where each stage sends work back and how many rounds
4
+ * it may, each stage's model, effort and skills with what it did over the last thirty days, and which chain
5
+ * a task of each shape takes. `--for` draws one shape's chain alone.
5
6
  *
6
7
  * Opening a project, the pipeline was a graph, a router, ten specs and a table in CLAUDE.md to read before
7
8
  * anyone could say which stage came after which, on which model, with which skills. Every fact here is
8
- * already composed, and `nina check` already holds it to the graph; this only reads it back and draws it.
9
+ * already composed, and `nina check` already holds it to the graph, or already measured; this only reads it
10
+ * back and draws it.
9
11
  */
10
12
 
11
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
13
+ import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
12
14
  import { basename, join, resolve } from 'node:path';
13
15
  import { PINK, useColor } from '../banner.mjs';
14
- import { HARNESS } from '../paths.mjs';
16
+ import { HARNESS, slugFor, snapshotsDir } from '../paths.mjs';
15
17
  import { TERMINALS, parseGraph } from '../graph.mjs';
16
18
  import { required } from '../tools.mjs';
17
19
  import { isLoopBack } from '../transcripts.mjs';
@@ -20,6 +22,9 @@ import { frontmatter } from './pills.mjs';
20
22
  /** A stage line under "## Stages": "- `name` — what it does". */
21
23
  const STAGE_LINE = /^- `([a-z][a-z-]*)` — (.*)$/;
22
24
 
25
+ /** How far back the history beside each stage looks, unless `--since` says. */
26
+ const HISTORY_DAYS = 30;
27
+
23
28
  /**
24
29
  * What each stage does, in the graph's own words: shorter than a spec's `description:`, which is written
25
30
  * for Claude Code to choose an agent by.
@@ -48,7 +53,8 @@ export function stageWords(text) {
48
53
  * - The gates: a stage off the line that a line stage sends work to on a condition, and that hands it on
49
54
  * to the next stage of the line.
50
55
  * - The ends: where the line's last stage sends work on a condition, followed forward to a terminal.
51
- * - The loop-backs: every edge on a verdict that sends work back, to a stage or to the owner.
56
+ * - The loop-backs: every edge on a verdict that sends work back, to a stage or to the owner, grouped by
57
+ * the stage that sends it.
52
58
  *
53
59
  * @param {{stages: Set<string>, edges: object[]}} graph - A parsed graph.
54
60
  * @returns {{line: string[], gates: object[], ends: object[], back: object[]}}
@@ -81,17 +87,18 @@ export function shapeOf(graph) {
81
87
  }
82
88
  return { path, when: e.when };
83
89
  });
84
- // Grouped by the stage that sends the work back, in the order the stages are listed.
85
90
  const back = graph.edges.filter((e) => isLoopBack(e.token)).sort((a, b) => order.indexOf(a.from) - order.indexOf(b.from));
86
91
  return { line, gates, ends, back };
87
92
  }
88
93
 
89
94
  /**
90
95
  * The rows of the "Required chain by task shape" table in a composed CLAUDE.md: the shape, and the chain it
91
- * takes — the part the table sets in bold, where it does.
96
+ * takes — the part the table sets in bold, where it does. A row whose chain runs stage → stage, or is
97
+ * "none", is a chain of its own; any other (`+ dba before reviewer`) is an add-on to whichever chain the
98
+ * task takes.
92
99
  *
93
100
  * @param {string} text - The composed CLAUDE.md.
94
- * @returns {{shape: string, chain: string}[]}
101
+ * @returns {{shape: string, chain: string, addOn: boolean}[]}
95
102
  */
96
103
  export function chainsByShape(text) {
97
104
  const at = text.indexOf('| Task shape |');
@@ -102,27 +109,116 @@ export function chainsByShape(text) {
102
109
  const [shape = '', chain = ''] = row.split('|').slice(1).map((c) => c.trim());
103
110
  const bold = /\*\*(.+?)\*\*/.exec(chain)?.[1];
104
111
  const plain = (s) => s.replace(/\*\*/g, '').replace(/`/g, '').trim();
105
- rows.push({ shape: plain(shape), chain: plain(bold ?? chain) });
112
+ const taken = plain(bold ?? chain);
113
+ rows.push({ shape: plain(shape), chain: taken, addOn: !taken.includes('→') && !/^none\b/i.test(taken) });
106
114
  }
107
115
  return rows;
108
116
  }
109
117
 
118
+ /**
119
+ * The task shapes a `--for` names: by its number in the list, or by every word it gives.
120
+ *
121
+ * @param {{shape: string}[]} shapes - The chain table's rows.
122
+ * @param {string} query - A number, or words.
123
+ * @returns {object[]}
124
+ */
125
+ export function pickShape(shapes, query) {
126
+ if (/^\d+$/.test(query.trim())) {
127
+ const row = shapes[Number(query) - 1];
128
+ return row ? [row] : [];
129
+ }
130
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
131
+ if (words.length === 0) return [];
132
+ return shapes.filter((r) => words.every((w) => r.shape.toLowerCase().includes(w)));
133
+ }
134
+
135
+ /**
136
+ * What each stage did since a date, from the measurement store: its runs, the verdicts that could be read,
137
+ * and how many of those sent work back. A run a hook denied never ran, and is not counted.
138
+ *
139
+ * @param {object[]} records - The project's records.
140
+ * @param {string} since - An ISO date.
141
+ * @returns {Map<string, {runs: number, read: number, back: number}>}
142
+ */
143
+ export function historyOf(records, since) {
144
+ const byRole = new Map();
145
+ for (const r of records) {
146
+ if (!r.role || r.status === 'denied' || String(r.ts ?? '') < since) continue;
147
+ const s = byRole.get(r.role) ?? { runs: 0, read: 0, back: 0 };
148
+ s.runs += 1;
149
+ if (r.verdict && r.verdict !== 'UNCLEAR' && r.verdict !== 'NONE') {
150
+ s.read += 1;
151
+ if (isLoopBack(r.verdict)) s.back += 1;
152
+ }
153
+ byRole.set(r.role, s);
154
+ }
155
+ return byRole;
156
+ }
157
+
158
+ /**
159
+ * The project's records in the measurement store, under the name Claude Code gives its directory — as
160
+ * given, or with its links resolved, since either may be the one the sessions ran in.
161
+ *
162
+ * @param {string} dir - The project.
163
+ * @returns {object[]|null} Null when the store has nothing for it.
164
+ */
165
+ function storedRuns(dir) {
166
+ const names = new Set([slugFor(dir)]);
167
+ try {
168
+ names.add(slugFor(realpathSync(dir)));
169
+ } catch {
170
+ // A directory that cannot be resolved is looked up as given.
171
+ }
172
+ for (const name of names) {
173
+ const file = join(snapshotsDir(), `${name}.jsonl`);
174
+ if (!existsSync(file)) continue;
175
+ return readFileSync(file, 'utf8')
176
+ .split('\n')
177
+ .filter(Boolean)
178
+ .flatMap((l) => {
179
+ try {
180
+ return [JSON.parse(l)];
181
+ } catch {
182
+ return [];
183
+ }
184
+ });
185
+ }
186
+ return null;
187
+ }
188
+
110
189
  /**
111
190
  * Runs the command.
112
191
  *
113
- * @param {string[]} argv - `[--project <dir>]`.
192
+ * @param {string[]} argv - `[--project <dir>] [--for <number or words>] [--since <YYYY-MM-DD>]`.
114
193
  * @returns {Promise<number>} Process exit code.
115
194
  */
116
195
  export async function pipeline(argv) {
117
- const dir = resolve(argv.includes('--project') ? argv[argv.indexOf('--project') + 1] : process.cwd());
196
+ const arg = (flag) => (argv.includes(flag) ? argv[argv.indexOf(flag) + 1] ?? '' : null);
197
+ const dir = resolve(arg('--project') ?? process.cwd());
118
198
  const graphPath = join(dir, '.claude', 'graph.md');
119
199
  if (!existsSync(graphPath)) {
120
200
  console.error(` no .claude/graph.md in ${dir} — this draws a composed pipeline: \`nina compose\` writes one`);
121
201
  return 1;
122
202
  }
123
- const graph = parseGraph(readFileSync(graphPath, 'utf8'));
124
- const words = stageWords(readFileSync(graphPath, 'utf8'));
125
- const { line, gates, ends, back } = shapeOf(graph);
203
+ const graphText = readFileSync(graphPath, 'utf8');
204
+ const graph = parseGraph(graphText);
205
+ const words = stageWords(graphText);
206
+ const shape = shapeOf(graph);
207
+ const claude = join(dir, 'CLAUDE.md');
208
+ const shapes = existsSync(claude) ? chainsByShape(readFileSync(claude, 'utf8')) : [];
209
+
210
+ // `--for`: one shape's chain, and only the stages on it.
211
+ const query = arg('--for');
212
+ let chosen = null;
213
+ if (query !== null) {
214
+ const picked = pickShape(shapes, query);
215
+ if (picked.length !== 1) {
216
+ console.error(picked.length === 0 ? ` no task shape matches "${query}" — by number or by words:` : ` "${query}" matches ${picked.length} task shapes — narrow it, or give the number:`);
217
+ for (const [i, r] of shapes.entries()) if (picked.length === 0 || picked.includes(r)) console.error(` ${String(i + 1).padStart(2)} ${r.shape}`);
218
+ return 1;
219
+ }
220
+ chosen = picked[0];
221
+ }
126
222
 
127
223
  const color = useColor();
128
224
  const name = (s) => (color && !TERMINALS.has(s) ? `\x1b[38;2;${PINK.join(';')}m${s}\x1b[0m` : s);
@@ -130,6 +226,12 @@ export async function pipeline(argv) {
130
226
  const width = Math.max(80, Math.min(process.stdout.columns ?? 110, 140));
131
227
  const fit = (s, room) => (s.length > room ? `${s.slice(0, Math.max(room - 1, 1))}…` : s);
132
228
  const arrow = ' ─▶ ';
229
+ const many = graph.many ?? new Map();
230
+ // A stage that may run as several agents at once carries ×n wherever the line draws it. Alignment is
231
+ // counted on the plain text, and the names are painted after.
232
+ const plainStage = (s) => `${s}${many.has(s) ? ' ×n' : ''}`;
233
+ const shownStage = (s) => `${name(s)}${many.has(s) ? ' ×n' : ''}`;
234
+ const padName = (s, n) => `${name(s)}${' '.repeat(Math.max(n - s.length, 1))}`;
133
235
 
134
236
  let profile = {};
135
237
  try {
@@ -138,32 +240,54 @@ export async function pipeline(argv) {
138
240
  // A pipeline composed by hand draws all the same; only the header has less to say.
139
241
  }
140
242
  const surfaces = (profile.surfaces ?? []).slice().sort();
141
- console.log(` pipeline · ${basename(dir)}${profile.core ? ` · core ${profile.core}` : ''}${surfaces.length ? ` · surfaces ${surfaces.join(', ')}` : ''}\n`);
243
+ console.log(` pipeline · ${basename(dir)}${profile.core ? ` · core ${profile.core}` : ''}${surfaces.length ? ` · surfaces ${surfaces.join(', ')}` : ''}`);
142
244
 
143
- // Alignment is counted on the plain text, and the names are painted after.
144
- const padName = (s, n) => `${name(s)}${' '.repeat(Math.max(n - s.length, 1))}`;
245
+ // The stages this drawing covers: all of them, or those the chosen shape's chain names.
246
+ const onChain = chosen ? (chosen.chain.match(/[a-z][a-z-]*/g) ?? []).filter((w) => graph.stages.has(w)) : null;
247
+ const covered = new Set(onChain ?? graph.stages);
145
248
 
146
- // The line, and where its last stage sends work.
147
- const plainHead = ` ${line.join(arrow)}`;
148
- const head = ` ${line.map(name).join(arrow)}`;
149
- if (ends.length === 0) console.log(head);
150
- const lead = ' '.repeat(plainHead.length + 1);
151
- const joint = (i) => (ends.length === 1 ? '──▶ ' : i === 0 ? '─┬─▶ ' : i === ends.length - 1 ? ' └─▶ ' : ' ├─▶ ');
152
- const endCol = Math.max(0, ...ends.map((end, i) => `${lead}${joint(i)}${end.path.join(arrow)}`.length)) + 3;
153
- ends.forEach((end, i) => {
154
- const plain = `${lead}${joint(i)}${end.path.join(arrow)}`;
155
- console.log(`${i === 0 ? `${head} ` : lead}${joint(i)}${end.path.map(name).join(arrow)}${' '.repeat(endCol - plain.length)}${dim(fit(end.when, width - endCol))}`);
156
- });
249
+ if (chosen) {
250
+ console.log(` for: ${chosen.shape}\n`);
251
+ if (onChain.length === 0) console.log(` ${chosen.chain}`);
252
+ else console.log(` ${onChain.map(shownStage).join(arrow)}`);
253
+ const addOns = shapes.filter((r) => r.addOn && r !== chosen);
254
+ if (addOns.length > 0 && onChain.length > 0) {
255
+ console.log(`\n ${dim('and, when the change calls for them')}`);
256
+ const col = Math.min(Math.max(...addOns.map((r) => r.chain.length)), 44) + 4;
257
+ for (const r of addOns) console.log(` ${fit(r.chain, col - 4).padEnd(col)}${dim(fit(r.shape, width - col - 6))}`);
258
+ }
259
+ } else {
260
+ console.log('');
261
+ // The line, and where its last stage sends work.
262
+ const plainHead = ` ${shape.line.map(plainStage).join(arrow)}`;
263
+ const head = ` ${shape.line.map(shownStage).join(arrow)}`;
264
+ if (shape.ends.length === 0) console.log(head);
265
+ const lead = ' '.repeat(plainHead.length + 1);
266
+ const joint = (i) => (shape.ends.length === 1 ? '──▶ ' : i === 0 ? '─┬─▶ ' : i === shape.ends.length - 1 ? ' └─▶ ' : ' ├─▶ ');
267
+ const endCol = Math.max(0, ...shape.ends.map((end, i) => `${lead}${joint(i)}${end.path.join(arrow)}`.length)) + 3;
268
+ shape.ends.forEach((end, i) => {
269
+ const plain = `${lead}${joint(i)}${end.path.join(arrow)}`;
270
+ console.log(`${i === 0 ? `${head} ` : lead}${joint(i)}${end.path.map(name).join(arrow)}${' '.repeat(endCol - plain.length)}${dim(fit(end.when, width - endCol))}`);
271
+ });
157
272
 
158
- if (gates.length > 0) {
159
- console.log(`\n ${dim('gates on the way, when the diff calls for them')}`);
160
- const chain = (g) => `${g.from}${arrow}${g.gate}${arrow}${g.to}`;
161
- const col = Math.max(...gates.map((g) => chain(g).length)) + 4;
162
- for (const g of gates) {
163
- console.log(` ${name(g.from)}${arrow}${name(g.gate)}${arrow}${name(g.to)}${' '.repeat(col - chain(g).length)}${dim(fit(g.when, width - col - 6))}`);
273
+ if (shape.gates.length > 0) {
274
+ console.log(`\n ${dim('gates on the way, when the diff calls for them')}`);
275
+ const chain = (g) => `${g.from}${arrow}${g.gate}${arrow}${g.to}`;
276
+ const col = Math.max(...shape.gates.map((g) => chain(g).length)) + 4;
277
+ for (const g of shape.gates) {
278
+ console.log(` ${name(g.from)}${arrow}${name(g.gate)}${arrow}${name(g.to)}${' '.repeat(col - chain(g).length)}${dim(fit(g.when, width - col - 6))}`);
279
+ }
164
280
  }
165
281
  }
166
282
 
283
+ const drawnMany = [...many].filter(([s]) => covered.has(s));
284
+ if (drawnMany.length > 0) {
285
+ console.log(`\n ${dim('×n runs as several agents at once, each on its own share of the work')}`);
286
+ const col = Math.max(...drawnMany.map(([s]) => s.length)) + 3;
287
+ for (const [s, { when }] of drawnMany) console.log(` ${padName(s, col)}${dim(fit(when, width - col - 8))}`);
288
+ }
289
+
290
+ const back = shape.back.filter((e) => covered.has(e.from) && (covered.has(e.to) || TERMINALS.has(e.to)));
167
291
  if (back.length > 0) {
168
292
  console.log(`\n ${dim('sent back: to whom, on which verdict, and how many rounds per issue')}`);
169
293
  const fromCol = Math.max(...back.map((e) => e.from.length)) + 2;
@@ -180,20 +304,30 @@ export async function pipeline(argv) {
180
304
  }
181
305
  }
182
306
 
183
- // Each stage: what it does, on which model and effort, with which skills.
307
+ // Each stage: what it does, on which model and effort, with which skills, and what it did lately.
184
308
  const agentsDir = join(dir, '.claude', 'agents');
185
309
  const specs = new Map();
186
310
  for (const file of existsSync(agentsDir) ? readdirSync(agentsDir).filter((f) => f.endsWith('.md')).sort() : []) {
187
311
  specs.set(file.replace(/\.md$/, ''), readFileSync(join(agentsDir, file), 'utf8'));
188
312
  }
189
- const stages = [...graph.stages];
313
+ const since = arg('--since') || new Date(Date.now() - HISTORY_DAYS * 86_400_000).toISOString().slice(0, 10);
314
+ const runs = storedRuns(dir);
315
+ const history = runs ? historyOf(runs, since) : null;
316
+ const stages = [...graph.stages].filter((s) => covered.has(s));
190
317
  const col = Math.max(...stages.map((s) => s.length), 10) + 2;
191
- console.log(`\n ${dim('stages')}`);
318
+ console.log(`\n ${dim(history ? `stages · what each ran since ${since}, from the measured history` : 'stages · no measured history for this project yet')}`);
192
319
  for (const stage of stages) {
193
320
  const spec = specs.get(stage) ?? '';
194
321
  const front = frontmatter(spec) ?? {};
195
- const runs = [front.model, front.effort].filter(Boolean).join(' · ') || 'no spec';
196
- console.log(` ${padName(stage, col)}${runs.padEnd(26)}${dim(fit(words.get(stage) ?? '', width - col - 32))}`);
322
+ const on = [front.model, front.effort].filter(Boolean).join(' · ') || 'no spec';
323
+ console.log(` ${padName(stage, col)}${on.padEnd(26)}${dim(fit(words.get(stage) ?? '', width - col - 32))}`);
324
+ if (history) {
325
+ const h = history.get(stage);
326
+ const said = !h
327
+ ? 'no run'
328
+ : `${h.runs} run(s)${h.read === 0 ? ', no verdict read' : `, sent back ${h.back} of ${h.read} (${Math.round((100 * h.back) / h.read)}%)`}`;
329
+ console.log(` ${' '.repeat(col)}${dim('ran ')}${said}`);
330
+ }
197
331
  const skills = [...required(spec).skills].sort();
198
332
  if (skills.length > 0) {
199
333
  const lines = [];
@@ -210,22 +344,23 @@ export async function pipeline(argv) {
210
344
  }
211
345
  }
212
346
 
213
- // An agent the project wrote for itself is not a stage, and the graph does not route to it.
214
- const own = [...specs.keys()].filter((role) => !graph.stages.has(role));
215
- if (own.length > 0) {
216
- console.log(`\n ${dim("the project's own agents, outside the pipeline")}`);
217
- for (const role of own) {
218
- const front = frontmatter(specs.get(role)) ?? {};
219
- console.log(` ${role}${' '.repeat(Math.max(col - role.length, 2))}${dim(fit(front.description ?? '', width - col - 6))}`);
347
+ if (!chosen) {
348
+ // An agent the project wrote for itself is not a stage, and the graph does not route to it.
349
+ const own = [...specs.keys()].filter((role) => !graph.stages.has(role));
350
+ if (own.length > 0) {
351
+ console.log(`\n ${dim("the project's own agents, outside the pipeline")}`);
352
+ for (const role of own) {
353
+ const front = frontmatter(specs.get(role)) ?? {};
354
+ console.log(` ${role}${' '.repeat(Math.max(col - role.length, 2))}${dim(fit(front.description ?? '', width - col - 6))}`);
355
+ }
356
+ }
357
+ if (shapes.length > 0) {
358
+ console.log(`\n ${dim('which chain a task takes, by its shape (CLAUDE.md) — `nina pipeline --for <number or words>` draws one')}`);
359
+ const shapeCol = Math.min(Math.max(...shapes.map((r) => r.shape.length)), 56) + 2;
360
+ for (const [i, r] of shapes.entries()) {
361
+ console.log(` ${String(i + 1).padStart(2)} ${fit(r.shape, shapeCol - 2).padEnd(shapeCol)}${fit(r.chain, width - shapeCol - 10)}`);
362
+ }
220
363
  }
221
- }
222
-
223
- const claude = join(dir, 'CLAUDE.md');
224
- const shapes = existsSync(claude) ? chainsByShape(readFileSync(claude, 'utf8')) : [];
225
- if (shapes.length > 0) {
226
- console.log(`\n ${dim('which chain a task takes, by its shape (CLAUDE.md)')}`);
227
- const shapeCol = Math.min(Math.max(...shapes.map((r) => r.shape.length)), 58) + 2;
228
- for (const r of shapes) console.log(` ${fit(r.shape, shapeCol - 2).padEnd(shapeCol)}${fit(r.chain, width - shapeCol - 6)}`);
229
364
  }
230
365
  console.log('');
231
366
  return 0;
package/src/graph.mjs CHANGED
@@ -24,6 +24,13 @@ const STAGE = /^- `([a-z][a-z-]*)` — /;
24
24
  */
25
25
  const EDGE = /^- `([a-z][a-z-]*)` → `([a-z][a-z-]*)` on `([A-Z][A-Z-]*)`(?: — (.*?))?(?: · max (\d+))?\s*$/;
26
26
 
27
+ /**
28
+ * A line under "## Concurrency" naming a stage that may run as several agents at once, each on a share of
29
+ * the work: "- `name` × many — when". Declared rather than left to the prose, so a drawing of the pipeline
30
+ * can show it and `check` can hold it to the stages.
31
+ */
32
+ const MANY = /^- `([a-z][a-z-]*)` × many — (.*)$/;
33
+
27
34
  /** Where work may end: finished, or handed to the owner. Neither needs a spec. */
28
35
  export const TERMINALS = new Set(['done', 'human']);
29
36
 
@@ -40,6 +47,8 @@ export function parseGraph(text) {
40
47
  const stages = new Set();
41
48
  const edges = [];
42
49
  const stray = [];
50
+ /** @type {Map<string, {when: string, line: number}>} */
51
+ const many = new Map();
43
52
  const sections = new Set();
44
53
  const twice = [];
45
54
  let section = '';
@@ -61,9 +70,12 @@ export function parseGraph(text) {
61
70
  return;
62
71
  }
63
72
  edges.push({ from: m[1], to: m[2], token: m[3], when: m[4] ?? '', max: m[5] ? Number(m[5]) : null, line: i + 1 });
73
+ } else if (section === 'concurrency') {
74
+ const m = MANY.exec(line);
75
+ if (m) many.set(m[1], { when: m[2].trim(), line: i + 1 });
64
76
  }
65
77
  });
66
- return { stages, edges, stray, sections, twice };
78
+ return { stages, edges, stray, sections, twice, many };
67
79
  }
68
80
 
69
81
  /**
@@ -122,6 +134,9 @@ export function validateGraph(graph, specs, roles = new Set()) {
122
134
  for (const s of graph.twice ?? []) problems.push(`stage \`${s}\` is listed more than once`);
123
135
 
124
136
  for (const s of stray) problems.push(`graph.md:${s.line} is under "## Edges" but is not an edge: ${s.text}`);
137
+ for (const [s, { line }] of graph.many ?? []) {
138
+ if (!stages.has(s)) problems.push(`graph.md:${line} says \`${s}\` runs as several agents, and it is not a stage here`);
139
+ }
125
140
 
126
141
  // Every stage is a spec on disk and every spec on disk is a stage — the profile decides both, so
127
142
  // a mismatch means a layer named a stage the profile does not compose, or composed one no edge