vouchington-tooling 0.44.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 (51) hide show
  1. package/dist/agent-blackboard/client.d.mts +6 -0
  2. package/dist/cli/usage.d.mts +1 -1
  3. package/dist/cli/usage.mjs +1 -1
  4. package/dist/mcp-server/context.d.mts +3 -0
  5. package/dist/mcp-server/dispatch.d.mts +3 -0
  6. package/dist/mcp-server/dispatch.mjs +7 -1
  7. package/dist/mcp-server/fake-blackboard.test-helpers.d.mts +2 -0
  8. package/dist/mcp-server/fake-blackboard.test-helpers.mjs +3 -0
  9. package/dist/mcp-server/handlers-pr.d.mts +1 -1
  10. package/dist/mcp-server/handlers-pr.mjs +6 -97
  11. package/dist/mcp-server/handlers-retrospective-save.d.mts +10 -0
  12. package/dist/mcp-server/handlers-retrospective-save.mjs +138 -0
  13. package/dist/mcp-server/handlers-retrospective.d.mts +1 -12
  14. package/dist/mcp-server/handlers-retrospective.mjs +1 -154
  15. package/dist/mcp-server/handlers-session.mjs +25 -6
  16. package/dist/mcp-server/handlers-snapshot-cleanup.d.mts +7 -0
  17. package/dist/mcp-server/handlers-snapshot-cleanup.mjs +36 -0
  18. package/dist/mcp-server/harness.test-helpers.d.mts +2 -0
  19. package/dist/mcp-server/harness.test-helpers.mjs +1 -0
  20. package/dist/mcp-server/policy-run.d.mts +1 -0
  21. package/dist/mcp-server/policy-run.mjs +1 -0
  22. package/dist/mcp-server/pr-body.d.mts +4 -2
  23. package/dist/mcp-server/pr-body.mjs +15 -3
  24. package/dist/mcp-server/pr-defaults.d.mts +3 -0
  25. package/dist/mcp-server/pr-defaults.mjs +7 -2
  26. package/dist/mcp-server/pr-input.d.mts +12 -5
  27. package/dist/mcp-server/pr-input.mjs +21 -4
  28. package/dist/mcp-server/pr-live.d.mts +3 -1
  29. package/dist/mcp-server/pr-live.mjs +7 -2
  30. package/dist/mcp-server/pr-mode.d.mts +7 -0
  31. package/dist/mcp-server/pr-mode.mjs +34 -0
  32. package/dist/mcp-server/pr-no-source.d.mts +9 -0
  33. package/dist/mcp-server/pr-no-source.mjs +22 -0
  34. package/dist/mcp-server/pr-prepare.d.mts +25 -0
  35. package/dist/mcp-server/pr-prepare.mjs +103 -0
  36. package/dist/mcp-server/preview-registry.d.mts +25 -0
  37. package/dist/mcp-server/preview-registry.mjs +38 -0
  38. package/dist/mcp-server/retrospective-check.d.mts +3 -0
  39. package/dist/mcp-server/retrospective-check.mjs +38 -0
  40. package/dist/mcp-server/retrospective-input.mjs +2 -2
  41. package/dist/mcp-server/retrospective-preview.d.mts +17 -0
  42. package/dist/mcp-server/retrospective-preview.mjs +60 -0
  43. package/dist/mcp-server/snapshot-registry.d.mts +25 -0
  44. package/dist/mcp-server/snapshot-registry.mjs +32 -0
  45. package/dist/mcp-server/tools-pr.mjs +20 -4
  46. package/dist/mcp-server/tools-retrospective.mjs +25 -5
  47. package/dist/mcp-server/tools.mjs +6 -0
  48. package/docs/mcp-server.md +95 -23
  49. package/package.json +3 -3
  50. package/skills/retrospective/SKILL.md +3 -1
  51. package/skills/retrospective-distill/SKILL.md +5 -0
@@ -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.
@@ -3,12 +3,24 @@ function relatedLine(issue) {
3
3
  const verb = issue.relation === 'closes' ? 'Closes' : 'Refs';
4
4
  return `- ${verb} #${issue.number} — ${issue.reason}`;
5
5
  }
