@uniqbit/mate-core 0.15.5 → 0.16.0-canary.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 (183) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +2 -2
  2. package/claude-plugin/hooks/hooks.json +3 -6
  3. package/claude-plugin/hooks/session-guidance.mjs +8 -0
  4. package/claude-plugin/hooks/ts-loader.mjs +19 -0
  5. package/package.json +6 -4
  6. package/src/cli/commands/artifact/artifact.ts +19 -3
  7. package/src/cli/commands/artifact/finish/command.ts +183 -57
  8. package/src/cli/commands/artifact/finish/engine.ts +80 -91
  9. package/src/cli/commands/artifact/finish/finisher.ts +26 -33
  10. package/src/cli/commands/artifact/finish/git.ts +34 -23
  11. package/src/cli/commands/artifact/finish/index.ts +9 -3
  12. package/src/cli/commands/artifact/finish/openspec.ts +225 -97
  13. package/src/cli/commands/artifact/pending/command.ts +175 -0
  14. package/src/cli/commands/artifact/pending/discovery.ts +249 -0
  15. package/src/cli/commands/artifact/pending/index.ts +17 -0
  16. package/src/cli/commands/cap/index-cmd.ts +9 -1
  17. package/src/cli/commands/cap/index.ts +2 -6
  18. package/src/cli/commands/companion/companion.ts +5 -1
  19. package/src/cli/commands/companion/link.ts +2 -2
  20. package/src/cli/commands/companion/sync.ts +92 -0
  21. package/src/cli/commands/doctor.ts +0 -3
  22. package/src/cli/commands/launch/shared.ts +23 -5
  23. package/src/cli/commands/report/collector.ts +72 -78
  24. package/src/cli/commands/report/contract.ts +40 -1
  25. package/src/cli/commands/report/highlight.ts +27 -0
  26. package/src/cli/commands/report/index.ts +11 -18
  27. package/src/cli/commands/report/renderer.ts +199 -2
  28. package/src/cli/commands/report/types.ts +26 -1
  29. package/src/cli/commands/shared/companion-selection.ts +107 -10
  30. package/src/cli/commands/studio/areas.ts +68 -0
  31. package/src/cli/commands/studio/index.ts +69 -0
  32. package/src/cli/commands/studio/inventory.ts +55 -0
  33. package/src/cli/commands/studio/mate-inventory.ts +43 -0
  34. package/src/cli/commands/studio/openspec-cli.ts +198 -0
  35. package/src/cli/commands/studio/payload.ts +184 -0
  36. package/src/cli/commands/studio/routes.ts +2 -0
  37. package/src/cli/commands/studio/selection.ts +61 -0
  38. package/src/cli/commands/studio/server.ts +201 -0
  39. package/src/cli/commands/studio/snapshot.ts +63 -0
  40. package/src/cli/commands/studio/topology.ts +199 -0
  41. package/src/cli/commands/studio/views/client.ts +197 -0
  42. package/src/cli/commands/studio/views/companion-picker.tsx +91 -0
  43. package/src/cli/commands/studio/views/companion-selector.tsx +90 -0
  44. package/src/cli/commands/studio/views/dashboard/changes.tsx +107 -0
  45. package/src/cli/commands/studio/views/dashboard/index.tsx +19 -0
  46. package/src/cli/commands/studio/views/document.tsx +256 -0
  47. package/src/cli/commands/studio/views/error.tsx +20 -0
  48. package/src/cli/commands/studio/views/model.ts +34 -0
  49. package/src/cli/commands/studio/views/pairings.tsx +38 -0
  50. package/src/cli/commands/studio/views/skills/index.tsx +87 -0
  51. package/src/cli/commands/studio/views/specs/index.tsx +101 -0
  52. package/src/cli/commands/studio/views/styles.ts +389 -0
  53. package/src/cli/commands/studio/views/warnings.tsx +21 -0
  54. package/src/cli/commands/studio/views/workflow/index.tsx +21 -0
  55. package/src/cli/commands/studio/views/workflow/steps.ts +329 -0
  56. package/src/cli/commands/studio/views/workflow/transcript.tsx +190 -0
  57. package/src/cli/commands/unwrap.ts +70 -0
  58. package/src/cli/commands/wrap.ts +164 -0
  59. package/src/cli/main.ts +68 -19
  60. package/src/cli/parse-flags.ts +36 -11
  61. package/src/cli/usage.ts +12 -3
  62. package/src/framework.ts +1 -7
  63. package/src/hooks/session-banner.ts +64 -11
  64. package/src/hooks/session-guidance.ts +40 -0
  65. package/src/hooks/validate-artifact-path.ts +108 -35
  66. package/src/lib/fs-utils.ts +9 -0
  67. package/src/lib/install.ts +33 -0
  68. package/src/lib/orchestrator/adapters/base.ts +14 -125
  69. package/src/lib/orchestrator/adapters/claude.ts +0 -11
  70. package/src/lib/orchestrator/adapters/opencode.ts +2 -32
  71. package/src/lib/orchestrator/companion-git-sync.ts +94 -84
  72. package/src/lib/orchestrator/config-store.ts +2 -21
  73. package/src/lib/orchestrator/editor.ts +12 -22
  74. package/src/lib/orchestrator/framework-context.ts +17 -6
  75. package/src/lib/orchestrator/global-config-store.ts +1 -1
  76. package/src/lib/orchestrator/launcher.ts +97 -7
  77. package/src/lib/orchestrator/opencode-guidance.ts +4 -56
  78. package/src/lib/orchestrator/projection-claude-entry.ts +198 -0
  79. package/src/lib/orchestrator/projection-claude-skills.ts +120 -0
  80. package/src/lib/orchestrator/projection-companion-link.ts +62 -0
  81. package/src/lib/orchestrator/projection-entries.ts +377 -0
  82. package/src/lib/orchestrator/projection-record.ts +56 -0
  83. package/src/lib/orchestrator/projection-runtime-documents.ts +424 -0
  84. package/src/lib/orchestrator/projection-types.ts +169 -0
  85. package/src/lib/orchestrator/repo-local-registry.ts +37 -133
  86. package/src/lib/orchestrator/repo-local-store.ts +96 -0
  87. package/src/lib/orchestrator/setup-compatibilities.ts +1 -9
  88. package/src/lib/orchestrator/types.ts +1 -0
  89. package/src/lib/orchestrator/working-repo-projection.ts +366 -0
  90. package/src/lib/orchestrator/workspace-inventory.ts +1 -1
  91. package/src/lib/package-paths.ts +11 -1
  92. package/src/opencode/companion-hooks.ts +89 -245
  93. package/src/opencode/companion-policy.ts +35 -10
  94. package/src/opencode/index.ts +1 -0
  95. package/src/opencode/projected-guidance.ts +56 -0
  96. package/src/opencode/tui.tsx +13 -4
  97. package/src/playbooks/companion-guidance.ts +32 -116
  98. package/src/plugins.ts +0 -1
  99. package/src/runtime/companion-git-state.ts +156 -0
  100. package/src/runtime/companion-git.ts +203 -0
  101. package/src/runtime/companion-guidance.ts +222 -0
  102. package/src/runtime/companion-sync.ts +298 -0
  103. package/src/runtime/env-names.ts +30 -0
  104. package/src/runtime/env.ts +67 -35
  105. package/src/runtime/framework.ts +10 -0
  106. package/src/runtime/freshness.ts +58 -0
  107. package/src/runtime/index.ts +104 -0
  108. package/src/runtime/install.ts +30 -0
  109. package/src/runtime/policy.ts +66 -0
  110. package/src/runtime/projected-guidance.ts +45 -0
  111. package/src/runtime/projection.ts +224 -0
  112. package/src/runtime/repo-local.ts +64 -0
  113. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +56 -0
  114. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/spec.md +48 -0
  115. package/src/templates/capabilities/openspec-cap/mate-minimal/templates/tasks.md +22 -0
  116. package/src/templates/capabilities/openspec-cap/mate-v1/schema.yaml +31 -90
  117. package/src/templates/capabilities/openspec-cap/mate-v1/templates/design.md +3 -0
  118. package/src/templates/capabilities/openspec-cap/mate-v1/templates/explore-brief.md +6 -6
  119. package/src/templates/capabilities/openspec-cap/mate-v1/templates/spec.md +3 -5
  120. package/src/templates/capabilities/openspec-cap/mate-v1/templates/tasks.md +3 -0
  121. package/src/templates/capabilities/openspec-cap/openspec-conventions.yaml +30 -0
  122. package/src/templates/capabilities/react-doctor/claude/hooks/react-doctor.sh +2 -2
  123. package/src/templates/mate-skills/agents/mate-artifact-publish/SKILL.md +185 -0
  124. package/src/templates/mate-skills/agents/mate-artifact-publish/references/openspec.md +227 -0
  125. package/src/templates/mate-skills/agents/mate-domain-modeling/SKILL.md +68 -0
  126. package/src/templates/mate-skills/agents/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
  127. package/src/templates/mate-skills/agents/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
  128. package/src/templates/mate-skills/agents/mate-grill-me/SKILL.md +14 -0
  129. package/src/templates/mate-skills/agents/mate-grill-with-docs/SKILL.md +29 -0
  130. package/src/templates/mate-skills/agents/mate-grilling/SKILL.md +49 -0
  131. package/src/templates/mate-skills/agents/mate-interview-me/SKILL.md +147 -0
  132. package/src/templates/{capabilities/openspec-cap/mate-skills → mate-skills}/agents/mate-openspec-backfill/SKILL.md +4 -2
  133. package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +139 -0
  134. package/src/templates/mate-skills/agents/mate-simplify-code/SKILL.md +503 -0
  135. package/src/templates/mate-skills/claude/mate-artifact-publish/SKILL.md +185 -0
  136. package/src/templates/mate-skills/claude/mate-artifact-publish/references/openspec.md +227 -0
  137. package/src/templates/mate-skills/claude/mate-domain-modeling/SKILL.md +68 -0
  138. package/src/templates/mate-skills/claude/mate-domain-modeling/references/ADR-FORMAT.md +9 -0
  139. package/src/templates/mate-skills/claude/mate-domain-modeling/references/CONTEXT-FORMAT.md +18 -0
  140. package/src/templates/mate-skills/claude/mate-grill-me/SKILL.md +14 -0
  141. package/src/templates/mate-skills/claude/mate-grill-with-docs/SKILL.md +29 -0
  142. package/src/templates/mate-skills/claude/mate-grilling/SKILL.md +49 -0
  143. package/src/templates/mate-skills/claude/mate-interview-me/SKILL.md +147 -0
  144. package/src/templates/mate-skills/claude/mate-openspec-backfill/SKILL.md +67 -0
  145. package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +139 -0
  146. package/src/templates/mate-skills/claude/mate-simplify-code/SKILL.md +503 -0
  147. package/src/templates/report-assets/README.md +32 -0
  148. package/src/templates/report-assets/mermaid.LICENSE +21 -0
  149. package/src/templates/report-assets/mermaid.min.js +4376 -0
  150. package/src/templates/root/TEMPLATE_CLAUDE.md +1 -9
  151. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +1476 -197
  152. package/src/tools/setup/capabilities/graphify.ts +16 -7
  153. package/src/tools/setup/capabilities/openspec.ts +63 -56
  154. package/src/tools/setup/capabilities/tokensave.ts +47 -0
  155. package/src/tools/setup/engine.ts +34 -6
  156. package/src/tools/setup/mate.ts +42 -13
  157. package/src/tools/setup/plugin.ts +9 -0
  158. package/src/tools/setup/plugins/guidance.ts +11 -1
  159. package/src/tools/setup/providers/claude-format.ts +49 -4
  160. package/src/tools/setup/providers/claude-plugin-hooks.ts +117 -0
  161. package/src/tools/setup/providers/claude.ts +55 -220
  162. package/src/tools/setup/providers/opencode.ts +41 -14
  163. package/src/tools/setup/runtime-documents.ts +174 -0
  164. package/src/tools/setup/surface-target.ts +50 -0
  165. package/src/tools/setup/working-repo-cleanup.ts +33 -26
  166. package/src/tools/setup/working-repo-local-state.ts +21 -1
  167. package/src/tools/setup.ts +25 -3
  168. package/wrappers/bin/graphify +57 -8
  169. package/wrappers/bin/openspec +50 -3
  170. package/claude-plugin/hooks/artifact-finish-nudge.mjs +0 -8
  171. package/src/cli/commands/cap/headroom.ts +0 -52
  172. package/src/cli/commands/workspace/list.ts +0 -25
  173. package/src/cli/commands/workspace/materialize.ts +0 -46
  174. package/src/cli/commands/workspace/workspace.ts +0 -22
  175. package/src/hooks/artifact-finish-nudge.ts +0 -244
  176. package/src/lib/orchestrator/headroom/proxy.ts +0 -116
  177. package/src/lib/orchestrator/workspace-materialize.ts +0 -80
  178. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/SKILL.md +0 -51
  179. package/src/templates/capabilities/openspec-cap/mate-skills/agents/mate-artifact-finish/references/openspec.md +0 -134
  180. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/SKILL.md +0 -58
  181. package/src/templates/capabilities/openspec-cap/mate-skills/claude/mate-artifact-finish/references/openspec.md +0 -139
  182. package/src/tools/setup/capabilities/headroom.ts +0 -57
  183. /package/src/cli/commands/{workspace → companion}/open.ts +0 -0
