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
@@ -13,6 +13,8 @@ export type FakeOptions = {
13
13
  omitArchive?: boolean;
14
14
  omitSnapshots?: boolean;
15
15
  exportResult?: unknown;
16
+ /** Gives the fake client its own capability-aware snapshot cleanup. */
17
+ clientCleanup?: (options: unknown) => Promise<void>;
16
18
  /** Makes `ensure` fail for any other identity, and `get` report a session with this one. */
17
19
  conflictingSession?: {
18
20
  id: string;
@@ -81,6 +81,9 @@ export function fakeBlackboard(options = {}) {
81
81
  Sessions,
82
82
  Entries,
83
83
  ...(options.omitSnapshots ? {} : { Snapshots }),
84
+ ...(options.clientCleanup === undefined
85
+ ? {}
86
+ : { cleanupSnapshotPartitions: options.clientCleanup }),
84
87
  };
85
88
  return { dependencies: { loadClient: async () => module }, calls, stored };
86
89
  }
@@ -1,4 +1,4 @@
1
- import { type ToolHandler } from './context.mts';
1
+ import type { ToolHandler } from './context.mts';
2
2
  export declare const handlePrValidate: ToolHandler;
3
3
  export declare const handlePrCreate: ToolHandler;
4
4
  export declare const handlePrUpdate: ToolHandler;
@@ -1,115 +1,24 @@
1
- import { hostname, userInfo } from 'node:os';
2
- import { validateGitHubBodyLength } from "../gh-cli/body-length.mjs";
3
1
  import { createPullRequest } from "../gh-cli/pr-create.mjs";
4
- import { assertPolicyAllowed } from "./context.mjs";
5
- import { runValidator } from "./policy-run.mjs";
6
- import { renderPullRequestBody, worktreeLabel } from "./pr-body.mjs";
7
2
  import { pullRequestNumber, withBodyFile } from "./pr-body-file.mjs";
8
3
  import { pullRequestDefaults } from "./pr-defaults.mjs";
9
- import { createGhRunner, ghEnvironment } from "./pr-gh.mjs";
10
- import { checkTitleLength, positiveInteger, readPullRequestInput, } from "./pr-input.mjs";
11
- import { preserveShepherdJournal } from "./pr-journal.mjs";
12
- import { assertJournalUnchanged, credentials, liveBase, livePullRequest, redactCredentials, } from "./pr-live.mjs";
13
- import { parseWorktreeList } from "./worktree.mjs";
14
- function gitIn(context) {
15
- return (args) => context.runGit(['-C', context.worktree, ...args]);
16
- }
17
- function ghIn(context) {
18
- const run = context.runGh ?? createGhRunner(context.env);
19
- const env = ghEnvironment(context.env);
20
- return (args) => run(args, { cwd: context.worktree, env });
21
- }
22
- async function currentBranch(git) {
23
- const branch = (await git(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
24
- if (branch === 'HEAD')
25
- throw new Error('the worktree has a detached HEAD: check out a branch');
26
- return branch;
27
- }
28
- async function label(context, git) {
29
- const listing = await git(['worktree', 'list', '--porcelain', '-z']);
30
- return worktreeLabel(context.worktree, parseWorktreeList(listing)[0]);
31
- }
4
+ import { positiveInteger } from "./pr-input.mjs";
5
+ import { assertJournalUnchanged } from "./pr-live.mjs";
6
+ import { ghIn, gitIn, policyFor, prepare } from "./pr-prepare.mjs";
32
7
  function readPr(args, required) {
33
8
  if (args.pr === undefined && !required)
34
9
  return undefined;
35
10
  return positiveInteger(args.pr, 'pr');
36
11
  }
37
- function policyFor(context) {
38
- return {
39
- worktree: context.worktree,
40
- env: context.env,
41
- ...(context.spawnPolicy === undefined ? {} : { spawn: context.spawnPolicy }),
42
- };
43
- }
44
- /** Renders the body, preserves the live journal for an update, and runs the policy validator. */
45
- async function prepare(args, context, pr) {
46
- assertPolicyAllowed(context);
47
- // No server credential may reach the policy or gh, whatever the caller wrote: every field is
48
- // redacted before anything is rendered or forwarded.
49
- const secrets = credentials(context.env);
50
- const given = redactCredentials(readPullRequestInput(args), secrets);
51
- checkTitleLength(given.title);
52
- const git = gitIn(context);
53
- const head = await currentBranch(git);
54
- // Branch names are refs passed to gh as is: redacting one would name a branch that does not exist.
55
- if (secrets.some((secret) => head.includes(secret)))
56
- throw new Error('the worktree branch name contains a server credential: rename the branch');
57
- if (secrets.some((secret) => args.base?.includes(secret)))
58
- throw new Error('base contains a server credential: use another base branch');
59
- const live = pr === undefined ? undefined : await livePullRequest(ghIn(context), pr);
60
- if (live !== undefined && live.headRefName !== head)
61
- throw new Error(`pull request ${pr} has head ${live.headRefName}, not the worktree branch ${head}`);
62
- const base = live === undefined
63
- ? given.base
64
- : liveBase(pr, live, redactCredentials(args.base, secrets));
65
- const input = { ...given, base };
66
- if (head === base)
67
- throw new Error(`head branch ${head} must differ from base ${base}`);
68
- const device = (context.device ?? (() => ({ user: userInfo().username, host: hostname() })))();
69
- let body = renderPullRequestBody(input, {
70
- sessionId: context.sessionId,
71
- ...device,
72
- worktree: await label(context, git),
73
- });
74
- if (live !== undefined)
75
- body = preserveShepherdJournal(body, live.body);
76
- body = redactCredentials(body, secrets);
77
- const length = validateGitHubBodyLength(body);
78
- if (!length.ok)
79
- throw new Error(`the rendered body has ${length.characterCount} characters; GitHub allows ${length.maxCharacterCount}`);
80
- const report = await runValidator({
81
- ...policyFor(context),
82
- export: 'validatePullRequest',
83
- input: {
84
- title: input.title,
85
- body,
86
- base: input.base,
87
- head,
88
- mode: input.mode,
89
- ...(pr === undefined ? {} : { pr }),
90
- },
91
- });
92
- const errors = 'errors' in report ? report.errors : [];
93
- const warnings = 'warnings' in report ? report.warnings : [];
94
- return {
95
- input,
96
- head,
97
- body,
98
- errors,
99
- warnings,
100
- ...(live === undefined ? {} : { liveBody: live.body }),
101
- };
102
- }
103
12
  function rejectErrors(prepared) {
104
13
  if (prepared.errors.length)
105
14
  throw new Error(`policy rejected the pull request:\n- ${prepared.errors.join('\n- ')}`);
106
15
  }
107
16
  export const handlePrValidate = async (args, context) => {
108
- const { body, errors, warnings } = await prepare(args, context, readPr(args, false));
17
+ const { body, errors, warnings } = await prepare(args, context, 'validate', readPr(args, false));
109
18
  return { body, errors, warnings };
110
19
  };
111
20
  export const handlePrCreate = async (args, context) => {
112
- const prepared = await prepare(args, context, undefined);
21
+ const prepared = await prepare(args, context, 'create', undefined);
113
22
  rejectErrors(prepared);
114
23
  const { input, head, body } = prepared;
115
24
  const defaults = await pullRequestDefaults(policyFor(context), input);
@@ -127,7 +36,7 @@ export const handlePrCreate = async (args, context) => {
127
36
  };
128
37
  export const handlePrUpdate = async (args, context) => {
129
38
  const pr = readPr(args, true);
130
- const prepared = await prepare(args, context, pr);
39
+ const prepared = await prepare(args, context, 'update', pr);
131
40
  rejectErrors(prepared);
132
41
  await assertJournalUnchanged(ghIn(context), pr, prepared.liveBody, prepared.input.base);
133
42
  const url = await withBodyFile(prepared.body, async (bodyFile) => (await ghIn(context)([
@@ -0,0 +1,10 @@
1
+ import { type ToolHandler } from './context.mts';
2
+ /** The default event id, hashed when the session id would push it past the 256-byte limit. */
3
+ export declare function defaultSourceEventId(sessionId: string): string;
4
+ /**
5
+ * Saves the session's retrospective: composes it when `compose` is given, checks the arguments
6
+ * against the document's front matter, runs the checkout's `validateRetrospective` policy (in a
7
+ * child process) and writes nothing if it reports errors, then writes through the same verified
8
+ * path as `journal_append`.
9
+ */
10
+ export declare const retrospectiveSave: ToolHandler;
@@ -0,0 +1,138 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createFeedbackEnvelope, validateFeedbackEnvelope } from "../agent-blackboard/index.mjs";
3
+ import { optionalRecord, optionalString, requiredChoice, requiredString, requiredStringArray, } from "./args.mjs";
4
+ import { assertPolicyAllowed, readIdentity } from "./context.mjs";
5
+ import { MODES, OUTCOMES, readCoverage } from "./handlers-journal.mjs";
6
+ import { runValidator } from "./policy-run.mjs";
7
+ import { previewRegistry } from "./preview-registry.mjs";
8
+ import { deliver, saveFromPreview } from "./retrospective-preview.mjs";
9
+ import { parseRetrospectiveFrontMatter } from "./retrospective-front-matter.mjs";
10
+ import { FEEDBACK_MARKDOWN_MAX_BYTES, redactFeedbackText, } from "../agent-blackboard/feedback-codec.mjs";
11
+ import { composeFromArgs, sensitiveValues } from "./retrospective-input.mjs";
12
+ import { checkFrontMatter, resolveRepositories } from "./retrospective-resolve.mjs";
13
+ function readMarkdownSource(args) {
14
+ const compose = optionalRecord(args, 'compose');
15
+ if ((args.markdown === undefined) === (compose === undefined))
16
+ throw new Error('pass exactly one of markdown or compose');
17
+ return compose === undefined ? { markdown: requiredString(args, 'markdown') } : { compose };
18
+ }
19
+ /** The default event id, hashed when the session id would push it past the 256-byte limit. */
20
+ export function defaultSourceEventId(sessionId) {
21
+ const id = `retrospective-${sessionId}`;
22
+ return Buffer.byteLength(id) <= 256
23
+ ? id
24
+ : `retrospective-${createHash('sha256').update(sessionId).digest('hex')}`;
25
+ }
26
+ /**
27
+ * Saves the session's retrospective: composes it when `compose` is given, checks the arguments
28
+ * against the document's front matter, runs the checkout's `validateRetrospective` policy (in a
29
+ * child process) and writes nothing if it reports errors, then writes through the same verified
30
+ * path as `journal_append`.
31
+ */
32
+ export const retrospectiveSave = async (args, context) => {
33
+ assertPolicyAllowed(context);
34
+ const identity = readIdentity(args, context.sessionId);
35
+ const mode = requiredChoice(args, 'mode', MODES);
36
+ const sourceEventId = optionalString(args, 'sourceEventId') ?? defaultSourceEventId(context.sessionId);
37
+ if (args.previewToken !== undefined)
38
+ return saveFromPreview(args, context, identity, mode, sourceEventId);
39
+ const workOutcome = requiredChoice(args, 'workOutcome', OUTCOMES);
40
+ const feedbackCoverage = readCoverage(args);
41
+ const argRepositories = args.repositories === undefined ? undefined : requiredStringArray(args, 'repositories');
42
+ const category = optionalString(args, 'category');
43
+ if (args.dryRun !== undefined && typeof args.dryRun !== 'boolean')
44
+ throw new Error('dryRun must be a boolean');
45
+ const policy = {
46
+ worktree: context.worktree,
47
+ env: context.env,
48
+ ...(context.spawnPolicy === undefined ? {} : { spawn: context.spawnPolicy }),
49
+ };
50
+ const { markdown: given, compose } = readMarkdownSource(args);
51
+ if (compose !== undefined && args.knownSensitiveValues !== undefined)
52
+ throw new Error('with compose, pass knownSensitiveValues inside compose');
53
+ // A markdown save redacts every argument with the same values a compose save would.
54
+ const secrets = sensitiveValues(compose ?? { knownSensitiveValues: args.knownSensitiveValues }, context.env);
55
+ // Everything the policy sees is derived from the redacted document, never the raw input.
56
+ const redactedGiven = given === undefined ? undefined : redactFeedbackText(given, secrets);
57
+ // Reject a document that can never be stored before any policy export runs for it.
58
+ if (redactedGiven !== undefined && Buffer.byteLength(redactedGiven) > FEEDBACK_MARKDOWN_MAX_BYTES)
59
+ throw new Error(`markdown must be at most ${FEEDBACK_MARKDOWN_MAX_BYTES} bytes`);
60
+ const early = redactedGiven === undefined ? undefined : parseRetrospectiveFrontMatter(redactedGiven);
61
+ const repositories = await resolveRepositories(argRepositories, early?.repositories, policy);
62
+ const markdown = redactedGiven ??
63
+ redactFeedbackText(await composeFromArgs(compose, {
64
+ sessionId: context.sessionId,
65
+ repositories,
66
+ workOutcome,
67
+ feedbackCoverage,
68
+ worktree: context.worktree,
69
+ env: context.env,
70
+ dependencies: context.dependencies,
71
+ }), secrets);
72
+ const front = early ?? parseRetrospectiveFrontMatter(markdown);
73
+ checkFrontMatter(front, {
74
+ sessionId: context.sessionId,
75
+ workOutcome,
76
+ // The document was redacted before parsing, so compare it with equally redacted sources.
77
+ feedbackCoverage: {
78
+ ...feedbackCoverage,
79
+ sources: feedbackCoverage.sources.map((source) => redactFeedbackText(source, secrets)),
80
+ },
81
+ repositories: argRepositories,
82
+ });
83
+ // Building the envelope first rejects empty or oversized markdown before any policy runs.
84
+ const envelope = createFeedbackEnvelope({
85
+ schemaVersion: 1,
86
+ type: 'retrospective',
87
+ sourceEventId,
88
+ // The server owns the timestamp, as in journal_append.
89
+ timestamp: new Date().toISOString(),
90
+ date: front.date,
91
+ issues: front.issues,
92
+ prs: front.prs,
93
+ repositories,
94
+ markdown,
95
+ workOutcome,
96
+ feedbackCoverage,
97
+ ...(category === undefined ? {} : { category }),
98
+ }, { knownSensitiveValues: secrets });
99
+ const redactedFront = parseRetrospectiveFrontMatter(envelope.markdown);
100
+ validateFeedbackEnvelope(envelope);
101
+ const report = await runValidator({
102
+ ...policy,
103
+ export: 'validateRetrospective',
104
+ input: {
105
+ sessionId: context.sessionId,
106
+ parentSessionId: identity.parentSessionId,
107
+ mode,
108
+ workOutcome,
109
+ // The envelope's copy, which is redacted like the markdown.
110
+ feedbackCoverage: envelope.feedbackCoverage,
111
+ repositories,
112
+ markdown: envelope.markdown,
113
+ sourceEventId,
114
+ metadata: {
115
+ date: redactedFront.date,
116
+ issues: redactedFront.issues,
117
+ prs: redactedFront.prs,
118
+ description: redactedFront.description,
119
+ },
120
+ },
121
+ });
122
+ if ('errors' in report && report.errors.length)
123
+ throw new Error(`policy rejected the retrospective:\n- ${report.errors.join('\n- ')}`);
124
+ const warnings = 'warnings' in report ? report.warnings : [];
125
+ if (args.dryRun === true) {
126
+ const previewToken = previewRegistry.put({
127
+ sessionId: context.sessionId,
128
+ parentSessionId: identity.parentSessionId,
129
+ mode,
130
+ worktree: context.worktree,
131
+ sourceEventId,
132
+ envelope,
133
+ warnings,
134
+ });
135
+ return { markdown: envelope.markdown, previewToken, policy: { warnings } };
136
+ }
137
+ return { ...(await deliver(envelope, identity, mode, context)), policy: { warnings } };
138
+ };
@@ -1,12 +1 @@
1
- import { type ToolHandler } from './context.mts';
2
- /** Whether the session already holds a retrospective, and which (sourceEventId, issues, prs). */
3
- export declare const retrospectiveCheck: ToolHandler;
4
- /** The default event id, hashed when the session id would push it past the 256-byte limit. */
5
- export declare function defaultSourceEventId(sessionId: string): string;
6
- /**
7
- * Saves the session's retrospective: composes it when `compose` is given, checks the arguments
8
- * against the document's front matter, runs the checkout's `validateRetrospective` policy (in a
9
- * child process) and writes nothing if it reports errors, then writes through the same verified
10
- * path as `journal_append`.
11
- */
12
- export declare const retrospectiveSave: ToolHandler;
1
+ export { defaultSourceEventId, retrospectiveSave } from './handlers-retrospective-save.mts';
@@ -1,154 +1 @@
1
- import { createHash } from 'node:crypto';
2
- import { createFeedbackEnvelope, validateFeedbackEnvelope, writeFeedback, } from "../agent-blackboard/index.mjs";
3
- import { optionalRecord, optionalString, requiredChoice, requiredString, requiredStringArray, } from "./args.mjs";
4
- import { assertPolicyAllowed, outboxDirectory, readIdentity } from "./context.mjs";
5
- import { byCreatedAt, deliveryResponse, MODES, OUTCOMES, readCoverage, } from "./handlers-journal.mjs";
6
- import { readEntriesIfPresent } from "./journal-read.mjs";
7
- import { runValidator } from "./policy-run.mjs";
8
- import { parseRetrospectiveFrontMatter } from "./retrospective-front-matter.mjs";
9
- import { FEEDBACK_MARKDOWN_MAX_BYTES, redactFeedbackText, } from "../agent-blackboard/feedback-codec.mjs";
10
- import { composeFromArgs, sensitiveValues } from "./retrospective-input.mjs";
11
- import { checkFrontMatter, resolveRepositories } from "./retrospective-resolve.mjs";
12
- function retrospectiveData(entry) {
13
- const data = entry?.data;
14
- return data?.type === 'retrospective' ? data : undefined;
15
- }
16
- /** Whether the session already holds a retrospective, and which (sourceEventId, issues, prs). */
17
- export const retrospectiveCheck = async (args, context) => {
18
- const sourceEventId = optionalString(args, 'sourceEventId');
19
- const entries = await readEntriesIfPresent(context.sessionId, context.env, context.dependencies);
20
- const found = (entries ?? [])
21
- .toSorted(byCreatedAt)
22
- .flatMap((entry) => {
23
- const data = retrospectiveData(entry);
24
- return data === undefined ? [] : [data];
25
- })
26
- .filter((data) => sourceEventId === undefined || data.sourceEventId === sourceEventId)
27
- .map((data) => ({
28
- sourceEventId: data.sourceEventId,
29
- timestamp: data.timestamp,
30
- issues: data.issues ?? [],
31
- prs: data.prs ?? [],
32
- }));
33
- return { sessionId: context.sessionId, saved: found.length > 0, entries: found };
34
- };
35
- function readMarkdownSource(args) {
36
- const compose = optionalRecord(args, 'compose');
37
- if ((args.markdown === undefined) === (compose === undefined))
38
- throw new Error('pass exactly one of markdown or compose');
39
- return compose === undefined ? { markdown: requiredString(args, 'markdown') } : { compose };
40
- }
41
- /** The default event id, hashed when the session id would push it past the 256-byte limit. */
42
- export function defaultSourceEventId(sessionId) {
43
- const id = `retrospective-${sessionId}`;
44
- return Buffer.byteLength(id) <= 256
45
- ? id
46
- : `retrospective-${createHash('sha256').update(sessionId).digest('hex')}`;
47
- }
48
- /**
49
- * Saves the session's retrospective: composes it when `compose` is given, checks the arguments
50
- * against the document's front matter, runs the checkout's `validateRetrospective` policy (in a
51
- * child process) and writes nothing if it reports errors, then writes through the same verified
52
- * path as `journal_append`.
53
- */
54
- export const retrospectiveSave = async (args, context) => {
55
- assertPolicyAllowed(context);
56
- const identity = readIdentity(args, context.sessionId);
57
- const mode = requiredChoice(args, 'mode', MODES);
58
- const workOutcome = requiredChoice(args, 'workOutcome', OUTCOMES);
59
- const feedbackCoverage = readCoverage(args);
60
- const argRepositories = args.repositories === undefined ? undefined : requiredStringArray(args, 'repositories');
61
- const sourceEventId = optionalString(args, 'sourceEventId') ?? defaultSourceEventId(context.sessionId);
62
- const category = optionalString(args, 'category');
63
- if (args.dryRun !== undefined && typeof args.dryRun !== 'boolean')
64
- throw new Error('dryRun must be a boolean');
65
- const policy = {
66
- worktree: context.worktree,
67
- env: context.env,
68
- ...(context.spawnPolicy === undefined ? {} : { spawn: context.spawnPolicy }),
69
- };
70
- const { markdown: given, compose } = readMarkdownSource(args);
71
- const secrets = sensitiveValues(compose, context.env);
72
- // Everything the policy sees is derived from the redacted document, never the raw input.
73
- const redactedGiven = given === undefined ? undefined : redactFeedbackText(given, secrets);
74
- // Reject a document that can never be stored before any policy export runs for it.
75
- if (redactedGiven !== undefined && Buffer.byteLength(redactedGiven) > FEEDBACK_MARKDOWN_MAX_BYTES)
76
- throw new Error(`markdown must be at most ${FEEDBACK_MARKDOWN_MAX_BYTES} bytes`);
77
- const early = redactedGiven === undefined ? undefined : parseRetrospectiveFrontMatter(redactedGiven);
78
- const repositories = await resolveRepositories(argRepositories, early?.repositories, policy);
79
- const markdown = redactedGiven ??
80
- redactFeedbackText(await composeFromArgs(compose, {
81
- sessionId: context.sessionId,
82
- repositories,
83
- workOutcome,
84
- feedbackCoverage,
85
- worktree: context.worktree,
86
- env: context.env,
87
- dependencies: context.dependencies,
88
- }), secrets);
89
- const front = early ?? parseRetrospectiveFrontMatter(markdown);
90
- checkFrontMatter(front, {
91
- sessionId: context.sessionId,
92
- workOutcome,
93
- // The document was redacted before parsing, so compare it with equally redacted sources.
94
- feedbackCoverage: {
95
- ...feedbackCoverage,
96
- sources: feedbackCoverage.sources.map((source) => redactFeedbackText(source, secrets)),
97
- },
98
- repositories: argRepositories,
99
- });
100
- // Building the envelope first rejects empty or oversized markdown before any policy runs.
101
- const envelope = createFeedbackEnvelope({
102
- schemaVersion: 1,
103
- type: 'retrospective',
104
- sourceEventId,
105
- // The server owns the timestamp, as in journal_append.
106
- timestamp: new Date().toISOString(),
107
- date: front.date,
108
- issues: front.issues,
109
- prs: front.prs,
110
- repositories,
111
- markdown,
112
- workOutcome,
113
- feedbackCoverage,
114
- ...(category === undefined ? {} : { category }),
115
- }, { knownSensitiveValues: secrets });
116
- const redactedFront = parseRetrospectiveFrontMatter(envelope.markdown);
117
- validateFeedbackEnvelope(envelope);
118
- const report = await runValidator({
119
- ...policy,
120
- export: 'validateRetrospective',
121
- input: {
122
- sessionId: context.sessionId,
123
- parentSessionId: identity.parentSessionId,
124
- mode,
125
- workOutcome,
126
- // The envelope's copy, which is redacted like the markdown.
127
- feedbackCoverage: envelope.feedbackCoverage,
128
- repositories,
129
- markdown: envelope.markdown,
130
- sourceEventId,
131
- metadata: {
132
- date: redactedFront.date,
133
- issues: redactedFront.issues,
134
- prs: redactedFront.prs,
135
- description: redactedFront.description,
136
- },
137
- },
138
- });
139
- if ('errors' in report && report.errors.length)
140
- throw new Error(`policy rejected the retrospective:\n- ${report.errors.join('\n- ')}`);
141
- const warnings = 'warnings' in report ? report.warnings : [];
142
- if (args.dryRun === true)
143
- return { markdown: envelope.markdown, policy: { warnings } };
144
- const directory = mode === 'interactive' ? outboxDirectory(context.worktree) : undefined;
145
- const result = await writeFeedback({
146
- identity,
147
- envelope,
148
- mode,
149
- ...(directory === undefined ? {} : { outboxDirectory: directory }),
150
- env: context.env,
151
- dependencies: context.dependencies,
152
- });
153
- return { ...deliveryResponse(result, context, mode), policy: { warnings } };
154
- };
1
+ export { defaultSourceEventId, retrospectiveSave } from "./handlers-retrospective-save.mjs";
@@ -1,5 +1,6 @@
1
1
  import { archiveBlackboardSession, ensureBlackboardSession, exportSnapshot, } from "../agent-blackboard/index.mjs";
2
2
  import { optionalRecord, optionalString, requiredNullableString } from "./args.mjs";
3
+ import { isObject } from "../agent-blackboard/snapshot-partition-guards.mjs";
3
4
  import { readIdentity } from "./context.mjs";
4
5
  function readSelection(args) {
5
6
  const data = optionalRecord(args, 'data');
@@ -33,15 +34,33 @@ export const sessionArchive = async (_args, context) => archiveBlackboardSession
33
34
  env: context.env,
34
35
  dependencies: context.dependencies,
35
36
  });
37
+ /** A partition directory and signed receipt, only when the client itself reported both. */
38
+ function recordedPartitions(outcome) {
39
+ const { directory, cleanupReceipt } = outcome;
40
+ return typeof directory === 'string' && isObject(cleanupReceipt)
41
+ ? { directory, receipt: cleanupReceipt }
42
+ : {};
43
+ }
36
44
  export const snapshotExport = async (args, context) => {
37
45
  const selection = readSelection(args);
46
+ const outcome = await exportSnapshot({
47
+ // An empty selection means "everything"; leave it unset rather than send an empty filter.
48
+ ...(Object.keys(selection).length === 0 ? {} : { selection }),
49
+ env: context.env,
50
+ dependencies: context.dependencies,
51
+ });
52
+ // The client's own token stays server-side: the server records the export and mints a token bound to
53
+ // the caller's sessionId and worktree, which `snapshot_cleanup` checks before touching disk.
54
+ const { cleanupToken: clientToken, ...reported } = outcome;
38
55
  return {
39
56
  sessionId: context.sessionId,
40
- ...(await exportSnapshot({
41
- // An empty selection means "everything"; leave it unset rather than send an empty filter.
42
- ...(Object.keys(selection).length === 0 ? {} : { selection }),
43
- env: context.env,
44
- dependencies: context.dependencies,
45
- })),
57
+ ...reported,
58
+ cleanupToken: context.snapshots.register({
59
+ sessionId: context.sessionId,
60
+ worktree: context.worktree,
61
+ path: outcome.path,
62
+ ...(typeof clientToken === 'string' ? { clientToken } : {}),
63
+ ...recordedPartitions(outcome),
64
+ }),
46
65
  };
47
66
  };
@@ -0,0 +1,7 @@
1
+ import type { ToolHandler } from './context.mts';
2
+ /**
3
+ * Deletes the files one `snapshot_export` recorded. The caller supplies only the token: the paths
4
+ * and the signed receipt come from the server's own record, and the record is checked against the
5
+ * caller's sessionId and resolved worktree before any filesystem call.
6
+ */
7
+ export declare const snapshotCleanup: ToolHandler;
@@ -0,0 +1,36 @@
1
+ import { cleanupSnapshotPartitions } from "../agent-blackboard/index.mjs";
2
+ import { loadClient } from "../agent-blackboard/client.mjs";
3
+ import { requiredString } from "./args.mjs";
4
+ /**
5
+ * Deletes the files one `snapshot_export` recorded. The caller supplies only the token: the paths
6
+ * and the signed receipt come from the server's own record, and the record is checked against the
7
+ * caller's sessionId and resolved worktree before any filesystem call.
8
+ */
9
+ export const snapshotCleanup = async (args, context) => {
10
+ const token = requiredString(args, 'cleanupToken');
11
+ // Taking the record is synchronous, so a concurrent second call finds the token already spent.
12
+ const record = context.snapshots.take(token, context.sessionId, context.worktree);
13
+ try {
14
+ const client = await loadClient(context.dependencies);
15
+ // A client that wrote an ownership marker must clean with its own capability, or the marker
16
+ // outlives the file. Older clients fall back to the in-tree receipt-based cleanup.
17
+ if (client.cleanupSnapshotPartitions && record.clientToken !== undefined)
18
+ await client.cleanupSnapshotPartitions({
19
+ path: record.path,
20
+ ...(record.directory === undefined ? {} : { directory: record.directory }),
21
+ cleanupToken: record.clientToken,
22
+ });
23
+ else
24
+ await cleanupSnapshotPartitions({
25
+ path: record.path,
26
+ ...(record.directory === undefined ? {} : { directory: record.directory }),
27
+ ...(record.receipt === undefined ? {} : { receipt: record.receipt }),
28
+ });
29
+ }
30
+ catch (error) {
31
+ context.snapshots.restore(token, record);
32
+ const reason = error instanceof Error ? error.message : String(error);
33
+ throw new Error(`snapshot cleanup failed and the cleanupToken is still valid; retry the same call. ${reason}`, { cause: error });
34
+ }
35
+ return { sessionId: context.sessionId, cleaned: true };
36
+ };
@@ -3,6 +3,7 @@ import { type FakeBlackboard, type FakeOptions } from './fake-blackboard.test-he
3
3
  import { type RepoFixture } from './git-fixture.test-helpers.mts';
4
4
  import type { GhRunner } from './pr-gh.mts';
5
5
  import type { PolicySpawner } from './policy-spawn.mts';
6
+ import type { SnapshotRegistry } from './snapshot-registry.mts';
6
7
  /** A fresh pair of real repositories for every test. */
7
8
  export declare function useRepoFixture(): () => RepoFixture;
8
9
  export declare function outboxPath(worktree: string): string;
@@ -21,6 +22,7 @@ export declare function harness(fixture: RepoFixture, options?: FakeOptions & {
21
22
  user: string;
22
23
  host: string;
23
24
  };
25
+ snapshots?: SnapshotRegistry;
24
26
  }): Harness;
25
27
  export declare function textOf(result: ToolCallResult): string;
26
28
  export declare function jsonOf(result: ToolCallResult): Record<string, unknown>;
@@ -27,6 +27,7 @@ export function harness(fixture, options = {}) {
27
27
  ...(options.spawnPolicy === undefined ? {} : { spawnPolicy: options.spawnPolicy }),
28
28
  ...(options.runGh === undefined ? {} : { runGh: options.runGh }),
29
29
  ...(options.device === undefined ? {} : { device: options.device }),
30
+ ...(options.snapshots === undefined ? {} : { snapshots: options.snapshots }),
30
31
  };
31
32
  return { fake, call: (name, args) => callTool(name, args, environment) };
32
33
  }
@@ -4,6 +4,7 @@ export declare const POLICY_TIMEOUTS_MS: {
4
4
  readonly validateRetrospective: 30000;
5
5
  readonly validatePullRequest: 120000;
6
6
  readonly pullRequestDefaults: 120000;
7
+ readonly renderNoSourceIssue: 30000;
7
8
  };
8
9
  type PolicyExport = keyof typeof POLICY_TIMEOUTS_MS;
9
10
  export type PolicyResult = {
@@ -6,6 +6,7 @@ export const POLICY_TIMEOUTS_MS = {
6
6
  validateRetrospective: 30_000,
7
7
  validatePullRequest: 120_000,
8
8
  pullRequestDefaults: 120_000,
9
+ renderNoSourceIssue: 30_000,
9
10
  };
10
11
  const POLICY_MAX_OUTPUT_BYTES = 1_000_000;
11
12
  const STDERR_EXCERPT_BYTES = 2_000;
@@ -10,9 +10,11 @@ export type PullRequestProvenance = {
10
10
  };
11
11
  /**
12
12
  * The pull request body: the Summary, Impact, Related issues and Details sections followed by the
13
- * provenance lines. Pure; the caller supplies the device and worktree facts.
13
+ * provenance lines. With `noSourceIssue` the Related issues section holds `noSourceStatement`
14
+ * (default {@link defaultNoSourceStatement}) instead of `- None`. Pure; the caller supplies the
15
+ * device and worktree facts.
14
16
  */
15
- export declare function renderPullRequestBody(input: PullRequestInput, provenance: PullRequestProvenance): string;
17
+ export declare function renderPullRequestBody(input: PullRequestInput, provenance: PullRequestProvenance, noSourceStatement?: string): string;
16
18
  /**
17
19
  * The worktree label: the path after `/worktrees/` when present, `main` for the repository's main
18
20
  * worktree, else the directory name.