@xhulz/nina 0.83.0 → 0.84.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/README.md +2 -0
  2. package/bin/nina.mjs +6 -1
  3. package/package.json +1 -1
  4. package/releases/0.84.0/core/GAPS.md +125 -0
  5. package/releases/0.84.0/core/answered.json +134 -0
  6. package/releases/0.84.0/core/defaults/tree/.claude/agents/architect.md +1 -0
  7. package/releases/0.84.0/core/defaults/tree/.claude/agents/dba.md +3 -0
  8. package/releases/0.84.0/core/defaults/tree/.claude/agents/devops.md +1 -0
  9. package/releases/0.84.0/core/defaults/tree/.claude/agents/implementer.md +1 -0
  10. package/releases/0.84.0/core/defaults/tree/.claude/agents/integration-tester.md +3 -0
  11. package/releases/0.84.0/core/defaults/tree/.claude/agents/qa.md +7 -0
  12. package/releases/0.84.0/core/defaults/tree/.claude/agents/reviewer.md +3 -0
  13. package/releases/0.84.0/core/defaults/tree/.claude/agents/secops.md +3 -0
  14. package/releases/0.84.0/core/defaults/tree/.claude/agents/solidity-auditor.md +3 -0
  15. package/releases/0.84.0/core/defaults/tree/.claude/agents/solidity-dev.md +3 -0
  16. package/releases/0.84.0/core/tiers.json +5 -0
  17. package/releases/0.84.0/core/tree/.claude/agents/architect.md +140 -0
  18. package/releases/0.84.0/core/tree/.claude/agents/dba.md +123 -0
  19. package/releases/0.84.0/core/tree/.claude/agents/design-reviewer.md +82 -0
  20. package/releases/0.84.0/core/tree/.claude/agents/devops.md +122 -0
  21. package/releases/0.84.0/core/tree/.claude/agents/implementer.md +208 -0
  22. package/releases/0.84.0/core/tree/.claude/agents/integration-tester.md +174 -0
  23. package/releases/0.84.0/core/tree/.claude/agents/planner.md +132 -0
  24. package/releases/0.84.0/core/tree/.claude/agents/qa.md +154 -0
  25. package/releases/0.84.0/core/tree/.claude/agents/reviewer.md +191 -0
  26. package/releases/0.84.0/core/tree/.claude/agents/secops.md +124 -0
  27. package/releases/0.84.0/core/tree/.claude/agents/solidity-auditor.md +130 -0
  28. package/releases/0.84.0/core/tree/.claude/agents/solidity-dev.md +127 -0
  29. package/releases/0.84.0/core/tree/.claude/agents-overview.md +65 -0
  30. package/releases/0.84.0/core/tree/.claude/frontend.md +10 -0
  31. package/releases/0.84.0/core/tree/.claude/graph.md +79 -0
  32. package/releases/0.84.0/core/tree/.claude/patterns.md +256 -0
  33. package/releases/0.84.0/core/tree/.claude/pills/README.md +150 -0
  34. package/releases/0.84.0/core/tree/.claude/pipeline.md +101 -0
  35. package/releases/0.84.0/core/tree/.claude/retrieval.md +103 -0
  36. package/releases/0.84.0/core/tree/.claude/router.md +224 -0
  37. package/releases/0.84.0/core/tree/.claude/templates/integration.md +48 -0
  38. package/releases/0.84.0/core/tree/CLAUDE.md +167 -0
  39. package/releases/0.84.0/core/tree/scripts/cost-watch.mjs +27 -0
  40. package/releases/0.84.0/core/tree/scripts/edit-guard.mjs +24 -0
  41. package/releases/0.84.0/core/tree/scripts/harness-check.mjs +84 -0
  42. package/releases/0.84.0/core/tree/scripts/loop-gate.mjs +19 -0
  43. package/releases/0.84.0/core/vocabulary.json +18 -0
  44. package/releases/0.84.0/surfaces/blockchain/tree/.claude/agents/architect.md +5 -0
  45. package/releases/0.84.0/surfaces/blockchain/tree/.claude/agents/devops.md +5 -0
  46. package/releases/0.84.0/surfaces/blockchain/tree/.claude/agents/reviewer.md +5 -0
  47. package/releases/0.84.0/surfaces/blockchain/tree/.claude/agents/solidity-auditor.md +18 -0
  48. package/releases/0.84.0/surfaces/blockchain/tree/.claude/agents/solidity-dev.md +16 -0
  49. package/releases/0.84.0/surfaces/blockchain/tree/.claude/agents-overview.md +6 -0
  50. package/releases/0.84.0/surfaces/blockchain/tree/.claude/graph.md +11 -0
  51. package/releases/0.84.0/surfaces/blockchain/tree/.claude/router.md +6 -0
  52. package/releases/0.84.0/surfaces/blockchain/tree/CLAUDE.md +12 -0
  53. package/releases/0.84.0/surfaces/d1/tree/.claude/agents/dba.md +15 -0
  54. package/releases/0.84.0/surfaces/d1/tree/.claude/agents/devops.md +2 -0
  55. package/releases/0.84.0/surfaces/d1/tree/.claude/agents/implementer.md +2 -0
  56. package/releases/0.84.0/surfaces/d1/tree/.claude/patterns.md +8 -0
  57. package/releases/0.84.0/surfaces/d1/tree/CLAUDE.md +2 -0
  58. package/releases/0.84.0/surfaces/db/tree/.claude/agents/architect.md +5 -0
  59. package/releases/0.84.0/surfaces/db/tree/.claude/agents/dba.md +54 -0
  60. package/releases/0.84.0/surfaces/db/tree/.claude/agents/devops.md +5 -0
  61. package/releases/0.84.0/surfaces/db/tree/.claude/agents/implementer.md +2 -0
  62. package/releases/0.84.0/surfaces/db/tree/.claude/agents/integration-tester.md +10 -0
  63. package/releases/0.84.0/surfaces/db/tree/.claude/agents/planner.md +2 -0
  64. package/releases/0.84.0/surfaces/db/tree/.claude/agents/reviewer.md +22 -0
  65. package/releases/0.84.0/surfaces/db/tree/.claude/agents/secops.md +2 -0
  66. package/releases/0.84.0/surfaces/db/tree/.claude/agents-overview.md +5 -0
  67. package/releases/0.84.0/surfaces/db/tree/.claude/graph.md +8 -0
  68. package/releases/0.84.0/surfaces/db/tree/.claude/patterns.md +22 -0
  69. package/releases/0.84.0/surfaces/db/tree/.claude/pipeline.md +13 -0
  70. package/releases/0.84.0/surfaces/db/tree/.claude/retrieval.md +5 -0
  71. package/releases/0.84.0/surfaces/db/tree/.claude/router.md +4 -0
  72. package/releases/0.84.0/surfaces/db/tree/CLAUDE.md +17 -0
  73. package/releases/0.84.0/surfaces/drizzle/tree/.claude/agents/dba.md +9 -0
  74. package/releases/0.84.0/surfaces/drizzle/tree/.claude/agents/devops.md +5 -0
  75. package/releases/0.84.0/surfaces/drizzle/tree/.claude/agents/implementer.md +2 -0
  76. package/releases/0.84.0/surfaces/drizzle/tree/.claude/patterns.md +7 -0
  77. package/releases/0.84.0/surfaces/edge-cf/tree/.claude/agents/architect.md +14 -0
  78. package/releases/0.84.0/surfaces/edge-cf/tree/.claude/agents/dba.md +2 -0
  79. package/releases/0.84.0/surfaces/edge-cf/tree/.claude/agents/devops.md +29 -0
  80. package/releases/0.84.0/surfaces/edge-cf/tree/.claude/agents/implementer.md +14 -0
  81. package/releases/0.84.0/surfaces/edge-cf/tree/.claude/agents/integration-tester.md +11 -0
  82. package/releases/0.84.0/surfaces/edge-cf/tree/.claude/agents/qa.md +3 -0
  83. package/releases/0.84.0/surfaces/edge-cf/tree/.claude/agents/reviewer.md +8 -0
  84. package/releases/0.84.0/surfaces/edge-cf/tree/.claude/agents/secops.md +8 -0
  85. package/releases/0.84.0/surfaces/edge-cf/tree/.claude/patterns.md +17 -0
  86. package/releases/0.84.0/surfaces/edge-cf/tree/.claude/retrieval.md +8 -0
  87. package/releases/0.84.0/surfaces/edge-cf/tree/CLAUDE.md +29 -0
  88. package/releases/0.84.0/surfaces/frontend/tree/.claude/agents/architect.md +20 -0
  89. package/releases/0.84.0/surfaces/frontend/tree/.claude/agents/devops.md +28 -0
  90. package/releases/0.84.0/surfaces/frontend/tree/.claude/agents/implementer.md +25 -0
  91. package/releases/0.84.0/surfaces/frontend/tree/.claude/agents/qa.md +17 -0
  92. package/releases/0.84.0/surfaces/frontend/tree/.claude/agents/reviewer.md +56 -0
  93. package/releases/0.84.0/surfaces/frontend/tree/.claude/patterns.md +8 -0
  94. package/releases/0.84.0/surfaces/frontend/tree/.claude/retrieval.md +2 -0
  95. package/releases/0.84.0/surfaces/frontend/tree/.claude/router.md +14 -0
  96. package/releases/0.84.0/surfaces/frontend/tree/CLAUDE.md +5 -0
  97. package/releases/0.84.0/surfaces/integrations/tree/.claude/agents/architect.md +16 -0
  98. package/releases/0.84.0/surfaces/integrations/tree/.claude/agents/implementer.md +12 -0
  99. package/releases/0.84.0/surfaces/integrations/tree/.claude/agents/integration-tester.md +20 -0
  100. package/releases/0.84.0/surfaces/integrations/tree/.claude/agents/planner.md +2 -0
  101. package/releases/0.84.0/surfaces/integrations/tree/.claude/agents/reviewer.md +21 -0
  102. package/releases/0.84.0/surfaces/integrations/tree/.claude/agents/secops.md +2 -0
  103. package/releases/0.84.0/surfaces/integrations/tree/.claude/agents-overview.md +8 -0
  104. package/releases/0.84.0/surfaces/integrations/tree/.claude/graph.md +10 -0
  105. package/releases/0.84.0/surfaces/integrations/tree/.claude/patterns.md +38 -0
  106. package/releases/0.84.0/surfaces/integrations/tree/.claude/pipeline.md +16 -0
  107. package/releases/0.84.0/surfaces/integrations/tree/.claude/retrieval.md +16 -0
  108. package/releases/0.84.0/surfaces/integrations/tree/.claude/router.md +6 -0
  109. package/releases/0.84.0/surfaces/integrations/tree/CLAUDE.md +17 -0
  110. package/releases/0.84.0/surfaces/money/tree/.claude/agents/architect.md +12 -0
  111. package/releases/0.84.0/surfaces/money/tree/.claude/agents/dba.md +14 -0
  112. package/releases/0.84.0/surfaces/money/tree/.claude/agents/implementer.md +11 -0
  113. package/releases/0.84.0/surfaces/money/tree/.claude/agents/integration-tester.md +3 -0
  114. package/releases/0.84.0/surfaces/money/tree/.claude/agents/reviewer.md +23 -0
  115. package/releases/0.84.0/surfaces/money/tree/.claude/agents/secops.md +5 -0
  116. package/releases/0.84.0/surfaces/money/tree/.claude/agents/solidity-auditor.md +4 -0
  117. package/releases/0.84.0/surfaces/money/tree/.claude/agents/solidity-dev.md +4 -0
  118. package/releases/0.84.0/surfaces/money/tree/.claude/graph.md +2 -0
  119. package/releases/0.84.0/surfaces/money/tree/.claude/patterns.md +49 -0
  120. package/releases/0.84.0/surfaces/money/tree/.claude/pipeline.md +2 -0
  121. package/releases/0.84.0/surfaces/money/tree/.claude/retrieval.md +2 -0
  122. package/releases/0.84.0/surfaces/money/tree/CLAUDE.md +11 -0
  123. package/releases/0.84.0/surfaces/pii/tree/.claude/agents/dba.md +2 -0
  124. package/releases/0.84.0/surfaces/pii/tree/.claude/agents/devops.md +8 -0
  125. package/releases/0.84.0/surfaces/pii/tree/.claude/agents/reviewer.md +8 -0
  126. package/releases/0.84.0/surfaces/pii/tree/.claude/agents/secops.md +2 -0
  127. package/releases/0.84.0/surfaces/pii/tree/.claude/agents/solidity-auditor.md +5 -0
  128. package/releases/0.84.0/surfaces/pii/tree/CLAUDE.md +5 -0
  129. package/releases/0.84.0/surfaces/prisma/tree/.claude/agents/architect.md +2 -0
  130. package/releases/0.84.0/surfaces/prisma/tree/.claude/agents/dba.md +42 -0
  131. package/releases/0.84.0/surfaces/prisma/tree/.claude/agents/devops.md +11 -0
  132. package/releases/0.84.0/surfaces/prisma/tree/.claude/agents/implementer.md +2 -0
  133. package/releases/0.84.0/surfaces/prisma/tree/.claude/agents/integration-tester.md +2 -0
  134. package/releases/0.84.0/surfaces/prisma/tree/.claude/agents/reviewer.md +2 -0
  135. package/releases/0.84.0/surfaces/prisma/tree/.claude/patterns.md +15 -0
  136. package/releases/0.84.0/surfaces/prisma/tree/CLAUDE.md +5 -0
  137. package/src/commands/dashboard.mjs +31 -6
  138. package/src/dashboard.mjs +13 -8
  139. package/src/demo.mjs +417 -0
  140. package/src/office.mjs +16 -3