@@ -4,6 +4,7 @@ import type { SavingsEntry, SpendingEntry, ToolStatus } from "./types";
4
4
 
5
5
  export interface CollectorDeps {
6
6
  spawn?: typeof spawnSync;
7
+ now?: () => Date;
7
8
  }
8
9
 
9
10
  function spawnCmd(cmd: string, args: string[], opts: { cwd: string }, deps: CollectorDeps) {
@@ -21,14 +22,28 @@ function parseJson<T>(output: string): T | null {
21
22
  }
22
23
  }
23
24
 
25
+ function reportSince(days: number, deps: CollectorDeps): string {
26
+ const since = new Date(deps.now?.() ?? new Date());
27
+ since.setDate(since.getDate() - days);
28
+ return since.toISOString().slice(0, 10);
29
+ }
30
+
31
+ function utcDateFromEpochSeconds(value: unknown): string | null {
32
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
33
+ const date = new Date(value * 1000);
34
+ return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 10);
35
+ }
36
+
37
+ function finiteNumber(value: unknown): number {
38
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
39
+ }
40
+
24
41
  // ccusage JSON: { daily: [{ period, totalCost, totalTokens, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, modelBreakdowns: [{ modelName, cost, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens }] }], totals: { totalCost, totalTokens, ... } }
