@raquezha/notrace 0.0.6 → 0.1.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @raquezha/notrace
2
2
 
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a2fc3cb: Implement machine-global observability dashboard and Mistral-style timeline parser.
8
+ - Storage migrated from `.notrace/` in the local working directory to a machine-wide `~/.notrace/` directory to prevent repository pollution and enable global insights.
9
+ - Dashboard updated with a new `Project` column for multi-repo tracking.
10
+ - Timeline parser overhauled to render LLM arrays, tool execution cards, and code blocks beautifully instead of raw JSON dumps.
11
+
12
+ ### Patch Changes
13
+
14
+ - 8f31379: fix(noheadroom): match lowercase footer casing
15
+ feat(notrace): add session export to HTML retrospective
16
+
17
+ ## 0.0.7
18
+
19
+ ### Patch Changes
20
+
21
+ - 5a3e563: Improve session reports by rendering the session ID as a copyable chip under the notrace logo.
22
+ - 5a3e563: Enhance the trace header to include the active git branch alongside the repository name, and clarify the capture setting label.
23
+ - 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.
24
+
3
25
  ## 0.0.6
4
26
 
5
27
  ### 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 || "-"}`);
@@ -22,8 +22,11 @@ function appendWorkLogEntry(taskDir, message) {
22
22
  }
23
23
  const before = lines.slice(0, nextSection);
24
24
  const after = lines.slice(nextSection);
25
+ while (before.length > logIndex + 1 && before[before.length - 1]?.trim() === "") {
26
+ before.pop();
27
+ }
25
28
  before.push(entry);
26
- writeFileSync(workMd, `${before.join("\n")}\n${after.join("\n")}`);
29
+ writeFileSync(workMd, `${[...before, ...after].join("\n").replace(/\n*$/, "\n")}`);
27
30
  }
28
31
  catch { }
29
32
  }
@@ -1,5 +1,7 @@
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 * as os from "node:os";
4
+ import { execSync } from "node:child_process";
3
5
  import { getActiveAdapter } from "./adapters.js";
4
6
  import { generateHtmlReport, generateDashboardHtml } from "./renderer.js";
5
7
  const REDACTED = "[REDACTED by notrace]";
@@ -21,6 +23,8 @@ function isSensitiveKey(key) {
21
23
  return SENSITIVE_KEY_RE.test(normalized);
22
24
  }
