honestweek 0.2.0 → 0.3.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 (68) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +3 -2
  3. package/README.md +252 -197
  4. package/SKILL.md +25 -74
  5. package/bin/honestweek.mjs +81 -24
  6. package/flows/client.md +19 -0
  7. package/flows/digest.md +11 -0
  8. package/flows/mine.md +18 -0
  9. package/flows/view.md +45 -0
  10. package/flows/weekly.md +29 -0
  11. package/lib/ask.mjs +1142 -0
  12. package/lib/build.mjs +12 -3
  13. package/lib/config-lookup.mjs +179 -0
  14. package/lib/config.mjs +20 -0
  15. package/lib/demo/week.mjs +3 -0
  16. package/lib/digest-carry.mjs +3 -2
  17. package/lib/digest-store.mjs +3 -2
  18. package/lib/digest.mjs +21 -14
  19. package/lib/discover.mjs +28 -22
  20. package/lib/emit/index.mjs +18 -6
  21. package/lib/harvest.mjs +19 -1
  22. package/lib/history.mjs +10 -2
  23. package/lib/init.mjs +241 -55
  24. package/lib/mine.mjs +18 -8
  25. package/lib/preview.mjs +66 -13
  26. package/lib/private-words.mjs +25 -5
  27. package/lib/problems/index.mjs +60 -7
  28. package/lib/prompt-lane.mjs +14 -13
  29. package/lib/prompt-store.mjs +2 -1
  30. package/lib/prompts.mjs +13 -7
  31. package/lib/replay/assemble.mjs +25 -8
  32. package/lib/replay/index.mjs +200 -12
  33. package/lib/replay/lookup.mjs +6 -3
  34. package/lib/replay/saved-sessions.mjs +315 -0
  35. package/lib/replay/views.mjs +12 -1
  36. package/lib/{view → replay}/word-index.mjs +3 -3
  37. package/lib/replay/words.mjs +122 -0
  38. package/lib/repo-identity.mjs +81 -13
  39. package/lib/saved/checks.mjs +381 -0
  40. package/lib/saved/history.mjs +235 -0
  41. package/lib/saved/saver.mjs +111 -0
  42. package/lib/saved/store.mjs +256 -0
  43. package/lib/status.mjs +288 -0
  44. package/lib/validate.mjs +12 -3
  45. package/lib/view/assets/common.css +2 -0
  46. package/lib/view/assets/common.js +20 -2
  47. package/lib/view/assets/problems.js +4 -3
  48. package/lib/view/assets/replay.js +3 -1
  49. package/lib/view/assets/search.js +1 -1
  50. package/lib/view/assets/sessions.js +1 -0
  51. package/lib/view/assets/settings.html +16 -0
  52. package/lib/view/assets/settings.js +108 -4
  53. package/lib/view/assets/setup.html +7 -0
  54. package/lib/view/assets/setup.js +9 -0
  55. package/lib/view/codex-judge.mjs +1 -1
  56. package/lib/view/data.mjs +160 -76
  57. package/lib/view/own-week.mjs +119 -0
  58. package/lib/view/page-link.mjs +84 -0
  59. package/lib/view/problems-route.mjs +29 -3
  60. package/lib/view/replay-export.mjs +1 -1
  61. package/lib/view/selftest/clickthrough.js +9 -5
  62. package/lib/view/server.mjs +3 -1
  63. package/lib/view/settings.mjs +102 -34
  64. package/lib/view/setup.mjs +27 -14
  65. package/lib/view/suggest-words.mjs +67 -0
  66. package/lib/view.mjs +82 -133
  67. package/lib/worktrees.mjs +31 -18
  68. package/package.json +2 -1
package/lib/preview.mjs CHANGED
@@ -18,11 +18,13 @@ import { spawn } from 'node:child_process';
18
18
  import { createHash } from 'node:crypto';
19
19
 
20
20
  import { loadConfig, DEFAULT_OUTPUT_FILES } from './config.mjs';
21
+ import { commandConfig } from './config-lookup.mjs';
21
22
  import { isLoopbackHost } from './loopback-host.mjs';
22
23
  import { systemProgram } from './windows-root.mjs';
23
24
  import { SCRIPT as PAGE_SCRIPT } from './emit/page.mjs';
24
25
  import { SCRIPT as GOALS_SCRIPT } from './emit/goals-page.mjs';
25
26
  import { SCRIPT as CLIENT_SCRIPT } from './emit/client.mjs';
27
+ import { currentCommand } from './invocation.mjs';
26
28
 
27
29
  // The only inline scripts an HTML preview may run: the fixed ones honestweek's own
28
30
  // HTML emitters write (the report page, the goals page, the client report). Each is
@@ -34,6 +36,9 @@ export const HTML_PAGE_CSP = `default-src 'none'; style-src 'unsafe-inline'; scr
34
36
 
35
37
  const CONFIG_FILE = 'honestweek.config.json';
36
38
  const LOOPBACK = '127.0.0.1';
39
+ // preview has no key, so it stops on its own once nobody has opened it for this long.
40
+ export const IDLE_STOP_MS = 30 * 60 * 1000;
41
+ const SYSTEM_TIMERS = { setTimeout, clearTimeout };
37
42
 
38
43
  function defaultIo() {
39
44
  return {
@@ -289,9 +294,11 @@ export function defaultOpener(url, { platform = process.platform, env = process.
289
294
  * that ENFORCES the "no external resources" promise. By default it serves `html`
290
295
  * at `/`; pass `routes` (a `{ '<path>': html }` map) to serve MULTIPLE pages (the
291
296
  * standalone report + goals pair), each under the same CSP. Every other path 404s.
292
- * Resolves once listening (port 0 yields an OS-assigned free port).
297
+ * Resolves once listening (port 0 yields an OS-assigned free port). With `idleMs`, it closes
298
+ * itself that long after the last page it served (or after starting, with none) and then calls
299
+ * `onIdle`; `timers` replaces setTimeout and clearTimeout for tests.
293
300
  */
294
- export function startServer({ host = LOOPBACK, port = 0, html = '', csp = "default-src 'none'; style-src 'unsafe-inline'", routes = null } = {}) {
301
+ export function startServer({ host = LOOPBACK, port = 0, html = '', csp = "default-src 'none'; style-src 'unsafe-inline'", routes = null, idleMs = 0, onIdle = () => {}, timers = SYSTEM_TIMERS } = {}) {
295
302
  // The path -> body table. A single-file preview is just the one '/' route, so the
296
303
  // multi-page and single-page paths share exactly one serving codepath.
297
304
  const table = routes && typeof routes === 'object' ? routes : { '/': html };
@@ -313,6 +320,20 @@ export function startServer({ host = LOOPBACK, port = 0, html = '', csp = "defau
313
320
  res.end(`${msg}\n`);
314
321
  };
315
322
  return new Promise((resolve, reject) => {
323
+ let idle = null;
324
+ const stopIdle = () => {
325
+ idle = null;
326
+ server.closeAllConnections?.();
327
+ server.close(() => onIdle());
328
+ };
329
+ // Only a page it serves counts as a visit and restarts the wait. A refused request (another
330
+ // host, a method it doesn't take, a path it doesn't have) doesn't.
331
+ const arm = () => {
332
+ if (!idleMs) return;
333
+ if (idle !== null) timers.clearTimeout(idle);
334
+ idle = timers.setTimeout(stopIdle, idleMs);
335
+ idle?.unref?.();
336
+ };
316
337
  const server = createServer((req, res) => {
317
338
  const method = req.method || 'GET';
318
339
  if (method !== 'GET' && method !== 'HEAD') return fail(res, 405, '405 method not allowed', { Allow: 'GET, HEAD' });
@@ -320,6 +341,7 @@ export function startServer({ host = LOOPBACK, port = 0, html = '', csp = "defau
320
341
  if (!isLoopbackHost(req.headers.host, server.address()?.port, host)) return fail(res, 403, '403 this server answers only 127.0.0.1 and localhost');
321
342
  const path = (req.url || '/').split('?')[0];
322
343
  if (!Object.prototype.hasOwnProperty.call(table, path)) return fail(res, 404, '404 not found');
344
+ arm();
323
345
  res.writeHead(200, headers('text/html; charset=utf-8'));
324
346
  res.end(method === 'HEAD' ? undefined : table[path]);
325
347
  });
@@ -327,12 +349,18 @@ export function startServer({ host = LOOPBACK, port = 0, html = '', csp = "defau
327
349
  server.listen({ host, port }, () => {
328
350
  const addr = server.address();
329
351
  const boundPort = addr && typeof addr === 'object' ? addr.port : port;
352
+ arm();
330
353
  resolve({
331
354
  server,
332
355
  url: `http://${host}:${boundPort}/`,