6
+ /** The default no-source statement; a policy `renderNoSourceIssue` export may replace it. */
7
+ function defaultNoSourceStatement(reason) {
8
+ return `- No source issue — ${reason}`;
9
+ }
6
10
  /**
7
11
  * The pull request body: the Summary, Impact, Related issues and Details sections followed by the
8
- * provenance lines. Pure; the caller supplies the device and worktree facts.
12
+ * provenance lines. With `noSourceIssue` the Related issues section holds `noSourceStatement`
13
+ * (default {@link defaultNoSourceStatement}) instead of `- None`. Pure; the caller supplies the
14
+ * device and worktree facts.
9
15
  */
10
- export function renderPullRequestBody(input, provenance) {
11
- const related = input.relatedIssues.length ? input.relatedIssues.map(relatedLine) : ['- None'];
16
+ export function renderPullRequestBody(input, provenance, noSourceStatement) {
17
+ const related = input.relatedIssues.length
18
+ ? input.relatedIssues.map(relatedLine)
19
+ : [
20
+ input.noSourceIssue === undefined
21
+ ? '- None'
22
+ : (noSourceStatement ?? defaultNoSourceStatement(input.noSourceIssue.reason)),
23
+ ];
12
24
  const agent = input.model === undefined ? input.agent : `${input.agent} (${input.model})`;
13
25
  return [
14
26
  '## Summary',
@@ -13,4 +13,7 @@ export declare function pullRequestDefaults(policy: Omit<PolicyRun, 'export' | '
13
13
  title: string;
14
14
  base: string;
15
15
  relatedIssues: RelatedIssue[];
16
+ noSourceIssue?: {
17
+ reason: string;
18
+ };
16
19
  }): Promise<PullRequestDefaults>;
@@ -11,11 +11,16 @@ function optionalName(value, field) {
11
11
  * checkout declares no policy or the policy lacks the export.
12
12
  */
13
13
  export async function pullRequestDefaults(policy, input) {
14
- const { title, base, relatedIssues } = input;
14
+ const { title, base, relatedIssues, noSourceIssue } = input;
15
15
  const outcome = await runPolicyExport({
16
16
  ...policy,
17
17
  export: 'pullRequestDefaults',
18
- input: { title, base, relatedIssues },
18
+ input: {
19
+ title,
20
+ base,
21
+ relatedIssues,
22
+ ...(noSourceIssue === undefined ? {} : { noSourceIssue }),
23
+ },
19
24
  });
20
25
  if (outcome.status !== 'ok')
21
26
  return { labels: [] };
@@ -1,5 +1,5 @@
1
1
  import { type Args } from './args.mts';
2
- declare const PR_MODES: readonly ["interactive", "autonomous"];
2
+ import { type PullRequestMode } from './pr-mode.mts';
3
3
  export type RelatedIssue = {
4
4
  number: number;
5
5
  relation: 'closes' | 'refs';
@@ -10,17 +10,24 @@ export type PullRequestInput = {
10
10
  summary: string;
11
11
  impact: string;
12
12
  relatedIssues: RelatedIssue[];
13
+ /** Set instead of relatedIssues when the pull request has no source issue. */
14
+ noSourceIssue?: {
15
+ reason: string;
16
+ };
13
17
  details: string[];
14
18
  base: string;
15
19
  agent: string;
16
20
  model?: string;
17
- mode: (typeof PR_MODES)[number];
21
+ /** The trusted mode, see {@link trustedMode}; never the caller's word alone. */
22
+ mode: PullRequestMode;
18
23
  workspaceSetup: string;
19
24
  };
20
25
  /** A positive integer argument, such as a pull request number or an issue number. */
21
26
  export declare function positiveInteger(value: unknown, label: string): number;
22
27
  /** Rechecked after redaction, which can lengthen a title. */
23
28
  export declare function checkTitleLength(title: string): void;
24
- /** Reads and validates the structured pull request fields shared by the three pr_* tools. */
25
- export declare function readPullRequestInput(args: Args): PullRequestInput;
26
- export {};
29
+ /**
30
+ * Reads and validates the structured pull request fields shared by the three pr_* tools. The mode
31
+ * comes from the server's `env`; the caller's optional `mode` can only narrow it.
32
+ */
33
+ export declare function readPullRequestInput(args: Args, env: NodeJS.ProcessEnv): PullRequestInput;
@@ -1,4 +1,5 @@
1
1
  import { optionalString, rejectUnknown, requireObject, requiredChoice, requiredString, requiredStringArray, } from "./args.mjs";
2
+ import { trustedMode } from "./pr-mode.mjs";
2
3
  const PR_MODES = ['interactive', 'autonomous'];
3
4
  const RELATIONS = ['closes', 'refs'];
4
5
  const MAX_TITLE_CHARACTERS = 256;
@@ -22,6 +23,15 @@ function readRelatedIssues(args) {
22
23
  };
23
24
  });
24
25
  }
26
+ function readNoSourceIssue(args, related) {
27
+ if (args.noSourceIssue === undefined)
28
+ return undefined;
29
+ const value = requireObject(args.noSourceIssue);
30
+ rejectUnknown(value, ['reason']);
31
+ if (related.length)
32
+ throw new Error('noSourceIssue and a non-empty relatedIssues are mutually exclusive');
33
+ return { reason: singleLine(value, 'reason') };
34
+ }
25
35
  function readDetails(args) {
26
36
  const details = requiredStringArray(args, 'details');
27
37
  details.forEach((detail, index) => {
@@ -41,21 +51,28 @@ function singleLine(args, key) {
41
51
  throw new Error(`${key} must be a single line`);
42
52
  return value;
43
53
  }
44
- /** Reads and validates the structured pull request fields shared by the three pr_* tools. */
45
- export function readPullRequestInput(args) {
54
+ /**
55
+ * Reads and validates the structured pull request fields shared by the three pr_* tools. The mode
56
+ * comes from the server's `env`; the caller's optional `mode` can only narrow it.
57
+ */
58
+ export function readPullRequestInput(args, env) {
46
59
  const title = singleLine(args, 'title');
47
60
  checkTitleLength(title);
48
61
  const model = args.model === undefined ? undefined : singleLine(args, 'model');
62
+ const relatedIssues = readRelatedIssues(args);
63
+ const noSourceIssue = readNoSourceIssue(args, relatedIssues);
64
+ const requested = args.mode === undefined ? undefined : requiredChoice(args, 'mode', PR_MODES);
49
65
  return {
50
66
  title,
51
67
  summary: requiredString(args, 'summary'),
52
68
  impact: requiredString(args, 'impact'),
53
- relatedIssues: readRelatedIssues(args),
69
+ relatedIssues,
70
+ ...(noSourceIssue === undefined ? {} : { noSourceIssue }),
54
71
  details: readDetails(args),
55
72
  base: optionalString(args, 'base') ?? 'main',
56
73
  agent: singleLine(args, 'agent'),
57
74
  ...(model === undefined ? {} : { model }),
58
- mode: requiredChoice(args, 'mode', PR_MODES),
75
+ mode: trustedMode(env, requested),
59
76
  // It renders on its own provenance line; a newline would forge the lines after it.
60
77
  workspaceSetup: singleLine(args, 'workspaceSetup'),
61
78
  };
@@ -5,7 +5,9 @@ export type LivePullRequest = {
5
5
  baseRefName: string;
6
6
  state: string;
7
7
  };
8
- /** The live pull request as gh reports it, rejected unless it is open. */
8
+ /** The live pull request as gh reports it, whatever its state. */
9
+ export declare function readLivePullRequest(gh: RunTextCommand, pr: number): Promise<LivePullRequest>;
10
+ /** The live pull request, rejected unless it is open. */
9
11
  export declare function livePullRequest(gh: RunTextCommand, pr: number): Promise<LivePullRequest>;
10
12
  /**
11
13
  * The base to validate an existing pull request against: its live base. An explicit `base` that
@@ -1,11 +1,16 @@
1
1
  import { findShepherdJournal } from "./pr-journal.mjs";
2
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) {
3
+ /** The live pull request as gh reports it, whatever its state. */
4
+ export async function readLivePullRequest(gh, pr) {
5
5
  const output = await gh(['pr', 'view', String(pr), '--json', FIELDS.join(',')]);
6
6
  const live = JSON.parse(output);
7
7
  if (!isLive(live))
8
8
  throw new Error(`gh pr view ${pr} did not return ${FIELDS.join(', ')}`);
9
+ return live;
10
+ }
11
+ /** The live pull request, rejected unless it is open. */
12
+ export async function livePullRequest(gh, pr) {
13
+ const live = await readLivePullRequest(gh, pr);
9
14
  // A closed or merged PR can share a reused branch name; its history must not be rewritten.
10
15
  if (live.state !== 'OPEN')
11
16
  throw new Error(`pull request ${pr} is ${live.state}, not OPEN`);
@@ -0,0 +1,7 @@
1
+ export type PullRequestMode = 'interactive' | 'autonomous';
2
+ /**
3
+ * The mode the policy sees. It fails closed: an automation marker in the server's launch
4
+ * environment means `autonomous` whatever the caller asks, and the caller's `requested` mode can
5
+ * only narrow `interactive` to `autonomous`, never widen it.
6
+ */
7
+ export declare function trustedMode(env: NodeJS.ProcessEnv, requested: PullRequestMode | undefined): PullRequestMode;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Environment variables that mark an automated launch. A marker counts when it is set to anything
3
+ * except an empty string, `0` or `false`.
4
+ */
5
+ const AUTOMATION_MARKERS = [
6
+ 'CI',
7
+ 'GITHUB_ACTIONS',
8
+ 'GITLAB_CI',
9
+ 'BUILDKITE',
10
+ 'CIRCLECI',
11
+ 'JENKINS_URL',
12
+ 'TF_BUILD',
13
+ 'CODEBUILD_BUILD_ID',
14
+ 'VOUCHINGTON_AUTOMATION',
15
+ ];
16
+ /** Whether the server's own launch environment carries an automation marker. */
17
+ function isAutomationContext(env) {
18
+ // Claude Code sets this to exactly '0' for unattended runs; a missing or other value never
19
+ // means autonomous, so it can only narrow.
20
+ if (env.CLAUDE_CODE_SESSION_ATTENDED === '0')
21
+ return true;
22
+ return AUTOMATION_MARKERS.some((name) => {
23
+ const value = env[name]?.trim().toLowerCase();
24
+ return value !== undefined && value !== '' && value !== '0' && value !== 'false';
25
+ });
26
+ }
27
+ /**
28
+ * The mode the policy sees. It fails closed: an automation marker in the server's launch
29
+ * environment means `autonomous` whatever the caller asks, and the caller's `requested` mode can
30
+ * only narrow `interactive` to `autonomous`, never widen it.
31
+ */
32
+ export function trustedMode(env, requested) {
33
+ return isAutomationContext(env) || requested === 'autonomous' ? 'autonomous' : 'interactive';
34
+ }
@@ -0,0 +1,9 @@
1
+ import { type PolicyRun } from './policy-run.mts';
2
+ import type { PullRequestInput } from './pr-input.mts';
3
+ /**
4
+ * The statement for the Related issues section of a pull request with no source issue, from the
5
+ * policy's optional `renderNoSourceIssue({ reason })` export (a non-empty string, rendered as is).
6
+ * Undefined when the pull request has a source issue, or the checkout declares no such export, in
7
+ * which case the generic default statement renders.
8
+ */
9
+ export declare function noSourceStatement(policy: Omit<PolicyRun, 'export' | 'input'>, input: PullRequestInput): Promise<string | undefined>;
@@ -0,0 +1,22 @@
1
+ import { runPolicyExport } from "./policy-run.mjs";
2
+ /**
3
+ * The statement for the Related issues section of a pull request with no source issue, from the
4
+ * policy's optional `renderNoSourceIssue({ reason })` export (a non-empty string, rendered as is).
5
+ * Undefined when the pull request has a source issue, or the checkout declares no such export, in
6
+ * which case the generic default statement renders.
7
+ */
8
+ export async function noSourceStatement(policy, input) {
9
+ if (input.noSourceIssue === undefined)
10
+ return undefined;
11
+ const outcome = await runPolicyExport({
12
+ ...policy,
13
+ export: 'renderNoSourceIssue',
14
+ input: { reason: input.noSourceIssue.reason },
15
+ });
16
+ if (outcome.status !== 'ok')
17
+ return undefined;
18
+ if (typeof outcome.result !== 'string' || !outcome.result.trim())
19
+ throw new Error('policy renderNoSourceIssue must return a non-empty string');
20
+ // Only blank lines around the statement go; the first line's indentation is significant Markdown.
21
+ return outcome.result.replace(/^(?:[ \t]*\r?\n)+/, '').trimEnd();
22
+ }
@@ -0,0 +1,25 @@
1
+ import type { RunTextCommand } from '../gh-cli/exec.mts';
2
+ import type { Args } from './args.mts';
3
+ import { type ToolContext } from './context.mts';
4
+ import type { PolicyRun } from './policy-run.mts';
5
+ import { type PullRequestInput } from './pr-input.mts';
6
+ export type Prepared = {
7
+ input: PullRequestInput;
8
+ head: string;
9
+ body: string;
10
+ errors: string[];
11
+ warnings: string[];
12
+ /** The live body the update was prepared from. */
13
+ liveBody?: string;
14
+ };
15
+ /**
16
+ * `create` has no live pull request. `update` requires one that is OPEN and whose head is the
17
+ * worktree branch. `validate` only reads one, of any state and head, for its Shepherd Journal,
18
+ * base and head.
19
+ */
20
+ export type PrepareKind = 'create' | 'update' | 'validate';
21
+ export declare function gitIn(context: ToolContext): RunTextCommand;
22
+ export declare function ghIn(context: ToolContext): RunTextCommand;
23
+ export declare function policyFor(context: ToolContext): Omit<PolicyRun, 'export' | 'input'>;
24
+ /** Renders the body, preserves the live journal for an update, and runs the policy validator. */
25
+ export declare function prepare(args: Args, context: ToolContext, kind: PrepareKind, pr: number | undefined): Promise<Prepared>;
@@ -0,0 +1,103 @@
1
+ import { hostname, userInfo } from 'node:os';
2
+ import { validateGitHubBodyLength } from "../gh-cli/body-length.mjs";
3
+ import { assertPolicyAllowed } from "./context.mjs";
4
+ import { renderPullRequestBody, worktreeLabel } from "./pr-body.mjs";
5
+ import { createGhRunner, ghEnvironment } from "./pr-gh.mjs";
6
+ import { checkTitleLength, readPullRequestInput } from "./pr-input.mjs";
7
+ import { preserveShepherdJournal } from "./pr-journal.mjs";
8
+ import { credentials, liveBase, livePullRequest, readLivePullRequest, redactCredentials, } from "./pr-live.mjs";
9
+ import { noSourceStatement } from "./pr-no-source.mjs";
10
+ import { runValidator } from "./policy-run.mjs";
11
+ import { parseWorktreeList } from "./worktree.mjs";
12
+ export function gitIn(context) {
13
+ return (args) => context.runGit(['-C', context.worktree, ...args]);
14
+ }
15
+ export function ghIn(context) {
16
+ const run = context.runGh ?? createGhRunner(context.env);
17
+ const env = ghEnvironment(context.env);
18
+ return (args) => run(args, { cwd: context.worktree, env });
19
+ }
20
+ export function policyFor(context) {
21
+ return {
22
+ worktree: context.worktree,
23
+ env: context.env,
24
+ ...(context.spawnPolicy === undefined ? {} : { spawn: context.spawnPolicy }),
25
+ };
26
+ }
27
+ async function currentBranch(git) {
28
+ const branch = (await git(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
29
+ if (branch === 'HEAD')
30
+ throw new Error('the worktree has a detached HEAD: check out a branch');
31
+ return branch;
32
+ }
33
+ async function label(context, git) {
34
+ const listing = await git(['worktree', 'list', '--porcelain', '-z']);
35
+ return worktreeLabel(context.worktree, parseWorktreeList(listing)[0]);
36
+ }
37
+ async function liveFor(kind, context, pr) {
38
+ if (pr === undefined)
39
+ return undefined;
40
+ return kind === 'update'
41
+ ? livePullRequest(ghIn(context), pr)
42
+ : readLivePullRequest(ghIn(context), pr);
43
+ }
44
+ /** Renders the body, preserves the live journal for an update, and runs the policy validator. */
45
+ export async function prepare(args, context, kind, 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, context.env), secrets);
51
+ checkTitleLength(given.title);
52
+ const git = gitIn(context);
53
+ const live = await liveFor(kind, context, pr);
54
+ // A read-only validation of an existing pull request judges the head that pull request has.
55
+ const worktreeHead = live !== undefined && kind === 'validate' ? undefined : await currentBranch(git);
56
+ const head = worktreeHead ?? live.headRefName;
57
+ // Branch names are refs passed to gh as is: redacting one would name a branch that does not exist.
58
+ if (secrets.some((secret) => head.includes(secret)))
59
+ throw new Error('the worktree branch name contains a server credential: rename the branch');
60
+ if (secrets.some((secret) => args.base?.includes(secret)))
61
+ throw new Error('base contains a server credential: use another base branch');
62
+ if (live !== undefined && kind === 'update' && live.headRefName !== head)
63
+ throw new Error(`pull request ${pr} has head ${live.headRefName}, not the worktree branch ${head}`);
64
+ const base = live === undefined
65
+ ? given.base
66
+ : liveBase(pr, live, redactCredentials(args.base, secrets));
67
+ const input = { ...given, base };
68
+ // A live pull request may come from a fork whose branch shares the base's name.
69
+ if (head === base && !(kind === 'validate' && live !== undefined))
70
+ throw new Error(`head branch ${head} must differ from base ${base}`);
71
+ const device = (context.device ?? (() => ({ user: userInfo().username, host: hostname() })))();
72
+ const statement = await noSourceStatement(policyFor(context), input);
73
+ let body = renderPullRequestBody(input, { sessionId: context.sessionId, ...device, worktree: await label(context, git) }, statement);
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
+ ...(input.noSourceIssue === undefined ? {} : { noSourceIssue: input.noSourceIssue }),
90
+ ...(pr === undefined ? {} : { pr }),
91
+ },
92
+ });
93
+ const errors = 'errors' in report ? report.errors : [];
94
+ const warnings = 'warnings' in report ? report.warnings : [];
95
+ return {
96
+ input,
97
+ head,
98
+ body,
99
+ errors,
100
+ warnings,
101
+ ...(live === undefined ? {} : { liveBody: live.body }),
102
+ };
103
+ }
@@ -0,0 +1,25 @@
1
+ import type { FeedbackEnvelope } from '../agent-blackboard/feedback-types.mts';
2
+ /** What a dry run validated: the redacted envelope and the scope its token is bound to. */
3
+ export type Preview = {
4
+ sessionId: string;
5
+ parentSessionId: string | null;
6
+ mode: 'interactive' | 'autonomous';
7
+ worktree: string;
8
+ sourceEventId: string;
9
+ envelope: FeedbackEnvelope;
10
+ warnings: string[];
11
+ };
12
+ export type PreviewScope = Omit<Preview, 'envelope' | 'warnings'>;
13
+ /** In-memory, single-use preview tokens. A restarted server starts with none. */
14
+ export declare class PreviewRegistry {
15
+ #private;
16
+ constructor(limit?: number);
17
+ /** Stores a preview under a fresh 32-byte base64url token, evicting the oldest past the cap. */
18
+ put(preview: Preview): string;
19
+ /** Removes and returns the preview, only when it is bound to exactly this scope. */
20
+ take(token: string, scope: PreviewScope): Preview | undefined;
21
+ /** Stores a preview under a token (a taken one too), evicting the oldest past the cap. */
22
+ restore(token: string, preview: Preview): void;
23
+ clear(): void;
24
+ }
25
+ export declare const previewRegistry: PreviewRegistry;
@@ -0,0 +1,38 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ const KEYS = ['sessionId', 'parentSessionId', 'mode', 'worktree', 'sourceEventId'];
3
+ /** In-memory, single-use preview tokens. A restarted server starts with none. */
4
+ export class PreviewRegistry {
5
+ #previews = new Map();
6
+ #limit;
7
+ constructor(limit = 32) {
8
+ this.#limit = limit;
9
+ }
10
+ /** Stores a preview under a fresh 32-byte base64url token, evicting the oldest past the cap. */
11
+ put(preview) {
12
+ const token = randomBytes(32).toString('base64url');
13
+ this.restore(token, preview);
14
+ return token;
15
+ }
16
+ /** Removes and returns the preview, only when it is bound to exactly this scope. */
17
+ take(token, scope) {
18
+ const preview = this.#previews.get(token);
19
+ // The scope is what the policy saw at preview time, so every field must match.
20
+ if (preview === undefined || KEYS.some((key) => preview[key] !== scope[key]))
21
+ return undefined;
22
+ this.#previews.delete(token);
23
+ return preview;
24
+ }
25
+ /** Stores a preview under a token (a taken one too), evicting the oldest past the cap. */
26
+ restore(token, preview) {
27
+ this.#previews.set(token, preview);
28
+ for (const oldest of this.#previews.keys()) {
29
+ if (this.#previews.size <= this.#limit)
30
+ break;
31
+ this.#previews.delete(oldest);
32
+ }
33
+ }
34
+ clear() {
35
+ this.#previews.clear();
36
+ }
37
+ }
38
+ export const previewRegistry = new PreviewRegistry();
@@ -0,0 +1,3 @@
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;