ushman-ledger 0.3.0 → 1.2.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 (80) hide show
  1. package/AGENTS.md +11 -7
  2. package/CHANGELOG.md +8 -12
  3. package/README.md +28 -57
  4. package/dist/archive-journal.d.ts +29 -18
  5. package/dist/archive-journal.d.ts.map +1 -1
  6. package/dist/archive-journal.js +17 -17
  7. package/dist/blobs.js +3 -3
  8. package/dist/builders.d.ts +79 -358
  9. package/dist/builders.d.ts.map +1 -1
  10. package/dist/builders.js +15 -60
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +227 -52
  13. package/dist/doctor.d.ts.map +1 -1
  14. package/dist/doctor.js +104 -4
  15. package/dist/handle.d.ts +4 -2
  16. package/dist/handle.d.ts.map +1 -1
  17. package/dist/handle.js +20 -15
  18. package/dist/helpers.d.ts +7 -0
  19. package/dist/helpers.d.ts.map +1 -0
  20. package/dist/helpers.js +38 -0
  21. package/dist/index.d.ts +4 -5
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3 -4
  24. package/dist/lab-min.d.ts +7 -7
  25. package/dist/lab-min.d.ts.map +1 -1
  26. package/dist/lab-min.js +7 -9
  27. package/dist/list.d.ts +104 -303
  28. package/dist/list.d.ts.map +1 -1
  29. package/dist/note.d.ts +20 -0
  30. package/dist/note.d.ts.map +1 -1
  31. package/dist/note.js +5 -0
  32. package/dist/patch-resolver.d.ts +27 -0
  33. package/dist/patch-resolver.d.ts.map +1 -0
  34. package/dist/patch-resolver.js +184 -0
  35. package/dist/read-index.d.ts +45 -57
  36. package/dist/read-index.d.ts.map +1 -1
  37. package/dist/read-index.js +16 -34
  38. package/dist/record.d.ts.map +1 -1
  39. package/dist/record.js +19 -130
  40. package/dist/recovery.d.ts +19 -8
  41. package/dist/recovery.d.ts.map +1 -1
  42. package/dist/recovery.js +13 -13
  43. package/dist/render/migration-log.d.ts +3 -0
  44. package/dist/render/migration-log.d.ts.map +1 -0
  45. package/dist/render/migration-log.js +72 -0
  46. package/dist/render/retro.d.ts.map +1 -1
  47. package/dist/render/retro.js +41 -25
  48. package/dist/render/workspace-narrative.d.ts +6 -0
  49. package/dist/render/workspace-narrative.d.ts.map +1 -0
  50. package/dist/render/workspace-narrative.js +69 -0
  51. package/dist/schema/entry-core.d.ts +110 -0
  52. package/dist/schema/entry-core.d.ts.map +1 -0
  53. package/dist/schema/entry-core.js +143 -0
  54. package/dist/schema/entry-migrations.d.ts +3 -0
  55. package/dist/schema/entry-migrations.d.ts.map +1 -0
  56. package/dist/schema/entry-migrations.js +48 -0
  57. package/dist/schema/entry-read.d.ts +694 -0
  58. package/dist/schema/entry-read.d.ts.map +1 -0
  59. package/dist/schema/entry-read.js +92 -0
  60. package/dist/schema/entry-write.d.ts +865 -0
  61. package/dist/schema/entry-write.d.ts.map +1 -0
  62. package/dist/schema/entry-write.js +105 -0
  63. package/dist/schema/entry.d.ts +6 -3295
  64. package/dist/schema/entry.d.ts.map +1 -1
  65. package/dist/schema/entry.js +10 -619
  66. package/dist/schema/manifest.d.ts +28 -41
  67. package/dist/schema/manifest.d.ts.map +1 -1
  68. package/dist/schema/manifest.js +20 -24
  69. package/dist/schema/note.d.ts +3 -9
  70. package/dist/schema/note.d.ts.map +1 -1
  71. package/dist/schema/note.js +13 -2
  72. package/dist/storage/filesystem.d.ts +2 -1
  73. package/dist/storage/filesystem.d.ts.map +1 -1
  74. package/dist/storage/filesystem.js +6 -4
  75. package/dist/storage/lock-reclaimer.d.ts +2 -0
  76. package/dist/storage/lock-reclaimer.d.ts.map +1 -0
  77. package/dist/storage/lock-reclaimer.js +45 -0
  78. package/dist/version.d.ts +1 -1
  79. package/dist/version.js +1 -1
  80. package/package.json +3 -4
package/dist/cli.js CHANGED
@@ -5,14 +5,35 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { promisify } from 'node:util';
8
- import { ZodError } from 'zod';
8
+ import * as v from 'valibot';
9
9
  import { openLedger } from "./handle.js";
10
- import { LEDGER_KINDS, LEDGER_PHASES, parseLedgerRecord } from "./schema/entry.js";
10
+ import { deriveFilesChangedFromPatch } from "./patch-resolver.js";
11
+ import { ChangeLogParityStatusSchema, ChangeLogSmokeResultSchema, ChangeLogSubkindSchema, LEDGER_KINDS, LEDGER_PHASES, parseLedgerRecord, WorkspaceRelativePathSchema, } from "./schema/entry.js";
11
12
  import { NoteSubkindSchema } from "./schema/note.js";