333
356
  port: boundPort,
334
357
  host,
335
- close: () => new Promise((r) => server.close(() => r())),
358
+ close: () =>
359
+ new Promise((r) => {
360
+ if (idle !== null) timers.clearTimeout(idle);
361
+ idle = null;
362
+ server.close(() => r());
363
+ }),
336
364
  });
337
365
  });
338
366
  });
@@ -354,7 +382,7 @@ function parseValued(argv, name) {
354
382
  const HELP = `honestweek preview: serve the built output as HTML on a local-only server.
355
383
 
356
384
  Usage:
357
- honestweek preview [--file <path>] [--port <n>] [--no-open]
385
+ ${currentCommand()} preview [--file <path>] [--config <file>] [--port <n>] [--no-open]
358
386
 
359
387
  Reads what build wrote (config.output.file, or --file <path>). A Markdown output
360
388
  is converted to a self-contained HTML page; the standalone "page" mode already
@@ -364,8 +392,11 @@ both pages are served so their cross-links resolve. Either way it is served on
364
392
  127.0.0.1 (loopback only) under a no-external-egress CSP. Nothing is published and
365
393
  nothing leaves your machine.
366
394
 
395
+ It stops on its own after 30 minutes with no visits. Run it again to see the page.
396
+
367
397
  Options:
368
398
  --file <path> Preview this Markdown file instead of the configured output.file.
399
+ --config <file> Read this config instead of the one honestweek finds.
369
400
  --port <n> Bind this port (default: an OS-assigned free port).
370
401
  --no-open Do not launch a browser; just print the URL.
371
402
  -h, --help Show this help.
@@ -376,9 +407,10 @@ Options:
376
407
  *
377
408
  * 0 on success; 1 on a setup error (no config + no --file, output file absent,
378
409
  * bad --port, port in use). It NEVER returns 2 — preview renders, it never
379
- * judges. When `block` is true (the CLI default) it serves until Ctrl-C. Tests
380
- * pass `block: false` (and read the live handle via `onServe`) so they never
381
- * enter the blocking serve loop.
410
+ * judges. When `block` is true (the CLI default) it serves until Ctrl-C, or until
411
+ * `idleMs` (30 minutes) pass with no request. Tests pass `block: false` (and read
412
+ * the live handle via `onServe`) so they never enter the blocking serve loop, and
413
+ * `timers` to run the idle stop without waiting.
382
414
  */
383
415
  export async function runPreview({
384
416
  cwd = process.cwd(),
@@ -389,6 +421,8 @@ export async function runPreview({
389
421
  env = process.env,
390
422
  block = true,
391
423
  onServe,
424
+ idleMs = IDLE_STOP_MS,
425
+ timers = SYSTEM_TIMERS,
392
426
  } = {}) {
393
427
  if (argv.includes('--help') || argv.includes('-h')) {
394
428
  io.out(HELP);
@@ -402,9 +436,16 @@ export async function runPreview({
402
436
  if (fileArg) {
403
437
  mdPath = isAbsolute(fileArg) ? fileArg : join(cwd, fileArg);
404
438
  } else {
439
+ // The configured output sits beside the config, wherever the lookup found it.
440
+ const opened = commandConfig({ command: 'preview', cwd, argv, err: io.err, writes: false });
441
+ if (opened.error) {
442
+ io.err(`preview: ${opened.error}\n`);
443
+ return io.exit(1) ?? 1;
444
+ }
445
+ cwd = opened.cwd;
405
446
  let config;
406
447
  try {
407
- config = loadConfig(join(cwd, CONFIG_FILE));
448
+ config = loadConfig(opened.found.path);
408
449
  } catch (err) {
409
450
  io.err(`preview: ${err.message}\n`);
410
451
  io.err(`preview: no ${CONFIG_FILE} to read output.file from. Run init, or pass --file <path>.\n`);
@@ -416,7 +457,7 @@ export async function runPreview({
416
457
  }
417
458
 
418
459
  if (!existsSync(mdPath)) {
419
- io.err(`preview: output file not found at ${mdPath}. Run \`honestweek build\` first (or pass --file <path>).\n`);
460
+ io.err(`preview: output file not found at ${mdPath}. Run \`${currentCommand()} build\` first (or pass --file <path>).\n`);
420
461
  return io.exit(1) ?? 1;
421
462
  }
422
463
 
@@ -466,9 +507,16 @@ export async function runPreview({
466
507
  }
467
508
  }
468
509
 
510
+ const minutes = Math.round(idleMs / 60000);
511
+ const idleWords = `${minutes} minute${minutes === 1 ? '' : 's'}`;
512
+ let finish = () => {};
513
+ const onIdle = () => {
514
+ io.out(`\nhonestweek preview: stopped after ${idleWords} with no visits. Run it again to see the page.\n`);
515
+ finish();
516
+ };
469
517
  let handle;
