@raquezha/notrace 0.0.6 → 0.0.7

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @raquezha/notrace
2
2
 
3
+ ## 0.0.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 5a3e563: Improve session reports by rendering the session ID as a copyable chip under the notrace logo.
8
+ - 5a3e563: Enhance the trace header to include the active git branch alongside the repository name, and clarify the capture setting label.
9
+ - 7664e50: Polish notrace reliability and installed-package ergonomics: add review/compare package CLIs, validate run records before writing, atomically write private artifacts, recover from corrupt index JSON, and verify capture modes.
10
+
3
11
  ## 0.0.6
4
12
 
5
13
  ### Patch Changes
package/README.md CHANGED
@@ -103,10 +103,12 @@ Mode meanings:
103
103
  - `redacted`: captured payloads with common secret-like values redacted
104
104
  - `metadata`: minimal capture, no prompt/tool bodies
105
105
 
106
- **Security warning:** even redacted reports can contain sensitive prompts, tool payloads, local paths, and outputs. Do not publish generated reports.
106
+ **Security warning:** `full` reports can contain prompts, tool arguments, tool outputs, local paths, model payloads, and secrets returned by tools. `redacted` mode removes common secret-shaped values and sensitive keys, but redaction is best-effort and can miss project-specific secrets. `metadata` mode is safest for sharing because prompt/tool bodies are omitted, but reports can still reveal repository names, paths, timing, models, providers, and workflow metadata. Do not publish generated reports without review.
107
107
 
108
108
  ## Review
109
109
 
110
+ From this monorepo:
111
+
110
112
  ```bash
111
113
  npm run review:notrace -- \
112
114
  .notrace/sessions/<id>/notrace.json \
@@ -116,6 +118,17 @@ npm run review:notrace -- \
116
118
  --next-change "Try same task with RepoScry enabled."
117
119
  ```
118
120
 
121
+ From an installed package:
122
+
123
+ ```bash
124
+ npx -p @raquezha/notrace notrace-review \
125
+ .notrace/sessions/<id>/notrace.json \
126
+ --outcome partial \
127
+ --friction high \
128
+ --lesson "Headroom reduced tokens but needed manual steering." \
129
+ --next-change "Try same task with RepoScry enabled."
130
+ ```
131
+
119
132
  Review fields:
120
133
  - `outcome`: `success`, `partial`, `failed`, `abandoned`, `inconclusive`
121
134
  - `friction`: `low`, `medium`, `high`
@@ -124,12 +137,22 @@ Review fields:
124
137
 
125
138
  ## Compare
126
139
 
140
+ From this monorepo:
141
+
127
142
  ```bash
128
143
  npm run compare:notrace -- \
129
144
  .notrace/sessions/<baseline-id>/notrace.json \
130
145
  .notrace/sessions/<candidate-id>/notrace.json
131
146
  ```
132
147
 
148
+ From an installed package:
149
+
150
+ ```bash
151
+ npx -p @raquezha/notrace notrace-compare \
152
+ .notrace/sessions/<baseline-id>/notrace.json \
153
+ .notrace/sessions/<candidate-id>/notrace.json
154
+ ```
155
+
133
156
  ## Templates
134
157
 