25
42
  export async function collectCcusageSpending(
26
43
  days: number,
27
44
  deps: CollectorDeps = {},
28
45
  ): Promise<{ entries: SpendingEntry[]; status: ToolStatus; friendlyError?: string }> {
29
- const since = new Date();
30
- since.setDate(since.getDate() - days);
31
- const sinceStr = since.toISOString().slice(0, 10);
46
+ const sinceStr = reportSince(days, deps);
32
47
 
33
48
  // Try bunx first, then npx
34
49
  const runners = [
@@ -96,7 +111,7 @@ export async function collectCcusageSpending(
96
111
  };
97
112
  }
98
113
 
99
- // tokensave gain JSON: { saved_tokens, calls, usd }
114
+ /** tokensave gain --history JSON: [{ day, saved_tokens, calls, usd }] */
100
115
  export async function collectTokenSaveSavings(
101
116
  repoPath: string,
102
117
  days: number,
@@ -104,7 +119,7 @@ export async function collectTokenSaveSavings(
104
119
  ): Promise<{ entry: SavingsEntry | null; status: ToolStatus }> {
105
120
  const result = spawnCmd(
106
121
  "tokensave",
107
- ["gain", "--json", "--range", `${days}d`],
122
+ ["gain", "--history", "--json", "--range", "all"],
108
123
  { cwd: repoPath },
109
124
  deps,
110
125
  );
@@ -116,37 +131,58 @@ export async function collectTokenSaveSavings(
116
131
  };
117
132
  }
118
133
 
119
- const data = parseJson<{ saved_tokens?: number; calls?: number; usd?: number }>(result.stdout);
120
- if (!data) {
134
+ const data = parseJson<
135
+ Array<{ day?: number; saved_tokens?: number; calls?: number; usd?: number } | null>
136
+ >(result.stdout);
137
+ if (!data || !Array.isArray(data) || data.length === 0) {
121
138
  return {
122
139
  entry: null,
123
140
  status: { name: "tokensave", enabled: false, status: "no data" },
124
141
  };
125
142
  }
126
143
 
144
+ const sinceStr = reportSince(days, deps);
145
+ let matchingDays = 0;
146
+ let tokensSaved = 0;
147
+ let calls = 0;
148
+ let costSaved = 0;
149
+ for (const day of data) {
150
+ const date = utcDateFromEpochSeconds(day?.day);
151
+ if (!date || date < sinceStr) continue;
152
+ matchingDays++;
153
+ tokensSaved += finiteNumber(day?.saved_tokens);
154
+ calls += finiteNumber(day?.calls);
155
+ costSaved += finiteNumber(day?.usd);
156
+ }
157
+
158
+ if (matchingDays === 0) {
159
+ return {
160
+ entry: null,
161
+ status: { name: "tokensave", enabled: false, status: "no window data" },
162
+ };
163
+ }
164
+
127
165
  return {
128
166
  entry: {
129
167
  tool: "tokensave",
130
- tokensSaved: data.saved_tokens ?? 0,
131
- calls: data.calls ?? 0,
132
- costSaved: data.usd ?? 0,
133
- efficiency:
134
- (data.calls ?? 0) > 0
135
- ? `${formatEfficiency(data.saved_tokens ?? 0, data.calls ?? 0)}`
136
- : "N/A",
168
+ tokensSaved,
169
+ calls,
170
+ costSaved,
171
+ efficiency: calls > 0 ? formatEfficiency(tokensSaved, calls) : "N/A",
137
172
  },
138
173
  status: { name: "tokensave", enabled: true, status: "ok" },
139
174
  };
140
175
  }
141
176
 
142
- // rtk gain JSON: { summary: { total_saved, total_commands, avg_savings_pct } }
177
+ /** rtk gain --daily JSON: { daily: [{ date, saved_tokens, commands }] } */
143
178
  export async function collectRTKSavings(
144
179
  repoPath: string,
180
+ days: number,
145
181
  deps: CollectorDeps = {},
146
182
  ): Promise<{ entry: SavingsEntry | null; status: ToolStatus }> {
147
183
  const result = spawnCmd(
148
184
  "rtk",
149
- ["gain", "--project", "--format", "json"],
185
+ ["gain", "--project", "--daily", "--format", "json"],
150
186
  { cwd: repoPath },
151
187
  deps,
152
188
  );
@@ -159,81 +195,42 @@ export async function collectRTKSavings(
159
195
  }
160
196
 
161
197
  const data = parseJson<{
162
- summary?: { total_saved?: number; total_commands?: number; avg_savings_pct?: number };
198
+ daily?: Array<{ date?: string; saved_tokens?: number; commands?: number } | null>;
163
199
  }>(result.stdout);
164
- if (!data || !data.summary) {
200
+ if (!data || !Array.isArray(data.daily) || data.daily.length === 0) {
165
201
  return {
166
202
  entry: null,
167
203
  status: { name: "rtk", enabled: false, status: "no data" },
168
204
  };
169
205
  }
170
206
 
171
- const s = data.summary;
172
- return {
173
- entry: {
174
- tool: "rtk",
175
- tokensSaved: s.total_saved ?? 0,
176
- calls: s.total_commands ?? 0,
177
- costSaved: 0,
178
- efficiency:
179
- (s.total_commands ?? 0) > 0
180
- ? `${formatEfficiency(s.total_saved ?? 0, s.total_commands ?? 0)}`
181
- : "N/A",
182
- },
183
- status: { name: "rtk", enabled: true, status: "ok" },
184
- };
185
- }
186
-
187
- // headroom savings JSON: { windows: { last_7_days: { tokens_saved, cost_usd, calls } } }
188
- export async function collectHeadroomSavings(
189
- days: number,
190
- deps: CollectorDeps = {},
191
- ): Promise<{ entry: SavingsEntry | null; status: ToolStatus }> {
192
- const result = spawnCmd(
193
- "headroom",
194
- ["savings", "--json", "--days", String(days)],
195
- { cwd: process.cwd() },
196
- deps,
197
- );
198
-
199
- if (result.error || result.status !== 0) {
200
- return {
201
- entry: null,
202
- status: { name: "headroom", enabled: false, status: "not enabled" },
203
- };
204
- }
205
-
206
- const data = parseJson<{
207
- windows?: Record<string, { tokens_saved?: number; cost_usd?: number; calls?: number }>;
208
- }>(result.stdout);
209
- if (!data || !data.windows) {
210
- return {
211
- entry: null,
212
- status: { name: "headroom", enabled: false, status: "no data" },
213
- };
207
+ const sinceStr = reportSince(days, deps);
208
+ let matchingDays = 0;
209
+ let tokensSaved = 0;
210
+ let calls = 0;
211
+ for (const day of data.daily) {
212
+ if (typeof day?.date !== "string" || day.date < sinceStr) continue;
213
+ matchingDays++;
214
+ tokensSaved += finiteNumber(day?.saved_tokens);
215
+ calls += finiteNumber(day?.commands);
214
216
  }
215
217
 
216
- const key = `last_${days}_days`;
217
- const window = data.windows[key] ?? data.windows["last_7_days"] ?? data.windows["all_time"];
218
- if (!window) {
218
+ if (matchingDays === 0) {
219
219
  return {
220
220
  entry: null,
221
- status: { name: "headroom", enabled: false, status: "no window data" },
221
+ status: { name: "rtk", enabled: false, status: "no window data" },
222
222
  };
223
223
  }
224
224
 
225
225
  return {
226
226
  entry: {
227
- tool: "headroom",
228
- tokensSaved: window.tokens_saved ?? 0,
229
- calls: window.calls ?? 0,
230
- costSaved: window.cost_usd ?? 0,
231
- efficiency:
232
- (window.calls ?? 0) > 0
233
- ? `${formatEfficiency(window.tokens_saved ?? 0, window.calls ?? 0)}`
234
- : "N/A",
227
+ tool: "rtk",
228
+ tokensSaved,
229
+ calls,
230
+ costSaved: 0,
231
+ efficiency: calls > 0 ? formatEfficiency(tokensSaved, calls) : "N/A",
235
232
  },
236
- status: { name: "headroom", enabled: true, status: "ok" },
233
+ status: { name: "rtk", enabled: true, status: "ok" },
237
234
  };
238
235
  }
239
236
 
@@ -253,7 +250,6 @@ export function mergeResults(
253
250
  tokensaveWorking: SavingsEntry | null,
254
251
  tokensaveCompanion: SavingsEntry | null,
255
252
  rtkEntry: SavingsEntry | null,
256
- headroomEntry: SavingsEntry | null,
257
253
  ): {
258
254
  spending: SpendingEntry[];
259
255
  savings: SavingsEntry[];
@@ -279,9 +275,7 @@ export function mergeResults(
279
275
  };
280
276
  }
281
277
 
282
- const savings: SavingsEntry[] = [mergedTokensave, rtkEntry, headroomEntry].filter(
283
- Boolean,
284
- ) as SavingsEntry[];
278
+ const savings: SavingsEntry[] = [mergedTokensave, rtkEntry].filter(Boolean) as SavingsEntry[];
285
279
  const totalSavings = savings.reduce((sum, e) => sum + e.costSaved, 0);
286
280
 
287
281
  return {
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  REPORT_DOCUMENT_VERSION,
3
+ type ReportDiagramSection,
4
+ type ReportDiffSection,
3
5
  type ReportDocument,
4
6
  type ReportKeyValue,
5
7
  type ReportMetric,
@@ -114,6 +116,32 @@ function validateStatuses(value: unknown, path: string, issues: ReportValidation
114
116
  return true;
115
117
  }
116
118
 
119
+ const DIAGRAM_PAYLOADS = ["mermaid", "text"] as const;
120
+
121
+ /** A diagram carries exactly one payload; an empty or non-string payload counts as absent. */
122
+ function validateDiagram(
123
+ value: Record<string, unknown>,
124
+ path: string,
125
+ issues: ReportValidationIssue[],
126
+ ): void {
127
+ const supplied = DIAGRAM_PAYLOADS.filter((key) => value[key] !== undefined);
128
+ for (const key of supplied) {
129
+ if (typeof value[key] !== "string" || (value[key] as string).trim() === "") {
130
+ issues.push({ path: `${path}.${key}`, message: "must be a non-empty string" });
131
+ }
132
+ }
133
+ if (supplied.length === 1) return;
134
+
135
+ const name = typeof value.id === "string" && value.id.trim() !== "" ? value.id : path;
136
+ issues.push({
137
+ path,
138
+ message:
139
+ supplied.length === 0
140
+ ? `section "${name}" must carry exactly one of mermaid or text, but carries neither`
141
+ : `section "${name}" must carry exactly one of mermaid or text, but carries both`,
142
+ });
143
+ }
144
+
117
145
  function validateSection(value: unknown, path: string, issues: ReportValidationIssue[]): void {
118
146
  if (!isRecord(value)) {
119
147
  issues.push({ path, message: "must be an object" });
@@ -182,10 +210,19 @@ function validateSection(value: unknown, path: string, issues: ReportValidationI
182
210
  issues.push({ path: `${path}.content`, message: "must be a string" });
183
211
  }
184
212
  return;
213
+ case "diagram":
214
+ validateDiagram(value, path, issues);
215
+ return;
216
+ case "diff":
217
+ if (typeof value.patch !== "string") {
218
+ issues.push({ path: `${path}.patch`, message: "must be a string" });
219
+ }
220
+ return;
185
221
  default:
186
222
  issues.push({
187
223
  path: `${path}.type`,
188
- message: "must be one of metadata, metrics, key-value, table, statuses, or text",
224
+ message:
225
+ "must be one of metadata, metrics, key-value, table, statuses, text, diagram, or diff",
189
226
  });
190
227
  }
191
228
  }
@@ -248,6 +285,8 @@ export function parseReportDocument(json: string): ReportDocument {
248
285
  }
249
286
 
250
287
  export type {
288
+ ReportDiagramSection,
289
+ ReportDiffSection,
251
290
  ReportDocument,
252
291
  ReportKeyValue,
253
292
  ReportMetric,
@@ -0,0 +1,27 @@
1
+ import { normalizeLanguage, renderNodesToHtml, renderTokens, tokenize } from "@tanstack/highlight";
2
+ import { createThemeCss } from "@tanstack/highlight/theme";
3
+ import githubLight from "@tanstack/highlight/themes/github-light";
4
+
5
+ /** Sole boundary against `@tanstack/highlight`, so an upstream API break stays a single-file fix. */
6
+
7
+ /** Language every unknown or unsupported candidate resolves to. */
8
+ export const PLAIN_LANGUAGE = "plaintext";
9
+
10
+ export function resolveHighlightLanguage(candidate: string | undefined): string {
11
+ return normalizeLanguage(candidate);
12
+ }
13
+
14
+ /**
15
+ * Class-annotated inline markup for one fragment, escaped and without a block wrapper.
16
+ * An unknown language yields plain escaped text carrying no token classes.
17
+ */
18
+ export function highlightInline(code: string, language: string | undefined): string {
19
+ const resolved = resolveHighlightLanguage(language);
20
+ const { tokens } = tokenize(code, { lang: resolved });
21
+ return renderNodesToHtml(renderTokens(tokens));
22
+ }
23
+
24
+ /** Token colours as an inlinable stylesheet; carries no script, font, or external reference. */
25
+ export function highlightStyleBlock(codeBlockSelector: string): string {
26
+ return createThemeCss({ light: githubLight, codeBlockSelector });
27
+ }
@@ -4,7 +4,6 @@ import { resolveFrameworkContext } from "../../../lib/orchestrator/framework-con
4
4
  import { ensureUnambiguousCompanion } from "../shared/companion-selection";
5
5
  import {
6
6
  collectCcusageSpending,
7
- collectHeadroomSavings,
8
7
  collectRTKSavings,
9
8
  collectTokenSaveSavings,
10
9
  mergeResults,
@@ -135,12 +134,16 @@ export async function runReportCommand(
135
134
  .map((a) => a.trim())
136
135
  .filter(Boolean)
137
136
  : [];
137
+ const reportNow = new Date();
138
+ const collectorDeps: CollectorDeps = {
139
+ spawn: deps.spawn,
140
+ now: deps.now ?? (() => reportNow),
141
+ };
138
142
 
139
143
  const tokensaveEnabled = enabledCapabilities.includes("tokensave");
140
- const headroomEnabled = enabledCapabilities.includes("headroom");
141
144
  const rtkEnabled = enabledCapabilities.includes("rtk");
142
145
 
143
- const ccusageResult = await collectCcusageSpending(options.days, deps);
146
+ const ccusageResult = await collectCcusageSpending(options.days, collectorDeps);
144
147
  if (ccusageResult.friendlyError) console.warn(ccusageResult.friendlyError);
145
148
 
146
149
  let tokensaveWorking: Awaited<ReturnType<typeof collectTokenSaveSavings>> = {
@@ -155,26 +158,21 @@ export async function runReportCommand(
155
158
  entry: null,
156
159
  status: { name: "rtk", enabled: false, status: "not enabled" },
157
160
  };
158
- let headroomResult: Awaited<ReturnType<typeof collectHeadroomSavings>> = {
159
- entry: null,
160
- status: { name: "headroom", enabled: false, status: "not enabled" },
161
- };
162
161
 
163
162
  if (tokensaveEnabled) {
164
163
  [tokensaveWorking, tokensaveCompanion] = await Promise.all([
165
- collectTokenSaveSavings(workingRepoPath, options.days, deps),
166
- collectTokenSaveSavings(companionPath, options.days, deps),
164
+ collectTokenSaveSavings(workingRepoPath, options.days, collectorDeps),
165
+ collectTokenSaveSavings(companionPath, options.days, collectorDeps),
167
166
  ]);
168
167
  }
169
- if (rtkEnabled) rtkResult = await collectRTKSavings(workingRepoPath, deps);
170
- if (headroomEnabled) headroomResult = await collectHeadroomSavings(options.days, deps);
168
+ if (rtkEnabled)
169
+ rtkResult = await collectRTKSavings(workingRepoPath, options.days, collectorDeps);
171
170
 
172
171
  const merged = mergeResults(
173
172
  ccusageResult.entries,
174
173
  tokensaveWorking.entry,
175
174
  tokensaveCompanion.entry,
176
175
  rtkResult.entry,
177
- headroomResult.entry,
178
176
  );
179
177
  const reportData: ReportData = {
180
178
  days: options.days,
@@ -185,12 +183,7 @@ export async function runReportCommand(
185
183
  enabledCapabilities,
186
184
  spending: merged.spending,
187
185
  savings: merged.savings,
188
- toolStatus: [
189
- ccusageResult.status,
190
- tokensaveWorking.status,
191
- rtkResult.status,
192
- headroomResult.status,
193
- ],
186
+ toolStatus: [ccusageResult.status, tokensaveWorking.status, rtkResult.status],
194
187
  totalSpending: merged.totalSpending,
195
188
  totalSavings: merged.totalSavings,
196
189
  netSpend: merged.netSpend,
@@ -1,13 +1,45 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
1
4
  import { reportDataToDocument } from "./adapter";
2
5
  import { validateReportDocument } from "./contract";
6
+ import { highlightInline, highlightStyleBlock } from "./highlight";
3
7
  import type {
4
8
  ReportData,
9
+ ReportDiagramSection,
10
+ ReportDiffSection,
5
11
  ReportDocument,
6
12
  ReportKeyValue,
7
13
  ReportSection,
8
14
  ReportValue,
9
15
  } from "./types";
10
16
 
17
+ const MERMAID_RUNTIME_PATH = join(
18
+ import.meta.dirname,
19
+ "../../../templates/report-assets/mermaid.min.js",
20
+ );
21
+
22
+ const MERMAID_RUNTIME_ID = "mermaid-runtime";
23
+
24
+ const CODE_BLOCK_SELECTOR = "pre.report-code";
25
+
26
+ let mermaidRuntime: string | undefined;
27
+
28
+ /** A literal `</script` inside the bundle would close the inline element, so it is neutralised. */
29
+ function readMermaidRuntime(): string {
30
+ mermaidRuntime ??= readFileSync(MERMAID_RUNTIME_PATH, "utf8").replaceAll(
31
+ "</script",
32
+ String.raw`<\/script`,
33
+ );
34
+ return mermaidRuntime;
35
+ }
36
+
37
+ const carriesMermaid = (sections: ReportSection[]): boolean =>
38
+ sections.some((section) => section.type === "diagram" && section.mermaid !== undefined);
39
+
40
+ const carriesVisual = (sections: ReportSection[]): boolean =>
41
+ sections.some((section) => section.type === "diagram" || section.type === "diff");
42
+
11
43
  type RenderInput = ReportDocument | ReportData;
12
44
 
13
45
  function isReportDocument(input: RenderInput): input is ReportDocument {
@@ -82,9 +114,19 @@ function sectionMarkdown(section: ReportSection): string {
82
114
  : "No data available.";
83
115
  case "text":
84
116
  return section.content;
117
+ case "diagram":
118
+ return section.mermaid === undefined
119
+ ? fence("", section.text)
120
+ : fence("mermaid", section.mermaid);
121
+ case "diff":
122
+ return section.patch.trim() ? fence("diff", section.patch) : "No data available.";
85
123
  }
86
124
  }
87
125
 
126
+ function fence(language: string, content: string): string {
127
+ return `\`\`\`${language}\n${content}\n\`\`\``;
128
+ }
129
+
88
130
  export function renderMarkdown(input: RenderInput): string {
89
131
  const data = normalize(input);
90
132
  const sections: string[] = [`# ${data.title}\n`];
@@ -153,6 +195,130 @@ function renderHTMLKeyValues(items: ReportKeyValue[]): string {
153
195
  : emptyHTML("No metadata available.");
154
196
  }
155
197
 
198
+ type DiffLineKind = "add" | "del" | "context" | "hunk";
199
+
200
+ interface DiffLine {
201
+ kind: DiffLineKind;
202
+ text: string;
203
+ }
204
+
205
+ interface DiffFile {
206
+ path?: string;
207
+ lines: DiffLine[];
208
+ }
209
+
210
+ const DIFF_METADATA_PREFIXES = [
211
+ "index ",
212
+ "old mode ",
213
+ "new mode ",
214
+ "new file mode ",
215
+ "deleted file mode ",
216
+ "similarity index ",
217
+ "dissimilarity index ",
218
+ "rename from ",
219
+ "rename to ",
220
+ "copy from ",
221
+ "copy to ",
222
+ "Binary files ",
223
+ "GIT binary patch",
224
+ ];
225
+
226
+ function gitHeaderPath(line: string): string | undefined {
227
+ const rest = line.slice("diff --git ".length).trim();
228
+ const paired = /^a\/(.+?) b\/(.+)$/.exec(rest);
229
+ return paired ? paired[2] : rest || undefined;
230
+ }
231
+
232
+ /** Strips the `a/` or `b/` prefix and the optional trailing timestamp git may append. */
233
+ function fileHeaderPath(value: string): string | undefined {
234
+ const path = value.split("\t")[0].trim();
235
+ if (path === "" || path === "/dev/null") return undefined;
236
+ return path.replace(/^[ab]\//, "");
237
+ }
238
+
239
+ function diffLineKind(line: string): DiffLineKind {
240
+ if (line.startsWith("+")) return "add";
241
+ if (line.startsWith("-")) return "del";
242
+ return "context";
243
+ }
244
+
245
+ /** `---`/`+++` only head a file outside a hunk; inside one they are ordinary changed lines. */
246
+ function parseUnifiedDiff(patch: string): DiffFile[] {
247
+ const files: DiffFile[] = [];
248
+ let current: DiffFile | undefined;
249
+ let inHunk = false;
250
+
251
+ const open = (path?: string): DiffFile => {
252
+ const file: DiffFile = { path, lines: [] };
253
+ files.push(file);
254
+ return file;
255
+ };
256
+
257
+ for (const line of patch.replace(/\n+$/, "").split("\n")) {
258
+ if (line.startsWith("diff --git ")) {
259
+ current = open(gitHeaderPath(line));
260
+ inHunk = false;
261
+ continue;
262
+ }
263
+ if (!inHunk && (line.startsWith("--- ") || line.startsWith("+++ "))) {
264
+ const path = fileHeaderPath(line.slice(4));
265
+ if (current === undefined) current = open(path);
266
+ else current.path ??= path;
267
+ continue;
268
+ }
269
+ if (line.startsWith("@@")) {
270
+ current ??= open();
271
+ current.lines.push({ kind: "hunk", text: line });
272
+ inHunk = true;
273
+ continue;
274
+ }
275
+ if (!inHunk && DIFF_METADATA_PREFIXES.some((prefix) => line.startsWith(prefix))) continue;
276
+ if (line === "" && current === undefined) continue;
277
+ current ??= open();
278
+ current.lines.push({ kind: diffLineKind(line), text: line });
279
+ }
280
+
281
+ return files.filter((file) => file.lines.length > 0);
282
+ }
283
+
284
+ function diffLanguage(path: string | undefined): string | undefined {
285
+ if (path === undefined) return undefined;
286
+ const name = path.slice(path.lastIndexOf("/") + 1);
287
+ const dot = name.lastIndexOf(".");
288
+ return dot > 0 ? name.slice(dot + 1) : undefined;
289
+ }
290
+
291
+ function renderHTMLDiffLine(line: DiffLine, language: string | undefined): string {
292
+ if (line.kind === "hunk") {
293
+ return `<div class="diff-line diff-hunk">${escapeHTML(line.text)}</div>`;
294
+ }
295
+ const glyph = /^[+\- ]/.test(line.text) ? line.text.slice(0, 1) : "";
296
+ const content = line.text.slice(glyph.length);
297
+ return `<div class="diff-line diff-${line.kind}"><span class="diff-glyph">${escapeHTML(glyph)}</span><span class="diff-code">${highlightInline(content, language)}</span></div>`;
298
+ }
299
+
300
+ function renderHTMLDiff(section: ReportDiffSection): string {
301
+ const files = section.patch.trim() === "" ? [] : parseUnifiedDiff(section.patch);
302
+ if (files.length === 0) return emptyHTML("No diff available.");
303
+
304
+ return files
305
+ .map((file) => {
306
+ const language = diffLanguage(file.path);
307
+ const heading =
308
+ file.path === undefined ? "" : `<p class="diff-file">${escapeHTML(file.path)}</p>`;
309
+ const lines = file.lines.map((line) => renderHTMLDiffLine(line, language)).join("");
310
+ return `<div class="diff-file-group">${heading}<div class="report-diff">${lines}</div></div>`;
311
+ })
312
+ .join("");
313
+ }
314
+
315
+ function renderHTMLDiagram(section: ReportDiagramSection): string {
316
+ if (section.mermaid === undefined) {
317
+ return `<pre class="report-code report-diagram-text">${escapeHTML(section.text)}</pre>`;
318
+ }
319
+ return `<div class="report-diagram"><pre class="mermaid">${escapeHTML(section.mermaid)}</pre></div>`;
320
+ }
321
+
156
322
  function renderHTMLSectionBody(section: ReportSection): string {
157
323
  switch (section.type) {
158
324
  case "metadata":
@@ -186,6 +352,10 @@ function renderHTMLSectionBody(section: ReportSection): string {
186
352
  return section.content
187
353
  ? `<p class="report-text">${escapeHTML(section.content).replaceAll("\n", "<br>")}</p>`
188
354
  : emptyHTML("No text available.");
355
+ case "diagram":
356
+ return renderHTMLDiagram(section);
357
+ case "diff":
358
+ return renderHTMLDiff(section);
189
359
  }
190
360
  }
191
361
 
@@ -210,6 +380,13 @@ export function renderHTML(input: RenderInput): string {
210
380
  ? ` data-working-repository="${escapeHTML(legacy.workingRepoPath)}" data-companion-repository="${escapeHTML(legacy.companionRepoPath)}"`
211
381
  : "";
212
382
 
383
+ const visualStyles = carriesVisual(data.sections)
384
+ ? `\n <style>${highlightStyleBlock(CODE_BLOCK_SELECTOR)}${VISUAL_SECTION_CSS}</style>`
385
+ : "";
386
+ const diagramRuntime = carriesMermaid(data.sections)
387
+ ? `\n <script id="${MERMAID_RUNTIME_ID}">${readMermaidRuntime()}</script>\n <script>mermaid.initialize({ startOnLoad: true, securityLevel: "strict" });</script>`
388
+ : "";
389
+
213
390
  return `<!doctype html>
214
391
  <html lang="en">
215
392
  <head>
@@ -241,7 +418,7 @@ export function renderHTML(input: RenderInput): string {
241
418
  table { width: 100%; }
242
419
  th, td { color: #000; }
243
420
  }
244
- </style>
421
+ </style>${visualStyles}
245
422
  </head>
246
423
  <body>
247
424
  <main class="report" data-report-version="${data.version}"${repositoryAttributes}>
@@ -253,11 +430,31 @@ export function renderHTML(input: RenderInput): string {
253
430
  </header>
254
431
  ${renderHTMLSection(summary)}
255
432
  ${data.sections.map(renderHTMLSection).join("\n ")}
256
- </main>
433
+ </main>${diagramRuntime}
257
434
  </body>
258
435
  </html>`;
259
436
  }
260
437
 
438
+ const VISUAL_SECTION_CSS = `
439
+ pre.report-code { background: #f7f9fb; border: 1px solid #e8edf1; border-radius: 10px; margin: 0; overflow-x: auto; padding: 14px 16px; }
440
+ .report-diagram { max-width: 100%; overflow-x: auto; }
441
+ .report-diagram svg { height: auto; max-width: 100%; }
442
+ .report-diagram .mermaid { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82rem; margin: 0; white-space: pre; }
443
+ .diff-file-group + .diff-file-group { margin-top: 18px; }
444
+ .diff-file { color: #536273; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; font-weight: 700; margin: 0 0 8px; }
445
+ .report-diff { border: 1px solid #e8edf1; border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82rem; overflow-x: auto; padding: 6px 0; }
446
+ .diff-line { -webkit-print-color-adjust: exact; min-width: max-content; padding: 0 12px; print-color-adjust: exact; white-space: pre; }
447
+ .diff-glyph { display: inline-block; user-select: none; width: 1ch; }
448
+ .diff-add { background: #e6ffec; }
449
+ .diff-del { background: #ffebe9; }
450
+ .diff-hunk { background: #eef2f6; color: #536273; }
451
+ @media print {
452
+ pre.report-code, .report-diff { border-color: #999; }
453
+ .report-diagram, .report-diff, pre.report-code { overflow: visible; }
454
+ .diff-line { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
455
+ .diff-hunk { color: #333; }
456
+ }`;
457
+
261
458
  type ReportMetricsSection = Extract<ReportSection, { type: "metrics" }>;
262
459
 
263
460
  export { escapeHTML };