vouchington-tooling 0.42.0 → 0.44.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 (93) hide show
  1. package/README.md +45 -1
  2. package/dist/cli/commands/fresh-base.d.mts +6 -0
  3. package/dist/cli/commands/fresh-base.mjs +14 -0
  4. package/dist/cli/index.mjs +3 -0
  5. package/dist/cli/parse-fresh-base.d.mts +2 -0
  6. package/dist/cli/parse-fresh-base.mjs +15 -0
  7. package/dist/cli/parse.d.mts +5 -0
  8. package/dist/cli/parse.mjs +3 -0
  9. package/dist/cli/usage.d.mts +1 -1
  10. package/dist/cli/usage.mjs +3 -0
  11. package/dist/fresh-base/classify.d.mts +21 -0
  12. package/dist/fresh-base/classify.mjs +29 -0
  13. package/dist/fresh-base/index.d.mts +4 -0
  14. package/dist/fresh-base/index.mjs +2 -0
  15. package/dist/fresh-base/run.d.mts +25 -0
  16. package/dist/fresh-base/run.mjs +66 -0
  17. package/dist/gh-cli/closing-refs.d.mts +24 -0
  18. package/dist/gh-cli/closing-refs.mjs +37 -0
  19. package/dist/gh-cli/index.d.mts +3 -0
  20. package/dist/gh-cli/index.mjs +2 -0
  21. package/dist/gh-cli/non-closing-refs.d.mts +11 -0
  22. package/dist/gh-cli/non-closing-refs.mjs +24 -0
  23. package/dist/gh-cli/pr-create.d.mts +2 -0
  24. package/dist/gh-cli/pr-create.mjs +5 -1
  25. package/dist/index.d.mts +6 -0
  26. package/dist/index.mjs +3 -0
  27. package/dist/mcp-server/context.d.mts +11 -0
  28. package/dist/mcp-server/dispatch.d.mts +8 -0
  29. package/dist/mcp-server/dispatch.mjs +7 -0
  30. package/dist/mcp-server/handlers-pr.d.mts +4 -0
  31. package/dist/mcp-server/handlers-pr.mjs +143 -0
  32. package/dist/mcp-server/harness.test-helpers.d.mts +8 -1
  33. package/dist/mcp-server/harness.test-helpers.mjs +3 -1
  34. package/dist/mcp-server/pr-body-file.d.mts +4 -0
  35. package/dist/mcp-server/pr-body-file.mjs +22 -0
  36. package/dist/mcp-server/pr-body.d.mts +20 -0
  37. package/dist/mcp-server/pr-body.mjs +47 -0
  38. package/dist/mcp-server/pr-defaults.d.mts +16 -0
  39. package/dist/mcp-server/pr-defaults.mjs +35 -0
  40. package/dist/mcp-server/pr-gh.d.mts +16 -0
  41. package/dist/mcp-server/pr-gh.mjs +56 -0
  42. package/dist/mcp-server/pr-input.d.mts +26 -0
  43. package/dist/mcp-server/pr-input.mjs +62 -0
  44. package/dist/mcp-server/pr-journal.d.mts +12 -0
  45. package/dist/mcp-server/pr-journal.mjs +86 -0
  46. package/dist/mcp-server/pr-live.d.mts +24 -0
  47. package/dist/mcp-server/pr-live.mjs +49 -0
  48. package/dist/mcp-server/pr-tools.test-helpers.d.mts +54 -0
  49. package/dist/mcp-server/pr-tools.test-helpers.mjs +67 -0
  50. package/dist/mcp-server/tools-pr.d.mts +2 -0
  51. package/dist/mcp-server/tools-pr.mjs +69 -0
  52. package/dist/mcp-server/tools.mjs +2 -0
  53. package/dist/pull-request-diff/diff-size.d.mts +29 -0
  54. package/dist/pull-request-diff/diff-size.mjs +35 -0
  55. package/dist/pull-request-diff/index.d.mts +2 -0
  56. package/dist/pull-request-diff/index.mjs +1 -0
  57. package/dist/retrospective-distill/args.d.mts +13 -0
  58. package/dist/retrospective-distill/args.mjs +27 -0
  59. package/dist/retrospective-distill/checkpoint-entry.d.mts +2 -0
  60. package/dist/retrospective-distill/checkpoint-entry.mjs +11 -0
  61. package/dist/retrospective-distill/fixtures.test-helpers.d.mts +3 -0
  62. package/dist/retrospective-distill/fixtures.test-helpers.mjs +21 -0
  63. package/dist/retrospective-distill/flag-args.d.mts +5 -0
  64. package/dist/retrospective-distill/flag-args.mjs +31 -0
  65. package/dist/retrospective-distill/index.d.mts +10 -0
  66. package/dist/retrospective-distill/index.mjs +6 -0
  67. package/dist/retrospective-distill/partition-records.d.mts +6 -0
  68. package/dist/retrospective-distill/partition-records.mjs +49 -0
  69. package/dist/retrospective-distill/retrospective-entry.d.mts +3 -0
  70. package/dist/retrospective-distill/retrospective-entry.mjs +4 -0
  71. package/dist/retrospective-distill/run.d.mts +5 -0
  72. package/dist/retrospective-distill/run.mjs +28 -0
  73. package/dist/retrospective-distill/session-types.d.mts +17 -0
  74. package/dist/retrospective-distill/session-types.mjs +1 -0
  75. package/dist/retrospective-distill/shape.d.mts +29 -0
  76. package/dist/retrospective-distill/shape.mjs +111 -0
  77. package/docs/mcp-server.md +80 -6
  78. package/package.json +18 -3
  79. package/skills/agent-workflow/SKILL.md +20 -0
  80. package/skills/agent-workflow/references/four-refs.md +74 -0
  81. package/skills/blackboard/SKILL.md +21 -1
  82. package/skills/github-issue/SKILL.md +6 -0
  83. package/skills/manifest.json +11 -5
  84. package/skills/postgres-node-performance-tuning/SKILL.md +2 -0
  85. package/skills/postgres-node-performance-tuning/references/performance-patterns.md +76 -0
  86. package/skills/postgres-node-performance-tuning/references/reading-plans.md +111 -0
  87. package/skills/postgres-schema-design/references/relations.md +18 -0
  88. package/skills/pr-description/SKILL.md +8 -0
  89. package/skills/retrospective-distill/SKILL.md +67 -4
  90. package/skills/revisit-followups/SKILL.md +17 -0
  91. package/skills/stacked-prs/SKILL.md +47 -16
  92. package/skills/stacked-prs/references/drain.md +168 -0
  93. package/skills/stacked-prs/references/gh-stack.md +143 -0