470
518
  try {
471
- handle = await startServer({ host: LOOPBACK, port, html, csp, routes });
519
+ handle = await startServer({ host: LOOPBACK, port, html, csp, routes, idleMs, onIdle, timers });
472
520
  } catch (err) {
473
521
  if (err && err.code === 'EADDRINUSE') {
474
522
  io.err(`preview: port ${port} is already in use. Omit --port to pick a free one, or choose another.\n`);
@@ -481,7 +529,7 @@ export async function runPreview({
481
529
  io.out(
482
530
  `honestweek preview: serving ${mdPath} at ${handle.url}` +
483
531
  (routes ? ` (+ ${handle.url}goals.html)` : '') +
484
- ` (press Ctrl+C to stop).\n`
532
+ (idleMs > 0 ? ` (press Ctrl+C to stop; it stops on its own after ${idleWords} with no visits).\n` : ` (press Ctrl+C to stop).\n`)
485
533
  );
486
534
  if (typeof onServe === 'function') onServe(handle);
487
535
  if (!noOpen) opener(handle.url, { platform, env });
@@ -489,12 +537,17 @@ export async function runPreview({
489
537
  if (!block) return 0;
490
538
 
491
539
  await new Promise((resolve) => {
492
- process.once('SIGINT', () => {
540
+ const onSigint = () => {
493
541
  handle.close().then(() => {
494
542
  io.out('\nhonestweek preview: stopped.\n');
495
543
  resolve();
496
544
  });
497
- });
545
+ };
546
+ finish = () => {
547
+ process.removeListener('SIGINT', onSigint);
548
+ resolve();
549
+ };
550
+ process.once('SIGINT', onSigint);
498
551
  });
499
552
  return 0;
500
553
  }
@@ -10,22 +10,42 @@
10
10
  //
11
11
  // Zero runtime dependencies.
12
12
 
13
+ /** Whether a listed word is one the redactor uses: a blank one hides nothing. */
14
+ export const isUsedWord = (t) => typeof t === 'string' && t.trim() !== '';
15
+
16
+ const used = (list) => (Array.isArray(list) ? list.filter(isUsedWord).length : 0);
17
+
13
18
  /** How many private words a config lists: its redaction names, terms and codenames, counting
14
19
  * only the entries the redactor uses (a blank one hides nothing). */
15
20
  export function privateWordCount(config) {
16
21
  const r = config?.redaction ?? {};
17
- const used = (list) => (Array.isArray(list) ? list.filter((t) => typeof t === 'string' && t.trim()).length : 0);
18
22
  return ['names', 'terms', 'codenames'].reduce((n, k) => n + used(r[k]), 0);
19
23
  }
20
24
 
25
+ /** How many words a config keeps out of public renditions only (neverPublicTerms). They're
26
+ * left out of privateWordCount, since the private view shows them as written, but `init`
27
+ * counts them with the others when it keeps them from an old config and when it says how
28
+ * many words the new config lists. */
29
+ export function neverPublicWordCount(config) {
30
+ return used(config?.privacy?.publicRenditions?.neverPublicTerms);
31
+ }
32
+
21
33
  /**
22
34
  * The note for a config with no private words. `command` is how the person runs honestweek;
23
- * `restart` adds that view must be started again to pick the words up.
35
+ * `restart` adds that view must be started again to pick the words up. `settings` is false where
36
+ * Settings can't change this config (one named with --config outside the folder view started
37
+ * in), so the note doesn't send the person there. `publicOnly`, the count of words the config
38
+ * keeps out of public renditions only, says those don't hide anything either, so the note
39
+ * doesn't read as if the config listed none.
24
40
  */
25
- export function privateWordsNote(command = 'honestweek', { restart = true } = {}) {
41
+ export function privateWordsNote(command = 'honestweek', { restart = true, settings = true, publicOnly = 0 } = {}) {
42
+ const opening = publicOnly
43
+ ? `Your config keeps ${publicOnly === 1 ? '1 word' : `${publicOnly} words`} out of public versions only, so names and client words in your logs, ${publicOnly === 1 ? 'that one' : 'those'} included, show as written in your own pages, even with Show private text off. `
44
+ : 'No private words are set up, so names and client words in your logs show as written, even with Show private text off. ';
45
+ const suggest = settings ? `open Settings in ${command} view and press "Suggest words from my sessions", or ` : '';
26
46
  return (
27
- 'No private words are set up, so names and client words in your logs show as written, even with Show private text off. ' +
47
+ opening +
28
48
  `To hide them, list them under "redaction" in honestweek.config.json, like this: "names": ["Jane Doe"], "terms": ["Acme"]${restart ? `, then start ${command} view again` : ''}. ` +
29
- `For a list of candidates from last week's sessions, run ${command} discover, then ${command} harvest, and read honestweek.harvest.json.`
49
+ `For likely ones, ${suggest}run ${command} discover, then ${command} harvest, and read honestweek.harvest.json.`
30
50
  );
31
51
  }
@@ -135,18 +135,21 @@ export const PATTERN_CHECKS = Object.freeze({
135
135
  export const CLAIM_PATTERNS = Object.freeze(['unverified-done-claim', 'claim-contradicts-evidence']);
136
136
  /** The levels a finding needs to sit in the page's main list; the rest go under "Possible". */
137
137
  export const SURE = Object.freeze(['recorded', 'derived']);
138
- const isSure = (f) => SURE.includes(f.verdictEvidence);
138
+ export const isSure = (f) => SURE.includes(f.verdictEvidence);
139
139
 
140
140
  const RANK = { recorded: 0, derived: 1, inferred: 2, missing: 3 };
141
- const weakest = (levels) => levels.filter((l) => l in RANK).sort((a, b) => RANK[b] - RANK[a])[0] ?? 'inferred';
141
+ export const weakest = (levels) => levels.filter((l) => l in RANK).sort((a, b) => RANK[b] - RANK[a])[0] ?? 'inferred';
142
142
  const findingOrder = (x, y) => (x.severity === y.severity ? 0 : x.severity === 'look' ? -1 : 1) || (y.estimate ?? -1) - (x.estimate ?? -1) || String(y.at ?? '').localeCompare(String(x.at ?? ''));
143
143
 
144
144
  /**
145
- * runProblems(h, { builtT, longSessionTokens }) -> { window, catalog, groups, priorityRule, patterns, checks,
145
+ * runProblems(h, { builtT, longSessionTokens, saved }) -> { window, catalog, groups, priorityRule, patterns, checks,
146
146
  * coverage, rules, thresholds, ms }. Every pattern carries all its findings, best first; callers
147
- * trim what they show.
147
+ * trim what they show. `saved` maps a session that came back from saved results (lib/saved/) to
148
+ * the findings saved with it, which stand in for whatever the checks find in it now, and
149
+ * `savedStatus` each such session to the patterns' states when it was checked: a check that can't run
150
+ * now (no session read this run has the text it needs) counts as run when it ran then.
148
151
  */
149
- export function runProblems(h, { builtT = Date.now(), longSessionTokens = null } = {}) {
152
+ export function runProblems(h, { builtT = Date.now(), longSessionTokens = null, saved = null, savedStatus = null } = {}) {
150
153
  const started = Date.now();
151
154
  const catalog = loadCatalog();
152
155
  const c = createContext(h, { builtT, longSessionTokens });
@@ -160,6 +163,18 @@ export function runProblems(h, { builtT = Date.now(), longSessionTokens = null }
160
163
  const groupName = new Map(catalog.groups.map((g) => [g.id, g.name]));
161
164
  const allTokens = c.usage.allTokens;
162
165
 
166
+ // Which checks of each pattern ran when the sessions that came back from saved results were checked.
167
+ const savedLooked = new Map();
168
+ for (const list of savedStatus?.values() ?? []) {
169
+ for (const x of Array.isArray(list) ? list : []) {
170
+ if (!(x?.status === 'found' || x?.status === 'clear') || !Array.isArray(x.ran)) continue;
171
+ if (!savedLooked.has(x.id)) savedLooked.set(x.id, new Set());
172
+ for (const check of x.ran) savedLooked.get(x.id).add(check);
173
+ }
174
+ }
175
+ // How a pattern's check counted tokens when those sessions were checked.
176
+ const savedCost = new Map();
177
+ for (const list of savedStatus?.values() ?? []) for (const x of Array.isArray(list) ? list : []) if (x?.tokens && !savedCost.has(x.id)) savedCost.set(x.id, x.tokens);
163
178
  const patterns = catalog.patterns.map((p) => {
164
179
  const measures = [];
165
180
  const found = [];
@@ -183,6 +198,26 @@ export function runProblems(h, { builtT = Date.now(), longSessionTokens = null }
183
198
  found.push({ ...f, pattern: p.id, checkTitle: r.title });
184
199
  }
185
200
  }
201
+ // A session that came back as saved (lib/saved/) has no raw text for the checks to read, so
202
+ // its findings are the ones saved with it, each with the evidence word it had then.
203
+ if (saved?.size) {
204
+ for (let i = found.length - 1; i >= 0; i -= 1) if (saved.has(found[i].session)) found.splice(i, 1);
205
+ for (const list of saved.values()) for (const f of list) if (f?.pattern === p.id) found.push({ ...f });
206
+ // Each check's own count, the same way: its findings in sessions read now, and the ones saved
207
+ // for the rest. A check that couldn't run now ran when those sessions were checked.
208
+ for (const m of measures) {
209
+ const r = byCheck.get(m.check);
210
+ const pick = PATTERN_CHECKS[p.id].find((x) => x.check === m.check);
211
+ const live = (pick?.filter ? r.findings.filter(pick.filter) : r.findings).filter((f) => !saved.has(f.session));
212
+ const kept = [...saved.values()].flat().filter((f) => f?.pattern === p.id && f.check === m.check);
213
+ m.count = live.length + kept.length;
214
+ m.look = [...live, ...kept].filter((f) => f.severity === 'look').length;
215
+ if (!m.ran && savedLooked.get(p.id)?.has(m.check)) {
216
+ m.ran = true;
217
+ m.notRun = null;
218
+ }
219
+ }
220
+ }
186
221
  found.sort(findingOrder);
187
222
  const level = p.detection?.level ?? null;
188
223
  const ran = measures.filter((m) => m.ran);
@@ -221,7 +256,7 @@ export function runProblems(h, { builtT = Date.now(), longSessionTokens = null }
221
256
  // Riding with the test prompt: a temporary change to make first, an estimate of what the
222
257
  // prompt costs, and what to look for when the fix works and when it doesn't.
223
258
  ...Object.fromEntries(['testSetup', 'testCost', 'testCostWhy', 'testExpect'].map((k) => [k, PATTERN_CHECKS[p.id] && typeof p.testPrompt === 'string' ? p[k] ?? null : null])),
224
- tokens: status === 'found' || status === 'clear' ? tokens : null,
259
+ tokens: status === 'found' || status === 'clear' ? (saved?.size ? withSavedTokens(tokens, found, savedCost.get(p.id)) : tokens) : null,
225
260
  // The weakest level among the findings counted; with none, among the checks that ran.
226
261
  countEvidence: found.length ? weakest(found.map((f) => f.verdictEvidence)) : ran.length ? weakest(ran.map((m) => m.evidence)) : null,
227
262
  draft: DRAFTS[p.id] ?? null,
@@ -244,7 +279,9 @@ export function runProblems(h, { builtT = Date.now(), longSessionTokens = null }
244
279
  // Each check's numbers, as its card states them, and where each comes from: null for
245
280
  // honestweek's own choice, or a catalog source's address (context.mjs, NUMBERS).
246
281
  const numbersOf = (id) => (CHECKS.find((x) => x.id === id)?.numbers ?? []).map((key) => ({ key, says: NUMBERS[key].says, source: NUMBERS[key].source }));
247
- const checks = results.map((r) => ({ id: r.id, title: r.title, how: r.how, evidence: r.evidence, ran: r.ran, notRun: r.notRun, checked: r.checked, stats: r.stats, cost: r.cost, findingsTotal: r.findings.length, patterns: Object.entries(PATTERN_CHECKS).filter(([, ms]) => ms.some((m) => m.check === r.id)).map(([id]) => id), numbers: numbersOf(r.id) }));
282
+ // A check that couldn't run now but ran on the sessions that came back from saved results says so.
283
+ const savedChecks = new Set([...savedLooked.values()].flatMap((x) => [...x]));
284
+ const checks = results.map((r) => ({ id: r.id, title: r.title, how: r.how, evidence: r.evidence, ran: r.ran, notRun: r.notRun, checked: r.checked, ...(saved?.size && !r.ran && savedChecks.has(r.id) ? { saved: { findings: [...saved.values()].flat().filter((f) => f?.check === r.id).length } } : {}), stats: r.stats, cost: r.cost, findingsTotal: r.findings.length, patterns: Object.entries(PATTERN_CHECKS).filter(([, ms]) => ms.some((m) => m.check === r.id)).map(([id]) => id), numbers: numbersOf(r.id) }));
248
285
  const statusCounts = { found: 0, clear: 0, unchecked: 0, undetectable: 0 };
249
286
  for (const p of patterns) statusCounts[p.status]++;
250
287
  return {
@@ -262,6 +299,8 @@ export function runProblems(h, { builtT = Date.now(), longSessionTokens = null }
262
299
  tokens: { value: allTokens, evidence: 'recorded' },
263
300
  usageRecorded: c.usage.liveCalls > 0,
264
301
  rawText: c.hasRaw,
302
+ // Sessions in the window whose findings came back from saved results, not from this run's checks.
303
+ ...(saved?.size ? { saved: { value: c.sessions.filter((s) => saved.has(s.key) && (c.eventsBySession.get(s.key) ?? []).some((e) => c.inWin(e.t))).length, evidence: 'derived' } } : {}),
265
304
  },
266
305
  rules: RULES,
267
306
  ruleSources: RULE_SOURCES,
@@ -277,6 +316,20 @@ export function runProblems(h, { builtT = Date.now(), longSessionTokens = null }
277
316
  const DAY_MS = 86_400_000;
278
317
  const dayOf = (s) => Date.parse(`${s}T00:00:00Z`);
279
318
  const dayText = (t) => new Date(t).toISOString().slice(0, 10);
319
+ /**
320
+ * A pattern's token count once saved findings stand in for some sessions: the estimate of its
321
+ * findings worth a look is theirs too. When its check couldn't count tokens now (no session read
322
+ * this run has what it needs), the count is the sum of its findings' estimates, counted the way
323
+ * the check counted them when those sessions were checked.
324
+ */
325
+ function withSavedTokens(tokens, found, meta) {
326
+ const est = (list) => list.reduce((s, f) => s + (Number.isFinite(f.estimate) ? f.estimate : 0), 0);
327
+ const lookTokens = est(found.filter((f) => f.severity === 'look'));
328
+ if (tokens) return { ...tokens, lookTokens };
329
+ if (!meta) return null;
330
+ return { tokens: est(found), lookTokens, waste: meta.waste !== false, label: String(meta.label ?? ''), evidence: meta.evidence ?? 'inferred' };
331
+ }
332
+
280
333
  /** The window just before from..to (inclusive calendar days), of the same length. */
281
334
  export function earlierWindow({ from, to }) {
282
335
  const a = dayOf(from);
@@ -11,6 +11,7 @@ import { curateDigest } from './digest-curation.mjs';
11
11
  import { assertNoDigestPending, validateDigestLane, validateDigestReview } from './digest-store.mjs';
12
12
  import { assertLifecycleJoins, readCarry } from './digest-carry.mjs';
13
13
  import { verifyCarryReceipts } from './carry-receipts.mjs';
14
+ import { currentCommand } from './invocation.mjs';
14
15
 
15
16
  function preflightError(message) {
16
17
  const error = new Error(message);
@@ -43,42 +44,42 @@ async function loadValidatedDigestLane({cwd,config,week,now,roots,hasGoals,lane,
43
44
  assertNoDigestPending(cwd);
44
45
  validateDigestLane(lane,config);
45
46
  const outputBinding=await preflightPromptOutput({cwd,config,hasGoals});
46
- if(lane.week.start!==week.start||lane.week.end!==week.end)throw new Error(`${PROMPT_LANE} is for another week; rerun honestweek digest prepare.`);
47
+ if(lane.week.start!==week.start||lane.week.end!==week.end)throw new Error(`${PROMPT_LANE} is for another week; rerun ${currentCommand()} digest prepare.`);
47
48
  const storePath=join(cwd,PROMPT_STORE);let initialStoreBytes;
48
- try{initialStoreBytes=readFileSync(storePath,'utf8');}catch{throw new Error(`${PROMPT_STORE} not found or unreadable; run honestweek digest prepare.`);}
49
+ try{initialStoreBytes=readFileSync(storePath,'utf8');}catch{throw new Error(`${PROMPT_STORE} not found or unreadable; run ${currentCommand()} digest prepare.`);}
49
50
  const store=validatePromptStore(parsePrivateJson(initialStoreBytes,PROMPT_STORE));
50
51
  const reviewPath=join(cwd,'honestweek.curated.json');let initialReviewBytes;
51
- try{initialReviewBytes=readFileSync(reviewPath,'utf8');}catch{throw new Error('honestweek.curated.json not found or unreadable; run honestweek digest prepare.');}
52
+ try{initialReviewBytes=readFileSync(reviewPath,'utf8');}catch{throw new Error(`honestweek.curated.json not found or unreadable; run ${currentCommand()} digest prepare.`);}
52
53
  const review=validateDigestReview(parsePrivateJson(initialReviewBytes,'honestweek.curated.json'),config);
53
54
  const priorCarry=readCarry(cwd,config,{optional:true});
54
55
  await verifyCarryReceipts({ carry:priorCarry.value, week, config, roots, now });
55
- if(review.version===3&&review.lifecycle.carryHash!==priorCarry.hash)throw new Error('honestweek.curated.json carry binding is stale; rerun honestweek digest prepare.');
56
+ if(review.version===3&&review.lifecycle.carryHash!==priorCarry.hash)throw new Error(`honestweek.curated.json carry binding is stale; rerun ${currentCommand()} digest prepare.`);
56
57
  assertLifecycleJoins({review,lane,priorCarryHash:priorCarry.hash,config});
57
58
  const range=localDateRangeInstants(week.start,week.end,config.week.timezone);
58
59
  const scanned=await scanPromptSources({config,weekStart:range.start,weekEnd:range.endExclusive,roots,now});
59
- if(Object.values(scanned.sourceStatus).every((s)=>s.state==='absent'))throw new Error('no Claude Code or Codex digest source is available; rerun honestweek digest prepare when a source is present.');
60
+ if(Object.values(scanned.sourceStatus).every((s)=>s.state==='absent'))throw new Error(`no Claude Code or Codex digest source is available; rerun ${currentCommand()} digest prepare when a source is present.`);
60
61
  const freshStore=mergePromptStore(store,scanned,now);
61
62
  const evidence=await scanDigestEvidence({config,promptStore:freshStore,roots,sourceStatus:scanned.sourceStatus});
62
63
  const rebuilt=curateDigest(freshStore,evidence,config,week,now,{outputBinding,priorReview:review,carry:priorCarry.value,carryHash:priorCarry.hash});
63
- if(readFileSync(initialLaneBytes.path,'utf8')!==initialLaneBytes.text||readFileSync(storePath,'utf8')!==initialStoreBytes||readFileSync(reviewPath,'utf8')!==initialReviewBytes)throw new Error('digest controls or sources changed during validation; rerun honestweek digest prepare.');
64
- if(JSON.stringify(normalized(review))!==JSON.stringify(normalized(rebuilt.review))||JSON.stringify(normalized(lane))!==JSON.stringify(normalized(rebuilt.lane)))throw new Error(`${PROMPT_LANE} or honestweek.curated.json is stale or non-canonical; rerun honestweek digest prepare.`);
64
+ if(readFileSync(initialLaneBytes.path,'utf8')!==initialLaneBytes.text||readFileSync(storePath,'utf8')!==initialStoreBytes||readFileSync(reviewPath,'utf8')!==initialReviewBytes)throw new Error(`digest controls or sources changed during validation; rerun ${currentCommand()} digest prepare.`);
65
+ if(JSON.stringify(normalized(review))!==JSON.stringify(normalized(rebuilt.review))||JSON.stringify(normalized(lane))!==JSON.stringify(normalized(rebuilt.lane)))throw new Error(`${PROMPT_LANE} or honestweek.curated.json is stale or non-canonical; rerun ${currentCommand()} digest prepare.`);
65
66
  return{items:lane.items,lane,review};
66
67
  }
67
68
 
68
69
  export async function loadValidatedPromptLane({cwd,config,week,now=new Date(),roots=resolvePromptRoots(),hasGoals=false}){
69
70
  assertNoDigestPending(cwd);
70
71
  const path=join(cwd,PROMPT_LANE);if(!existsSync(path))return{items:[],lane:null};
71
- const initialLaneBytes=readFileSync(path,'utf8');let lane;try{lane=JSON.parse(initialLaneBytes);}catch(e){throw new Error(`${PROMPT_LANE} is not valid JSON (${e.message}); rerun honestweek prompts curate.`);}
72
+ const initialLaneBytes=readFileSync(path,'utf8');let lane;try{lane=JSON.parse(initialLaneBytes);}catch(e){throw new Error(`${PROMPT_LANE} is not valid JSON (${e.message}); rerun ${currentCommand()} prompts curate.`);}
72
73
  if(lane?.version===2)return loadValidatedDigestLane({cwd,config,week,now,roots,hasGoals,lane,initialLaneBytes:{path,text:initialLaneBytes}});
73
- if(!lane||lane.version!==1||!Array.isArray(lane.items)||!lane.week)throw new Error(`${PROMPT_LANE} has an unsupported schema; rerun honestweek prompts curate.`);
74
+ if(!lane||lane.version!==1||!Array.isArray(lane.items)||!lane.week)throw new Error(`${PROMPT_LANE} has an unsupported schema; rerun ${currentCommand()} prompts curate.`);
74
75
  const outputBinding=lane.items.length?await preflightPromptOutput({cwd,config,hasGoals}):lane.outputBinding;
75
- if(lane.week.start!==week.start||lane.week.end!==week.end)throw new Error(`${PROMPT_LANE} is for another week; rerun honestweek prompts curate.`);
76
+ if(lane.week.start!==week.start||lane.week.end!==week.end)throw new Error(`${PROMPT_LANE} is for another week; rerun ${currentCommand()} prompts curate.`);
76
77
  const storePath=join(cwd,PROMPT_STORE);const initialStoreBytes=readFileSync(storePath,'utf8');const store=readPromptStore(cwd);
77
78
  const range=localDateRangeInstants(week.start,week.end,config.week.timezone);
78
79
  const scanned=await scanPromptSources({config,weekStart:range.start,weekEnd:range.endExclusive,roots,now});
79
- if(Object.values(scanned.sourceStatus).every((s)=>s.state==='absent'))throw new Error('no Claude Code or Codex prompt source is available; rerun honestweek prompts curate when a source is present.');
80
+ if(Object.values(scanned.sourceStatus).every((s)=>s.state==='absent'))throw new Error(`no Claude Code or Codex prompt source is available; rerun ${currentCommand()} prompts curate when a source is present.`);
80
81
  const freshStore=mergePromptStore(store,scanned,now);const rebuilt=curatePrompts(freshStore,config,week,now,{outputBinding});
81
- if(readFileSync(path,'utf8')!==initialLaneBytes||readFileSync(storePath,'utf8')!==initialStoreBytes)throw new Error('prompt controls changed during validation; rerun honestweek prompts curate.');
82
- if(JSON.stringify(normalized(lane))!==JSON.stringify(normalized(rebuilt)))throw new Error(`${PROMPT_LANE} is stale or non-canonical; rerun honestweek prompts curate.`);
82
+ if(readFileSync(path,'utf8')!==initialLaneBytes||readFileSync(storePath,'utf8')!==initialStoreBytes)throw new Error(`prompt controls changed during validation; rerun ${currentCommand()} prompts curate.`);
83
+ if(JSON.stringify(normalized(lane))!==JSON.stringify(normalized(rebuilt)))throw new Error(`${PROMPT_LANE} is stale or non-canonical; rerun ${currentCommand()} prompts curate.`);
83
84
  return{items:lane.items,lane};
84
85
  }
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { atomicWriteJson } from './atomic-json.mjs';
4
4
  import { sha256 } from './prompt-identity.mjs';
5
5
  import { DETECTOR_ORDER, REPLACEABLE_DETECTORS } from './prompt-privacy.mjs';
6
+ import { currentCommand } from './invocation.mjs';
6
7
 
7
8
  export const PROMPT_STORE = 'honestweek.prompts.json';
8
9
  export const PROMPT_LANE = 'honestweek.prompt-items.json';
@@ -70,7 +71,7 @@ export function validatePromptStore(store) {
70
71
 
71
72
  export function readPromptStore(cwd, { optional = false } = {}) {
72
73
  const path = join(cwd, PROMPT_STORE);
73
- if (!existsSync(path)) { if (optional) return null; throw new Error(`${PROMPT_STORE} not found; run honestweek prompts sync.`); }
74
+ if (!existsSync(path)) { if (optional) return null; throw new Error(`${PROMPT_STORE} not found; run ${currentCommand()} prompts sync.`); }
74
75
  let value; try { value = JSON.parse(readFileSync(path, 'utf8')); } catch (e) { throw new Error(`${PROMPT_STORE} is not valid JSON (${e.message}).`); }
75
76
  return validatePromptStore(value);
76
77
  }
package/lib/prompts.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { loadConfig } from './config.mjs';
4
+ import { commandConfig, configAgain, takeConfigFlag } from './config-lookup.mjs';
4
5
  import { localDateInTimezone, localDateRangeInstants, resolveWeek } from './resolve-week.mjs';
5
6
  import { resolvePromptRoots, scanPromptSources } from './prompt-adapters.mjs';
6
7
  import { mergePromptStore, PROMPT_GITIGNORE, PROMPT_LANE, readPromptStore, uniquePrompt, writePromptStore } from './prompt-store.mjs';
@@ -11,6 +12,7 @@ import { withPromptLock } from './prompt-lock.mjs';
11
12
  import { assertNoDigestPending } from './digest-store.mjs';
12
13
  import { assertNoCarryPending } from './digest-carry.mjs';
13
14
  import { recoverConfiguredCarry } from './carry-recovery.mjs';
15
+ import { currentCommand } from './invocation.mjs';
14
16
 
15
17
  function ioDefault(){return{out:(s)=>process.stdout.write(s),err:(s)=>process.stderr.write(s),exit:(c)=>process.exit(c)};}
16
18
  function flag(argv,name){const i=argv.indexOf(name);return i>=0?argv[i+1]:undefined;}
@@ -19,7 +21,7 @@ function prefixes(prompts){return new Map(prompts.map((p)=>{let n=12;while(n<64&
19
21
  function nextPageCommand(command, args, offset) {
20
22
  const kept=[];
21
23
  for(let i=0;i<args.length;i++){if(args[i]==='--offset'){i++;continue;}kept.push(args[i]);}
22
- return `honestweek prompts ${command}${kept.length?` ${kept.join(' ')}`:''} --offset ${offset}`;
24
+ return `${currentCommand()} prompts ${command}${kept.length?` ${kept.join(' ')}`:''} --offset ${offset}`;
23
25
  }
24
26
  function activeLaneVersion(cwd) {
25
27
  try { return JSON.parse(readFileSync(join(cwd, PROMPT_LANE), 'utf8'))?.version ?? null; }
@@ -37,11 +39,15 @@ async function sync(cwd,config,argv,now,roots){
37
39
  function mutate(cwd,store,ref,state,now){const p=uniquePrompt(store,ref);if(state==='deleted'){const status=store.sourceStatus[p.source];store.version=2;store.prompts=store.prompts.filter((x)=>x.ref!==p.ref);store.tombstones.push({ref:p.ref,refCanonical:p.refCanonical,source:p.source,sessionKey:p.sessionKey,turn:p.turn,deletedAt:now.toISOString(),week:{start:status.weekStart,end:status.weekEnd}});store.tombstones.sort((a,b)=>a.ref.localeCompare(b.ref));}else p.state=state;store.generatedAt=now.toISOString();writePromptStore(cwd,store);return p;}
38
40
 
39
41
  export async function runPrompts({cwd=process.cwd(),argv=[],now=new Date(),io=ioDefault(),roots}={}){
40
- const [command,...rest]=argv;
42
+ const taken=takeConfigFlag(argv);
43
+ const [command,...rest]=taken.argv;
41
44
  // Help before config, and anywhere in argv: `prompts sync --help` used to run
42
45
  // a live sync. Asking what a command does must not require a config or do work.
43
- if(!command||argv.some((a)=>a==='-h'||a==='--help')){io.out('Usage: honestweek prompts <sync|list|source|review|keep|hide|delete|curate> [options]\n');return 0;}
44
- let config;try{config=loadConfig(join(cwd,'honestweek.config.json'));}catch(e){io.err(`prompts: ${e.message}\n`);return io.exit(1)??1;}
46
+ if(!command||argv.some((a)=>a==='-h'||a==='--help')){io.out(`Usage: ${currentCommand()} prompts <sync|list|source|review|keep|hide|delete|curate> [options] [--config <file>]\n`);return 0;}
47
+ if(taken.error){io.err(`prompts: ${taken.error}\n`);return io.exit(1)??1;}
48
+ // The prompt files sit beside the config, wherever the lookup found it.
49
+ const opened=commandConfig({command:'prompts',cwd,argv,err:io.err});cwd=opened.cwd;
50
+ let config;try{config=loadConfig(opened.found.path);}catch(e){io.err(`prompts: ${e.message}\n`);return io.exit(1)??1;}
45
51
  let privateInboxUpdated = false;
46
52
  try{
47
53
  return await withPromptLock(cwd,async()=>{
@@ -62,7 +68,7 @@ export async function runPrompts({cwd=process.cwd(),argv=[],now=new Date(),io=io
62
68
  }
63
69
  if(command==='keep'||command==='hide'||command==='delete'){
64
70
  const store=readPromptStore(cwd);
65
- if(command==='delete'){if(!rest.includes('--yes'))throw new Error('delete requires --yes.');const laneVersion=activeLaneVersion(cwd);const p=mutate(cwd,store,rest[0],'deleted',now);const refresh=laneVersion===2?'honestweek digest prepare':'honestweek prompts curate';io.out(`prompts delete: removed private prompt ${p.ref.slice(0,12)} and left a no-text tombstone. This cannot recall ${PROMPT_LANE} or ${config.output.file??'the built page'}. Run ${refresh}, honestweek validate, then honestweek build; remove the local output now if needed.\n`);return 0;}
71
+ if(command==='delete'){if(!rest.includes('--yes'))throw new Error('delete requires --yes.');const laneVersion=activeLaneVersion(cwd);const p=mutate(cwd,store,rest[0],'deleted',now);const refresh=laneVersion===2?`${currentCommand()} digest prepare`:`${currentCommand()} prompts curate`;io.out(`prompts delete: removed private prompt ${p.ref.slice(0,12)} and left a no-text tombstone. This cannot recall ${PROMPT_LANE} or ${config.output.file??'the built page'}. Run ${refresh}, ${currentCommand()} validate, then ${currentCommand()} build; remove the local output now if needed.\n`);return 0;}
66
72
  const p=mutate(cwd,store,rest[0],command==='keep'?'kept':'hidden',now);io.out(`prompts ${command}: ${p.ref.slice(0,12)} is ${command==='keep'?'kept':'hidden'}. Keep never overrides receipt or privacy gates.\n`);return 0;
67
73
  }
68
74
  const store=readPromptStore(cwd);
@@ -82,7 +88,7 @@ export async function runPrompts({cwd=process.cwd(),argv=[],now=new Date(),io=io
82
88
  const pre=prefixes(store.prompts);for(const p of rows.slice(offset,offset+limit)){const e=decisions?.get(p.ref);io.out(`${pre.get(p.ref)} ${p.state} ${p.source} ${p.timestamp} ${p.project??'private'}${e?` decision=${e.decision} score=${e.score} reason=${e.p.state==='kept'?'explicit keep':e.codes.join(',')||e.decision}`:''} ${[...p.text].slice(0,120).join('')} [source session ${p.sessionKey.slice(0,12)} turn ${p.turn}]\n`);}
83
89
  if(command==='review')io.out('Review controls: use prompts hide <ref> or prompts delete <ref> --yes. Keep cannot override receipt or privacy gates.\n');
84
90
  const shown=Math.min(limit,Math.max(0,rows.length-offset));const remaining=Math.max(0,rows.length-offset-shown);const start=shown?offset+1:0;const end=shown?offset+shown:0;
85
- io.out(`prompts ${command}: showing ${start}-${end} of ${rows.length}; remaining ${remaining}.${remaining?` Next: ${nextPageCommand(command,rest,offset+shown)}.`:''}\n`);return 0;
91
+ io.out(`prompts ${command}: showing ${start}-${end} of ${rows.length}; remaining ${remaining}.${remaining?` Next: ${nextPageCommand(command,[...rest,...configAgain(taken.config)],offset+shown)}.`:''}\n`);return 0;
86
92
  }
87
93
  if(command==='source'){
88
94
  const p=uniquePrompt(store,rest[0]);const status=store.sourceStatus[p.source];const range=localDateRangeInstants(status.weekStart,status.weekEnd,config.week.timezone);
@@ -92,7 +98,7 @@ export async function runPrompts({cwd=process.cwd(),argv=[],now=new Date(),io=io
92
98
  throw new Error(`unknown prompts command ${JSON.stringify(command)}.`);
93
99
  });
94
100
  }catch(e){
95
- if(privateInboxUpdated)io.err('prompts: private inbox updated; prompt lane not updated; rerun honestweek prompts curate.\n');
101
+ if(privateInboxUpdated)io.err(`prompts: private inbox updated; prompt lane not updated; rerun ${currentCommand()} prompts curate.\n`);
96
102
  io.err(`prompts: ${e.message}\n`);const code=command==='list'||command==='source'||e?.promptPreflight?1:2;return io.exit(code)??code;
97
103
  }
98
104
  }
@@ -64,6 +64,9 @@ export function assemble({ parsed, quietMs = DEFAULT_QUIET_MS }) {
64
64
  const shared = new Map(); // "copy->original" -> count
65
65
  const eventIndex = new Map();
66
66
  for (const p of parsed) for (const e of p.result.events) eventIndex.set(e.id, e);
67
+ // A saved session's copied records (lib/replay/saved-sessions.mjs): never steps, only what the
68
+ // original needs to say it was copied.
69
+ for (const p of parsed) for (const e of p.result.copyStubs ?? []) if (!eventIndex.has(e.id)) eventIndex.set(e.id, e);
67
70
  const lastAtOf = (key) => bySource.get(key)?.result.stats.lastAt ?? '';
68
71
  for (const list of owners.values()) {
69
72
  if (list.length < 2) continue;
@@ -80,7 +83,9 @@ export function assemble({ parsed, quietMs = DEFAULT_QUIET_MS }) {
80
83
  const gone = eventIndex.get(id);
81
84
  if (!keep || !gone || keep.kind !== gone.kind || id.split('.').at(-1) !== keep.id.split('.').at(-1)) return;
82
85
  drop.set(id, keep.id);
83
- keep.copies = [...(keep.copies ?? []), ...gone.refs];
86
+ // A saved step (lib/replay/saved-sessions.mjs) may already name this copy.
87
+ const known = (r) => keep._saved && (keep.copies ?? []).some((c) => c.src === r.src && c.line === r.line && c.off === r.off);
88
+ keep.copies = [...(keep.copies ?? []), ...gone.refs.filter((r) => !known(r))];
84
89
  // Which file's session the record belongs to is the rule's choice, not a record's.
85
90
  if (!keep.inferred.some((x) => x.key === 'canonicalCopy')) keep.inferred.push({ key: 'canonicalCopy', value: keep.session, rule: 'canonical.earliest-ending-copy' });
86
91
  // A resumed copy can hold the result the original never recorded (the first
@@ -203,10 +208,11 @@ export function assemble({ parsed, quietMs = DEFAULT_QUIET_MS }) {
203
208
  if (call.end && call.facts.handback?._status === 'completed') agent.completion = { at: call.end.at, status: 'completed', via: 'call-result', harnessDurationMs: call.facts.handback.harnessDurationMs };
204
209
  }
205
210
  for (const [agentId, call] of launchByAgentId) {
206
- if (!agentIdToKey.has(agentId)) call.missing.push('agent-transcript');
211
+ // A saved step's missing evidence is as it was saved (lib/replay/saved-sessions.mjs).
212
+ if (!agentIdToKey.has(agentId) && !call._saved) call.missing.push('agent-transcript');
207
213
  }
208
214
  for (const e of events) {
209
- if (e.kind === 'action' && e.facts.category === 'delegate' && e.facts.tool !== 'Workflow' && e.source.startsWith('cc-') && !e.facts.spawnedAgent && !e.missing.includes('agent-transcript') && !e.missing.includes('result')) {
215
+ if (!e._saved && e.kind === 'action' && e.facts.category === 'delegate' && e.facts.tool !== 'Workflow' && e.source.startsWith('cc-') && !e.facts.spawnedAgent && !e.missing.includes('agent-transcript') && !e.missing.includes('result')) {
210
216
  e.missing.push('agent-transcript');
211
217
  }
212
218
  }
@@ -232,7 +238,7 @@ export function assemble({ parsed, quietMs = DEFAULT_QUIET_MS }) {
232
238
  } else if (call) {
233
239
  n.event.facts.about = call.id;
234
240
  links.push({ type: 'completion-notice', from: n.event.id, to: call.id, evidence: EVIDENCE.RECORDED });
235
- } else {
241
+ } else if (!n.event._saved) {
236
242
  n.event.missing.push('reported-task');
237
243
  }
238
244
  }
@@ -347,7 +353,8 @@ export function assemble({ parsed, quietMs = DEFAULT_QUIET_MS }) {
347
353
  const target = sessionsByKey.get(key);
348
354
  if (!(sessionStartT.get(key) >= c.event.t)) continue;
349
355
  links.push({ type: 'handoff', from: c.event.id, to: key, evidence: EVIDENCE.INFERRED, inferred: [{ key: 'handoff', value: true, rule: 'handoff.chip-start' }] });
350
- c.event.inferred.push({ key: 'startedSession', value: key, rule: 'handoff.chip-start' });
356
+ // A saved step already carries it (lib/replay/saved-sessions.mjs); it's said once.
357
+ if (!(c.event._saved && c.event.inferred.some((x) => x.key === 'startedSession' && x.value === key))) c.event.inferred.push({ key: 'startedSession', value: key, rule: 'handoff.chip-start' });
351
358
  if (target) target.startedFrom = c.event.id;
352
359
  }
353
360
  }
@@ -463,6 +470,18 @@ export function assemble({ parsed, quietMs = DEFAULT_QUIET_MS }) {
463
470
  }
464
471
 
465
472
  // Threads: sessions joined by recorded or derived links only.
473
+ const sourceSession = new Map(parsed.map((p) => [p.source.key, p.source.sessionKey]));
474
+ const threads = threadsOf(sessionsByKey, links, sourceSession);
475
+
476
+ return { events, links, anomalies, agents, sessionsByKey, turns, threads };
477
+ }
478
+
479
+ /**
480
+ * threadsOf(sessionsByKey, links, sourceSession) -> threads
481
+ * Sessions joined by a continuation (a resumed copy) or a delivered message, each group a thread
482
+ * whose id is a hash of its earliest member. Sets each session's `thread`.
483
+ */
484
+ export function threadsOf(sessionsByKey, links, sourceSession) {
466
485
  const parent = new Map([...sessionsByKey.keys()].map((k) => [k, k]));
467
486
  const find = (k) => {
468
487
  while (parent.get(k) !== k) {
@@ -477,7 +496,6 @@ export function assemble({ parsed, quietMs = DEFAULT_QUIET_MS }) {
477
496
  const rb = find(b);
478
497
  if (ra !== rb) parent.set(ra < rb ? rb : ra, ra < rb ? ra : rb);
479
498
  };
480
- const sourceSession = new Map(parsed.map((p) => [p.source.key, p.source.sessionKey]));
481
499
  for (const l of links) {
482
500
  if (l.type === 'continuation') union(sourceSession.get(l.from), sourceSession.get(l.to));
483
501
  if (l.type === 'message-delivered') union(l.sessions[0], l.sessions[1]);
@@ -504,8 +522,7 @@ export function assemble({ parsed, quietMs = DEFAULT_QUIET_MS }) {
504
522
  const fb = sessionsByKey.get(b.sessions[0]).firstAt ?? '';
505
523
  return fa < fb ? -1 : fa > fb ? 1 : a.id < b.id ? -1 : 1;
506
524
  });
507
-
508
- return { events, links, anomalies, agents, sessionsByKey, turns, threads };
525
+ return threads;
509
526
  }
510
527
 
511
528
  /** What the last records of a session show. Never "ended": logs do not record that. */