12
13
  import { LEDGER_LIBRARY_VERSION } from "./version.js";
13
14
  const execFileAsync = promisify(execFile);
15
+ const GIT_DIFF_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
14
16
  const GIT_DIFF_TIMEOUT_MS = 30_000;
15
- const RENDER_TARGETS = ['retro', 'jsonl', 'timeline-html', 'dependency-graph', 'analytics-summary'];
17
+ const RENDER_TARGETS = [
18
+ 'retro',
19
+ 'jsonl',
20
+ 'timeline-html',
21
+ 'dependency-graph',
22
+ 'migration-log-md',
23
+ 'workspace-narrative-md',
24
+ ];
25
+ const CHANGE_LOG_RECORD_ONLY_FLAGS = [
26
+ 'commands',
27
+ 'commands-from',
28
+ 'files-changed',
29
+ 'hypothesis',
30
+ 'parity-status',
31
+ 'rollback-plan',
32
+ 'rolls-back',
33
+ 'smoke-notes',
34
+ 'smoke-result',
35
+ 'subkind',
36
+ ];
16
37
  class CliUsageError extends Error {
17
38
  }
18
39
  const DEFAULT_CONTEXT = {
@@ -34,12 +55,12 @@ const renderValidValues = () => `Valid values:
34
55
  const renderHelp = (commandName) => `${commandName}
35
56
 
36
57
  Commands:
37
- ${commandName} record [--workspace=<ws>] --kind=<kind> --phase=<phase> --summary="..." [--rationale="..."] [--action=<operator-action>] [--check-id=<check-id>] [--diff=<patch-file>] [--diff-from-git=<ref>] [--idempotency-key=<key>] [--from-stdin]
58
+ ${commandName} record [--workspace=<ws>] --kind=<kind> --phase=<phase> --summary="..." [--rationale="..."] [--action=<operator-action>] [--check-id=<check-id>] [--diff=<patch-file>] [--diff-from-git=<ref>] [--idempotency-key=<key>] [--subkind=<change-log-subkind>] [--files-changed=<csv>] [--hypothesis="..."] [--commands="cmd1\ncmd2" | --commands-from=<file>] [--smoke-result=<result>] [--smoke-notes="..."] [--parity-status=<status>] [--rollback-plan="..."] [--rolls-back=<entry-id>] [--from-stdin]
38
59
  ${commandName} note <subkind> [--workspace=<ws>] --phase=<phase> --summary="..." [--body=<markdown-file>] [--from-stdin]
39
60
  ${commandName} list [--workspace=<ws>] [--phase=<phase>] [--kind=<kind>] [--since=<iso>] [--limit=<n>] [--json]
40
61
  ${commandName} show [--workspace=<ws>] <entry-id>
41
62
  ${commandName} tail [--workspace=<ws>] [--phase=<phase>] [--limit=<n>]
42
- ${commandName} render [--workspace=<ws>] [--to=retro|jsonl|timeline-html|dependency-graph|analytics-summary] [--phase=<phase>] [--out=<file>] [--fresh] [--json]
63
+ ${commandName} render [--workspace=<ws>] [--to=retro|jsonl|timeline-html|dependency-graph|migration-log-md|workspace-narrative-md] [--phase=<phase>] [--since=<iso>] [--limit=<n>] [--out=<file>]
43
64
  ${commandName} archive [--workspace=<ws>] --out=<file.tgz>
44
65
  ${commandName} doctor [--workspace=<ws>]
45
66
  ${commandName} --version
@@ -48,7 +69,7 @@ ${renderValidValues()}`;
48
69
  const renderCommandHelp = (commandName, command) => {
49
70
  switch (command) {
50
71
  case 'record':
51
- return `${commandName} record [--workspace=<ws>] --kind=<kind> --phase=<phase> --summary="..." [--rationale="..."] [--action=<operator-action>] [--check-id=<check-id>] [--diff=<patch-file>] [--diff-from-git=<ref>] [--idempotency-key=<key>] [--from-stdin]
72
+ return `${commandName} record [--workspace=<ws>] --kind=<kind> --phase=<phase> --summary="..." [--rationale="..."] [--action=<operator-action>] [--check-id=<check-id>] [--diff=<patch-file>] [--diff-from-git=<ref>] [--idempotency-key=<key>] [--subkind=<change-log-subkind>] [--files-changed=<csv>] [--hypothesis="..."] [--commands="cmd1\ncmd2" | --commands-from=<file>] [--smoke-result=<result>] [--smoke-notes="..."] [--parity-status=<status>] [--rollback-plan="..."] [--rolls-back=<entry-id>] [--from-stdin]
52
73
 
53
74
  ${renderValidValues()}`;
54
75
  case 'note':
@@ -60,9 +81,7 @@ ${renderValidValues()}`;
60
81
 
61
82
  ${renderValidValues()}`;
62
83
  case 'render':
63
- return `${commandName} render [--workspace=<ws>] [--to=<target>] [--phase=<phase>] [--out=<file>] [--fresh] [--json]
64
-
65
- Use \`--fresh\` to bypass the cached analytics summary. \`--json\` remains a compatibility alias for the same behavior.
84
+ return `${commandName} render [--workspace=<ws>] [--to=<target>] [--phase=<phase>] [--since=<iso>] [--limit=<n>] [--out=<file>]
66
85
 
67
86
  ${renderValidValues()}`;
68
87
  default:
@@ -101,6 +120,9 @@ const parseArgv = (argv) => {
101
120
  };
102
121
  const getFlag = (flags, name) => {
103
122
  const value = flags[name];
123
+ if (value === true) {
124
+ throw new CliUsageError(`Missing value for --${name}.`);
125
+ }
104
126
  return typeof value === 'string' ? value : undefined;
105
127
  };
106
128
  const hasFlag = (flags, name) => flags[name] === true || typeof flags[name] === 'string';
@@ -142,7 +164,7 @@ const materializeGitDiff = async (workspaceRoot, gitRef) => {
142
164
  try {
143
165
  const { stdout } = await execFileAsync('git', ['diff', gitRef], {
144
166
  cwd: workspaceRoot,
145
- maxBuffer: 10 * 1024 * 1024,
167
+ maxBuffer: GIT_DIFF_MAX_BUFFER_BYTES,
146
168
  timeout: GIT_DIFF_TIMEOUT_MS,
147
169
  });
148
170
  tempDir = await mkdtemp(path.join(os.tmpdir(), 'ushman-ledger-git-diff-'));
@@ -236,18 +258,121 @@ const parseRenderTarget = (flags) => {
236
258
  }
237
259
  return target;
238
260
  };
261
+ const splitCommandLines = (value) => value
262
+ .split(/\r?\n/u)
263
+ .map((command) => command.trim())
264
+ .filter((command) => command.length > 0);
265
+ const getUsedFlags = (flags, names) => names.filter((name) => hasFlag(flags, name)).map((name) => `--${name}`);
266
+ const rejectUnsupportedFlags = (supportedKindLabel, flags, names) => {
267
+ const usedFlags = getUsedFlags(flags, names);
268
+ if (usedFlags.length === 0) {
269
+ return;
270
+ }
271
+ throw new CliUsageError(`${usedFlags.join(', ')} ${usedFlags.length === 1 ? 'is' : 'are'} only supported for ${supportedKindLabel} records.`);
272
+ };
273
+ const parseRequiredChangeLogSubkind = (flags) => {
274
+ const subkind = getRequiredString(flags, 'subkind');
275
+ if (!ChangeLogSubkindSchema.options.includes(subkind)) {
276
+ throw new CliUsageError(`Invalid --subkind value: ${subkind}. Expected one of: ${ChangeLogSubkindSchema.options.join(', ')}.`);
277
+ }
278
+ return subkind;
279
+ };
280
+ const parseOptionalChangeLogPicklist = ({ flagName, flags, options, }) => {
281
+ const value = getFlag(flags, flagName);
282
+ if (!value) {
283
+ return undefined;
284
+ }
285
+ if (!options.includes(value)) {
286
+ throw new CliUsageError(`Invalid --${flagName} value: ${value}. Expected one of: ${options.join(', ')}.`);
287
+ }
288
+ return value;
289
+ };
290
+ const parseFilesChangedCsv = (raw) => {
291
+ const uniquePaths = [
292
+ ...new Set(raw
293
+ .split(',')
294
+ .map((value) => value.trim())
295
+ .filter((value) => value.length > 0)
296
+ .map((filePath) => {
297
+ try {
298
+ return v.parse(WorkspaceRelativePathSchema, filePath);
299
+ }
300
+ catch {
301
+ throw new CliUsageError(`--files-changed path is not a normalized workspace-relative path: ${filePath}`);
302
+ }
303
+ })),
304
+ ];
305
+ if (uniquePaths.length === 0) {
306
+ throw new CliUsageError('--files-changed must include at least one workspace-relative path.');
307
+ }
308
+ return uniquePaths.map((filePath) => ({ path: filePath }));
309
+ };
310
+ const readCommandLines = async (flags) => {
311
+ const inlineCommands = getFlag(flags, 'commands');
312
+ const commandsFrom = getFlag(flags, 'commands-from');
313
+ if (inlineCommands && commandsFrom) {
314
+ throw new CliUsageError('Use either --commands or --commands-from, not both.');
315
+ }
316
+ if (commandsFrom) {
317
+ const commandsPath = path.resolve(commandsFrom);
318
+ await ensureFileExists(commandsPath, '--commands-from');
319
+ return splitCommandLines(await readFile(commandsPath, 'utf8'));
320
+ }
321
+ if (inlineCommands) {
322
+ return splitCommandLines(inlineCommands);
323
+ }
324
+ return undefined;
325
+ };
326
+ const resolveDiffInput = async ({ flags, kind, workspaceRoot, }) => {
327
+ const diffPath = getFlag(flags, 'diff');
328
+ const diffFromGit = getFlag(flags, 'diff-from-git');
329
+ if (diffPath && diffFromGit) {
330
+ throw new CliUsageError('Use either --diff or --diff-from-git, not both.');
331
+ }
332
+ if (!diffPath && !diffFromGit) {
333
+ return {};
334
+ }
335
+ if (kind !== 'agent-patch' && kind !== 'operator-patch' && kind !== 'change-log') {
336
+ throw new CliUsageError('--diff and --diff-from-git are only supported for patch and change-log records.');
337
+ }
338
+ if (diffPath) {
339
+ const resolvedDiffPath = path.resolve(diffPath);
340
+ await ensureFileExists(resolvedDiffPath, '--diff');
341
+ return {
342
+ diffPath: resolvedDiffPath,
343
+ patchText: kind === 'change-log' ? await readFile(resolvedDiffPath, 'utf8') : undefined,
344
+ };
345
+ }
346
+ const materialized = await materializeGitDiff(workspaceRoot, diffFromGit ?? '');
347
+ return {
348
+ cleanupTempDir: materialized.tempDir,
349
+ diffPath: materialized.patchPath,
350
+ gitRef: diffFromGit,
351
+ patchText: kind === 'change-log' ? await readFile(materialized.patchPath, 'utf8') : undefined,
352
+ };
353
+ };
239
354
  const validateRecordStdinFlags = (flags) => {
240
355
  const conflictingFlags = [
241
356
  'agent',
242
357
  'action',
243
358
  'check-id',
359
+ 'commands',
360
+ 'commands-from',
244
361
  'diff',
245
362
  'diff-from-git',
363
+ 'files-changed',
364
+ 'hypothesis',
246
365
  'idempotency-key',
247
366
  'kind',
248
367
  'operator',
368
+ 'parity-status',
249
369
  'phase',
250
370
  'rationale',
371
+ 'rollback-plan',
372
+ 'rolls-back',
373
+ 'smoke-notes',
374
+ 'smoke-result',
375
+ 'subkind',
251
376
  'summary',
252
377
  ].filter((flagName) => hasFlag(flags, flagName));
253
378
  if (conflictingFlags.length === 0) {
@@ -259,6 +384,9 @@ const validateRecordStdinFlags = (flags) => {
259
384
  };
260
385
  const buildBaseRecordFromFlags = (parsed, context) => {
261
386
  const kind = getRequiredKind(parsed.flags);
387
+ if (kind !== 'change-log') {
388
+ rejectUnsupportedFlags('change-log', parsed.flags, CHANGE_LOG_RECORD_ONLY_FLAGS);
389
+ }
262
390
  const record = {
263
391
  emitter: {
264
392
  tool: context.defaultEmitter.tool,
@@ -298,43 +426,68 @@ const buildBaseRecordFromFlags = (parsed, context) => {
298
426
  rationale: rationale ?? '',
299
427
  };
300
428
  }
429
+ if (kind === 'change-log') {
430
+ record.subkind = parseRequiredChangeLogSubkind(parsed.flags);
431
+ }
301
432
  const idempotencyKey = getFlag(parsed.flags, 'idempotency-key');
302
433
  if (idempotencyKey) {
303
434
  record.idempotencyKey = idempotencyKey;
304
435
  }
305
436
  return { kind, record };
306
437
  };
307
- const applyPatchInputToRecord = async ({ kind, record, workspaceRoot, parsed, }) => {
308
- const diffPath = getFlag(parsed.flags, 'diff');
309
- if (diffPath) {
310
- if (kind !== 'agent-patch' && kind !== 'operator-patch') {
311
- throw new CliUsageError('--diff is only supported for patch records.');
312
- }
313
- await ensureFileExists(path.resolve(diffPath), '--diff');
314
- record.diffPath = diffPath;
315
- }
316
- const diffFromGit = getFlag(parsed.flags, 'diff-from-git');
317
- if (!diffFromGit) {
318
- return {};
319
- }
438
+ const applyPatchInputToRecord = async ({ kind, patchInput, record, }) => {
320
439
  if (kind !== 'agent-patch' && kind !== 'operator-patch') {
321
- throw new CliUsageError('--diff-from-git is only supported for patch records.');
322
- }
323
- if (diffPath) {
324
- throw new CliUsageError('Use either --diff or --diff-from-git, not both.');
440
+ return {};
325
441
  }
326
- const materialized = await materializeGitDiff(workspaceRoot, diffFromGit);
327
442
  return {
328
- cleanupTempDir: materialized.tempDir,
443
+ cleanupTempDir: patchInput.cleanupTempDir,
329
444
  record: {
330
445
  ...record,
331
- diffPath: materialized.patchPath,
332
- links: {
333
- gitRef: diffFromGit,
334
- },
446
+ diffPath: patchInput.diffPath,
447
+ links: patchInput.gitRef
448
+ ? {
449
+ ...record.links,
450
+ gitRef: patchInput.gitRef,
451
+ }
452
+ : record.links,
335
453
  },
336
454
  };
337
455
  };
456
+ const applyChangeLogFlagsToRecord = async ({ flags, patchInput, record, }) => {
457
+ const filesChangedFlag = getFlag(flags, 'files-changed');
458
+ if (filesChangedFlag && patchInput.patchText) {
459
+ throw new CliUsageError('Use either --files-changed or --diff/--diff-from-git to populate change-log files.');
460
+ }
461
+ return {
462
+ ...record,
463
+ commandsRun: await readCommandLines(flags),
464
+ filesChanged: filesChangedFlag
465
+ ? parseFilesChangedCsv(filesChangedFlag)
466
+ : patchInput.patchText
467
+ ? deriveFilesChangedFromPatch(patchInput.patchText)
468
+ : undefined,
469
+ hypothesis: getFlag(flags, 'hypothesis'),
470
+ links: patchInput.gitRef
471
+ ? {
472
+ ...record.links,
473
+ gitRef: patchInput.gitRef,
474
+ }
475
+ : record.links,
476
+ parityStatus: parseOptionalChangeLogPicklist({
477
+ flagName: 'parity-status',
478
+ flags,
479
+ options: ChangeLogParityStatusSchema.options,
480
+ }),
481
+ rollbackPlan: getFlag(flags, 'rollback-plan'),
482
+ rollsBack: getFlag(flags, 'rolls-back'),
483
+ smokeNotes: getFlag(flags, 'smoke-notes'),
484
+ smokeResult: parseOptionalChangeLogPicklist({
485
+ flagName: 'smoke-result',
486
+ flags,
487
+ options: ChangeLogSmokeResultSchema.options,
488
+ }),
489
+ };
490
+ };
338
491
  const buildRecordFromFlags = async (parsed, context) => {
339
492
  const workspaceRoot = getWorkspaceRoot(parsed.flags);
340
493
  if (hasFlag(parsed.flags, 'from-stdin')) {
@@ -346,17 +499,41 @@ const buildRecordFromFlags = async (parsed, context) => {
346
499
  };
347
500
  }
348
501
  const { kind, record } = buildBaseRecordFromFlags(parsed, context);
349
- const patchInput = await applyPatchInputToRecord({
350
- kind,
351
- parsed,
352
- record,
353
- workspaceRoot,
354
- });
355
- return {
356
- cleanupTempDir: patchInput.cleanupTempDir,
357
- record: patchInput.record ?? record,
358
- workspaceRoot,
359
- };
502
+ let diffInput;
503
+ try {
504
+ diffInput = await resolveDiffInput({
505
+ flags: parsed.flags,
506
+ kind,
507
+ workspaceRoot,
508
+ });
509
+ if (kind === 'change-log') {
510
+ return {
511
+ cleanupTempDir: diffInput.cleanupTempDir,
512
+ record: await applyChangeLogFlagsToRecord({
513
+ flags: parsed.flags,
514
+ patchInput: diffInput,
515
+ record,
516
+ }),
517
+ workspaceRoot,
518
+ };
519
+ }
520
+ const patchInput = await applyPatchInputToRecord({
521
+ kind,
522
+ patchInput: diffInput,
523
+ record,
524
+ });
525
+ return {
526
+ cleanupTempDir: patchInput.cleanupTempDir,
527
+ record: patchInput.record ?? record,
528
+ workspaceRoot,
529
+ };
530
+ }
531
+ catch (error) {
532
+ if (diffInput?.cleanupTempDir) {
533
+ await rm(diffInput.cleanupTempDir, { force: true, recursive: true });
534
+ }
535
+ throw error;
536
+ }
360
537
  };
361
538
  const buildNoteRecord = async (parsed, context) => {
362
539
  const [subkind] = parsed.positionals;
@@ -462,15 +639,12 @@ const runTailCli = async (parsed, context) => runListCli({
462
639
  }, context);
463
640
  const runRenderCli = async (parsed, context) => {
464
641
  const target = parseRenderTarget(parsed.flags);
465
- const wantsFresh = hasFlag(parsed.flags, 'fresh') || hasFlag(parsed.flags, 'json');
466
- if (wantsFresh && target !== 'analytics-summary') {
467
- throw new CliUsageError('--fresh and --json are only supported for render --to=analytics-summary.');
468
- }
469
642
  const ledger = await openLedger(getWorkspaceRoot(parsed.flags));
470
643
  const content = await ledger.render({
471
- fresh: wantsFresh,
644
+ limit: parseLimit(parsed.flags),
472
645
  out: getFlag(parsed.flags, 'out'),
473
646
  phase: parseOptionalPhase(parsed.flags),
647
+ since: parseSince(parsed.flags),
474
648
  to: target,
475
649
  });
476
650
  if (!getFlag(parsed.flags, 'out')) {
@@ -501,10 +675,11 @@ const formatCliError = (error) => {
501
675
  if (error instanceof CliUsageError) {
502
676
  return error.message;
503
677
  }
504
- if (error instanceof ZodError) {
678
+ if (v.isValiError(error)) {
505
679
  return error.issues
506
680
  .map((issue) => {
507
- const pathLabel = issue.path.length > 0 ? issue.path.join('.') : 'input';
681
+ const pathSegments = issue.path?.map((segment) => String(segment.key)) ?? [];
682
+ const pathLabel = pathSegments.length > 0 ? pathSegments.join('.') : 'input';
508
683
  return `${pathLabel}: ${issue.message}`;
509
684
  })
510
685
  .join('\n');
@@ -557,6 +732,6 @@ if (isDirectExecution()) {
557
732
  process.exit(code);
558
733
  }, (error) => {
559
734
  process.stderr.write(`${formatCliError(error)}\n`);
560
- process.exit(error instanceof CliUsageError || error instanceof ZodError ? 1 : 2);
735
+ process.exit(error instanceof CliUsageError || v.isValiError(error) ? 1 : 2);
561
736
  });
562
737
  }
@@ -1 +1 @@
1
- {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../src/doctor.ts"],"names":[],"mappings":"AAMA,OAAO,EAAmB,KAAK,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAsL1E,eAAO,MAAM,eAAe,GACxB,eAAe,MAAM,EACrB,UAAS;IAAE,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,mBAAmB,CAAA;CAAO;;;EAyCzF,CAAC"}
1
+ {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../src/doctor.ts"],"names":[],"mappings":"AAMA,OAAO,EAAmB,KAAK,mBAAmB,EAAE,MAAM,eAAe,CAAC;AA8V1E,eAAO,MAAM,eAAe,GACxB,eAAe,MAAM,EACrB,UAAS;IAAE,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,mBAAmB,CAAA;CAAO;;;EAyCzF,CAAC"}
package/dist/doctor.js CHANGED
@@ -7,7 +7,9 @@ import { isReadIndexCurrent, readReadIndex } from "./read-index.js";
7
7
  import { loadLedgerState } from "./recovery.js";
8
8
  import { readManifest } from "./storage/filesystem.js";
9
9
  const BLOB_HASH_CONCURRENCY = 16;
10
+ const CHECKPOINT_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
10
11
  const ENTRY_READ_BATCH_SIZE = 32;
12
+ const OPEN_ISSUE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
11
13
  const checkPrevChain = (entry, previousByPhase, issues) => {
12
14
  const expectedPrev = previousByPhase.get(entry.phase) ?? null;
13
15
  if (entry.prevEntryId !== expectedPrev) {
@@ -66,13 +68,51 @@ const buildReadFailure = (error) => ({
66
68
  issues: [`Failed to read ledger state: ${error instanceof Error ? (error.message ?? error.name) : String(error)}.`],
67
69
  ok: false,
68
70
  });
69
- const inspectDoctorEntry = ({ blobChecks, entry, issues, latestByPhase, manifest, previousByPhase, unseenManifestEntryIds, }) => {
70
- unseenManifestEntryIds.delete(entry.id);
71
- checkPrevChain(entry, previousByPhase, issues);
71
+ const isChangeLogEntry = (entry) => entry.kind === 'change-log';
72
+ const isOpenIssueNote = (entry) => entry.kind === 'note' && entry.subkind === 'open-issue';
73
+ const trackResolutionLinks = (entry, resolvedLedgerIds) => {
74
+ if (entry.links.correctsLedgerId) {
75
+ resolvedLedgerIds.add(entry.links.correctsLedgerId);
76
+ }
77
+ if (entry.links.supersedesLedgerId) {
78
+ resolvedLedgerIds.add(entry.links.supersedesLedgerId);
79
+ }
80
+ };
81
+ const trackIdempotencyEntry = (entry, entriesByIdempotencyKey) => {
82
+ const idempotencyKey = entry.links.idempotencyKey;
83
+ if (!idempotencyKey) {
84
+ return;
85
+ }
86
+ const existingEntries = entriesByIdempotencyKey.get(idempotencyKey) ?? [];
87
+ existingEntries.push({ id: entry.id, ts: entry.ts });
88
+ entriesByIdempotencyKey.set(idempotencyKey, existingEntries);
89
+ };
90
+ const checkChangeLogWarnings = ({ checkpointEntries, entriesByIdempotencyKey, issues, nowMs, openIssueEntries, resolvedLedgerIds, }) => {
91
+ for (const checkpointEntry of checkpointEntries) {
92
+ const ageMs = nowMs - Date.parse(checkpointEntry.ts);
93
+ if (ageMs <= CHECKPOINT_MAX_AGE_MS) {
94
+ continue;
95
+ }
96
+ const idempotencyKey = checkpointEntry.links.idempotencyKey;
97
+ const hasFollowUp = typeof idempotencyKey === 'string' &&
98
+ (entriesByIdempotencyKey.get(idempotencyKey) ?? []).some((candidate) => candidate.id !== checkpointEntry.id && candidate.ts >= checkpointEntry.ts);
99
+ if (!hasFollowUp) {
100
+ issues.push(`Pre-change checkpoint ${checkpointEntry.id} is older than 24h and has no follow-up entry with matching idempotencyKey.`);
101
+ }
102
+ }
103
+ for (const openIssueEntry of openIssueEntries) {
104
+ const ageMs = nowMs - Date.parse(openIssueEntry.ts);
105
+ if (ageMs <= OPEN_ISSUE_MAX_AGE_MS || resolvedLedgerIds.has(openIssueEntry.id)) {
106
+ continue;
107
+ }
108
+ issues.push(`Open issue note ${openIssueEntry.id} is older than 30 days and has no resolution link.`);
109
+ }
110
+ };
111
+ const inspectManifestLocation = ({ entry, issues, latestByPhase, manifest, }) => {
72
112
  const manifestLocation = manifest.entryLocations[entry.id];
73
113
  if (!manifestLocation) {
74
114
  issues.push(`Manifest is missing entry location for ${entry.id}.`);
75
- return;
115
+ return null;
76
116
  }
77
117
  if (manifestLocation.phase !== entry.phase) {
78
118
  issues.push(`Manifest phase mismatch for ${entry.id}: expected ${entry.phase}, found ${manifestLocation.phase}.`);
@@ -81,6 +121,25 @@ const inspectDoctorEntry = ({ blobChecks, entry, issues, latestByPhase, manifest
81
121
  if (!currentLatest || manifestLocation.sequence > currentLatest.sequence) {
82
122
  latestByPhase.set(entry.phase, { entryId: entry.id, sequence: manifestLocation.sequence });
83
123
  }
124
+ return manifestLocation;
125
+ };
126
+ const inspectNarrativeEntry = ({ checkpointEntries, entry, issues, openIssueEntries, }) => {
127
+ if (isChangeLogEntry(entry)) {
128
+ if (entry.subkind === 'pre-change-checkpoint') {
129
+ checkpointEntries.push(entry);
130
+ }
131
+ if (entry.smokeResult === 'fail' && !entry.rollbackPlan) {
132
+ issues.push(`Change-log entry ${entry.id} has smokeResult=fail but no rollbackPlan.`);
133
+ }
134
+ if (entry.subkind === 'rollback' && !entry.rollsBack) {
135
+ issues.push(`Change-log rollback entry ${entry.id} is missing rollsBack.`);
136
+ }
137
+ }
138
+ if (isOpenIssueNote(entry)) {
139
+ openIssueEntries.push(entry);
140
+ }
141
+ };
142
+ const inspectPatchEntry = ({ blobChecks, entry, }) => {
84
143
  if (entry.kind !== 'agent-patch' && entry.kind !== 'operator-patch') {
85
144
  return;
86
145
  }
@@ -88,13 +147,42 @@ const inspectDoctorEntry = ({ blobChecks, entry, issues, latestByPhase, manifest
88
147
  blobChecks.push({ blobHash, entryId: entry.id });
89
148
  }
90
149
  };
150
+ const inspectDoctorEntry = ({ blobChecks, checkpointEntries, entry, entriesByIdempotencyKey, issues, latestByPhase, manifest, openIssueEntries, previousByPhase, resolvedLedgerIds, unseenManifestEntryIds, }) => {
151
+ unseenManifestEntryIds.delete(entry.id);
152
+ checkPrevChain(entry, previousByPhase, issues);
153
+ trackIdempotencyEntry(entry, entriesByIdempotencyKey);
154
+ trackResolutionLinks(entry, resolvedLedgerIds);
155
+ if (!inspectManifestLocation({
156
+ entry,
157
+ issues,
158
+ latestByPhase,
159
+ manifest,
160
+ })) {
161
+ return;
162
+ }
163
+ inspectNarrativeEntry({
164
+ checkpointEntries,
165
+ entry,
166
+ issues,
167
+ openIssueEntries,
168
+ });
169
+ inspectPatchEntry({
170
+ blobChecks,
171
+ entry,
172
+ });
173
+ };
91
174
  const collectDoctorState = async (workspaceRoot, manifest, readIndex) => {
92
175
  const issues = [];
93
176
  const previousByPhase = new Map();
94
177
  const latestByPhase = new Map();
95
178
  const unseenManifestEntryIds = new Set(Object.keys(manifest.entryLocations));
96
179
  const blobChecks = [];
180
+ const checkpointEntries = [];
181
+ const entriesByIdempotencyKey = new Map();
182
+ const openIssueEntries = [];
183
+ const resolvedLedgerIds = new Set();
97
184
  let entryCount = 0;
185
+ const nowMs = Date.now();
98
186
  const orderedEntries = getOrderedEntryLocations(manifest, readIndex, {});
99
187
  checkManifestSequenceOrder(orderedEntries, issues);
100
188
  for (let index = 0; index < orderedEntries.length; index += ENTRY_READ_BATCH_SIZE) {
@@ -111,15 +199,27 @@ const collectDoctorState = async (workspaceRoot, manifest, readIndex) => {
111
199
  entryCount += 1;
112
200
  inspectDoctorEntry({
113
201
  blobChecks,
202
+ checkpointEntries,
114
203
  entry: resolvedEntry.entry,
204
+ entriesByIdempotencyKey,
115
205
  issues,
116
206
  latestByPhase,
117
207
  manifest,
208
+ openIssueEntries,
118
209
  previousByPhase,
210
+ resolvedLedgerIds,
119
211
  unseenManifestEntryIds,
120
212
  });
121
213
  }
122
214
  }
215
+ checkChangeLogWarnings({
216
+ checkpointEntries,
217
+ entriesByIdempotencyKey,
218
+ issues,
219
+ nowMs,
220
+ openIssueEntries,
221
+ resolvedLedgerIds,
222
+ });
123
223
  return {
124
224
  blobChecks,
125
225
  entryCount,
package/dist/handle.d.ts CHANGED
@@ -3,6 +3,7 @@ import { runLedgerDoctor } from './doctor.ts';
3
3
  import { type LedgerFilter } from './list.ts';
4
4
  import { appendNote, type NoteBody } from './note.ts';
5
5
  import type { LedgerEntry, LedgerPhase } from './schema/entry.ts';
6
+ export type RenderTarget = 'dependency-graph' | 'jsonl' | 'migration-log-md' | 'retro' | 'timeline-html' | 'workspace-narrative-md';
6
7
  export type LedgerHandle = {
7
8
  readonly archive: (outPath: string) => Promise<{
8
9
  integrityHash: string;
@@ -17,10 +18,11 @@ export type LedgerHandle = {
17
18
  id: string;
18
19
  }>;
19
20
  readonly render: (options: {
20
- fresh?: boolean;
21
21
  out?: string;
22
+ limit?: number;
22
23
  phase?: LedgerPhase;
23
- to: 'analytics-summary' | 'dependency-graph' | 'jsonl' | 'retro' | 'timeline-html';
24
+ since?: string;
25
+ to: RenderTarget;
24
26
  }) => Promise<string>;
25
27
  readonly show: (entryId: string) => Promise<LedgerEntry | null>;
26
28
  };
@@ -1 +1 @@
1
- {"version":3,"file":"handle.d.ts","sourceRoot":"","sources":["../src/handle.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,EAA8B,KAAK,YAAY,EAAe,MAAM,WAAW,CAAC;AACvF,OAAO,EAAE,UAAU,EAAE,KAAK,QAAQ,EAAE,MAAM,WAAW,CAAC;AAQtD,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAwDlE,MAAM,MAAM,YAAY,GAAG;IACvB,QAAQ,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1E,QAAQ,CAAC,eAAe,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC,CAAC,CAAC;IACrF,QAAQ,CAAC,MAAM,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC,CAAC,CAAC;IAC5E,QAAQ,CAAC,IAAI,EAAE,CAAC,MAAM,CAAC,EAAE,YAAY,KAAK,aAAa,CAAC,WAAW,CAAC,CAAC;IACrE,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,QAAQ,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtG,QAAQ,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC7D,QAAQ,CAAC,MAAM,EAAE,CAAC,OAAO,EAAE;QACvB,KAAK,CAAC,EAAE,OAAO,CAAC;QAChB,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,KAAK,CAAC,EAAE,WAAW,CAAC;QACpB,EAAE,EAAE,mBAAmB,GAAG,kBAAkB,GAAG,OAAO,GAAG,OAAO,GAAG,eAAe,CAAC;KACtF,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;CACnE,CAAC;AAEF,eAAO,MAAM,UAAU,GAAU,eAAe,MAAM,KAAG,OAAO,CAAC,YAAY,CAsC5E,CAAC"}
1
+ {"version":3,"file":"handle.d.ts","sourceRoot":"","sources":["../src/handle.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,EAA8B,KAAK,YAAY,EAAe,MAAM,WAAW,CAAC;AACvF,OAAO,EAAE,UAAU,EAAE,KAAK,QAAQ,EAAE,MAAM,WAAW,CAAC;AAStD,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAGlE,MAAM,MAAM,YAAY,GAClB,kBAAkB,GAClB,OAAO,GACP,kBAAkB,GAClB,OAAO,GACP,eAAe,GACf,wBAAwB,CAAC;AAkD/B,MAAM,MAAM,YAAY,GAAG;IACvB,QAAQ,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1E,QAAQ,CAAC,eAAe,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC,CAAC,CAAC;IACrF,QAAQ,CAAC,MAAM,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC,CAAC,CAAC;IAC5E,QAAQ,CAAC,IAAI,EAAE,CAAC,MAAM,CAAC,EAAE,YAAY,KAAK,aAAa,CAAC,WAAW,CAAC,CAAC;IACrE,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,QAAQ,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtG,QAAQ,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC7D,QAAQ,CAAC,MAAM,EAAE,CAAC,OAAO,EAAE;QACvB,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,KAAK,CAAC,EAAE,WAAW,CAAC;QACpB,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,EAAE,EAAE,YAAY,CAAC;KACpB,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;CACnE,CAAC;AAEF,eAAO,MAAM,UAAU,GAAU,eAAe,MAAM,KAAG,OAAO,CAAC,YAAY,CAyC5E,CAAC"}