vouchington-tooling 0.43.0 → 0.45.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 (110) hide show
  1. package/README.md +45 -1
  2. package/dist/agent-blackboard/client.d.mts +6 -0
  3. package/dist/cli/commands/fresh-base.d.mts +6 -0
  4. package/dist/cli/commands/fresh-base.mjs +14 -0
  5. package/dist/cli/index.mjs +3 -0
  6. package/dist/cli/parse-fresh-base.d.mts +2 -0
  7. package/dist/cli/parse-fresh-base.mjs +15 -0
  8. package/dist/cli/parse.d.mts +5 -0
  9. package/dist/cli/parse.mjs +3 -0
  10. package/dist/cli/usage.d.mts +1 -1
  11. package/dist/cli/usage.mjs +3 -1
  12. package/dist/fresh-base/classify.d.mts +21 -0
  13. package/dist/fresh-base/classify.mjs +29 -0
  14. package/dist/fresh-base/index.d.mts +4 -0
  15. package/dist/fresh-base/index.mjs +2 -0
  16. package/dist/fresh-base/run.d.mts +25 -0
  17. package/dist/fresh-base/run.mjs +66 -0
  18. package/dist/gh-cli/closing-refs.d.mts +24 -0
  19. package/dist/gh-cli/closing-refs.mjs +37 -0
  20. package/dist/gh-cli/index.d.mts +3 -0
  21. package/dist/gh-cli/index.mjs +2 -0
  22. package/dist/gh-cli/non-closing-refs.d.mts +11 -0
  23. package/dist/gh-cli/non-closing-refs.mjs +24 -0
  24. package/dist/index.d.mts +6 -0
  25. package/dist/index.mjs +3 -0
  26. package/dist/mcp-server/context.d.mts +3 -0
  27. package/dist/mcp-server/dispatch.d.mts +3 -0
  28. package/dist/mcp-server/dispatch.mjs +7 -1
  29. package/dist/mcp-server/fake-blackboard.test-helpers.d.mts +2 -0
  30. package/dist/mcp-server/fake-blackboard.test-helpers.mjs +3 -0
  31. package/dist/mcp-server/handlers-pr.d.mts +1 -1
  32. package/dist/mcp-server/handlers-pr.mjs +6 -97
  33. package/dist/mcp-server/handlers-retrospective-save.d.mts +10 -0
  34. package/dist/mcp-server/handlers-retrospective-save.mjs +138 -0
  35. package/dist/mcp-server/handlers-retrospective.d.mts +1 -12
  36. package/dist/mcp-server/handlers-retrospective.mjs +1 -154
  37. package/dist/mcp-server/handlers-session.mjs +25 -6
  38. package/dist/mcp-server/handlers-snapshot-cleanup.d.mts +7 -0
  39. package/dist/mcp-server/handlers-snapshot-cleanup.mjs +36 -0
  40. package/dist/mcp-server/harness.test-helpers.d.mts +2 -0
  41. package/dist/mcp-server/harness.test-helpers.mjs +1 -0
  42. package/dist/mcp-server/policy-run.d.mts +1 -0
  43. package/dist/mcp-server/policy-run.mjs +1 -0
  44. package/dist/mcp-server/pr-body.d.mts +4 -2
  45. package/dist/mcp-server/pr-body.mjs +15 -3
  46. package/dist/mcp-server/pr-defaults.d.mts +3 -0
  47. package/dist/mcp-server/pr-defaults.mjs +7 -2
  48. package/dist/mcp-server/pr-input.d.mts +12 -5
  49. package/dist/mcp-server/pr-input.mjs +21 -4
  50. package/dist/mcp-server/pr-live.d.mts +3 -1
  51. package/dist/mcp-server/pr-live.mjs +7 -2
  52. package/dist/mcp-server/pr-mode.d.mts +7 -0
  53. package/dist/mcp-server/pr-mode.mjs +34 -0
  54. package/dist/mcp-server/pr-no-source.d.mts +9 -0
  55. package/dist/mcp-server/pr-no-source.mjs +22 -0
  56. package/dist/mcp-server/pr-prepare.d.mts +25 -0
  57. package/dist/mcp-server/pr-prepare.mjs +103 -0
  58. package/dist/mcp-server/preview-registry.d.mts +25 -0
  59. package/dist/mcp-server/preview-registry.mjs +38 -0
  60. package/dist/mcp-server/retrospective-check.d.mts +3 -0
  61. package/dist/mcp-server/retrospective-check.mjs +38 -0
  62. package/dist/mcp-server/retrospective-input.mjs +2 -2
  63. package/dist/mcp-server/retrospective-preview.d.mts +17 -0
  64. package/dist/mcp-server/retrospective-preview.mjs +60 -0
  65. package/dist/mcp-server/snapshot-registry.d.mts +25 -0
  66. package/dist/mcp-server/snapshot-registry.mjs +32 -0
  67. package/dist/mcp-server/tools-pr.mjs +20 -4
  68. package/dist/mcp-server/tools-retrospective.mjs +25 -5
  69. package/dist/mcp-server/tools.mjs +6 -0
  70. package/dist/pull-request-diff/diff-size.d.mts +29 -0
  71. package/dist/pull-request-diff/diff-size.mjs +35 -0
  72. package/dist/pull-request-diff/index.d.mts +2 -0
  73. package/dist/pull-request-diff/index.mjs +1 -0
  74. package/dist/retrospective-distill/args.d.mts +13 -0
  75. package/dist/retrospective-distill/args.mjs +27 -0
  76. package/dist/retrospective-distill/checkpoint-entry.d.mts +2 -0
  77. package/dist/retrospective-distill/checkpoint-entry.mjs +11 -0
  78. package/dist/retrospective-distill/fixtures.test-helpers.d.mts +3 -0
  79. package/dist/retrospective-distill/fixtures.test-helpers.mjs +21 -0
  80. package/dist/retrospective-distill/flag-args.d.mts +5 -0
  81. package/dist/retrospective-distill/flag-args.mjs +31 -0
  82. package/dist/retrospective-distill/index.d.mts +10 -0
  83. package/dist/retrospective-distill/index.mjs +6 -0
  84. package/dist/retrospective-distill/partition-records.d.mts +6 -0
  85. package/dist/retrospective-distill/partition-records.mjs +49 -0
  86. package/dist/retrospective-distill/retrospective-entry.d.mts +3 -0
  87. package/dist/retrospective-distill/retrospective-entry.mjs +4 -0
  88. package/dist/retrospective-distill/run.d.mts +5 -0
  89. package/dist/retrospective-distill/run.mjs +28 -0
  90. package/dist/retrospective-distill/session-types.d.mts +17 -0
  91. package/dist/retrospective-distill/session-types.mjs +1 -0
  92. package/dist/retrospective-distill/shape.d.mts +29 -0
  93. package/dist/retrospective-distill/shape.mjs +111 -0
  94. package/docs/mcp-server.md +95 -23
  95. package/package.json +20 -5
  96. package/skills/agent-workflow/SKILL.md +20 -0
  97. package/skills/agent-workflow/references/four-refs.md +74 -0
  98. package/skills/blackboard/SKILL.md +21 -1
  99. package/skills/github-issue/SKILL.md +6 -0
  100. package/skills/manifest.json +11 -5
  101. package/skills/postgres-node-performance-tuning/SKILL.md +2 -0
  102. package/skills/postgres-node-performance-tuning/references/performance-patterns.md +76 -0
  103. package/skills/postgres-node-performance-tuning/references/reading-plans.md +111 -0
  104. package/skills/postgres-schema-design/references/relations.md +18 -0
  105. package/skills/retrospective/SKILL.md +3 -1
  106. package/skills/retrospective-distill/SKILL.md +72 -4
  107. package/skills/revisit-followups/SKILL.md +17 -0
  108. package/skills/stacked-prs/SKILL.md +47 -16
  109. package/skills/stacked-prs/references/drain.md +168 -0
  110. package/skills/stacked-prs/references/gh-stack.md +143 -0