@@ -0,0 +1,49 @@
1
+ import { findShepherdJournal } from "./pr-journal.mjs";
2
+ const FIELDS = ['body', 'headRefName', 'baseRefName', 'state'];
3
+ /** The live pull request as gh reports it, rejected unless it is open. */
4
+ export async function livePullRequest(gh, pr) {
5
+ const output = await gh(['pr', 'view', String(pr), '--json', FIELDS.join(',')]);
6
+ const live = JSON.parse(output);
7
+ if (!isLive(live))
8
+ throw new Error(`gh pr view ${pr} did not return ${FIELDS.join(', ')}`);
9
+ // A closed or merged PR can share a reused branch name; its history must not be rewritten.
10
+ if (live.state !== 'OPEN')
11
+ throw new Error(`pull request ${pr} is ${live.state}, not OPEN`);
12
+ return live;
13
+ }
14
+ function isLive(value) {
15
+ return FIELDS.every((field) => typeof value?.[field] === 'string');
16
+ }
17
+ /**
18
+ * The base to validate an existing pull request against: its live base. An explicit `base` that
19
+ * differs is rejected, because `gh pr edit` here changes only the title and body.
20
+ */
21
+ export function liveBase(pr, live, given) {
22
+ if (given !== undefined && given !== live.baseRefName)
23
+ throw new Error(`pull request ${pr} targets ${live.baseRefName}, not ${given}`);
24
+ return live.baseRefName;
25
+ }
26
+ /**
27
+ * Re-reads the live pull request just before the edit and aborts if its Shepherd Journal or base
28
+ * changed since the body was prepared and validated, so an entry appended in between is never
29
+ * overwritten and a retargeted PR never gets a body validated against its old base.
30
+ */
31
+ export async function assertJournalUnchanged(gh, pr, preparedFrom, base) {
32
+ const now = await livePullRequest(gh, pr);
33
+ if (now.baseRefName !== base)
34
+ throw new Error(`pull request ${pr} was retargeted to ${now.baseRefName}; run pr_update again`);
35
+ if (findShepherdJournal(now.body) !== findShepherdJournal(preparedFrom))
36
+ throw new Error(`the Shepherd Journal of pull request ${pr} changed meanwhile; run pr_update again`);
37
+ }
38
+ /** The server's credential values that must never reach the policy or GitHub, longest first. */
39
+ export function credentials(env) {
40
+ const values = [env.AGENT_BLACKBOARD_TOKEN, env.GH_TOKEN, env.GITHUB_TOKEN].filter((value) => value !== undefined && value.length >= 4);
41
+ return [...new Set(values)].sort((a, b) => b.length - a.length);
42
+ }
43
+ /** Replaces every server credential in every string of a JSON-shaped value. */
44
+ export function redactCredentials(value, secrets) {
45
+ if (value === undefined || secrets.length === 0)
46
+ return value;
47
+ const json = secrets.reduce((text, secret) => text.split(JSON.stringify(secret).slice(1, -1)).join('[REDACTED]'), JSON.stringify(value));
48
+ return JSON.parse(json);
49
+ }
@@ -0,0 +1,54 @@
1
+ import type { RepoFixture } from './git-fixture.test-helpers.mts';
2
+ import type { GhRunner } from './pr-gh.mts';
3
+ import type { PolicyOutcome, PolicyRequest } from './policy-spawn.mts';
4
+ export declare const PR_ARGS: {
5
+ sessionId: string;
6
+ title: string;
7
+ summary: string;
8
+ impact: string;
9
+ relatedIssues: {
10
+ number: number;
11
+ relation: string;
12
+ reason: string;
13
+ }[];
14
+ details: string[];
15
+ base: string;
16
+ agent: string;
17
+ mode: string;
18
+ workspaceSetup: string;
19
+ };
20
+ export declare function declarePolicy(fixture: RepoFixture): void;
21
+ type Reply = (name: string, input: Record<string, unknown>) => unknown;
22
+ export declare function policy(reply: Reply): {
23
+ requests: {
24
+ name: string;
25
+ input: Record<string, unknown>;
26
+ }[];
27
+ spawnPolicy: (request: PolicyRequest) => Promise<PolicyOutcome>;
28
+ };
29
+ type GhCall = {
30
+ args: string[];
31
+ cwd: string;
32
+ env: NodeJS.ProcessEnv;
33
+ body?: string;
34
+ };
35
+ /** A fake gh: records calls (reading any --body-file while it still exists) and replies by verb. */
36
+ export declare function fakeGh(replies?: {
37
+ view?: unknown;
38
+ create?: string;
39
+ edit?: string;
40
+ }): {
41
+ calls: GhCall[];
42
+ bodyFiles: {
43
+ path: string;
44
+ mode: number;
45
+ }[];
46
+ runGh: GhRunner;
47
+ };
48
+ export declare const device: () => {
49
+ user: string;
50
+ host: string;
51
+ };
52
+ /** Gives the fixture an `origin` that already has the linked branch at its current commit. */
53
+ export declare function usePushedRemote(fixture: () => RepoFixture): void;
54
+ export {};
@@ -0,0 +1,67 @@
1
+ import { readFileSync, statSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { beforeEach } from 'vitest';
4
+ export const PR_ARGS = {
5
+ sessionId: 'native:owner',
6
+ title: 'Add the thing',
7
+ summary: 'Adds the thing.',
8
+ impact: 'Nobody breaks.',
9
+ relatedIssues: [{ number: 7, relation: 'closes', reason: 'tracks the work' }],
10
+ details: ['First paragraph.', '- a bullet'],
11
+ base: 'trunk',
12
+ agent: 'codex',
13
+ mode: 'autonomous',
14
+ workspaceSetup: 'fresh worktree',
15
+ };
16
+ export function declarePolicy(fixture) {
17
+ writeFileSync(join(fixture.linked, 'package.json'), JSON.stringify({ 'vouchington-tooling': { policy: 'policy.mts' } }));
18
+ writeFileSync(join(fixture.linked, 'policy.mts'), '');
19
+ }
20
+ export function policy(reply) {
21
+ const requests = [];
22
+ const spawnPolicy = async (request) => {
23
+ const { export: name, input } = JSON.parse(request.input);
24
+ requests.push({ name, input });
25
+ const result = reply(name, input);
26
+ const stdout = result === undefined ? { ok: true, skipped: true } : { ok: true, result };
27
+ return {
28
+ exitCode: 0,
29
+ stdout: JSON.stringify(stdout),
30
+ stderr: '',
31
+ timedOut: false,
32
+ overflowed: false,
33
+ };
34
+ };
35
+ return { requests, spawnPolicy };
36
+ }
37
+ /** A fake gh: records calls (reading any --body-file while it still exists) and replies by verb. */
38
+ export function fakeGh(replies = {}) {
39
+ const calls = [];
40
+ const bodyFiles = [];
41
+ const runGh = async (args, options) => {
42
+ const call = { args, ...options };
43
+ const file = args[args.indexOf('--body-file') + 1];
44
+ if (args.includes('--body-file') && file !== undefined) {
45
+ call.body = readFileSync(file, 'utf8');
46
+ bodyFiles.push({ path: file, mode: statSync(file).mode & 0o777 });
47
+ }
48
+ calls.push(call);
49
+ if (args[1] === 'view')
50
+ return JSON.stringify(replies.view);
51
+ if (args[1] === 'create')
52
+ return replies.create ?? 'https://github.com/o/r/pull/12\n';
53
+ return replies.edit ?? 'https://github.com/o/r/pull/5\n';
54
+ };
55
+ return { calls, bodyFiles, runGh };
56
+ }
57
+ export const device = () => ({ user: 'Jong', host: 'Laptop.Local' });
58
+ /** Gives the fixture an `origin` that already has the linked branch at its current commit. */
59
+ export function usePushedRemote(fixture) {
60
+ beforeEach(() => {
61
+ const f = fixture();
62
+ const origin = join(f.root, 'origin.git');
63
+ f.git(f.root, 'init', '--quiet', '--bare', origin);
64
+ f.git(f.linked, 'remote', 'add', 'origin', origin);
65
+ f.git(f.linked, 'push', '--quiet', 'origin', 'linked');
66
+ });
67
+ }
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from './tools-schema.mts';
2
+ export declare const PR_TOOLS: readonly ToolDefinition[];
@@ -0,0 +1,69 @@
1
+ import { ADDITIVE, MUTATING, READ_ONLY, tool, } from "./tools-schema.mjs";
2
+ /** These tools read or write GitHub, outside the server's own state. */
3
+ const openWorld = (annotations) => ({
4
+ ...annotations,
5
+ openWorldHint: true,
6
+ });
7
+ const text = (description) => ({ type: 'string', minLength: 1, description });
8
+ const PR_NUMBER = { type: 'integer', minimum: 1, description: 'Pull request number.' };
9
+ const FIELDS = {
10
+ title: { type: 'string', minLength: 1, maxLength: 256, description: 'Single-line PR title.' },
11
+ summary: text('What the change does, as markdown.'),
12
+ impact: text('Who or what is affected, as markdown.'),
13
+ relatedIssues: {
14
+ type: 'array',
15
+ description: 'Issues this pull request closes or refs; may be empty.',
16
+ items: {
17
+ type: 'object',
18
+ properties: {
19
+ number: { type: 'integer', minimum: 1 },
20
+ relation: { enum: ['closes', 'refs'] },
21
+ reason: text('Why the issue is related.'),
22
+ },
23
+ required: ['number', 'relation', 'reason'],
24
+ additionalProperties: false,
25
+ },
26
+ },
27
+ details: {
28
+ type: 'array',
29
+ minItems: 1,
30
+ items: { type: 'string', minLength: 1 },
31
+ description: 'Markdown paragraphs or bullets, joined by blank lines.',
32
+ },
33
+ base: { type: 'string', minLength: 1, description: 'Base branch. Default: main.' },
34
+ agent: text('Agent name for the Agent line.'),
35
+ model: text('Optional model name, rendered in parentheses after the agent.'),
36
+ mode: { enum: ['interactive', 'autonomous'], description: 'Delivery mode, given to the policy.' },
37
+ workspaceSetup: text('Text for the Workspace setup line, such as how the worktree was prepared.'),
38
+ };
39
+ const REQUIRED = [
40
+ 'title',
41
+ 'summary',
42
+ 'impact',
43
+ 'relatedIssues',
44
+ 'details',
45
+ 'agent',
46
+ 'mode',
47
+ 'workspaceSetup',
48
+ ];
49
+ const RENDERING = 'The server renders the body from the structured input (Summary, Impact, Related issues, ' +
50
+ 'Details, then Workspace setup, Agent, Device and Worktree lines) and runs the checkout ' +
51
+ 'policy validatePullRequest export in a child process; the head branch is the ' +
52
+ "worktree's current branch. Policy tools need a server launched inside a repository worktree. ";
53
+ export const PR_TOOLS = [
54
+ tool('pr_validate', 'Renders the pull request body and validates it with the checkout policy; writes nothing. ' +
55
+ RENDERING +
56
+ '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),
58
+ tool('pr_create', 'Creates a draft pull request from the worktree branch. ' +
59
+ RENDERING +
60
+ 'Validation errors block creation. The policy pullRequestDefaults export may add labels, a ' +
61
+ 'milestone and a project. The branch must already be pushed and current. It sees no stack ' +
62
+ 'membership, so a policy may reject a non-default base; create stacked layers with the ' +
63
+ "repository's stack tooling. Returns { number, url, warnings }.", openWorld(ADDITIVE), FIELDS, REQUIRED),
64
+ tool('pr_update', "Replaces an open pull request's title and body. " +
65
+ RENDERING +
66
+ "The pull request's head branch must be the worktree branch. The live Shepherd Journal is " +
67
+ 'preserved and nothing else is carried over from the live body. Validation errors block ' +
68
+ 'the update. Returns { number, url, warnings }.', openWorld(MUTATING), { ...FIELDS, pr: PR_NUMBER }, [...REQUIRED, 'pr']),
69
+ ];
@@ -1,4 +1,5 @@
1
1
  import { ADDITIVE, AGENT, COVERAGE, IDEMPOTENT_ADDITIVE, IDENTITY, IDENTITY_REQUIRED, ID_PATTERN, MUTATING, OUTCOMES, PARENT_SESSION_ID, READ_ONLY, VERSION, tool, } from "./tools-schema.mjs";
2
+ import { PR_TOOLS } from "./tools-pr.mjs";
2
3
  import { RETROSPECTIVE_TOOLS } from "./tools-retrospective.mjs";
3
4
  export const TOOLS = [
4
5
  tool('journal_append', 'Appends one journal entry to the caller session and returns the verified read-back receipt. ' +
@@ -46,6 +47,7 @@ export const TOOLS = [
46
47
  tool('session_ensure', 'Idempotently creates a session, or verifies that an existing one has the same identity. ' +
47
48
  'Returns the session id and whether it was created.', IDEMPOTENT_ADDITIVE, IDENTITY, IDENTITY_REQUIRED),
48
49
  ...RETROSPECTIVE_TOOLS,
50
+ ...PR_TOOLS,
49
51
  tool('snapshot_export', 'Exports non-archived sessions into a private local JSONL file chosen by the blackboard ' +
50
52
  'client. Returns the path, counts, checksum, manifest, and cleanupToken; never the records. ' +
51
53
  'sessionId identifies the caller.', ADDITIVE, {
@@ -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;