package/src/demo.mjs ADDED
@@ -0,0 +1,417 @@
1
+ /**
2
+ * `nina dashboard --demo` — the dashboard on a project that does not exist: for showing someone how NINA works without
3
+ * opening a real project, running Claude or spending a token.
4
+ *
5
+ * A notes app is composed into a scratch directory at the release this package carries, and given three weeks of
6
+ * invented history: each session's transcripts, written the way Claude Code writes them, and today's session through
7
+ * the loop gate's own hook handler. The snapshot then reads the transcripts into the store as it reads any project's.
8
+ * Nothing here writes a ledger entry or a record of its own: a demo built from hand-written JSON would drift from the
9
+ * format the first time either changed, and the test that runs it would still pass (one mechanism per fact). Every
10
+ * word in it — the features, the issues, the reports — is invented, and nothing is read from a real project.
11
+ *
12
+ * `NINA_DATA` and `NINA_TRANSCRIPTS` point into the scratch directory for the rest of the process, so the store, the
13
+ * gate's ledgers and the transcripts every tab and the office read are the demo's; the command's process ends with the
14
+ * demo, which removes the directory.
15
+ */
16
+
17
+ import { mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { handle } from './gate.mjs';
21
+ import { slugFor } from './paths.mjs';
22
+ import { byVersion, composeProject } from './commands/compose.mjs';
23
+ import { frontmatter } from './commands/pills.mjs';
24
+ import { snapshot } from './commands/snapshot.mjs';
25
+
26
+ /** The project's name, which the dashboard and the office show. */
27
+ export const DEMO_NAME = 'notes';
28
+
29
+ /** The stages that run beside the reviewer on a designed change's diff: the database gate, since the notes have a table. */
30
+ const BESIDE_REVIEW = ['dba'];
31
+
32
+ /** A minute. */
33
+ const MIN = 60_000;
34
+
35
+ /** A day. */
36
+ const DAY = 86_400_000;
37
+
38
+ /**
39
+ * The release the demo composes: the one this package carries as its own version, or its newest.
40
+ *
41
+ * @param {string} root - The NINA install directory.
42
+ * @returns {string}
43
+ */
44
+ function ownRelease(root) {
45
+ const version = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version;
46
+ const releases = readdirSync(join(root, 'releases')).filter((v) => /^\d+\.\d+\.\d+$/.test(v));
47
+ if (releases.includes(version)) return version;
48
+ return releases.sort(byVersion).at(-1);
49
+ }
50
+
51
+ /** The demo's profile: a notes app with a database and a web page, its vocabulary invented. */
52
+ const profileAt = (core) => ({
53
+ core,
54
+ surfaces: ['db', 'frontend'],
55
+ vocabulary: {
56
+ PROJECT: 'Notes',
57
+ OWNER: 'the maintainer',
58
+ API_DIR: 'src/server',
59
+ APP_DIR: 'src/web',
60
+ PKG_SCOPE: '@notes',
61
+ EMITTING_PKGS: '`core`',
62
+ DB_PKG_NAME: '@notes/db',
63
+ TENANT_KEY: 'workspaceId',
64
+ SECRETS_LOCAL: '.dev.vars',
65
+ SECRETS_PROD: "the host's secret store",
66
+ VISUAL_SERVE: 'pnpm dev',
67
+ },
68
+ });
69
+
70
+ /**
71
+ * Two lessons the pipeline wrote: the implementer's, ten days ago, after which the reviewer sent its work back far less
72
+ * often, and the architect's, six days ago. Where each lives, when it was written, and its text.
73
+ *
74
+ * @param {number} now
75
+ * @returns {{path: string, since: number, text: string}[]}
76
+ */
77
+ const PILLS = (now) => {
78
+ const ago = (n) => {
79
+ const d = new Date(now - n * DAY);
80
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
81
+ };
82
+ return [
83
+ {
84
+ path: 'implementer/a-call-that-sets-something-sets-it.md',
85
+ since: now - 10 * DAY,
86
+ text: `---
87
+ id: implementer-a-call-that-sets-something-sets-it
88
+ applies_to: [implementer]
89
+ severity: medium
90
+ status: active
91
+ date: ${ago(10)}
92
+ last_seen: ${ago(3)}
93
+ occurrences: 2
94
+ trigger: writing a call that sets a flag, a state or a field on a stored row
95
+ ---
96
+ **What went wrong:** an endpoint meant to archive a note flipped the flag, so a second call unarchived it.
97
+ **Rule:** a call that sets something sets it: the same call twice leaves the row as once.
98
+ **Why:** a retry, a double click and a replayed request all call twice.
99
+ **How to apply:** write a test that calls it twice and reads the row after.
100
+ source: an invented review, for the demo
101
+ `,
102
+ },
103
+ {
104
+ path: 'architect/say-what-a-second-call-does.md',
105
+ since: now - 6 * DAY,
106
+ text: `---
107
+ id: architect-say-what-a-second-call-does
108
+ applies_to: [architect]
109
+ severity: low
110
+ status: active
111
+ date: ${ago(6)}
112
+ occurrences: 1
113
+ trigger: specifying an endpoint that changes a stored row
114
+ ---
115
+ **What went wrong:** a spec left open what a second call does, and the design-reviewer sent it back.
116
+ **Rule:** a spec that changes a row says what the same call does the second time.
117
+ **Why:** the implementer picks one if the spec does not, and the review finds out a round later.
118
+ **How to apply:** one line under the endpoint: a second call is a no-op, an error, or a new row.
119
+ source: an invented review, for the demo
120
+ `,
121
+ },
122
+ ];
123
+ };
124
+
125
+ /** The features of three weeks of invented work, one a task: what the task is called, and what its steps call it. */
126
+ const FEATURES = [
127
+ ['Add tags to notes', 'tags'],
128
+ ['Search notes by title', 'title search'],
129
+ ['Share a note by link', 'share link'],
130
+ ['Paginate the notes list', 'pagination'],
131
+ ['Export notes as Markdown', 'Markdown export'],
132
+ ['Pin a note to the top', 'pinning'],
133
+ ['Undo a deleted note', 'undo'],
134
+ ['Show a word count', 'word count'],
135
+ ['Sort notes by date', 'date sort'],
136
+ ['Rename a notebook', 'notebook rename'],
137
+ ['Color a notebook', 'notebook colors'],
138
+ ['Move a note between notebooks', 'note move'],
139
+ ['Restore an old version of a note', 'versions'],
140
+ ['Limit a note to 100 KB', 'size limit'],
141
+ ];
142
+
143
+ /** How long each stage works, in minutes, at least and at most. */
144
+ const WORKS = {
145
+ planner: [3, 5],
146
+ architect: [8, 14],
147
+ 'design-reviewer': [4, 7],
148
+ implementer: [10, 20],
149
+ reviewer: [4, 7],
150
+ dba: [3, 5],
151
+ qa: [3, 6],
152
+ };
153
+
154
+ /** What each stage does in its own transcript, as the office says it: a shell command's description, or a file it works on. */
155
+ const TOOLS = {
156
+ planner: [['Read', { file_path: '.claude/architecture.md' }], ['Grep', { pattern: 'notes' }], ['Write', { file_path: '.claude/plans/plan.md' }]],
157
+ architect: [['Read', { file_path: '.claude/architecture.md' }], ['Read', { file_path: 'src/server/notes.ts' }], ['Bash', { description: 'Check how the notes table is indexed' }], ['Write', { file_path: '.claude/plans/specs/spec.md' }]],
158
+ 'design-reviewer': [['Read', { file_path: '.claude/plans/specs/spec.md' }], ['Bash', { description: 'Try the spec on a second call' }]],
159
+ implementer: [['Read', { file_path: '.claude/plans/specs/spec.md' }], ['Edit', { file_path: 'src/server/notes.ts' }], ['Edit', { file_path: 'src/server/notes.test.ts' }], ['Bash', { description: 'Run the typecheck' }], ['Bash', { description: 'Run the mutations on a copy' }]],
160
+ reviewer: [['Bash', { description: 'Show the diff' }], ['Read', { file_path: 'src/server/notes.ts' }], ['Bash', { description: 'Run the lint' }]],
161
+ dba: [['Read', { file_path: 'src/server/db/schema.ts' }], ['Bash', { description: 'Read the migration the change adds' }]],
162
+ qa: [['Bash', { description: 'Run the notes tests' }], ['Bash', { description: 'Run the whole suite once' }]],
163
+ };
164
+
165
+ /** Tokens a run writes, in thousands, at least and at most; what it reads back from its cache is ten to forty times that. */
166
+ const WRITES = { planner: [2, 5], architect: [12, 30], 'design-reviewer': [4, 9], implementer: [10, 25], reviewer: [5, 12], dba: [2, 5], qa: [1, 3] };
167
+
168
+ /**
169
+ * A small generator of numbers that is the same on every run, so the demo, and the test that reads it, never vary.
170
+ *
171
+ * @param {number} seed
172
+ * @returns {() => number} A number in [0, 1).
173
+ */
174
+ function seeded(seed) {
175
+ let s = seed >>> 0;
176
+ return () => {
177
+ s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
178
+ return s / 2 ** 32;
179
+ };
180
+ }
181
+
182
+ /**
183
+ * The runs of one task, from `start`: who works, on what words, when, and what each reports. `sendBack` says which
184
+ * gate sends the work back once; `design` whether it goes through the planner, the architect and the design-reviewer.
185
+ *
186
+ * @returns {{runs: object[], end: number}}
187
+ */
188
+ function taskRuns(random, start, { name, noun, design, sendBack = null, between = 0.2 }) {
189
+ const runs = [];
190
+ const slug = noun.toLowerCase().replace(/\s+/g, '-');
191
+ const span = (role) => {
192
+ const [lo, hi] = WORKS[role];
193
+ return Math.round((lo + random() * (hi - lo)) * MIN);
194
+ };
195
+ let at = start;
196
+ const run = (role, desc, report, { from = at, beside = false } = {}) => {
197
+ const r = { role, desc, report, start: from, end: from + span(role) };
198
+ runs.push(r);
199
+ if (!beside) at = r.end + between * MIN;
200
+ return r;
201
+ };
202
+ if (design) {
203
+ run('planner', name, 'VERDICT: PLAN-READY\n\nTwo steps: the endpoint, then the list.');
204
+ run('architect', `Spec the ${noun}`, 'VERDICT: SPEC-READY\n\nThe design is the question: the design-reviewer reads it first.');
205
+ if (sendBack === 'design-reviewer') {
206
+ run('design-reviewer', `Check the ${noun} design`, `VERDICT: REJECTED\nISSUES: ${slug}-second-call-undefined\n\nThe spec does not say what a second call does.`);
207
+ run('architect', `Correct the ${noun} spec`, 'VERDICT: SPEC-READY\n\nA second call is now a no-op, and a case reads it.');
208
+ }
209
+ run('design-reviewer', `Check the ${noun} design`, 'VERDICT: APPROVED\n\nThe design holds.');
210
+ }
211
+ run('implementer', design ? `Build the ${noun}` : name, 'VERDICT: DIFF-READY\n\nThe change and its tests, each with the mutation that turns it red.');
212
+ // The reviewer and the gates beside it go out together on the diff, and qa once each has approved.
213
+ const review = (round) => {
214
+ const from = at;
215
+ const sent = sendBack === 'reviewer' && round === 1;
216
+ const reviewer = run('reviewer', round === 1 ? `Review the ${noun} change` : `Review the ${noun} fix`, sent ? `VERDICT: REJECTED\nISSUES: ${slug}-not-idempotent\n\nA second call undoes the first.` : 'VERDICT: APPROVED\n\nThe diff matches its spec.', { from, beside: true });
217
+ const gates = round === 1 && design ? BESIDE_REVIEW.map((role) => run(role, `Check the ${noun} migration`, 'VERDICT: APPROVED\n\nThe migration is additive and indexed.', { from, beside: true })) : [];
218
+ at = Math.max(reviewer.end, ...gates.map((g) => g.end)) + between * MIN;
219
+ return sent;
220
+ };
221
+ if (review(1)) {
222
+ run('implementer', `Fix the ${noun} second call`, 'VERDICT: DIFF-READY\n\nA second call is a no-op now, and a test calls it twice.');
223
+ review(2);
224
+ }
225
+ if (sendBack === 'qa') {
226
+ run('qa', `Test the ${noun}`, `VERDICT: FAIL\nISSUES: ${slug}-test-order\n\nOne test reads a row another wrote.`);
227
+ run('implementer', `Fix the ${noun} test order`, 'VERDICT: DIFF-READY\n\nEach test makes its own rows.');
228
+ }
229
+ run('qa', `Test the ${noun}`, 'VERDICT: PASS\n\nThe tests the diff touches pass, and the suite once.');
230
+ return { runs, end: at };
231
+ }
232
+
233
+ /**
234
+ * Three weeks of invented sessions, and today's: each a list of runs. Today's is the one the office plays. Its first
235
+ * task goes the whole way, sent back once by the reviewer while the database gate approves beside it; its second
236
+ * starts while the first one's qa is at work, so two pieces of work are on the floor at once.
237
+ *
238
+ * @param {number} now - The moment the demo is made; today's last report came in three minutes before it.
239
+ * @returns {{id: string, prompt: string, at: number, runs: object[], asked?: [number, number]}[]}
240
+ */
241
+ export function demoSessions(now) {
242
+ const random = seeded(2026);
243
+ const sessions = [];
244
+ const days = [21, 20, 19, 18, 17, 15, 14, 13, 12, 11, 9, 8, 7, 6, 5, 3, 2, 1];
245
+ let feature = 0;
246
+ for (const back of days) {
247
+ const morning = new Date(now - back * DAY);
248
+ morning.setHours(9 + Math.floor(random() * 3), Math.floor(random() * 60), 0, 0);
249
+ const runs = [];
250
+ let at = morning.getTime();
251
+ for (let n = 0; n < 1 + Math.floor(random() * 3); n += 1) {
252
+ const [name, noun] = FEATURES[feature % FEATURES.length];
253
+ feature += 1;
254
+ // The reviewer sent the implementer's work back more than half the time before its lesson, ten days ago, and seldom after.
255
+ const roll = random();
256
+ const sendBack = roll < (back > 10 ? 0.6 : 0.1) ? 'reviewer' : roll > 0.92 ? 'qa' : roll > 0.85 ? 'design-reviewer' : null;
257
+ const task = taskRuns(random, at, { name, noun, design: random() < 0.7 || sendBack === 'design-reviewer', sendBack });
258
+ runs.push(...task.runs);
259
+ at = task.end + (5 + random() * 20) * MIN;
260
+ }
261
+ sessions.push({ id: sessionId(random), prompt: 'Next feature, please.', at: morning.getTime() - MIN, runs });
262
+ }
263
+ // Today's: the archive, sent back once, and the list's dates, begun while the archive's tests run. Laid out from zero,
264
+ // then moved so its last report came in three minutes ago: nothing in it happens after `now`.
265
+ const archive = taskRuns(seeded(7), MIN, { name: 'Add archiving to notes', noun: 'archive', design: true, sendBack: 'reviewer' });
266
+ const qa = archive.runs.at(-1);
267
+ const dates = taskRuns(seeded(11), qa.start + 1.5 * MIN, { name: 'Fix the dates on the notes list', noun: 'list dates', design: false });
268
+ const runs = [...archive.runs, ...dates.runs].sort((a, b) => a.start - b.start);
269
+ const shift = now - 3 * MIN - Math.max(...runs.map((r) => r.end));
270
+ for (const r of runs) Object.assign(r, { start: r.start + shift, end: r.end + shift });
271
+ const planned = archive.runs[0];
272
+ sessions.push({
273
+ id: sessionId(random),
274
+ prompt: 'Add archiving to notes, and fix the dates on the list.',
275
+ at: shift,
276
+ runs,
277
+ // The orchestrator asks the owner which of the planner's two steps goes first, and is answered before the architect goes.
278
+ asked: [planned.end + 0.05 * MIN, planned.end + 0.15 * MIN],
279
+ });
280
+ return sessions;
281
+ }
282
+
283
+ /** A session's id, shaped as Claude Code's are. */
284
+ function sessionId(random) {
285
+ const hex = (n) => Array.from({ length: n }, () => Math.floor(random() * 16).toString(16)).join('');
286
+ return `${hex(8)}-${hex(4)}-4${hex(3)}-a${hex(3)}-${hex(12)}`;
287
+ }
288
+
289
+ /** An agent's id, as hexadecimal as Claude Code's, which the transcript parser reads by that. */
290
+ const agentId = (random) => `a${Array.from({ length: 16 }, () => Math.floor(random() * 16).toString(16)).join('')}`;
291
+
292
+ /** What a stage runs on: the model and effort its composed spec names. */
293
+ function ranOn(project, role) {
294
+ const fields = frontmatter(readFileSync(join(project, '.claude', 'agents', `${role}.md`), 'utf8')) ?? {};
295
+ return { model: fields.model ?? null, effort: fields.effort ?? null };
296
+ }
297
+
298
+ /**
299
+ * Writes one session as Claude Code writes it: the orchestrator's transcript, with each dispatch launched in the
300
+ * background and its notification when it ends, and each run's own transcript under `subagents/`, with its tool
301
+ * calls, what each message spent, and the report it handed back.
302
+ */
303
+ function writeTranscripts(dir, project, session, random, pills) {
304
+ const stamp = (t) => new Date(t).toISOString();
305
+ const main = [];
306
+ const row = (fields) => main.push({ ...fields, cwd: project, sessionId: session.id, version: '2.1.312' });
307
+ row({ type: 'user', uuid: `${session.id}-u0`, timestamp: stamp(session.at), message: { role: 'user', content: session.prompt } });
308
+ const subagents = join(dir, session.id, 'subagents');
309
+ mkdirSync(subagents, { recursive: true });
310
+ for (const [i, run] of session.runs.entries()) {
311
+ run.toolUseId = `toolu_${session.id.slice(0, 8)}${String(i).padStart(3, '0')}`;
312
+ run.agent = agentId(random);
313
+ row({ type: 'assistant', uuid: `${run.toolUseId}-d`, timestamp: stamp(run.start), message: { role: 'assistant', content: [{ type: 'tool_use', id: run.toolUseId, name: 'Agent', input: { subagent_type: run.role, description: run.desc, prompt: `${run.desc}.`, run_in_background: true } }] } });
314
+ row({ type: 'user', uuid: `${run.toolUseId}-l`, timestamp: stamp(run.start + 1000), message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: run.toolUseId, content: `Async agent launched successfully.\nagentId: ${run.agent}` }] } });
315
+ row({
316
+ type: 'user',
317
+ uuid: `${run.toolUseId}-n`,
318
+ timestamp: stamp(run.end + 2000),
319
+ message: { role: 'user', content: `<task-notification>\n<task-id>${run.agent}</task-id>\n<tool-use-id>${run.toolUseId}</tool-use-id>\n<status>completed</status>\n<result>${run.report}</result>\n<duration_ms>${run.end - run.start}</duration_ms>\n</task-notification>` },
320
+ });
321
+
322
+ const { model, effort } = ranOn(project, run.role);
323
+ // Each stage opens the lessons its directory held when it was dispatched, as its spec tells it to.
324
+ const lessons = pills.filter((p) => p.path.startsWith(`${run.role}/`) && p.since <= run.start).map((p) => ['Read', { file_path: join(project, '.claude', 'pills', p.path) }]);
325
+ const tools = [...lessons, ...TOOLS[run.role]];
326
+ const [lo, hi] = WRITES[run.role];
327
+ const written = Math.round((lo + random() * (hi - lo)) * 1000);
328
+ const steps = tools.length + 1;
329
+ const own = [{ type: 'user', uuid: `${run.agent}-p`, timestamp: stamp(run.start), agentId: run.agent, sessionId: session.id, message: { role: 'user', content: `${run.desc}.` } }];
330
+ const said = (k, content, share) => {
331
+ const usage = { input_tokens: 4 + k, output_tokens: Math.round(written * share), cache_read_input_tokens: Math.round(written * (8 + random() * 24) * share), cache_creation_input_tokens: Math.round(written * (0.5 + random()) * share) };
332
+ own.push({ type: 'assistant', uuid: `${run.agent}-m${k}`, timestamp: stamp(run.start + ((run.end - run.start) * (k + 1)) / (steps + 1)), agentId: run.agent, sessionId: session.id, effort, message: { id: `msg_${run.agent}${k}`, role: 'assistant', model, usage, content } });
333
+ };
334
+ tools.forEach(([name, input], k) => {
335
+ const lines = name === 'Edit' ? { old_string: 'export {};', new_string: Array.from({ length: 12 + Math.floor(random() * 40) }, (_, n) => `// line ${n + 1}`).join('\n') } : name === 'Write' ? { content: Array.from({ length: 20 + Math.floor(random() * 30) }, (_, n) => `line ${n + 1}`).join('\n') } : {};
336
+ said(k, [{ type: 'tool_use', id: `${run.agent}-t${k}`, name, input: { ...input, ...lines } }], 1 / steps);
337
+ });
338
+ said(tools.length, [{ type: 'tool_use', id: `${run.agent}-h`, name: 'SubagentHandback', input: { message: run.report } }], 1 / steps);
339
+ writeFileSync(join(subagents, `agent-${run.agent}.jsonl`), `${own.map((r) => JSON.stringify(r)).join('\n')}\n`);
340
+ writeFileSync(join(subagents, `agent-${run.agent}.meta.json`), JSON.stringify({ agentType: run.role, toolUseId: run.toolUseId }));
341
+ }
342
+ writeFileSync(join(dir, `${session.id}.jsonl`), `${main.map((r) => JSON.stringify(r)).join('\n')}\n`);
343
+ }
344
+
345
+ /**
346
+ * Today's session through the loop gate, event by event, each at its moment: what its hooks would have been handed.
347
+ * The ledger it writes is the one the office plays.
348
+ */
349
+ function throughTheGate(project, session, subagents) {
350
+ const events = [{ at: session.at, input: { hook_event_name: 'UserPromptSubmit', prompt: session.prompt } }];
351
+ if (session.asked) {
352
+ events.push({ at: session.asked[0], input: { hook_event_name: 'PreToolUse', tool_name: 'AskUserQuestion', tool_input: {} } });
353
+ events.push({ at: session.asked[1], input: { hook_event_name: 'PostToolUse', tool_name: 'AskUserQuestion', tool_input: {} } });
354
+ }
355
+ for (const run of session.runs) {
356
+ const tool_input = { subagent_type: run.role, description: run.desc, prompt: `${run.desc}.`, run_in_background: true };
357
+ events.push({ at: run.start, input: { hook_event_name: 'PreToolUse', tool_name: 'Agent', tool_input, tool_use_id: run.toolUseId } });
358
+ events.push({ at: run.start + 1000, input: { hook_event_name: 'PostToolUse', tool_name: 'Agent', tool_input, tool_use_id: run.toolUseId, tool_response: { agentId: run.agent, isAsync: true, status: 'async_launched' } } });
359
+ events.push({ at: run.end, input: { hook_event_name: 'PostToolUse', tool_name: 'SubagentHandback', agent_id: run.agent, agent_type: run.role, tool_input: { message: run.report } } });
360
+ events.push({ at: run.end + 500, input: { hook_event_name: 'SubagentStop', agent_id: run.agent, agent_type: run.role, last_assistant_message: 'Handed back.', agent_transcript_path: join(subagents, `agent-${run.agent}.jsonl`), stop_hook_active: false } });
361
+ events.push({ at: run.end + 2000, input: { hook_event_name: 'UserPromptSubmit', prompt: `<task-notification>\n<task-id>${run.agent}</task-id>\n<tool-use-id>${run.toolUseId}</tool-use-id>\n<status>completed</status>\n</task-notification>` } });
362
+ }
363
+ events.sort((a, b) => a.at - b.at);
364
+ for (const { at, input } of events) handle({ ...input, session_id: session.id }, { root: project, now: new Date(at) });
365
+ }
366
+
367
+ /**
368
+ * Makes the demo: the project, its history, and the environment that points every reader at them.
369
+ *
370
+ * @param {{root: string}} ctx - CLI context: where the releases are.
371
+ * @param {{now?: number}} [options]
372
+ * @returns {Promise<{project: string, dir: string, session: string, remove: () => void}>} `dir` holds all of it, and
373
+ * `remove` takes it away.
374
+ */
375
+ export async function makeDemo(ctx, { now = Date.now() } = {}) {
376
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), 'nina-demo-')));
377
+ const remove = () => rmSync(dir, { recursive: true, force: true });
378
+ const before = { NINA_DATA: process.env.NINA_DATA, NINA_TRANSCRIPTS: process.env.NINA_TRANSCRIPTS };
379
+ try {
380
+ const project = join(dir, DEMO_NAME);
381
+ const data = join(dir, 'data');
382
+ const transcripts = join(dir, 'transcripts');
383
+ mkdirSync(join(project, '.nina'), { recursive: true });
384
+ writeFileSync(join(project, '.nina', 'profile.json'), `${JSON.stringify(profileAt(ownRelease(ctx.root)), null, 2)}\n`);
385
+ const composed = await composeProject(project, ctx);
386
+ if (composed.error) throw new Error(composed.error);
387
+
388
+ const pills = PILLS(now);
389
+ for (const pill of pills) {
390
+ const path = join(project, '.claude', 'pills', pill.path);
391
+ mkdirSync(join(path, '..'), { recursive: true });
392
+ writeFileSync(path, pill.text);
393
+ }
394
+
395
+ process.env.NINA_DATA = data;
396
+ process.env.NINA_TRANSCRIPTS = transcripts;
397
+ const slug = slugFor(project);
398
+ const own = join(transcripts, slug);
399
+ mkdirSync(own, { recursive: true });
400
+ const random = seeded(29);
401
+ const sessions = demoSessions(now);
402
+ for (const session of sessions) writeTranscripts(own, project, session, random, pills);
403
+ const today = sessions.at(-1);
404
+ throughTheGate(project, today, join(own, today.id, 'subagents'));
405
+ const captured = await snapshot(['--project', slug, '--exact', '--quiet'], ctx);
406
+ if (captured !== 0) throw new Error('the snapshot could not read the demo');
407
+ return { project, dir, session: today.id, remove };
408
+ } catch (error) {
409
+ // Pointed at a directory about to be gone, every reader after this would find nothing.
410
+ for (const [name, value] of Object.entries(before)) {
411
+ if (value === undefined) delete process.env[name];
412
+ else process.env[name] = value;
413
+ }
414
+ remove();
415
+ throw error;
416
+ }
417
+ }
package/src/office.mjs CHANGED
@@ -302,6 +302,9 @@ export function busyFor(starts) {
302
302
  const REPLAY_MAX_GAP_MS = 3000;
303
303
  const REPLAY_MIN_GAP_MS = 500;
304
304
 
305
+ /** How long a replay in a loop rests on its last beat before it plays again from the start: the party is seen out. */
306
+ export const LOOP_REST_MS = 8000;
307
+
305
308
  /**
306
309
  * Each run the ledger saw start, in order: when, at which stage, by which agent once known, whether it was a message
307
310
  * to a running agent, when it was refused, and when its verdict ended it. The beats and the timeline read the same.
@@ -626,10 +629,11 @@ const send = (res, event, data) => res.write(`event: ${event}\ndata: ${JSON.stri
626
629
  * anyone watches live. `serveOffice` mounts them at the root; the dashboard (`nina dashboard`) at `/office/`, where the
627
630
  * page's relative `events` reaches `/office/events`.
628
631
  *
629
- * @param {{target: string, wanted?: string, replay?: boolean}} options
632
+ * @param {{target: string, wanted?: string, replay?: boolean, loop?: boolean, rest?: number}} options - `loop`: a replay
633
+ * that ends plays again from the start, for an office left on a screen, after resting `rest` ms on its last beat.
630
634
  * @returns {{handle: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, path: string) => boolean, close: () => void}}
631
635
  */
632
- export function officeRoutes({ target, wanted, replay = false }) {
636
+ export function officeRoutes({ target, wanted, replay = false, loop = false, rest = LOOP_REST_MS }) {
633
637
  const room = officeRoom(target);
634
638
  const graph = parseGraph(readFileSync(join(target, '.claude', 'graph.md'), 'utf8'));
635
639
  // A function, so a `$&` or `$'` in a project's name or its owner's is text and not a replacement pattern.
@@ -726,7 +730,16 @@ export function officeRoutes({ target, wanted, replay = false }) {
726
730
  const next = () => {
727
731
  if (!clients.has(client)) return;
728
732
  if (i >= beats.length) {
729
- send(client.res, 'end', { beats: beats.length });
733
+ if (!loop) {
734
+ send(client.res, 'end', { beats: beats.length });
735
+ return;
736
+ }
737
+ // Played again from its `hello`, which empties the room the page drew.
738
+ const timer = setTimeout(() => {
739
+ timers.delete(timer);
740
+ if (clients.has(client)) play(client);
741
+ }, rest);
742
+ timers.add(timer);
730
743
  return;
731
744
  }
732
745
  const beat = beats[i];