135
158
  HTML source-of-truth lives in `templates/`:
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve, relative, dirname, basename, extname, join } from "node:path";
4
+
5
+ function usage() {
6
+ console.error("Usage: notrace-compare <baseline-notrace.json> <candidate-notrace.json>");
7
+ process.exit(1);
8
+ }
9
+
10
+ const [, , baselineArg, candidateArg] = process.argv;
11
+ if (!baselineArg || !candidateArg) usage();
12
+
13
+ function loadReview(runPath) {
14
+ const reviewPath = join(dirname(runPath), `${basename(runPath, extname(runPath))}.review.json`);
15
+ try {
16
+ const data = JSON.parse(readFileSync(reviewPath, "utf8"));
17
+ if (data?.kind !== "notrace-review") return null;
18
+ return data;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ function loadRun(filePath) {
25
+ const absolutePath = resolve(filePath);
26
+ const data = JSON.parse(readFileSync(absolutePath, "utf8"));
27
+ if (data?.kind !== "notrace-run") {
28
+ throw new Error(`${filePath} is not a notrace run record`);
29
+ }
30
+ return { path: absolutePath, data, review: loadReview(absolutePath) };
31
+ }
32
+
33
+ function fmtNumber(value) {
34
+ return Number(value || 0).toLocaleString();
35
+ }
36
+
37
+ function fmtUsd(value) {
38
+ return `$${Number(value || 0).toFixed(5)}`;
39
+ }
40
+
41
+ function fmtMs(value) {
42
+ const ms = Number(value || 0);
43
+ if (ms < 1000) return `${ms}ms`;
44
+ return `${(ms / 1000).toFixed(2)}s`;
45
+ }
46
+
47
+ function fmtDelta(delta, formatter = fmtNumber, invertGood = false) {
48
+ const good = invertGood ? delta > 0 : delta < 0;
49
+ const bad = invertGood ? delta < 0 : delta > 0;
50
+ const sign = delta > 0 ? "+" : "";
51
+ const text = `${sign}${formatter(delta)}`;
52
+ if (good) return `${text} better`;
53
+ if (bad) return `${text} worse`;
54
+ return `${text} same`;
55
+ }
56
+
57
+ function list(value) {
58
+ return Array.isArray(value) && value.length ? value.join(", ") : "-";
59
+ }
60
+
61
+ const baseline = loadRun(baselineArg);
62
+ const candidate = loadRun(candidateArg);
63
+
64
+ const a = baseline.data;
65
+ const b = candidate.data;
66
+ const aReview = baseline.review;
67
+ const bReview = candidate.review;
68
+ const aActivity = a.activity || {};
69
+ const bActivity = b.activity || {};
70
+ const aTotals = aActivity.totals || {};
71
+ const bTotals = bActivity.totals || {};
72
+
73
+ const rows = [
74
+ {
75
+ label: "Total tokens",
76
+ baseline: fmtNumber(aTotals.totalTokens),
77
+ candidate: fmtNumber(bTotals.totalTokens),
78
+ delta: fmtDelta((bTotals.totalTokens || 0) - (aTotals.totalTokens || 0))
79
+ },
80
+ {
81
+ label: "Input tokens",
82
+ baseline: fmtNumber(aTotals.inputTokens),
83
+ candidate: fmtNumber(bTotals.inputTokens),
84
+ delta: fmtDelta((bTotals.inputTokens || 0) - (aTotals.inputTokens || 0))
85
+ },
86
+ {
87
+ label: "Output tokens",
88
+ baseline: fmtNumber(aTotals.outputTokens),
89
+ candidate: fmtNumber(bTotals.outputTokens),
90
+ delta: fmtDelta((bTotals.outputTokens || 0) - (aTotals.outputTokens || 0))
91
+ },
92
+ {
93
+ label: "Duration",
94
+ baseline: fmtMs(aActivity.durationMs),
95
+ candidate: fmtMs(bActivity.durationMs),
96
+ delta: fmtDelta((bActivity.durationMs || 0) - (aActivity.durationMs || 0), fmtMs)
97
+ },
98
+ {
99
+ label: "LLM calls",
100
+ baseline: fmtNumber(aActivity.llmCallCount),
101
+ candidate: fmtNumber(bActivity.llmCallCount),
102
+ delta: fmtDelta((bActivity.llmCallCount || 0) - (aActivity.llmCallCount || 0))
103
+ },
104
+ {
105
+ label: "Tool calls",
106
+ baseline: fmtNumber(aActivity.toolCallCount),
107
+ candidate: fmtNumber(bActivity.toolCallCount),
108
+ delta: fmtDelta((bActivity.toolCallCount || 0) - (aActivity.toolCallCount || 0))
109
+ },
110
+ {
111
+ label: "Tool errors",
112
+ baseline: fmtNumber(aActivity.toolErrorCount),
113
+ candidate: fmtNumber(bActivity.toolErrorCount),
114
+ delta: fmtDelta((bActivity.toolErrorCount || 0) - (aActivity.toolErrorCount || 0))
115
+ },
116
+ {
117
+ label: "Cost (USD)",
118
+ baseline: fmtUsd(aTotals.totalCostUsd),
119
+ candidate: fmtUsd(bTotals.totalCostUsd),
120
+ delta: fmtDelta((bTotals.totalCostUsd || 0) - (aTotals.totalCostUsd || 0), fmtUsd)
121
+ }
122
+ ];
123
+
124
+ const labelWidth = Math.max(...rows.map((row) => row.label.length));
125
+ const baselineWidth = Math.max("Baseline".length, ...rows.map((row) => row.baseline.length));
126
+ const candidateWidth = Math.max("Candidate".length, ...rows.map((row) => row.candidate.length));
127
+
128
+ function pad(value, width) {
129
+ return String(value).padEnd(width);
130
+ }
131
+
132
+ console.log("notrace compare\n");
133
+ console.log(`Baseline : ${relative(process.cwd(), baseline.path) || baseline.path}`);
134
+ console.log(`Candidate: ${relative(process.cwd(), candidate.path) || candidate.path}`);
135
+ console.log("");
136
+ console.log(`Task : ${b.task?.id || a.task?.id || "(none)"}`);
137
+ console.log(`Capture : ${a.captureMode} -> ${b.captureMode}`);
138
+ console.log(`Models : ${list(a.conditions?.models)} -> ${list(b.conditions?.models)}`);
139
+ console.log(`Providers : ${list(a.conditions?.providers)} -> ${list(b.conditions?.providers)}`);
140
+ console.log("");
141
+ console.log(`Review : ${(aReview?.outcome || "-")}/${(aReview?.friction || "-")} -> ${(bReview?.outcome || "-")}/${(bReview?.friction || "-")}`);
142
+ if (aReview?.lesson || bReview?.lesson) {
143
+ console.log(`Lessons : ${(aReview?.lesson || "-")} -> ${(bReview?.lesson || "-")}`);
144
+ }
145
+ if (aReview?.nextChange || bReview?.nextChange) {
146
+ console.log(`Next : ${(aReview?.nextChange || "-")} -> ${(bReview?.nextChange || "-")}`);
147
+ }
148
+ console.log("");
149
+ console.log(`${pad("Metric", labelWidth)} | ${pad("Baseline", baselineWidth)} | ${pad("Candidate", candidateWidth)} | Delta`);
150
+ console.log(`${"-".repeat(labelWidth)}-+-${"-".repeat(baselineWidth)}-+-${"-".repeat(candidateWidth)}-+-${"-".repeat(24)}`);
151
+ for (const row of rows) {
152
+ console.log(`${pad(row.label, labelWidth)} | ${pad(row.baseline, baselineWidth)} | ${pad(row.candidate, candidateWidth)} | ${row.delta}`);
153
+ }
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { resolve, relative, dirname, basename, extname, join } from "node:path";
4
+
5
+ const VALID_OUTCOMES = new Set(["success", "partial", "failed", "abandoned", "inconclusive"]);
6
+ const VALID_FRICTION = new Set(["low", "medium", "high"]);
7
+
8
+ function appendWorkLogEntry(taskDir, message) {
9
+ const workMd = join(taskDir, "WORK.md");
10
+ if (!existsSync(workMd)) return;
11
+
12
+ const text = readFileSync(workMd, "utf8");
13
+ const entry = `- ${new Date().toISOString()}: ${message}`;
14
+
15
+ if (!/^(## )?\[LOG\]\s*$/m.test(text)) {
16
+ writeFileSync(workMd, `${text.trimEnd()}\n\n## [LOG]\n${entry}\n`, { encoding: "utf8" });
17
+ return;
18
+ }
19
+
20
+ const lines = text.split("\n");
21
+ const logIndex = lines.findIndex((line) => /^(## )?\[LOG\]\s*$/.test(line));
22
+ if (logIndex === -1) return;
23
+
24
+ let nextSectionIndex = lines.length;
25
+ for (let i = logIndex + 1; i < lines.length; i++) {
26
+ if (/^(## )?\[[A-Z0-9_-]+\]\s*$/.test(lines[i])) {
27
+ nextSectionIndex = i;
28
+ break;
29
+ }
30
+ }
31
+
32
+ const before = lines.slice(0, nextSectionIndex);
33
+ const after = lines.slice(nextSectionIndex);
34
+ while (before.length > logIndex + 1 && before[before.length - 1]?.trim() === "") {
35
+ before.pop();
36
+ }
37
+ before.push(entry);
38
+
39
+ writeFileSync(workMd, `${[...before, ...after].join("\n").replace(/\n*$/, "\n")}`, { encoding: "utf8" });
40
+ }
41
+
42
+ function usage() {
43
+ console.error("Usage: notrace-review <notrace.json> [--outcome value] [--friction value] [--lesson text] [--next-change text]");
44
+ process.exit(1);
45
+ }
46
+
47
+ const args = process.argv.slice(2);
48
+ if (!args.length) usage();
49
+
50
+ const runPath = resolve(args[0]);
51
+ const flags = args.slice(1);
52
+
53
+ function takeFlag(name) {
54
+ const index = flags.indexOf(name);
55
+ if (index === -1) return undefined;
56
+ const value = flags[index + 1];
57
+ if (!value || value.startsWith("--")) {
58
+ throw new Error(`Missing value for ${name}`);
59
+ }
60
+ return value;
61
+ }
62
+
63
+ const outcome = takeFlag("--outcome");
64
+ const friction = takeFlag("--friction");
65
+ const lesson = takeFlag("--lesson");
66
+ const nextChange = takeFlag("--next-change");
67
+
68
+ if (!existsSync(runPath)) {
69
+ throw new Error(`Run record not found: ${runPath}`);
70
+ }
71
+
72
+ const run = JSON.parse(readFileSync(runPath, "utf8"));
73
+ if (run?.kind !== "notrace-run") {
74
+ throw new Error(`Not a notrace run record: ${runPath}`);
75
+ }
76
+
77
+ if (outcome && !VALID_OUTCOMES.has(outcome)) {
78
+ throw new Error(`Invalid outcome: ${outcome}`);
79
+ }
80
+ if (friction && !VALID_FRICTION.has(friction)) {
81
+ throw new Error(`Invalid friction: ${friction}`);
82
+ }
83
+
84
+ const reviewPath = join(dirname(runPath), `${basename(runPath, extname(runPath))}.review.json`);
85
+ const existing = existsSync(reviewPath)
86
+ ? JSON.parse(readFileSync(reviewPath, "utf8"))
87
+ : {
88
+ schemaVersion: 1,
89
+ kind: "notrace-review",
90
+ traceId: run.traceId,
91
+ runRecord: basename(runPath),
92
+ outcome: null,
93
+ friction: null,
94
+ lesson: "",
95
+ nextChange: ""
96
+ };
97
+
98
+ const review = {
99
+ ...existing,
100
+ traceId: run.traceId,
101
+ runRecord: basename(runPath),
102
+ outcome: outcome ?? existing.outcome ?? null,
103
+ friction: friction ?? existing.friction ?? null,
104
+ lesson: lesson ?? existing.lesson ?? "",
105
+ nextChange: nextChange ?? existing.nextChange ?? ""
106
+ };
107
+
108
+ writeFileSync(reviewPath, `${JSON.stringify(review, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
109
+
110
+ if (run.repository?.cwd && (run.task?.dir || run.task?.path)) {
111
+ const taskDir = run.task?.dir
112
+ ? resolve(run.task.dir)
113
+ : resolve(run.repository.cwd, run.task.path);
114
+ appendWorkLogEntry(taskDir, `notrace review recorded: outcome=${review.outcome ?? "-"}, friction=${review.friction ?? "-"}, review=${relative(taskDir, reviewPath)}`);
115
+ } else {
116
+ appendWorkLogEntry(dirname(runPath), `notrace review recorded: outcome=${review.outcome ?? "-"}, friction=${review.friction ?? "-"}, review=${basename(reviewPath)}`);
117
+ }
118
+
119
+ console.log(`notrace review ✓ ${reviewPath}`);
120
+ console.log(` outcome : ${review.outcome ?? "-"}`);
121
+ console.log(` friction : ${review.friction ?? "-"}`);
122
+ console.log(` lesson : ${review.lesson || "-"}`);
123
+ console.log(` nextChange: ${review.nextChange || "-"}`);
@@ -1,5 +1,6 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, chmodSync } from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { execSync } from "node:child_process";
3
4
  import { getActiveAdapter } from "./adapters.js";
4
5
  import { generateHtmlReport, generateDashboardHtml } from "./renderer.js";
5
6
  const REDACTED = "[REDACTED by notrace]";
@@ -21,6 +22,8 @@ function isSensitiveKey(key) {
21
22
  return SENSITIVE_KEY_RE.test(normalized);
22
23
  }
23
24
  function sanitizeTraceValue(value) {
25
+ if (currentMode === "metadata")
26
+ return { omitted: true, reason: "metadata-capture" };
24
27
  if (currentMode === "full")
25
28
  return value;
26
29
  if (value == null || typeof value !== "object") {
@@ -47,6 +50,34 @@ function normalizeUsage(raw) {
47
50
  totalCostUsd: asNumber(usage.cost?.total),
48
51
  };
49
52
  }
53
+ function readJsonFile(filePath, fallback) {
54
+ try {
55
+ return JSON.parse(readFileSync(filePath, "utf-8"));
56
+ }
57
+ catch {
58
+ return fallback;
59
+ }
60
+ }
61
+ function writePrivateFileAtomic(filePath, content) {
62
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
63
+ writeFileSync(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
64
+ chmodSync(tmpPath, 0o600);
65
+ renameSync(tmpPath, filePath);
66
+ }
67
+ function validateRunRecord(record) {
68
+ if (record.kind !== "notrace-run")
69
+ throw new Error("notrace record validation failed: invalid kind");
70
+ if (record.schemaVersion !== SCHEMA_VERSION)
71
+ throw new Error("notrace record validation failed: invalid schemaVersion");
72
+ if (!record.traceId || !record.session?.id)
73
+ throw new Error("notrace record validation failed: missing session id");
74
+ if (!record.repository?.cwd)
75
+ throw new Error("notrace record validation failed: missing repository cwd");
76
+ if (!record.activity?.totals)
77
+ throw new Error("notrace record validation failed: missing activity totals");
78
+ if (!Array.isArray(record.events))
79
+ throw new Error("notrace record validation failed: events must be an array");
80
+ }
50
81
  function collectActivity(events, startedAt, endedAt) {
51
82
  const activity = {
52
83
  turnCount: 0,
@@ -224,13 +255,20 @@ export default function (pi) {
224
255
  const finalTraceId = ctx.sessionManager?.getSessionId?.() || traceId;
225
256
  const outputDir = path.join(notraceDir, "sessions", finalTraceId.replace(/[^a-z0-9]/gi, "-"));
226
257
  const repositoryName = path.basename(ctx.cwd);
258
+ let branchName = null;
259
+ try {
260
+ branchName = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim() || null;
261
+ }
262
+ catch {
263
+ // not a git repo or no commits yet
264
+ }
227
265
  const recordPath = path.join(outputDir, "notrace.json");
228
266
  let mergedEvents = events;
229
267
  let originalStartedAt = startTime;
230
268
  let originalTask = null;
231
269
  if (existsSync(recordPath)) {
232
270
  try {
233
- const oldRecord = JSON.parse(readFileSync(recordPath, "utf-8"));
271
+ const oldRecord = readJsonFile(recordPath, null);
234
272
  if (Array.isArray(oldRecord.events)) {
235
273
  mergedEvents = [...oldRecord.events, ...events];
236
274
  }
@@ -256,6 +294,7 @@ export default function (pi) {
256
294
  repository: {
257
295
  name: repositoryName,
258
296
  cwd: ctx.cwd,
297
+ branch: branchName,
259
298
  },
260
299
  session: {
261
300
  id: finalTraceId,
@@ -271,19 +310,20 @@ export default function (pi) {
271
310
  telemetry: { extensions: telemetry },
272
311
  events: mergedEvents,
273
312
  };
313
+ validateRunRecord(record);
274
314
  const html = generateHtmlReport(record);
275
315
  mkdirSync(outputDir, { recursive: true });
276
316
  const htmlPath = path.join(outputDir, "notrace.html");
277
- writeFileSync(htmlPath, html);
278
- writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`);
317
+ writePrivateFileAtomic(htmlPath, html);
318
+ writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`);
279
319
  const indexPath = path.join(notraceDir, "index.json");
280
- const existing = existsSync(indexPath) ? JSON.parse(readFileSync(indexPath, "utf-8")) : { repositoryName, sessions: [] };
320
+ const existing = readJsonFile(indexPath, { repositoryName, sessions: [] });
281
321
  let sessions = Array.isArray(existing.sessions) ? existing.sessions.filter((s) => s.sessionId !== finalTraceId) : [];
282
322
  if (!isGhostSession) {
283
323
  sessions.push(createIndexEntry(record, ctx.cwd, htmlPath, recordPath));
284
324
  }
285
- writeFileSync(indexPath, `${JSON.stringify({ repositoryName, sessions }, null, 2)}\n`);
286
- writeFileSync(path.join(notraceDir, "index.html"), generateDashboardHtml(sessions, { repositoryName }));
325
+ writePrivateFileAtomic(indexPath, `${JSON.stringify({ repositoryName, sessions }, null, 2)}\n`);
326
+ writePrivateFileAtomic(path.join(notraceDir, "index.html"), generateDashboardHtml(sessions, { repositoryName }));
287
327
  if (context) {
288
328
  adapter.attach(context, {
289
329
  html: path.relative(ctx.cwd, htmlPath),
@@ -83,7 +83,9 @@ function taskDisplay(taskish) {
83
83
  return "No active task";
84
84
  }
85
85
  function resolveRepoName(data) {
86
- return data?.repository?.name || data?.repositoryName || data?.repoName || "Repository";
86
+ const name = data?.repository?.name || data?.repositoryName || data?.repoName || "Repository";
87
+ const branch = data?.repository?.branch;
88
+ return branch ? `${name} @ ${branch}` : name;
87
89
  }
88
90
  function formatUsd(value) {
89
91
  const num = Number(value || 0);
@@ -202,6 +204,44 @@ function shell(title, body, script = "") {
202
204
  overflow: visible;
203
205
  }
204
206
  .subtitle { margin: 10px 0 0; color: var(--muted); }
207
+ .session-subtitle {
208
+ display: flex;
209
+ align-items: center;
210
+ gap: 10px;
211
+ flex-wrap: wrap;
212
+ }
213
+ .session-id-chip {
214
+ display: inline-flex;
215
+ align-items: center;
216
+ gap: 8px;
217
+ max-width: 100%;
218
+ padding: 6px 8px 6px 10px;
219
+ border: 1px solid var(--border);
220
+ border-radius: 999px;
221
+ background: rgba(0,0,0,0.18);
222
+ color: var(--text);
223
+ font-family: "SFMono-Regular", ui-monospace, Menlo, Monaco, Consolas, monospace;
224
+ font-size: 0.78rem;
225
+ word-break: break-all;
226
+ }
227
+ .copy-btn {
228
+ display: inline-flex;
229
+ align-items: center;
230
+ justify-content: center;
231
+ width: 26px;
232
+ height: 26px;
233
+ border: 1px solid rgba(255,255,255,0.12);
234
+ border-radius: 999px;
235
+ background: rgba(255,255,255,0.04);
236
+ color: var(--muted);
237
+ cursor: pointer;
238
+ transition: color 120ms ease, border-color 120ms ease, background 120ms ease;
239
+ }
240
+ .copy-btn:hover, .copy-btn.copied {
241
+ color: var(--text);
242
+ border-color: rgba(216,132,98,0.45);
243
+ background: var(--accent-soft);
244
+ }
205
245
  .meta {
206
246
  display: flex;
207
247
  gap: 8px;
@@ -401,6 +441,42 @@ function shell(title, body, script = "") {
401
441
  <body>${body}${script ? `<script>${script}</script>` : ""}</body>
402
442
  </html>`;
403
443
  }
444
+ function copyButton(value, label) {
445
+ return `<button class="copy-btn" type="button" data-copy-value="${escapeHtml(value)}" aria-label="Copy ${escapeHtml(label)}" title="Copy ${escapeHtml(label)}"><svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>`;
446
+ }
447
+ function copyScript() {
448
+ return `(() => {
449
+ document.querySelectorAll('[data-copy-value]').forEach((button) => {
450
+ button.addEventListener('click', async () => {
451
+ const value = button.getAttribute('data-copy-value') || '';
452
+ try {
453
+ if (navigator.clipboard?.writeText) {
454
+ await navigator.clipboard.writeText(value);
455
+ } else {
456
+ const textarea = document.createElement('textarea');
457
+ textarea.value = value;
458
+ textarea.style.position = 'fixed';
459
+ textarea.style.opacity = '0';
460
+ document.body.appendChild(textarea);
461
+ textarea.focus();
462
+ textarea.select();
463
+ document.execCommand('copy');
464
+ textarea.remove();
465
+ }
466
+ const previous = button.innerHTML;
467
+ button.classList.add('copied');
468
+ button.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg>';
469
+ setTimeout(() => {
470
+ button.classList.remove('copied');
471
+ button.innerHTML = previous;
472
+ }, 1400);
473
+ } catch {
474
+ button.textContent = 'ERR';
475
+ }
476
+ });
477
+ });
478
+ })();`;
479
+ }
404
480
  function renderJsonBlock(title, value) {
405
481
  return `<section class="block"><h4>${escapeHtml(title)}</h4><pre>${escapeHtml(typeof value === "string" ? value : JSON.stringify(value, null, 2))}</pre></section>`;
406
482
  }
@@ -557,12 +633,12 @@ export function generateHtmlReport(data) {
557
633
  <section class="hero">
558
634
  <div class="hero-top">
559
635
  <div>
560
- <div class="brand"><a class="brand-link" href="${escapeHtml(indexHref)}" onclick="if (window.history.length > 1) { window.history.back(); return false; }">${wordmarkSvg()}</a><p class="subtitle">Session retrospective for ${escapeHtml(data.traceId)}</p></div>
636
+ <div class="brand"><a class="brand-link" href="${escapeHtml(indexHref)}" onclick="if (window.history.length > 1) { window.history.back(); return false; }">${wordmarkSvg()}</a><p class="subtitle session-subtitle"><span>Session retrospective</span><span class="session-id-chip"><span>${escapeHtml(data.traceId)}</span>${copyButton(String(data.traceId || ""), "session ID")}</span></p></div>
561
637
  </div>
562
638
  <div class="meta">
563
- <span class="pill">${escapeHtml(repositoryName)}</span>
639
+ <span class="pill">${escapeHtml(resolveRepoName(data))}</span>
564
640
  <span class="pill">Started ${formatDateLong(data.session?.startedAt)}</span>
565
- <span class="pill">Capture ${escapeHtml(data.captureMode || "full")}</span>
641
+ <span class="pill">Mode: ${escapeHtml(data.captureMode || "full")}</span>
566
642
  </div>
567
643
  </div>
568
644
  <div class="metrics">
@@ -601,5 +677,5 @@ export function generateHtmlReport(data) {
601
677
  <div class="footer-meta"><a href="https://opensource.org/licenses/MIT">MIT</a></div>
602
678
  </footer>
603
679
  </div>`;
604
- return shell(`notrace - ${data.traceId}`, body);
680
+ return shell(`notrace - ${data.traceId}`, body, copyScript());
605
681
  }
@@ -30,6 +30,7 @@ export type NotraceHarnessInfo = {
30
30
  export type NotraceRepositoryInfo = {
31
31
  name: string;
32
32
  cwd: string;
33
+ branch?: string | null;
33
34
  };
34
35
  export type NotraceSessionInfo = {
35
36
  id: string;
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, chmodSync } from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { execSync } from "node:child_process";
4
5
  import type {
5
6
  NotraceActivity,
6
7
  NotraceCaptureMode,
@@ -64,6 +65,7 @@ function isSensitiveKey(key: string): boolean {
64
65
  }
65
66
 
66
67
  function sanitizeTraceValue(value: unknown): unknown {
68
+ if (currentMode === "metadata") return { omitted: true, reason: "metadata-capture" };
67
69
  if (currentMode === "full") return value;
68
70
  if (value == null || typeof value !== "object") {
69
71
  return typeof value === "string" ? value.replace(SENSITIVE_VALUE_RE, REDACTED).slice(0, 10000) : value;
@@ -90,6 +92,30 @@ function normalizeUsage(raw: unknown): Required<Pick<UsageLike, "inputTokens" |
90
92
  };
91
93
  }
92
94
 
95
+ function readJsonFile<T>(filePath: string, fallback: T): T {
96
+ try {
97
+ return JSON.parse(readFileSync(filePath, "utf-8")) as T;
98
+ } catch {
99
+ return fallback;
100
+ }
101
+ }
102
+
103
+ function writePrivateFileAtomic(filePath: string, content: string): void {
104
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
105
+ writeFileSync(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
106
+ chmodSync(tmpPath, 0o600);
107
+ renameSync(tmpPath, filePath);
108
+ }
109
+
110
+ function validateRunRecord(record: NotraceRunRecord): void {
111
+ if (record.kind !== "notrace-run") throw new Error("notrace record validation failed: invalid kind");
112
+ if (record.schemaVersion !== SCHEMA_VERSION) throw new Error("notrace record validation failed: invalid schemaVersion");
113
+ if (!record.traceId || !record.session?.id) throw new Error("notrace record validation failed: missing session id");
114
+ if (!record.repository?.cwd) throw new Error("notrace record validation failed: missing repository cwd");
115
+ if (!record.activity?.totals) throw new Error("notrace record validation failed: missing activity totals");
116
+ if (!Array.isArray(record.events)) throw new Error("notrace record validation failed: events must be an array");
117
+ }
118
+
93
119
  function collectActivity(events: NotraceEvent[], startedAt: number, endedAt: number): NotraceActivity {
94
120
  const activity: NotraceActivity = {
95
121
  turnCount: 0,
@@ -277,6 +303,12 @@ export default function (pi: ExtensionAPI) {
277
303
  const finalTraceId = ctx.sessionManager?.getSessionId?.() || traceId;
278
304
  const outputDir = path.join(notraceDir, "sessions", finalTraceId.replace(/[^a-z0-9]/gi, "-"));
279
305
  const repositoryName = path.basename(ctx.cwd);
306
+ let branchName: string | null = null;
307
+ try {
308
+ branchName = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim() || null;
309
+ } catch {
310
+ // not a git repo or no commits yet
311
+ }
280
312
  const recordPath = path.join(outputDir, "notrace.json");
281
313
 
282
314
  let mergedEvents = events;
@@ -284,7 +316,7 @@ export default function (pi: ExtensionAPI) {
284
316
  let originalTask: any = null;
285
317
  if (existsSync(recordPath)) {
286
318
  try {
287
- const oldRecord = JSON.parse(readFileSync(recordPath, "utf-8"));
319
+ const oldRecord = readJsonFile<any>(recordPath, null);
288
320
  if (Array.isArray(oldRecord.events)) {
289
321
  mergedEvents = [...oldRecord.events, ...events];
290
322
  }
@@ -313,6 +345,7 @@ export default function (pi: ExtensionAPI) {
313
345
  repository: {
314
346
  name: repositoryName,
315
347
  cwd: ctx.cwd,
348
+ branch: branchName,
316
349
  },
317
350
  session: {
318
351
  id: finalTraceId,
@@ -329,23 +362,24 @@ export default function (pi: ExtensionAPI) {
329
362
  events: mergedEvents,
330
363
  };
331
364
 
365
+ validateRunRecord(record);
332
366
  const html = generateHtmlReport(record);
333
367
 
334
368
  mkdirSync(outputDir, { recursive: true });
335
369
  const htmlPath = path.join(outputDir, "notrace.html");
336
- writeFileSync(htmlPath, html);
337
- writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`);
370
+ writePrivateFileAtomic(htmlPath, html);
371
+ writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`);
338
372
 
339
373
  const indexPath = path.join(notraceDir, "index.json");
340
- const existing = existsSync(indexPath) ? JSON.parse(readFileSync(indexPath, "utf-8")) : { repositoryName, sessions: [] };
374
+ const existing = readJsonFile<any>(indexPath, { repositoryName, sessions: [] });
341
375
  let sessions = Array.isArray(existing.sessions) ? existing.sessions.filter((s: any) => s.sessionId !== finalTraceId) : [];
342
376
 
343
377
  if (!isGhostSession) {
344
378
  sessions.push(createIndexEntry(record, ctx.cwd, htmlPath, recordPath));
345
379
  }
346
380
 
347
- writeFileSync(indexPath, `${JSON.stringify({ repositoryName, sessions }, null, 2)}\n`);
348
- writeFileSync(path.join(notraceDir, "index.html"), generateDashboardHtml(sessions, { repositoryName }));
381
+ writePrivateFileAtomic(indexPath, `${JSON.stringify({ repositoryName, sessions }, null, 2)}\n`);
382
+ writePrivateFileAtomic(path.join(notraceDir, "index.html"), generateDashboardHtml(sessions, { repositoryName }));
349
383
 
350
384
  if (context) {
351
385
  adapter.attach(context, {
@@ -87,7 +87,9 @@ function taskDisplay(taskish: any): string {
87
87
  }
88
88
 
89
89
  function resolveRepoName(data: any): string {
90
- return data?.repository?.name || data?.repositoryName || data?.repoName || "Repository";
90
+ const name = data?.repository?.name || data?.repositoryName || data?.repoName || "Repository";
91
+ const branch = data?.repository?.branch;
92
+ return branch ? `${name} @ ${branch}` : name;
91
93
  }
92
94
 
93
95
  function formatUsd(value: number | undefined): string {
@@ -211,6 +213,44 @@ function shell(title: string, body: string, script = ""): string {
211
213
  overflow: visible;
212
214
  }
213
215
  .subtitle { margin: 10px 0 0; color: var(--muted); }
216
+ .session-subtitle {
217
+ display: flex;
218
+ align-items: center;
219
+ gap: 10px;
220
+ flex-wrap: wrap;
221
+ }
222
+ .session-id-chip {
223
+ display: inline-flex;
224
+ align-items: center;
225
+ gap: 8px;
226
+ max-width: 100%;
227
+ padding: 6px 8px 6px 10px;
228
+ border: 1px solid var(--border);
229
+ border-radius: 999px;
230
+ background: rgba(0,0,0,0.18);
231
+ color: var(--text);
232
+ font-family: "SFMono-Regular", ui-monospace, Menlo, Monaco, Consolas, monospace;
233
+ font-size: 0.78rem;
234
+ word-break: break-all;
235
+ }
236
+ .copy-btn {
237
+ display: inline-flex;
238
+ align-items: center;
239
+ justify-content: center;
240
+ width: 26px;
241
+ height: 26px;
242
+ border: 1px solid rgba(255,255,255,0.12);
243
+ border-radius: 999px;
244
+ background: rgba(255,255,255,0.04);
245
+ color: var(--muted);
246
+ cursor: pointer;
247
+ transition: color 120ms ease, border-color 120ms ease, background 120ms ease;
248
+ }
249
+ .copy-btn:hover, .copy-btn.copied {
250
+ color: var(--text);
251
+ border-color: rgba(216,132,98,0.45);
252
+ background: var(--accent-soft);
253
+ }
214
254
  .meta {
215
255
  display: flex;
216
256
  gap: 8px;
@@ -411,6 +451,44 @@ function shell(title: string, body: string, script = ""): string {
411
451
  </html>`;
412
452
  }
413
453
 
454
+ function copyButton(value: string, label: string): string {
455
+ return `<button class="copy-btn" type="button" data-copy-value="${escapeHtml(value)}" aria-label="Copy ${escapeHtml(label)}" title="Copy ${escapeHtml(label)}"><svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>`;
456
+ }
457
+
458
+ function copyScript(): string {
459
+ return `(() => {
460
+ document.querySelectorAll('[data-copy-value]').forEach((button) => {
461
+ button.addEventListener('click', async () => {
462
+ const value = button.getAttribute('data-copy-value') || '';
463
+ try {
464
+ if (navigator.clipboard?.writeText) {
465
+ await navigator.clipboard.writeText(value);
466
+ } else {
467
+ const textarea = document.createElement('textarea');
468
+ textarea.value = value;
469
+ textarea.style.position = 'fixed';
470
+ textarea.style.opacity = '0';
471
+ document.body.appendChild(textarea);
472
+ textarea.focus();
473
+ textarea.select();
474
+ document.execCommand('copy');
475
+ textarea.remove();
476
+ }
477
+ const previous = button.innerHTML;
478
+ button.classList.add('copied');
479
+ button.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg>';
480
+ setTimeout(() => {
481
+ button.classList.remove('copied');
482
+ button.innerHTML = previous;
483
+ }, 1400);
484
+ } catch {
485
+ button.textContent = 'ERR';
486
+ }
487
+ });
488
+ });
489
+ })();`;
490
+ }
491
+
414
492
  function renderJsonBlock(title: string, value: unknown): string {
415
493
  return `<section class="block"><h4>${escapeHtml(title)}</h4><pre>${escapeHtml(typeof value === "string" ? value : JSON.stringify(value, null, 2))}</pre></section>`;
416
494
  }
@@ -568,12 +646,12 @@ export function generateHtmlReport(data: any): string {
568
646
  <section class="hero">
569
647
  <div class="hero-top">
570
648
  <div>
571
- <div class="brand"><a class="brand-link" href="${escapeHtml(indexHref)}" onclick="if (window.history.length > 1) { window.history.back(); return false; }">${wordmarkSvg()}</a><p class="subtitle">Session retrospective for ${escapeHtml(data.traceId)}</p></div>
649
+ <div class="brand"><a class="brand-link" href="${escapeHtml(indexHref)}" onclick="if (window.history.length > 1) { window.history.back(); return false; }">${wordmarkSvg()}</a><p class="subtitle session-subtitle"><span>Session retrospective</span><span class="session-id-chip"><span>${escapeHtml(data.traceId)}</span>${copyButton(String(data.traceId || ""), "session ID")}</span></p></div>
572
650
  </div>
573
651
  <div class="meta">
574
- <span class="pill">${escapeHtml(repositoryName)}</span>
652
+ <span class="pill">${escapeHtml(resolveRepoName(data))}</span>
575
653
  <span class="pill">Started ${formatDateLong(data.session?.startedAt)}</span>
576
- <span class="pill">Capture ${escapeHtml(data.captureMode || "full")}</span>
654
+ <span class="pill">Mode: ${escapeHtml(data.captureMode || "full")}</span>
577
655
  </div>
578
656
  </div>
579
657
  <div class="metrics">
@@ -612,5 +690,5 @@ export function generateHtmlReport(data: any): string {
612
690
  <div class="footer-meta"><a href="https://opensource.org/licenses/MIT">MIT</a></div>
613
691
  </footer>
614
692
  </div>`;
615
- return shell(`notrace - ${data.traceId}`, body);
693
+ return shell(`notrace - ${data.traceId}`, body, copyScript());
616
694
  }
@@ -36,6 +36,7 @@ export type NotraceHarnessInfo = {
36
36
  export type NotraceRepositoryInfo = {
37
37
  name: string;
38
38
  cwd: string;
39
+ branch?: string | null;
39
40
  };
40
41
 
41
42
  export type NotraceSessionInfo = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raquezha/notrace",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Zero-dependency, local-first interactive HTML Trace Viewer for the Pi Coding Agent",
5
5
  "main": "dist/notrace/index.js",
6
6
  "types": "dist/notrace/index.d.ts",
@@ -8,6 +8,8 @@
8
8
  "scripts": {
9
9
  "build": "tsc",
10
10
  "render:samples": "npm run build && node ./templates/render-samples.mjs",
11
+ "review": "node ./bin/notrace-review.mjs",
12
+ "compare": "node ./bin/notrace-compare.mjs",
11
13
  "prepare": "npm run build"
12
14
  },
13
15
  "peerDependencies": {
@@ -31,5 +33,9 @@
31
33
  "extensions": [
32
34
  "extensions"
33
35
  ]
36
+ },
37
+ "bin": {
38
+ "notrace-review": "./bin/notrace-review.mjs",
39
+ "notrace-compare": "./bin/notrace-compare.mjs"
34
40
  }
35
41
  }
@@ -68,6 +68,44 @@
68
68
  overflow: visible;
69
69
  }
70
70
  .subtitle { margin: 10px 0 0; color: var(--muted); }
71
+ .session-subtitle {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 10px;
75
+ flex-wrap: wrap;
76
+ }
77
+ .session-id-chip {
78
+ display: inline-flex;
79
+ align-items: center;
80
+ gap: 8px;
81
+ max-width: 100%;
82
+ padding: 6px 8px 6px 10px;
83
+ border: 1px solid var(--border);
84
+ border-radius: 999px;
85
+ background: rgba(0,0,0,0.18);
86
+ color: var(--text);
87
+ font-family: "SFMono-Regular", ui-monospace, Menlo, Monaco, Consolas, monospace;
88
+ font-size: 0.78rem;
89
+ word-break: break-all;
90
+ }
91
+ .copy-btn {
92
+ display: inline-flex;
93
+ align-items: center;
94
+ justify-content: center;
95
+ width: 26px;
96
+ height: 26px;
97
+ border: 1px solid rgba(255,255,255,0.12);
98
+ border-radius: 999px;
99
+ background: rgba(255,255,255,0.04);
100
+ color: var(--muted);
101
+ cursor: pointer;
102
+ transition: color 120ms ease, border-color 120ms ease, background 120ms ease;
103
+ }
104
+ .copy-btn:hover, .copy-btn.copied {
105
+ color: var(--text);
106
+ border-color: rgba(216,132,98,0.45);
107
+ background: var(--accent-soft);
108
+ }
71
109
  .meta {
72
110
  display: flex;
73
111
  gap: 8px;
@@ -68,6 +68,44 @@
68
68
  overflow: visible;
69
69
  }
70
70
  .subtitle { margin: 10px 0 0; color: var(--muted); }
71
+ .session-subtitle {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 10px;
75
+ flex-wrap: wrap;
76
+ }
77
+ .session-id-chip {
78
+ display: inline-flex;
79
+ align-items: center;
80
+ gap: 8px;
81
+ max-width: 100%;
82
+ padding: 6px 8px 6px 10px;
83
+ border: 1px solid var(--border);
84
+ border-radius: 999px;
85
+ background: rgba(0,0,0,0.18);
86
+ color: var(--text);
87
+ font-family: "SFMono-Regular", ui-monospace, Menlo, Monaco, Consolas, monospace;
88
+ font-size: 0.78rem;
89
+ word-break: break-all;
90
+ }
91
+ .copy-btn {
92
+ display: inline-flex;
93
+ align-items: center;
94
+ justify-content: center;
95
+ width: 26px;
96
+ height: 26px;
97
+ border: 1px solid rgba(255,255,255,0.12);
98
+ border-radius: 999px;
99
+ background: rgba(255,255,255,0.04);
100
+ color: var(--muted);
101
+ cursor: pointer;
102
+ transition: color 120ms ease, border-color 120ms ease, background 120ms ease;
103
+ }
104
+ .copy-btn:hover, .copy-btn.copied {
105
+ color: var(--text);
106
+ border-color: rgba(216,132,98,0.45);
107
+ background: var(--accent-soft);
108
+ }
71
109
  .meta {
72
110
  display: flex;
73
111
  gap: 8px;
@@ -285,12 +323,12 @@
285
323
  </g>
286
324
  <text x="0" y="114" fill="#ECE3DA" style="fill:#ECE3DA" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif" font-size="96" font-weight="900" letter-spacing="-7">no</text>
287
325
  <text x="82" y="114" fill="#d88462" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif" font-size="96" font-weight="900" letter-spacing="-7">trace</text>
288
- </svg></a><p class="subtitle">Session retrospective for 019ed2ee-5252-76ee-b353-ad925a6bad31</p></div>
326
+ </svg></a><p class="subtitle session-subtitle"><span>Session retrospective</span><span class="session-id-chip"><span>019ed2ee-5252-76ee-b353-ad925a6bad31</span><button class="copy-btn" type="button" data-copy-value="019ed2ee-5252-76ee-b353-ad925a6bad31" aria-label="Copy session ID" title="Copy session ID"><svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button></span></p></div>
289
327
  </div>
290
328
  <div class="meta">
291
329
  <span class="pill">nothing</span>
292
330
  <span class="pill">Started 2026-06-17 22:05</span>
293
- <span class="pill">Capture full</span>
331
+ <span class="pill">Mode: full</span>
294
332
  </div>
295
333
  </div>
296
334
  <div class="metrics">
@@ -427,5 +465,35 @@
427
465
  <div class="footer-tagline">Local-first retrospective engine</div>
428
466
  <div class="footer-meta"><a href="https://opensource.org/licenses/MIT">MIT</a></div>
429
467
  </footer>
430
- </div></body>
468
+ </div><script>(() => {
469
+ document.querySelectorAll('[data-copy-value]').forEach((button) => {
470
+ button.addEventListener('click', async () => {
471
+ const value = button.getAttribute('data-copy-value') || '';
472
+ try {
473
+ if (navigator.clipboard?.writeText) {
474
+ await navigator.clipboard.writeText(value);
475
+ } else {
476
+ const textarea = document.createElement('textarea');
477
+ textarea.value = value;
478
+ textarea.style.position = 'fixed';
479
+ textarea.style.opacity = '0';
480
+ document.body.appendChild(textarea);
481
+ textarea.focus();
482
+ textarea.select();
483
+ document.execCommand('copy');
484
+ textarea.remove();
485
+ }
486
+ const previous = button.innerHTML;
487
+ button.classList.add('copied');
488
+ button.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg>';
489
+ setTimeout(() => {
490
+ button.classList.remove('copied');
491
+ button.innerHTML = previous;
492
+ }, 1400);
493
+ } catch {
494
+ button.textContent = 'ERR';
495
+ }
496
+ });
497
+ });
498
+ })();</script></body>
431
499
  </html>
@@ -68,6 +68,44 @@
68
68
  overflow: visible;
69
69
  }
70
70
  .subtitle { margin: 10px 0 0; color: var(--muted); }
71
+ .session-subtitle {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 10px;
75
+ flex-wrap: wrap;
76
+ }
77
+ .session-id-chip {
78
+ display: inline-flex;
79
+ align-items: center;
80
+ gap: 8px;
81
+ max-width: 100%;
82
+ padding: 6px 8px 6px 10px;
83
+ border: 1px solid var(--border);
84
+ border-radius: 999px;
85
+ background: rgba(0,0,0,0.18);
86
+ color: var(--text);
87
+ font-family: "SFMono-Regular", ui-monospace, Menlo, Monaco, Consolas, monospace;
88
+ font-size: 0.78rem;
89
+ word-break: break-all;
90
+ }
91
+ .copy-btn {
92
+ display: inline-flex;
93
+ align-items: center;
94
+ justify-content: center;
95
+ width: 26px;
96
+ height: 26px;
97
+ border: 1px solid rgba(255,255,255,0.12);
98
+ border-radius: 999px;
99
+ background: rgba(255,255,255,0.04);
100
+ color: var(--muted);
101
+ cursor: pointer;
102
+ transition: color 120ms ease, border-color 120ms ease, background 120ms ease;
103
+ }
104
+ .copy-btn:hover, .copy-btn.copied {
105
+ color: var(--text);
106
+ border-color: rgba(216,132,98,0.45);
107
+ background: var(--accent-soft);
108
+ }
71
109
  .meta {
72
110
  display: flex;
73
111
  gap: 8px;
@@ -285,12 +323,12 @@
285
323
  </g>
286
324
  <text x="0" y="114" fill="#ECE3DA" style="fill:#ECE3DA" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif" font-size="96" font-weight="900" letter-spacing="-7">no</text>
287
325
  <text x="82" y="114" fill="#d88462" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif" font-size="96" font-weight="900" letter-spacing="-7">trace</text>
288
- </svg></a><p class="subtitle">Session retrospective for 019ed2ee-1000-76ee-b353-000000000001</p></div>
326
+ </svg></a><p class="subtitle session-subtitle"><span>Session retrospective</span><span class="session-id-chip"><span>019ed2ee-1000-76ee-b353-000000000001</span><button class="copy-btn" type="button" data-copy-value="019ed2ee-1000-76ee-b353-000000000001" aria-label="Copy session ID" title="Copy session ID"><svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button></span></p></div>
289
327
  </div>
290
328
  <div class="meta">
291
329
  <span class="pill">nothing</span>
292
330
  <span class="pill">Started 2026-06-17 17:00</span>
293
- <span class="pill">Capture full</span>
331
+ <span class="pill">Mode: full</span>
294
332
  </div>
295
333
  </div>
296
334
  <div class="metrics">
@@ -422,5 +460,35 @@
422
460
  <div class="footer-tagline">Local-first retrospective engine</div>
423
461
  <div class="footer-meta"><a href="https://opensource.org/licenses/MIT">MIT</a></div>
424
462
  </footer>
425
- </div></body>
463
+ </div><script>(() => {
464
+ document.querySelectorAll('[data-copy-value]').forEach((button) => {
465
+ button.addEventListener('click', async () => {
466
+ const value = button.getAttribute('data-copy-value') || '';
467
+ try {
468
+ if (navigator.clipboard?.writeText) {
469
+ await navigator.clipboard.writeText(value);
470
+ } else {
471
+ const textarea = document.createElement('textarea');
472
+ textarea.value = value;
473
+ textarea.style.position = 'fixed';
474
+ textarea.style.opacity = '0';
475
+ document.body.appendChild(textarea);
476
+ textarea.focus();
477
+ textarea.select();
478
+ document.execCommand('copy');
479
+ textarea.remove();
480
+ }
481
+ const previous = button.innerHTML;
482
+ button.classList.add('copied');
483
+ button.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg>';
484
+ setTimeout(() => {
485
+ button.classList.remove('copied');
486
+ button.innerHTML = previous;
487
+ }, 1400);
488
+ } catch {
489
+ button.textContent = 'ERR';
490
+ }
491
+ });
492
+ });
493
+ })();</script></body>
426
494
  </html>
@@ -68,6 +68,44 @@
68
68
  overflow: visible;
69
69
  }
70
70
  .subtitle { margin: 10px 0 0; color: var(--muted); }
71
+ .session-subtitle {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 10px;
75
+ flex-wrap: wrap;
76
+ }
77
+ .session-id-chip {
78
+ display: inline-flex;
79
+ align-items: center;
80
+ gap: 8px;
81
+ max-width: 100%;
82
+ padding: 6px 8px 6px 10px;
83
+ border: 1px solid var(--border);
84
+ border-radius: 999px;
85
+ background: rgba(0,0,0,0.18);
86
+ color: var(--text);
87
+ font-family: "SFMono-Regular", ui-monospace, Menlo, Monaco, Consolas, monospace;
88
+ font-size: 0.78rem;
89
+ word-break: break-all;
90
+ }
91
+ .copy-btn {
92
+ display: inline-flex;
93
+ align-items: center;
94
+ justify-content: center;
95
+ width: 26px;
96
+ height: 26px;
97
+ border: 1px solid rgba(255,255,255,0.12);
98
+ border-radius: 999px;
99
+ background: rgba(255,255,255,0.04);
100
+ color: var(--muted);
101
+ cursor: pointer;
102
+ transition: color 120ms ease, border-color 120ms ease, background 120ms ease;
103
+ }
104
+ .copy-btn:hover, .copy-btn.copied {
105
+ color: var(--text);
106
+ border-color: rgba(216,132,98,0.45);
107
+ background: var(--accent-soft);
108
+ }
71
109
  .meta {
72
110
  display: flex;
73
111
  gap: 8px;
@@ -285,12 +323,12 @@
285
323
  </g>
286
324
  <text x="0" y="114" fill="#ECE3DA" style="fill:#ECE3DA" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif" font-size="96" font-weight="900" letter-spacing="-7">no</text>
287
325
  <text x="82" y="114" fill="#d88462" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif" font-size="96" font-weight="900" letter-spacing="-7">trace</text>
288
- </svg></a><p class="subtitle">Session retrospective for 019ed2ee-1001-76ee-b353-000000000002</p></div>
326
+ </svg></a><p class="subtitle session-subtitle"><span>Session retrospective</span><span class="session-id-chip"><span>019ed2ee-1001-76ee-b353-000000000002</span><button class="copy-btn" type="button" data-copy-value="019ed2ee-1001-76ee-b353-000000000002" aria-label="Copy session ID" title="Copy session ID"><svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button></span></p></div>
289
327
  </div>
290
328
  <div class="meta">
291
329
  <span class="pill">nothing</span>
292
330
  <span class="pill">Started 2026-06-17 17:37</span>
293
- <span class="pill">Capture redacted</span>
331
+ <span class="pill">Mode: redacted</span>
294
332
  </div>
295
333
  </div>
296
334
  <div class="metrics">
@@ -421,5 +459,35 @@
421
459
  <div class="footer-tagline">Local-first retrospective engine</div>
422
460
  <div class="footer-meta"><a href="https://opensource.org/licenses/MIT">MIT</a></div>
423
461
  </footer>
424
- </div></body>
462
+ </div><script>(() => {
463
+ document.querySelectorAll('[data-copy-value]').forEach((button) => {
464
+ button.addEventListener('click', async () => {
465
+ const value = button.getAttribute('data-copy-value') || '';
466
+ try {
467
+ if (navigator.clipboard?.writeText) {
468
+ await navigator.clipboard.writeText(value);
469
+ } else {
470
+ const textarea = document.createElement('textarea');
471
+ textarea.value = value;
472
+ textarea.style.position = 'fixed';
473
+ textarea.style.opacity = '0';
474
+ document.body.appendChild(textarea);
475
+ textarea.focus();
476
+ textarea.select();
477
+ document.execCommand('copy');
478
+ textarea.remove();
479
+ }
480
+ const previous = button.innerHTML;
481
+ button.classList.add('copied');
482
+ button.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg>';
483
+ setTimeout(() => {
484
+ button.classList.remove('copied');
485
+ button.innerHTML = previous;
486
+ }, 1400);
487
+ } catch {
488
+ button.textContent = 'ERR';
489
+ }
490
+ });
491
+ });
492
+ })();</script></body>
425
493
  </html>
@@ -68,6 +68,44 @@
68
68
  overflow: visible;
69
69
  }
70
70
  .subtitle { margin: 10px 0 0; color: var(--muted); }
71
+ .session-subtitle {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 10px;
75
+ flex-wrap: wrap;
76
+ }
77
+ .session-id-chip {
78
+ display: inline-flex;
79
+ align-items: center;
80
+ gap: 8px;
81
+ max-width: 100%;
82
+ padding: 6px 8px 6px 10px;
83
+ border: 1px solid var(--border);
84
+ border-radius: 999px;
85
+ background: rgba(0,0,0,0.18);
86
+ color: var(--text);
87
+ font-family: "SFMono-Regular", ui-monospace, Menlo, Monaco, Consolas, monospace;
88
+ font-size: 0.78rem;
89
+ word-break: break-all;
90
+ }
91
+ .copy-btn {
92
+ display: inline-flex;
93
+ align-items: center;
94
+ justify-content: center;
95
+ width: 26px;
96
+ height: 26px;
97
+ border: 1px solid rgba(255,255,255,0.12);
98
+ border-radius: 999px;
99
+ background: rgba(255,255,255,0.04);
100
+ color: var(--muted);
101
+ cursor: pointer;
102
+ transition: color 120ms ease, border-color 120ms ease, background 120ms ease;
103
+ }
104
+ .copy-btn:hover, .copy-btn.copied {
105
+ color: var(--text);
106
+ border-color: rgba(216,132,98,0.45);
107
+ background: var(--accent-soft);
108
+ }
71
109
  .meta {
72
110
  display: flex;
73
111
  gap: 8px;
@@ -285,12 +323,12 @@
285
323
  </g>
286
324
  <text x="0" y="114" fill="#ECE3DA" style="fill:#ECE3DA" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif" font-size="96" font-weight="900" letter-spacing="-7">no</text>
287
325
  <text x="82" y="114" fill="#d88462" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif" font-size="96" font-weight="900" letter-spacing="-7">trace</text>
288
- </svg></a><p class="subtitle">Session retrospective for 019ed2ee-1002-76ee-b353-000000000003</p></div>
326
+ </svg></a><p class="subtitle session-subtitle"><span>Session retrospective</span><span class="session-id-chip"><span>019ed2ee-1002-76ee-b353-000000000003</span><button class="copy-btn" type="button" data-copy-value="019ed2ee-1002-76ee-b353-000000000003" aria-label="Copy session ID" title="Copy session ID"><svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button></span></p></div>
289
327
  </div>
290
328
  <div class="meta">
291
329
  <span class="pill">nothing</span>
292
330
  <span class="pill">Started 2026-06-17 18:14</span>
293
- <span class="pill">Capture metadata</span>
331
+ <span class="pill">Mode: metadata</span>
294
332
  </div>
295
333
  </div>
296
334
  <div class="metrics">
@@ -422,5 +460,35 @@
422
460
  <div class="footer-tagline">Local-first retrospective engine</div>
423
461
  <div class="footer-meta"><a href="https://opensource.org/licenses/MIT">MIT</a></div>
424
462
  </footer>
425
- </div></body>
463
+ </div><script>(() => {
464
+ document.querySelectorAll('[data-copy-value]').forEach((button) => {
465
+ button.addEventListener('click', async () => {
466
+ const value = button.getAttribute('data-copy-value') || '';
467
+ try {
468
+ if (navigator.clipboard?.writeText) {
469
+ await navigator.clipboard.writeText(value);
470
+ } else {
471
+ const textarea = document.createElement('textarea');
472
+ textarea.value = value;
473
+ textarea.style.position = 'fixed';
474
+ textarea.style.opacity = '0';
475
+ document.body.appendChild(textarea);
476
+ textarea.focus();
477
+ textarea.select();
478
+ document.execCommand('copy');
479
+ textarea.remove();
480
+ }
481
+ const previous = button.innerHTML;
482
+ button.classList.add('copied');
483
+ button.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg>';
484
+ setTimeout(() => {
485
+ button.classList.remove('copied');
486
+ button.innerHTML = previous;
487
+ }, 1400);
488
+ } catch {
489
+ button.textContent = 'ERR';
490
+ }
491
+ });
492
+ });
493
+ })();</script></body>
426
494
  </html>