23
25
  function sanitizeTraceValue(value) {
26
+ if (currentMode === "metadata")
27
+ return { omitted: true, reason: "metadata-capture" };
24
28
  if (currentMode === "full")
25
29
  return value;
26
30
  if (value == null || typeof value !== "object") {
@@ -47,6 +51,34 @@ function normalizeUsage(raw) {
47
51
  totalCostUsd: asNumber(usage.cost?.total),
48
52
  };
49
53
  }
54
+ function readJsonFile(filePath, fallback) {
55
+ try {
56
+ return JSON.parse(readFileSync(filePath, "utf-8"));
57
+ }
58
+ catch {
59
+ return fallback;
60
+ }
61
+ }
62
+ function writePrivateFileAtomic(filePath, content) {
63
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
64
+ writeFileSync(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
65
+ chmodSync(tmpPath, 0o600);
66
+ renameSync(tmpPath, filePath);
67
+ }
68
+ function validateRunRecord(record) {
69
+ if (record.kind !== "notrace-run")
70
+ throw new Error("notrace record validation failed: invalid kind");
71
+ if (record.schemaVersion !== SCHEMA_VERSION)
72
+ throw new Error("notrace record validation failed: invalid schemaVersion");
73
+ if (!record.traceId || !record.session?.id)
74
+ throw new Error("notrace record validation failed: missing session id");
75
+ if (!record.repository?.cwd)
76
+ throw new Error("notrace record validation failed: missing repository cwd");
77
+ if (!record.activity?.totals)
78
+ throw new Error("notrace record validation failed: missing activity totals");
79
+ if (!Array.isArray(record.events))
80
+ throw new Error("notrace record validation failed: events must be an array");
81
+ }
50
82
  function collectActivity(events, startedAt, endedAt) {
51
83
  const activity = {
52
84
  turnCount: 0,
@@ -116,9 +148,10 @@ function toTaskInfo(context) {
116
148
  dir: context.taskDir,
117
149
  };
118
150
  }
119
- function createIndexEntry(record, cwd, htmlPath, recordPath) {
151
+ function createIndexEntry(record, htmlPath, recordPath) {
120
152
  return {
121
153
  sessionId: record.traceId,
154
+ repositoryName: record.repository.name,
122
155
  startedAt: record.session.startedAt,
123
156
  endedAt: record.session.endedAt,
124
157
  captureMode: record.captureMode,
@@ -126,8 +159,8 @@ function createIndexEntry(record, cwd, htmlPath, recordPath) {
126
159
  conditions: record.conditions,
127
160
  activity: record.activity,
128
161
  artifacts: {
129
- html: path.relative(cwd, htmlPath),
130
- record: path.relative(cwd, recordPath),
162
+ html: htmlPath,
163
+ record: recordPath,
131
164
  },
132
165
  };
133
166
  }
@@ -220,17 +253,24 @@ export default function (pi) {
220
253
  const endedAt = Date.now();
221
254
  const adapter = getActiveAdapter(ctx.cwd);
222
255
  const context = adapter.getContext(ctx.cwd);
223
- const notraceDir = path.resolve(ctx.cwd, ".notrace");
256
+ const notraceDir = process.env.NOTRACE_DIR || path.join(os.homedir(), ".notrace");
224
257
  const finalTraceId = ctx.sessionManager?.getSessionId?.() || traceId;
225
258
  const outputDir = path.join(notraceDir, "sessions", finalTraceId.replace(/[^a-z0-9]/gi, "-"));
226
259
  const repositoryName = path.basename(ctx.cwd);
260
+ let branchName = null;
261
+ try {
262
+ branchName = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 1000 }).trim() || null;
263
+ }
264
+ catch {
265
+ // not a git repo or no commits yet
266
+ }
227
267
  const recordPath = path.join(outputDir, "notrace.json");
228
268
  let mergedEvents = events;
229
269
  let originalStartedAt = startTime;
230
270
  let originalTask = null;
231
271
  if (existsSync(recordPath)) {
232
272
  try {
233
- const oldRecord = JSON.parse(readFileSync(recordPath, "utf-8"));
273
+ const oldRecord = readJsonFile(recordPath, null);
234
274
  if (Array.isArray(oldRecord.events)) {
235
275
  mergedEvents = [...oldRecord.events, ...events];
236
276
  }
@@ -256,6 +296,7 @@ export default function (pi) {
256
296
  repository: {
257
297
  name: repositoryName,
258
298
  cwd: ctx.cwd,
299
+ branch: branchName,
259
300
  },
260
301
  session: {
261
302
  id: finalTraceId,
@@ -271,23 +312,50 @@ export default function (pi) {
271
312
  telemetry: { extensions: telemetry },
272
313
  events: mergedEvents,
273
314
  };
315
+ validateRunRecord(record);
274
316
  const html = generateHtmlReport(record);
275
317
  mkdirSync(outputDir, { recursive: true });
276
318
  const htmlPath = path.join(outputDir, "notrace.html");
277
- writeFileSync(htmlPath, html);
278
- writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`);
319
+ writePrivateFileAtomic(htmlPath, html);
320
+ writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`);
279
321
  const indexPath = path.join(notraceDir, "index.json");
280
- const existing = existsSync(indexPath) ? JSON.parse(readFileSync(indexPath, "utf-8")) : { repositoryName, sessions: [] };
281
- let sessions = Array.isArray(existing.sessions) ? existing.sessions.filter((s) => s.sessionId !== finalTraceId) : [];
282
- if (!isGhostSession) {
283
- sessions.push(createIndexEntry(record, ctx.cwd, htmlPath, recordPath));
322
+ const lockPath = `${indexPath}.lock`;
323
+ let lockAcquired = false;
324
+ for (let i = 0; i < 20; i++) {
325
+ try {
326
+ writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
327
+ lockAcquired = true;
328
+ break;
329
+ }
330
+ catch {
331
+ const t = Date.now();
332
+ while (Date.now() - t < 50) { } // busy wait 50ms
333
+ }
334
+ }
335
+ try {
336
+ const existing = readJsonFile(indexPath, { sessions: [] });
337
+ let sessions = Array.isArray(existing.sessions) ? existing.sessions.filter((s) => s.sessionId !== finalTraceId) : [];
338
+ if (!isGhostSession) {
339
+ sessions.push(createIndexEntry(record, htmlPath, recordPath));
340
+ }
341
+ writePrivateFileAtomic(indexPath, `${JSON.stringify({ sessions }, null, 2)}\n`);
342
+ writePrivateFileAtomic(path.join(notraceDir, "index.html"), generateDashboardHtml(sessions, {}));
343
+ }
344
+ finally {
345
+ if (lockAcquired && existsSync(lockPath)) {
346
+ try {
347
+ import("node:fs").then(fs => fs.rmSync ? fs.rmSync(lockPath) : fs.unlinkSync(lockPath));
348
+ }
349
+ catch { }
350
+ }
284
351
  }
285
- writeFileSync(indexPath, `${JSON.stringify({ repositoryName, sessions }, null, 2)}\n`);
286
- writeFileSync(path.join(notraceDir, "index.html"), generateDashboardHtml(sessions, { repositoryName }));
287
352
  if (context) {
353
+ const displayPath = htmlPath.startsWith(os.homedir())
354
+ ? `~${htmlPath.slice(os.homedir().length)}`
355
+ : htmlPath;
288
356
  adapter.attach(context, {
289
- html: path.relative(ctx.cwd, htmlPath),
290
- record: path.relative(ctx.cwd, recordPath)
357
+ html: displayPath,
358
+ record: recordPath
291
359
  });
292
360
  }
293
361
  console.log(`\n\x1b[1m\x1b[38;5;208m[notrace] Session Retrospective: file://${htmlPath}\x1b[0m\n`);