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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/README.md +252 -197
- package/SKILL.md +25 -74
- package/bin/honestweek.mjs +81 -24
- package/flows/client.md +19 -0
- package/flows/digest.md +11 -0
- package/flows/mine.md +18 -0
- package/flows/view.md +45 -0
- package/flows/weekly.md +29 -0
- package/lib/ask.mjs +1142 -0
- package/lib/build.mjs +12 -3
- package/lib/config-lookup.mjs +179 -0
- package/lib/config.mjs +20 -0
- package/lib/demo/week.mjs +3 -0
- package/lib/digest-carry.mjs +3 -2
- package/lib/digest-store.mjs +3 -2
- package/lib/digest.mjs +21 -14
- package/lib/discover.mjs +28 -22
- package/lib/emit/index.mjs +18 -6
- package/lib/harvest.mjs +19 -1
- package/lib/history.mjs +10 -2
- package/lib/init.mjs +241 -55
- package/lib/mine.mjs +18 -8
- package/lib/preview.mjs +66 -13
- package/lib/private-words.mjs +25 -5
- package/lib/problems/index.mjs +60 -7
- package/lib/prompt-lane.mjs +14 -13
- package/lib/prompt-store.mjs +2 -1
- package/lib/prompts.mjs +13 -7
- package/lib/replay/assemble.mjs +25 -8
- package/lib/replay/index.mjs +200 -12
- package/lib/replay/lookup.mjs +6 -3
- package/lib/replay/saved-sessions.mjs +315 -0
- package/lib/replay/views.mjs +12 -1
- package/lib/{view → replay}/word-index.mjs +3 -3
- package/lib/replay/words.mjs +122 -0
- package/lib/repo-identity.mjs +81 -13
- package/lib/saved/checks.mjs +381 -0
- package/lib/saved/history.mjs +235 -0
- package/lib/saved/saver.mjs +111 -0
- package/lib/saved/store.mjs +256 -0
- package/lib/status.mjs +288 -0
- package/lib/validate.mjs +12 -3
- package/lib/view/assets/common.css +2 -0
- package/lib/view/assets/common.js +20 -2
- package/lib/view/assets/problems.js +4 -3
- package/lib/view/assets/replay.js +3 -1
- package/lib/view/assets/search.js +1 -1
- package/lib/view/assets/sessions.js +1 -0
- package/lib/view/assets/settings.html +16 -0
- package/lib/view/assets/settings.js +108 -4
- package/lib/view/assets/setup.html +7 -0
- package/lib/view/assets/setup.js +9 -0
- package/lib/view/codex-judge.mjs +1 -1
- package/lib/view/data.mjs +160 -76
- package/lib/view/own-week.mjs +119 -0
- package/lib/view/page-link.mjs +84 -0
- package/lib/view/problems-route.mjs +29 -3
- package/lib/view/replay-export.mjs +1 -1
- package/lib/view/selftest/clickthrough.js +9 -5
- package/lib/view/server.mjs +3 -1
- package/lib/view/settings.mjs +102 -34
- package/lib/view/setup.mjs +27 -14
- package/lib/view/suggest-words.mjs +67 -0
- package/lib/view.mjs +82 -133
- package/lib/worktrees.mjs +31 -18
- 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: () =>
|
|
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
|
-
|
|
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
|
|
380
|
-
*
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/lib/private-words.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
49
|
+
`For likely ones, ${suggest}run ${command} discover, then ${command} harvest, and read honestweek.harvest.json.`
|
|
30
50
|
);
|
|
31
51
|
}
|
package/lib/problems/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/lib/prompt-lane.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
82
|
-
if(JSON.stringify(normalized(lane))!==JSON.stringify(normalized(rebuilt)))throw new Error(`${PROMPT_LANE} is stale or non-canonical; rerun
|
|
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
|
}
|
package/lib/prompt-store.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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(
|
|
44
|
-
|
|
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
|
|
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(
|
|
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
|
}
|
package/lib/replay/assemble.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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. */
|