@@ -12,7 +12,8 @@ const FIELDS = {
12
12
  impact: text('Who or what is affected, as markdown.'),
13
13
  relatedIssues: {
14
14
  type: 'array',
15
- description: 'Issues this pull request closes or refs; may be empty.',
15
+ description: 'Issues this pull request closes or refs. Empty when it has none; with no source issue ' +
16
+ 'at all, give noSourceIssue as well (it is rejected beside a non-empty list).',
16
17
  items: {
17
18
  type: 'object',
18
19
  properties: {
@@ -24,6 +25,15 @@ const FIELDS = {
24
25
  additionalProperties: false,
25
26
  },
26
27
  },
28
+ noSourceIssue: {
29
+ type: 'object',
30
+ description: 'Declares a pull request with no source issue, mutually exclusive with a non-empty ' +
31
+ 'relatedIssues. Rendered inside Related issues (the policy may supply the wording) and ' +
32
+ 'passed to the policy, which may allow it only in interactive mode.',
33
+ properties: { reason: text('Single-line reason there is no source issue.') },
34
+ required: ['reason'],
35
+ additionalProperties: false,
36
+ },
27
37
  details: {
28
38
  type: 'array',
29
39
  minItems: 1,
@@ -33,7 +43,12 @@ const FIELDS = {
33
43
  base: { type: 'string', minLength: 1, description: 'Base branch. Default: main.' },
34
44
  agent: text('Agent name for the Agent line.'),
35
45
  model: text('Optional model name, rendered in parentheses after the agent.'),
36
- mode: { enum: ['interactive', 'autonomous'], description: 'Delivery mode, given to the policy.' },
46
+ mode: {
47
+ enum: ['interactive', 'autonomous'],
48
+ description: 'Optional. The server derives the mode from its own launch environment and fails closed: ' +
49
+ 'an automation marker (CI, GITHUB_ACTIONS and similar) means autonomous. This argument ' +
50
+ 'can only narrow interactive to autonomous, never widen it.',
51
+ },
37
52
  workspaceSetup: text('Text for the Workspace setup line, such as how the worktree was prepared.'),
38
53
  };
39
54
  const REQUIRED = [
@@ -43,7 +58,6 @@ const REQUIRED = [
43
58
  'relatedIssues',
44
59
  'details',
45
60
  'agent',
46
- 'mode',
47
61
  'workspaceSetup',
48
62
  ];
49
63
  const RENDERING = 'The server renders the body from the structured input (Summary, Impact, Related issues, ' +
@@ -54,7 +68,9 @@ export const PR_TOOLS = [
54
68
  tool('pr_validate', 'Renders the pull request body and validates it with the checkout policy; writes nothing. ' +
55
69
  RENDERING +
56
70
  'Pass pr to validate an update of that pull request: the live body is read and its Shepherd ' +
57
- 'Journal preserved. Returns { body, errors, warnings }.', openWorld(READ_ONLY), { ...FIELDS, pr: PR_NUMBER }, REQUIRED),
71
+ 'Journal preserved; the pull request may be in any state and on any head (the head given to ' +
72
+ 'the policy is its own, and no liveness check applies, unlike pr_update). Returns ' +
73
+ '{ body, errors, warnings }.', openWorld(READ_ONLY), { ...FIELDS, pr: PR_NUMBER }, REQUIRED),
58
74
  tool('pr_create', 'Creates a draft pull request from the worktree branch. ' +
59
75
  RENDERING +
60
76
  'Validation errors block creation. The policy pullRequestDefaults export may add labels, a ' +
@@ -15,9 +15,15 @@ export const RETROSPECTIVE_TOOLS = [
15
15
  'takes only { sessionId?, harnessSessionId? } (sessionId must be the call sessionId; the ' +
16
16
  'transcript is found by harnessSessionId, the harness session UUID, else by sessionId when ' +
17
17
  'that is a UUID, in the default session roots of the worktree) or { status, reason } to report it ' +
18
- 'unassessed; any path override is rejected. The server redacts its blackboard token ' +
19
- 'from the document before the policy sees it. dryRun returns ' +
20
- '{ markdown, policy: { warnings } } and writes nothing. The worktree package.json may ' +
18
+ 'unassessed; any path override is rejected. The server redacts its blackboard token and ' +
19
+ 'knownSensitiveValues (also accepted beside markdown, not beside compose) from the ' +
20
+ 'document, category and feedbackCoverage before the policy sees them. dryRun returns ' +
21
+ '{ markdown, previewToken, policy: { warnings } } and writes nothing; the previewToken is ' +
22
+ 'opaque, single use, held in server memory (a restart drops it) and bound to the sessionId, ' +
23
+ 'worktree and sourceEventId. To save exactly the previewed content, call again with ' +
24
+ 'previewToken, the identity, mode and the same sourceEventId, and no markdown, compose, ' +
25
+ 'workOutcome, feedbackCoverage, repositories, category or knownSensitiveValues. The ' +
26
+ 'policy is not run again for a token save. The worktree package.json may ' +
21
27
  'declare "vouchington-tooling": { "policy": "<module>" }; its validateRetrospective export ' +
22
28
  'runs in a child process, and any error it reports rejects the save with nothing written. ' +
23
29
  'Policy tools need a server launched inside a repository worktree. The server owns the ' +
@@ -29,7 +35,10 @@ export const RETROSPECTIVE_TOOLS = [
29
35
  'follow journal_append.', ADDITIVE, {
30
36
  ...IDENTITY,
31
37
  mode: { enum: ['interactive', 'autonomous'], description: 'Delivery mode.' },
32
- workOutcome: { enum: OUTCOMES, description: 'Outcome of the work being reported.' },
38
+ workOutcome: {
39
+ enum: OUTCOMES,
40
+ description: 'Outcome of the work being reported; required without previewToken.',
41
+ },
33
42
  feedbackCoverage: COVERAGE,
34
43
  repositories: {
35
44
  type: 'array',
@@ -44,5 +53,16 @@ export const RETROSPECTIVE_TOOLS = [
44
53
  sourceEventId: { type: 'string', pattern: ID_PATTERN, description: 'Stable event id.' },
45
54
  category: { type: 'string', description: 'Optional context; never changes entry type.' },
46
55
  dryRun: { type: 'boolean', description: 'Compose and validate only; write nothing.' },
47
- }, [...IDENTITY_REQUIRED, 'mode', 'workOutcome', 'feedbackCoverage']),
56
+ knownSensitiveValues: {
57
+ type: 'array',
58
+ items: { type: 'string', minLength: 4 },
59
+ description: 'Values redacted from every argument of a markdown save.',
60
+ },
61
+ previewToken: {
62
+ type: 'string',
63
+ description: 'Token from a dry run; saves exactly that content. Exclusive with the content.',
64
+ },
65
+ },
66
+ // workOutcome and feedbackCoverage are required unless previewToken carries them.
67
+ [...IDENTITY_REQUIRED, 'mode']),
48
68
  ];
@@ -58,5 +58,11 @@ export const TOOLS = [
58
58
  dataArrayContains: { type: 'object', description: 'Exact string membership in arrays.' },
59
59
  inactiveForHours: { type: 'number', exclusiveMinimum: 0 },
60
60
  }),
61
+ tool('snapshot_cleanup', 'Deletes the snapshot file (and partition directory, when one was recorded) that ' +
62
+ 'snapshot_export produced. Takes only the cleanupToken it returned: the server resolves the ' +
63
+ 'paths from its own record, never from the caller. The token is bound to the sessionId and ' +
64
+ 'worktree that exported, is single-use, and is forgotten when the server restarts (the ' +
65
+ 'file is then left for the OS to reap). A failed cleanup keeps the token valid and names ' +
66
+ 'any retained tombstone so the same call can retry. Call it as a finally step.', { ...MUTATING, idempotentHint: false }, { cleanupToken: { type: 'string', description: 'The cleanupToken snapshot_export returned.' } }, ['cleanupToken']),
61
67
  tool('session_archive', 'Marks a session as distilled. Its metadata becomes immutable; entries stay appendable.', MUTATING),
62
68
  ];
@@ -0,0 +1,29 @@
1
+ export type DiffLineChanges = {
2
+ added: number;
3
+ deleted: number;
4
+ };
5
+ export type LargeDiffThresholds = {
6
+ /** A diff adding strictly more lines than this is large. */
7
+ addedLines: number;
8
+ /** A diff deleting strictly more lines than this is large. */
9
+ deletedLines: number;
10
+ };
11
+ /**
12
+ * Example thresholds for a "decide whether to split this PR" gate: additions are the trigger for a
13
+ * split, while deletion-heavy retirement work stays coherent at a larger limit. Callers pass their
14
+ * own `LargeDiffThresholds`; these are only a default.
15
+ */
16
+ export declare const DEFAULT_LARGE_DIFF_THRESHOLDS: Readonly<LargeDiffThresholds>;
17
+ /**
18
+ * Added + removed lines in a unified diff (`git diff <base>...HEAD` output), excluding the
19
+ * `+++`/`---` file-header lines so renamed/moved files don't double-count their path lines.
20
+ *
21
+ * Known limitation: this matches the literal `+++`/`---` prefix, not "is this actually a file
22
+ * header." An added line whose content starts with `++`, or a removed line whose content starts
23
+ * with `--` (a SQL comment, a CLI flag), renders as `+++`/`---` once the diff marker is prepended
24
+ * and is silently excluded from the count. For a coarse "probably needs a split" heuristic with an
25
+ * explicit override, that undercount is acceptable; this is not a full hunk parser.
26
+ */
27
+ export declare function countDiffLineChanges(diffText: string): DiffLineChanges;
28
+ /** True when either count is strictly greater than its threshold. */
29
+ export declare function exceedsLargeDiffThreshold(changes: DiffLineChanges, thresholds?: LargeDiffThresholds): boolean;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Example thresholds for a "decide whether to split this PR" gate: additions are the trigger for a
3
+ * split, while deletion-heavy retirement work stays coherent at a larger limit. Callers pass their
4
+ * own `LargeDiffThresholds`; these are only a default.
5
+ */
6
+ export const DEFAULT_LARGE_DIFF_THRESHOLDS = Object.freeze({
7
+ addedLines: 5000,
8
+ deletedLines: 20_000,
9
+ });
10
+ /**
11
+ * Added + removed lines in a unified diff (`git diff <base>...HEAD` output), excluding the
12
+ * `+++`/`---` file-header lines so renamed/moved files don't double-count their path lines.
13
+ *
14
+ * Known limitation: this matches the literal `+++`/`---` prefix, not "is this actually a file
15
+ * header." An added line whose content starts with `++`, or a removed line whose content starts
16
+ * with `--` (a SQL comment, a CLI flag), renders as `+++`/`---` once the diff marker is prepended
17
+ * and is silently excluded from the count. For a coarse "probably needs a split" heuristic with an
18
+ * explicit override, that undercount is acceptable; this is not a full hunk parser.
19
+ */
20
+ export function countDiffLineChanges(diffText) {
21
+ const changes = { added: 0, deleted: 0 };
22
+ for (const line of diffText.split('\n')) {
23
+ if (line.startsWith('+++') || line.startsWith('---'))
24
+ continue;
25
+ if (line.startsWith('+'))
26
+ changes.added++;
27
+ else if (line.startsWith('-'))
28
+ changes.deleted++;
29
+ }
30
+ return changes;
31
+ }
32
+ /** True when either count is strictly greater than its threshold. */
33
+ export function exceedsLargeDiffThreshold(changes, thresholds = DEFAULT_LARGE_DIFF_THRESHOLDS) {
34
+ return changes.added > thresholds.addedLines || changes.deleted > thresholds.deletedLines;
35
+ }
@@ -0,0 +1,2 @@
1
+ export { countDiffLineChanges, DEFAULT_LARGE_DIFF_THRESHOLDS, exceedsLargeDiffThreshold, } from './diff-size.mts';
2
+ export type { DiffLineChanges, LargeDiffThresholds } from './diff-size.mts';
@@ -0,0 +1 @@
1
+ export { countDiffLineChanges, DEFAULT_LARGE_DIFF_THRESHOLDS, exceedsLargeDiffThreshold, } from "./diff-size.mjs";
@@ -0,0 +1,13 @@
1
+ export type DistillClassifyArgs = {
2
+ partitionPath: string;
3
+ retroCutoff: string;
4
+ sessionCutoff: string;
5
+ };
6
+ /**
7
+ * Parses `<partition.jsonl> --retro-cutoff <iso> --session-cutoff <iso>`.
8
+ *
9
+ * The classifier never computes its own cutoffs: the eligibility rule is evaluated against
10
+ * whatever `retroCutoff`/`sessionCutoff` the caller passes in, so a classifier run always matches
11
+ * the caller's own idea of "eligible" for that same run.
12
+ */
13
+ export declare function parseDistillClassifyArgs(argv: string[]): DistillClassifyArgs;
@@ -0,0 +1,27 @@
1
+ import { parseStringFlags } from "./flag-args.mjs";
2
+ function requireIsoCutoff(value, flag) {
3
+ if (value === undefined)
4
+ throw new Error(`${flag} is required`);
5
+ if (Number.isNaN(Date.parse(value)))
6
+ throw new Error(`${flag} must be a valid ISO 8601 timestamp`);
7
+ return value;
8
+ }
9
+ /**
10
+ * Parses `<partition.jsonl> --retro-cutoff <iso> --session-cutoff <iso>`.
11
+ *
12
+ * The classifier never computes its own cutoffs: the eligibility rule is evaluated against
13
+ * whatever `retroCutoff`/`sessionCutoff` the caller passes in, so a classifier run always matches
14
+ * the caller's own idea of "eligible" for that same run.
15
+ */
16
+ export function parseDistillClassifyArgs(argv) {
17
+ const { values, positional } = parseStringFlags(argv, ['--retro-cutoff', '--session-cutoff']);
18
+ const [partitionPath, ...extra] = positional;
19
+ if (partitionPath === undefined || extra.length > 0) {
20
+ throw new Error('expected exactly one positional argument: the partition JSONL path');
21
+ }
22
+ return {
23
+ partitionPath,
24
+ retroCutoff: requireIsoCutoff(values.get('--retro-cutoff'), '--retro-cutoff'),
25
+ sessionCutoff: requireIsoCutoff(values.get('--session-cutoff'), '--session-cutoff'),
26
+ };
27
+ }
@@ -0,0 +1,2 @@
1
+ import type { SessionEntry } from './session-types.mts';
2
+ export declare function isCheckpointEntry(entry: SessionEntry): boolean;
@@ -0,0 +1,11 @@
1
+ // Historic hook-written journal entries stay in the journal until they are archived, even though
2
+ // no hook appends them anymore: agents write their own journal entries. They kept
3
+ // `type: "journal"` and carry this structural `checkpoint` field, which is the only marker that
4
+ // tells one apart from an agent-written entry — the rendered markdown heading is never inspected.
5
+ // Session-shape classification (shape.mts) reads it so those sessions stay `checkpoint-only`.
6
+ const CHECKPOINT_KINDS = ['compaction', 'command-failure', 'pr-create', 'push'];
7
+ const CHECKPOINT_ENTRY_FIELD = 'checkpoint';
8
+ export function isCheckpointEntry(entry) {
9
+ const value = entry.data[CHECKPOINT_ENTRY_FIELD];
10
+ return typeof value === 'string' && CHECKPOINT_KINDS.includes(value);
11
+ }
@@ -0,0 +1,3 @@
1
+ import type { Session, SessionEntry } from './session-types.mts';
2
+ export declare function sessionFixture(overrides?: Partial<Session>): Session;
3
+ export declare function entryFixture(overrides?: Partial<SessionEntry>): SessionEntry;
@@ -0,0 +1,21 @@
1
+ export function sessionFixture(overrides = {}) {
2
+ return {
3
+ id: 'session-1',
4
+ parentSessionId: null,
5
+ agent: 'claude',
6
+ version: '1',
7
+ createdAt: '2026-07-01T00:00:00.000Z',
8
+ lastEntryAt: null,
9
+ archivedAt: null,
10
+ data: {},
11
+ ...overrides,
12
+ };
13
+ }
14
+ export function entryFixture(overrides = {}) {
15
+ return {
16
+ sessionId: 'session-1',
17
+ createdAt: '2026-07-01T00:00:00.000Z',
18
+ data: {},
19
+ ...overrides,
20
+ };
21
+ }
@@ -0,0 +1,5 @@
1
+ export type ParsedFlagArgs = {
2
+ values: Map<string, string>;
3
+ positional: string[];
4
+ };
5
+ export declare function parseStringFlags(argv: string[], flags: readonly string[]): ParsedFlagArgs;
@@ -0,0 +1,31 @@
1
+ // Walks argv for `--flag value` pairs. A value may itself start with a dash (a file path, say), so
2
+ // a missing value is `undefined`, the end-of-options marker, or another recognized flag.
3
+ export function parseStringFlags(argv, flags) {
4
+ const values = new Map();
5
+ const positional = [];
6
+ let optionsEnded = false;
7
+ let consumed = -1;
8
+ for (const [i, arg] of argv.entries()) {
9
+ if (i === consumed)
10
+ continue;
11
+ if (!optionsEnded && arg === '--') {
12
+ optionsEnded = true;
13
+ }
14
+ else if (!optionsEnded && flags.includes(arg)) {
15
+ const value = argv[i + 1];
16
+ if (value === undefined || value === '--' || flags.includes(value))
17
+ throw new Error(`${arg} requires a value`);
18
+ if (values.has(arg))
19
+ throw new Error(`${arg} may only be specified once`);
20
+ values.set(arg, value);
21
+ consumed = i + 1;
22
+ }
23
+ else if (!optionsEnded && arg.startsWith('-')) {
24
+ throw new Error(`unknown option: ${arg}`);
25
+ }
26
+ else {
27
+ positional.push(arg);
28
+ }
29
+ }
30
+ return { values, positional };
31
+ }
@@ -0,0 +1,10 @@
1
+ export { parseDistillClassifyArgs } from './args.mts';
2
+ export type { DistillClassifyArgs } from './args.mts';
3
+ export { isCheckpointEntry } from './checkpoint-entry.mts';
4
+ export { readPartitionRecords } from './partition-records.mts';
5
+ export type { PartitionData } from './partition-records.mts';
6
+ export { isRetrospectiveEntry } from './retrospective-entry.mts';
7
+ export { runDistillClassify } from './run.mts';
8
+ export { classifySession } from './shape.mts';
9
+ export type { DistillCutoffs, SessionClassification, SessionShape } from './shape.mts';
10
+ export type { Session, SessionEntry } from './session-types.mts';
@@ -0,0 +1,6 @@
1
+ export { parseDistillClassifyArgs } from "./args.mjs";
2
+ export { isCheckpointEntry } from "./checkpoint-entry.mjs";
3
+ export { readPartitionRecords } from "./partition-records.mjs";
4
+ export { isRetrospectiveEntry } from "./retrospective-entry.mjs";
5
+ export { runDistillClassify } from "./run.mjs";
6
+ export { classifySession } from "./shape.mjs";
@@ -0,0 +1,6 @@
1
+ import type { Session, SessionEntry } from './session-types.mts';
2
+ export type PartitionData = {
3
+ sessions: Session[];
4
+ entriesBySession: Map<string, SessionEntry[]>;
5
+ };
6
+ export declare function readPartitionRecords(path: string): Promise<PartitionData>;
@@ -0,0 +1,49 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ function isRecord(value) {
3
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
4
+ }
5
+ // Mirrors the record envelope the agent-blackboard client's own partition writer emits: one JSON
6
+ // object per line, discriminated by `type`. A partition file is written read-only only after its
7
+ // own terminal `manifest` line has already validated, so this reader trusts the bytes on disk — it
8
+ // does not re-verify partition integrity, only recognizes the shapes it can use. An unrecognized
9
+ // `type` (a future upstream record kind, or the top-level snapshot stream's own `error` record,
10
+ // which partition files never carry) is skipped rather than treated as fatal, so a
11
+ // forward-compatible upstream release can't break this classifier.
12
+ function parseLine(line) {
13
+ let record;
14
+ try {
15
+ record = JSON.parse(line);
16
+ }
17
+ catch {
18
+ throw new Error('partition contains a line that is not valid JSON');
19
+ }
20
+ if (!isRecord(record) || typeof record.type !== 'string') {
21
+ throw new Error('partition contains a record with no string "type" field');
22
+ }
23
+ return record;
24
+ }
25
+ export async function readPartitionRecords(path) {
26
+ const raw = await readFile(path, 'utf8');
27
+ const sessions = [];
28
+ const entriesBySession = new Map();
29
+ for (const line of raw.split('\n')) {
30
+ if (line.trim() === '')
31
+ continue;
32
+ const record = parseLine(line);
33
+ if (record.type === 'session' && isRecord(record.session)) {
34
+ sessions.push(record.session);
35
+ continue;
36
+ }
37
+ if (record.type === 'entry' && isRecord(record.entry)) {
38
+ const entry = record.entry;
39
+ const existing = entriesBySession.get(entry.sessionId);
40
+ if (existing)
41
+ existing.push(entry);
42
+ else
43
+ entriesBySession.set(entry.sessionId, [entry]);
44
+ }
45
+ // 'manifest' (the partition's own terminal line) and any other record type carry no
46
+ // session-shape information for this classifier and are intentionally ignored.
47
+ }
48
+ return { sessions, entriesBySession };
49
+ }
@@ -0,0 +1,3 @@
1
+ import type { SessionEntry } from './session-types.mts';
2
+ /** Whether a journal entry was written as a session retrospective. */
3
+ export declare function isRetrospectiveEntry(entry: SessionEntry): boolean;
@@ -0,0 +1,4 @@
1
+ /** Whether a journal entry was written as a session retrospective. */
2
+ export function isRetrospectiveEntry(entry) {
3
+ return entry.data.type === 'retrospective';
4
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Pure local JSONL parsing plus classification — no network, blackboard client, or writes, so it
3
+ * can run under a read-only inspector sandbox. Returns one tab-separated line per session.
4
+ */
5
+ export declare function runDistillClassify(argv: string[]): Promise<string>;
@@ -0,0 +1,28 @@
1
+ import { parseDistillClassifyArgs } from "./args.mjs";
2
+ import { readPartitionRecords } from "./partition-records.mjs";
3
+ import { classifySession } from "./shape.mjs";
4
+ function formatLine(sessionId, shape, eligible) {
5
+ return `${sessionId}\t${shape}\t${eligible ? 'eligible' : 'not-yet-eligible'}`;
6
+ }
7
+ /**
8
+ * Pure local JSONL parsing plus classification — no network, blackboard client, or writes, so it
9
+ * can run under a read-only inspector sandbox. Returns one tab-separated line per session.
10
+ */
11
+ export async function runDistillClassify(argv) {
12
+ const args = parseDistillClassifyArgs(argv);
13
+ const { sessions, entriesBySession } = await readPartitionRecords(args.partitionPath);
14
+ if (sessions.length === 0)
15
+ return 'No sessions found in partition.';
16
+ const cutoffs = { retroCutoff: args.retroCutoff, sessionCutoff: args.sessionCutoff };
17
+ return sessions
18
+ .map((session) => {
19
+ const entries = entriesBySession.get(session.id) ?? [];
20
+ const { shape, eligible, quarantine, duplicateEntryCount } = classifySession(session, entries, cutoffs);
21
+ const line = formatLine(session.id, shape, eligible) +
22
+ (duplicateEntryCount ? `\tduplicate-events=${duplicateEntryCount}` : '');
23
+ return quarantine
24
+ ? `${line}\tquarantined=${quarantine.entryCount};reasons=${quarantine.reasons.join(',')}`
25
+ : line;
26
+ })
27
+ .join('\n');
28
+ }
@@ -0,0 +1,17 @@
1
+ /** Structurally compatible with the agent-blackboard client's `Session`, so no client install is needed. */
2
+ export interface Session {
3
+ id: string;
4
+ parentSessionId: string | null;
5
+ agent: string;
6
+ version: string;
7
+ createdAt: string;
8
+ lastEntryAt: string | null;
9
+ archivedAt: string | null;
10
+ data: Record<string, unknown>;
11
+ }
12
+ /** Structurally compatible with the agent-blackboard client's `SessionEntry`. */
13
+ export interface SessionEntry {
14
+ sessionId: string;
15
+ createdAt: string;
16
+ data: Record<string, unknown>;
17
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import type { Session, SessionEntry } from './session-types.mts';
2
+ /**
3
+ * Session shape taxonomy and distillation eligibility.
4
+ *
5
+ * This code is the source of truth for both. The retrospective-distill skill is the canonical
6
+ * human-facing description; when an inspector's hand classification and this output disagree on
7
+ * the same session, fix the skill to match this code, not the other way around.
8
+ *
9
+ * `zero-entry-child` / `zero-entry-root` name a session-topology fact (whether `parentSessionId`
10
+ * is set), not a completion signal: a delegated child that dies mid-task is a zero-entry-child
11
+ * session too, indistinguishable here from one that finished its bounded assignment cleanly.
12
+ * Docs referencing this split must not imply "child" means "completed normally".
13
+ */
14
+ export type SessionShape = 'retrospective' | 'entry-type-unresolved' | 'checkpoint-only' | 'journal-only' | 'zero-entry-child' | 'zero-entry-root';
15
+ export type SessionClassification = {
16
+ sessionId: string;
17
+ shape: SessionShape;
18
+ eligible: boolean;
19
+ quarantine?: {
20
+ entryCount: number;
21
+ reasons: string[];
22
+ };
23
+ duplicateEntryCount?: number;
24
+ };
25
+ export type DistillCutoffs = {
26
+ retroCutoff: string;
27
+ sessionCutoff: string;
28
+ };
29
+ export declare function classifySession(session: Session, entries: SessionEntry[], cutoffs: DistillCutoffs): SessionClassification;
@@ -0,0 +1,111 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { validateFeedbackEnvelope } from "../agent-blackboard/index.mjs";
3
+ import { isCheckpointEntry } from "./checkpoint-entry.mjs";
4
+ import { isRetrospectiveEntry } from "./retrospective-entry.mjs";
5
+ function quarantineReason(entry) {
6
+ if (entry.data.type !== 'journal' && entry.data.type !== 'retrospective')
7
+ return 'unknown-entry-type';
8
+ if (entry.data.schemaVersion !== undefined) {
9
+ try {
10
+ validateFeedbackEnvelope(entry.data);
11
+ }
12
+ catch {
13
+ return 'invalid-feedback-envelope';
14
+ }
15
+ }
16
+ return undefined;
17
+ }
18
+ function hasUnresolvedType(entry) {
19
+ return quarantineReason(entry) !== undefined;
20
+ }
21
+ function classifyShape(session, entries) {
22
+ if (entries.some(hasUnresolvedType))
23
+ return 'entry-type-unresolved';
24
+ if (entries.some(isRetrospectiveEntry))
25
+ return 'retrospective';
26
+ if (entries.length === 0) {
27
+ // Loose equality on purpose: a partition record missing `parentSessionId` entirely (the reader
28
+ // trusts the file on disk and does not re-validate every field against the Session interface —
29
+ // see partition-records.mts) must fall to zero-entry-root. This is a topology and capture-gap
30
+ // signal, never evidence of crash or clean completion. `=== null` would silently misroute
31
+ // `undefined` into the wrong, discarded bucket.
32
+ return session.parentSessionId == null ? 'zero-entry-root' : 'zero-entry-child';
33
+ }
34
+ return entries.every(isCheckpointEntry) ? 'checkpoint-only' : 'journal-only';
35
+ }
36
+ function newestRetrospectiveAt(entries) {
37
+ let newest;
38
+ for (const entry of entries) {
39
+ if (!isRetrospectiveEntry(entry))
40
+ continue;
41
+ if (newest === undefined || Date.parse(entry.createdAt) > Date.parse(newest))
42
+ newest = entry.createdAt;
43
+ }
44
+ return newest;
45
+ }
46
+ // Eligibility rule:
47
+ // (has a retrospective entry AND retroAt < retroCutoff) OR (lastActive < sessionCutoff).
48
+ // An entry-type-unresolved session gets no issue-filing or archival pass "this run, regardless of
49
+ // its age", so it is never eligible — that check short-circuits before the formula.
50
+ function isEligible(session, entries, shape, cutoffs) {
51
+ if (shape === 'entry-type-unresolved')
52
+ return false;
53
+ const retroAt = newestRetrospectiveAt(entries);
54
+ const staleRetro = retroAt !== undefined && Date.parse(retroAt) < Date.parse(cutoffs.retroCutoff);
55
+ const lastActive = entries.reduce((latest, entry) => Date.parse(entry.createdAt) > Date.parse(latest) ? entry.createdAt : latest, session.createdAt);
56
+ const staleSession = Date.parse(lastActive) < Date.parse(cutoffs.sessionCutoff);
57
+ return staleRetro || staleSession;
58
+ }
59
+ // An event is its session, `sourceEventId`, and content; the server assigns `timestamp` per attempt
60
+ // (a retry or a late write beside it can store the same event twice), so it is not identity.
61
+ function withoutTimestamp(data) {
62
+ return Object.fromEntries(Object.entries(data).filter(([key]) => key !== 'timestamp'));
63
+ }
64
+ function normalizeSourceEvents(entries) {
65
+ const events = new Map();
66
+ const unique = [];
67
+ let duplicateEntryCount = 0;
68
+ let conflictCount = 0;
69
+ for (const entry of entries) {
70
+ if (entry.data.schemaVersion !== 1 || quarantineReason(entry) !== undefined) {
71
+ unique.push(entry);
72
+ continue;
73
+ }
74
+ const key = `${entry.sessionId}:${String(entry.data.sourceEventId)}`;
75
+ const existing = events.get(key);
76
+ if (!existing) {
77
+ events.set(key, entry);
78
+ unique.push(entry);
79
+ continue;
80
+ }
81
+ if (!isDeepStrictEqual(withoutTimestamp(existing.data), withoutTimestamp(entry.data))) {
82
+ conflictCount++;
83
+ continue;
84
+ }
85
+ duplicateEntryCount++;
86
+ // At-least-once replay does not postpone a source event's distillation age.
87
+ if (Date.parse(entry.createdAt) < Date.parse(existing.createdAt)) {
88
+ unique[unique.indexOf(existing)] = entry;
89
+ events.set(key, entry);
90
+ }
91
+ }
92
+ return { unique, duplicateEntryCount, conflictCount };
93
+ }
94
+ export function classifySession(session, entries, cutoffs) {
95
+ const { unique, duplicateEntryCount, conflictCount } = normalizeSourceEvents(entries);
96
+ const shape = conflictCount ? 'entry-type-unresolved' : classifyShape(session, unique);
97
+ const reasons = entries
98
+ .map(quarantineReason)
99
+ .filter((reason) => reason !== undefined);
100
+ for (let index = 0; index < conflictCount; index++)
101
+ reasons.push('conflicting-source-event');
102
+ return {
103
+ sessionId: session.id,
104
+ shape,
105
+ eligible: isEligible(session, unique, shape, cutoffs),
106
+ ...(duplicateEntryCount ? { duplicateEntryCount } : {}),
107
+ ...(reasons.length === 0
108
+ ? {}
109
+ : { quarantine: { entryCount: reasons.length, reasons: [...new Set(reasons)].toSorted() } }),
110
+ };
111
+ }