vouchington-tooling 0.41.0 → 0.43.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 (65) hide show
  1. package/bin/policy-runner.mjs +26 -0
  2. package/dist/agent-blackboard/feedback-codec.d.mts +1 -0
  3. package/dist/agent-blackboard/feedback-codec.mjs +1 -1
  4. package/dist/cli/usage.d.mts +1 -1
  5. package/dist/cli/usage.mjs +3 -1
  6. package/dist/gh-cli/pr-create.d.mts +2 -0
  7. package/dist/gh-cli/pr-create.mjs +5 -1
  8. package/dist/mcp-server/context.d.mts +22 -2
  9. package/dist/mcp-server/context.mjs +14 -1
  10. package/dist/mcp-server/dispatch.d.mts +11 -0
  11. package/dist/mcp-server/dispatch.mjs +12 -0
  12. package/dist/mcp-server/handlers-journal.d.mts +14 -1
  13. package/dist/mcp-server/handlers-journal.mjs +14 -9
  14. package/dist/mcp-server/handlers-pr.d.mts +4 -0
  15. package/dist/mcp-server/handlers-pr.mjs +143 -0
  16. package/dist/mcp-server/handlers-retrospective.d.mts +12 -0
  17. package/dist/mcp-server/handlers-retrospective.mjs +154 -0
  18. package/dist/mcp-server/harness.test-helpers.d.mts +10 -1
  19. package/dist/mcp-server/harness.test-helpers.mjs +4 -1
  20. package/dist/mcp-server/journal-read.d.mts +9 -0
  21. package/dist/mcp-server/journal-read.mjs +33 -0
  22. package/dist/mcp-server/policy-config.d.mts +6 -0
  23. package/dist/mcp-server/policy-config.mjs +60 -0
  24. package/dist/mcp-server/policy-run.d.mts +40 -0
  25. package/dist/mcp-server/policy-run.mjs +116 -0
  26. package/dist/mcp-server/policy-spawn.d.mts +24 -0
  27. package/dist/mcp-server/policy-spawn.mjs +97 -0
  28. package/dist/mcp-server/pr-body-file.d.mts +4 -0
  29. package/dist/mcp-server/pr-body-file.mjs +22 -0
  30. package/dist/mcp-server/pr-body.d.mts +20 -0
  31. package/dist/mcp-server/pr-body.mjs +47 -0
  32. package/dist/mcp-server/pr-defaults.d.mts +16 -0
  33. package/dist/mcp-server/pr-defaults.mjs +35 -0
  34. package/dist/mcp-server/pr-gh.d.mts +16 -0
  35. package/dist/mcp-server/pr-gh.mjs +56 -0
  36. package/dist/mcp-server/pr-input.d.mts +26 -0
  37. package/dist/mcp-server/pr-input.mjs +62 -0
  38. package/dist/mcp-server/pr-journal.d.mts +12 -0
  39. package/dist/mcp-server/pr-journal.mjs +86 -0
  40. package/dist/mcp-server/pr-live.d.mts +24 -0
  41. package/dist/mcp-server/pr-live.mjs +49 -0
  42. package/dist/mcp-server/pr-tools.test-helpers.d.mts +54 -0
  43. package/dist/mcp-server/pr-tools.test-helpers.mjs +67 -0
  44. package/dist/mcp-server/retrospective-facts-input.d.mts +6 -0
  45. package/dist/mcp-server/retrospective-facts-input.mjs +25 -0
  46. package/dist/mcp-server/retrospective-front-matter.d.mts +22 -0
  47. package/dist/mcp-server/retrospective-front-matter.mjs +88 -0
  48. package/dist/mcp-server/retrospective-input.d.mts +29 -0
  49. package/dist/mcp-server/retrospective-input.mjs +145 -0
  50. package/dist/mcp-server/retrospective-resolve.d.mts +15 -0
  51. package/dist/mcp-server/retrospective-resolve.mjs +41 -0
  52. package/dist/mcp-server/tools-pr.d.mts +2 -0
  53. package/dist/mcp-server/tools-pr.mjs +69 -0
  54. package/dist/mcp-server/tools-retrospective.d.mts +2 -0
  55. package/dist/mcp-server/tools-retrospective.mjs +48 -0
  56. package/dist/mcp-server/tools-schema.d.mts +54 -0
  57. package/dist/mcp-server/tools-schema.mjs +70 -0
  58. package/dist/mcp-server/tools.d.mts +1 -18
  59. package/dist/mcp-server/tools.mjs +6 -70
  60. package/dist/mcp-server/worktree.d.mts +11 -4
  61. package/dist/mcp-server/worktree.mjs +31 -6
  62. package/docs/mcp-server.md +164 -16
  63. package/package.json +1 -1
  64. package/skills/pr-description/SKILL.md +8 -0
  65. package/skills/retrospective/SKILL.md +5 -2
@@ -0,0 +1,47 @@
1
+ import { basename } from 'node:path';
2
+ function relatedLine(issue) {
3
+ const verb = issue.relation === 'closes' ? 'Closes' : 'Refs';
4
+ return `- ${verb} #${issue.number} — ${issue.reason}`;
5
+ }
6
+ /**
7
+ * 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.
9
+ */
10
+ export function renderPullRequestBody(input, provenance) {
11
+ const related = input.relatedIssues.length ? input.relatedIssues.map(relatedLine) : ['- None'];
12
+ const agent = input.model === undefined ? input.agent : `${input.agent} (${input.model})`;
13
+ return [
14
+ '## Summary',
15
+ '',
16
+ input.summary,
17
+ '',
18
+ '## Impact',
19
+ '',
20
+ input.impact,
21
+ '',
22
+ '## Related issues',
23
+ '',
24
+ ...related,
25
+ '',
26
+ '## Details',
27
+ '',
28
+ input.details.join('\n\n'),
29
+ '',
30
+ `Workspace setup: ${input.workspaceSetup}`,
31
+ `Agent: ${agent} session ${provenance.sessionId}`,
32
+ `Device: ${provenance.user}@${provenance.host.toLowerCase()}`,
33
+ `Worktree: ${provenance.worktree}`,
34
+ '',
35
+ ].join('\n');
36
+ }
37
+ const MARKER = '/worktrees/';
38
+ /**
39
+ * The worktree label: the path after `/worktrees/` when present, `main` for the repository's main
40
+ * worktree, else the directory name.
41
+ */
42
+ export function worktreeLabel(worktree, mainWorktree) {
43
+ const marker = worktree.indexOf(MARKER);
44
+ if (marker !== -1)
45
+ return worktree.slice(marker + MARKER.length);
46
+ return worktree === mainWorktree ? 'main' : basename(worktree);
47
+ }
@@ -0,0 +1,16 @@
1
+ import { type PolicyRun } from './policy-run.mts';
2
+ import type { RelatedIssue } from './pr-input.mts';
3
+ export type PullRequestDefaults = {
4
+ labels: string[];
5
+ milestone?: string;
6
+ project?: string;
7
+ };
8
+ /**
9
+ * Labels, milestone and project from the policy's `pullRequestDefaults` export; empty when the
10
+ * checkout declares no policy or the policy lacks the export.
11
+ */
12
+ export declare function pullRequestDefaults(policy: Omit<PolicyRun, 'export' | 'input'>, input: {
13
+ title: string;
14
+ base: string;
15
+ relatedIssues: RelatedIssue[];
16
+ }): Promise<PullRequestDefaults>;
@@ -0,0 +1,35 @@
1
+ import { runPolicyExport } from "./policy-run.mjs";
2
+ function optionalName(value, field) {
3
+ if (value === undefined || value === null)
4
+ return undefined;
5
+ if (typeof value !== 'string' || !value.trim())
6
+ throw new Error(`policy pullRequestDefaults ${field} must be a non-empty string`);
7
+ return value;
8
+ }
9
+ /**
10
+ * Labels, milestone and project from the policy's `pullRequestDefaults` export; empty when the
11
+ * checkout declares no policy or the policy lacks the export.
12
+ */
13
+ export async function pullRequestDefaults(policy, input) {
14
+ const { title, base, relatedIssues } = input;
15
+ const outcome = await runPolicyExport({
16
+ ...policy,
17
+ export: 'pullRequestDefaults',
18
+ input: { title, base, relatedIssues },
19
+ });
20
+ if (outcome.status !== 'ok')
21
+ return { labels: [] };
22
+ const result = (outcome.result ?? {});
23
+ if (typeof result !== 'object' || Array.isArray(result))
24
+ throw new Error('policy pullRequestDefaults must return an object');
25
+ const labels = result.labels ?? [];
26
+ if (!Array.isArray(labels) || labels.some((label) => typeof label !== 'string' || !label.trim()))
27
+ throw new Error('policy pullRequestDefaults labels must be an array of non-empty strings');
28
+ const milestone = optionalName(result.milestone, 'milestone');
29
+ const project = optionalName(result.project, 'project');
30
+ return {
31
+ labels: labels,
32
+ ...(milestone === undefined ? {} : { milestone }),
33
+ ...(project === undefined ? {} : { project }),
34
+ };
35
+ }
@@ -0,0 +1,16 @@
1
+ export type GhRunner = (args: string[], options: {
2
+ cwd: string;
3
+ env: NodeJS.ProcessEnv;
4
+ }) => Promise<string>;
5
+ /**
6
+ * `gh` on the server's PATH, else a common absolute location. Relative PATH entries are skipped:
7
+ * gh runs with the target worktree as cwd, where a relative path could name a checkout file.
8
+ */
9
+ export declare function locateGh(env: NodeJS.ProcessEnv, fallbacks?: readonly string[]): Promise<string>;
10
+ /**
11
+ * The environment gh runs in: the server's, without the blackboard credentials, and without
12
+ * GH_REPO, which would point gh at a repository other than the validated worktree's.
13
+ */
14
+ export declare function ghEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
15
+ /** A runner that locates gh once, on first use, from the server's environment. */
16
+ export declare function createGhRunner(serverEnv: NodeJS.ProcessEnv, locate?: (env: NodeJS.ProcessEnv) => Promise<string>): GhRunner;
@@ -0,0 +1,56 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { constants } from 'node:fs';
3
+ import { access, stat } from 'node:fs/promises';
4
+ import { delimiter, isAbsolute, join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ const execFileAsync = promisify(execFile);
7
+ const FALLBACKS = ['/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh'];
8
+ const MAX_BUFFER_BYTES = 16_000_000;
9
+ /** The longest one gh call may run before it is killed. */
10
+ const GH_TIMEOUT_MS = 120_000;
11
+ async function isExecutableFile(path) {
12
+ try {
13
+ await access(path, constants.X_OK);
14
+ return (await stat(path)).isFile();
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ /**
21
+ * `gh` on the server's PATH, else a common absolute location. Relative PATH entries are skipped:
22
+ * gh runs with the target worktree as cwd, where a relative path could name a checkout file.
23
+ */
24
+ export async function locateGh(env, fallbacks = FALLBACKS) {
25
+ const onPath = (env.PATH ?? '')
26
+ .split(delimiter)
27
+ .filter((directory) => isAbsolute(directory))
28
+ .map((directory) => join(directory, 'gh'));
29
+ for (const candidate of [...onPath, ...fallbacks])
30
+ if (await isExecutableFile(candidate))
31
+ return candidate;
32
+ throw new Error("gh CLI not found on PATH; install gh or add it to the server's PATH");
33
+ }
34
+ /**
35
+ * The environment gh runs in: the server's, without the blackboard credentials, and without
36
+ * GH_REPO, which would point gh at a repository other than the validated worktree's.
37
+ */
38
+ export function ghEnvironment(env) {
39
+ const { AGENT_BLACKBOARD_URL: _url, AGENT_BLACKBOARD_TOKEN: _token, GH_REPO: _repo, ...rest } = env;
40
+ return rest;
41
+ }
42
+ /** A runner that locates gh once, on first use, from the server's environment. */
43
+ export function createGhRunner(serverEnv, locate = locateGh) {
44
+ let located;
45
+ return async (args, options) => {
46
+ located ??= locate(serverEnv);
47
+ const { stdout } = await execFileAsync(await located, args, {
48
+ cwd: options.cwd,
49
+ env: options.env,
50
+ maxBuffer: MAX_BUFFER_BYTES,
51
+ timeout: GH_TIMEOUT_MS,
52
+ killSignal: 'SIGKILL',
53
+ });
54
+ return stdout;
55
+ };
56
+ }
@@ -0,0 +1,26 @@
1
+ import { type Args } from './args.mts';
2
+ declare const PR_MODES: readonly ["interactive", "autonomous"];
3
+ export type RelatedIssue = {
4
+ number: number;
5
+ relation: 'closes' | 'refs';
6
+ reason: string;
7
+ };
8
+ export type PullRequestInput = {
9
+ title: string;
10
+ summary: string;
11
+ impact: string;
12
+ relatedIssues: RelatedIssue[];
13
+ details: string[];
14
+ base: string;
15
+ agent: string;
16
+ model?: string;
17
+ mode: (typeof PR_MODES)[number];
18
+ workspaceSetup: string;
19
+ };
20
+ /** A positive integer argument, such as a pull request number or an issue number. */
21
+ export declare function positiveInteger(value: unknown, label: string): number;
22
+ /** Rechecked after redaction, which can lengthen a title. */
23
+ 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 {};
@@ -0,0 +1,62 @@
1
+ import { optionalString, rejectUnknown, requireObject, requiredChoice, requiredString, requiredStringArray, } from "./args.mjs";
2
+ const PR_MODES = ['interactive', 'autonomous'];
3
+ const RELATIONS = ['closes', 'refs'];
4
+ const MAX_TITLE_CHARACTERS = 256;
5
+ /** A positive integer argument, such as a pull request number or an issue number. */
6
+ export function positiveInteger(value, label) {
7
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1)
8
+ throw new Error(`${label} must be a positive integer`);
9
+ return value;
10
+ }
11
+ function readRelatedIssues(args) {
12
+ const value = args.relatedIssues;
13
+ if (!Array.isArray(value))
14
+ throw new Error('relatedIssues is required and must be an array');
15
+ return value.map((item, index) => {
16
+ const issue = requireObject(item);
17
+ rejectUnknown(issue, ['number', 'relation', 'reason']);
18
+ return {
19
+ number: positiveInteger(issue.number, `relatedIssues[${index}].number`),
20
+ relation: requiredChoice(issue, 'relation', RELATIONS),
21
+ reason: requiredString(issue, 'reason'),
22
+ };
23
+ });
24
+ }
25
+ function readDetails(args) {
26
+ const details = requiredStringArray(args, 'details');
27
+ details.forEach((detail, index) => {
28
+ if (!detail.trim())
29
+ throw new Error(`details[${index}] must not be empty`);
30
+ });
31
+ return details;
32
+ }
33
+ /** Rechecked after redaction, which can lengthen a title. */
34
+ export function checkTitleLength(title) {
35
+ if (Array.from(title).length > MAX_TITLE_CHARACTERS)
36
+ throw new Error(`title must be at most ${MAX_TITLE_CHARACTERS} characters`);
37
+ }
38
+ function singleLine(args, key) {
39
+ const value = requiredString(args, key);
40
+ if (/[\r\n]/.test(value))
41
+ throw new Error(`${key} must be a single line`);
42
+ return value;
43
+ }
44
+ /** Reads and validates the structured pull request fields shared by the three pr_* tools. */
45
+ export function readPullRequestInput(args) {
46
+ const title = singleLine(args, 'title');
47
+ checkTitleLength(title);
48
+ const model = args.model === undefined ? undefined : singleLine(args, 'model');
49
+ return {
50
+ title,
51
+ summary: requiredString(args, 'summary'),
52
+ impact: requiredString(args, 'impact'),
53
+ relatedIssues: readRelatedIssues(args),
54
+ details: readDetails(args),
55
+ base: optionalString(args, 'base') ?? 'main',
56
+ agent: singleLine(args, 'agent'),
57
+ ...(model === undefined ? {} : { model }),
58
+ mode: requiredChoice(args, 'mode', PR_MODES),
59
+ // It renders on its own provenance line; a newline would forge the lines after it.
60
+ workspaceSetup: singleLine(args, 'workspaceSetup'),
61
+ };
62
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The Shepherd Journal of a pull request body, verbatim: a `<details>` block whose summary is
3
+ * "Shepherd Journal", else a legacy `## Shepherd Journal` section running to the next H2 or the
4
+ * end. Examples inside fenced code blocks are ignored. Undefined when the body has none; more than
5
+ * one candidate, or an unclosed one, is ambiguous and throws rather than guessing.
6
+ */
7
+ export declare function findShepherdJournal(body: string): string | undefined;
8
+ /**
9
+ * Appends the live body's journal to `rendered`. The rendered fields must not carry a journal of
10
+ * their own, and must not leave a code fence open: the journal would be hidden inside it.
11
+ */
12
+ export declare function preserveShepherdJournal(rendered: string, live: string): string;
@@ -0,0 +1,86 @@
1
+ const DETAILS_OPEN = /<details(?:\s[^>]*)?>/gi;
2
+ const DETAILS_CLOSE = /<\/details>/gi;
3
+ const SUMMARY = '<summary>Shepherd Journal</summary>';
4
+ /** An ATX H2 "Shepherd Journal", with optional indent and closing hashes. */
5
+ const LEGACY = /^ {0,3}##[ \t]+Shepherd[ \t]+Journal(?:[ \t]+#+)?[ \t]*$/;
6
+ /** A real ATX H2: `##` then whitespace or the end of the line (so not `###` or `##text`). */
7
+ const H2 = /^ {0,3}##(?:[ \t]|$)/;
8
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
9
+ /** A closing fence carries nothing but whitespace after its marker. */
10
+ const CLOSING_FENCE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/;
11
+ const count = (line, expression) => [...line.matchAll(expression)].length;
12
+ /**
13
+ * The lines with every fenced code block blanked, so journal-shaped examples there never match,
14
+ * and whether the text ends inside a fence that never closed.
15
+ */
16
+ function outsideFences(lines) {
17
+ let fence;
18
+ const visible = lines.map((line) => {
19
+ if (fence === undefined) {
20
+ const marker = FENCE.exec(line)?.[1];
21
+ if (marker === undefined)
22
+ return line;
23
+ fence = marker;
24
+ return '';
25
+ }
26
+ const marker = CLOSING_FENCE.exec(line)?.[1];
27
+ if (marker !== undefined && marker[0] === fence[0] && marker.length >= fence.length)
28
+ fence = undefined;
29
+ return '';
30
+ });
31
+ return { visible, open: fence !== undefined };
32
+ }
33
+ function detailsStarts(lines) {
34
+ return lines.flatMap((line, index) => line.trim() === SUMMARY && lines[index - 1]?.trim() === '<details>' ? [index] : []);
35
+ }
36
+ /** Depth is tracked on the fence-blanked lines; the block is sliced from the original ones. */
37
+ function detailsBlock(lines, visible, start) {
38
+ let depth = 1;
39
+ for (const [index, line] of visible.entries()) {
40
+ if (index < start)
41
+ continue;
42
+ depth += count(line, DETAILS_OPEN) - count(line, DETAILS_CLOSE);
43
+ if (depth <= 0)
44
+ return lines.slice(start - 1, index + 1);
45
+ }
46
+ // Treating an unclosed journal as absent would let an update overwrite its history.
47
+ throw new Error('the Shepherd Journal <details> block is never closed; repair it by hand');
48
+ }
49
+ function legacySection(lines, visible, start) {
50
+ const next = visible.findIndex((line, index) => index > start && H2.test(line));
51
+ const section = lines.slice(start, next === -1 ? lines.length : next);
52
+ while (section.at(-1)?.trim() === '')
53
+ section.pop();
54
+ return section;
55
+ }
56
+ /**
57
+ * The Shepherd Journal of a pull request body, verbatim: a `<details>` block whose summary is
58
+ * "Shepherd Journal", else a legacy `## Shepherd Journal` section running to the next H2 or the
59
+ * end. Examples inside fenced code blocks are ignored. Undefined when the body has none; more than
60
+ * one candidate, or an unclosed one, is ambiguous and throws rather than guessing.
61
+ */
62
+ export function findShepherdJournal(body) {
63
+ const lines = body.replaceAll('\r\n', '\n').split('\n');
64
+ const { visible } = outsideFences(lines);
65
+ const details = detailsStarts(visible);
66
+ const legacy = visible.flatMap((line, index) => (LEGACY.test(line) ? [index] : []));
67
+ if (details.length + legacy.length > 1)
68
+ throw new Error('the pull request body has more than one Shepherd Journal; resolve it by hand');
69
+ if (details.length === 1)
70
+ return detailsBlock(lines, visible, details[0]).join('\n');
71
+ return legacy.length === 1 ? legacySection(lines, visible, legacy[0]).join('\n') : undefined;
72
+ }
73
+ /**
74
+ * Appends the live body's journal to `rendered`. The rendered fields must not carry a journal of
75
+ * their own, and must not leave a code fence open: the journal would be hidden inside it.
76
+ */
77
+ export function preserveShepherdJournal(rendered, live) {
78
+ if (findShepherdJournal(rendered) !== undefined)
79
+ throw new Error('the pull request fields contain a Shepherd Journal; the server preserves the live one');
80
+ const journal = findShepherdJournal(live);
81
+ if (journal === undefined)
82
+ return rendered;
83
+ if (outsideFences(rendered.replaceAll('\r\n', '\n').split('\n')).open)
84
+ throw new Error('the pull request fields leave a code fence open; close it');
85
+ return `${rendered.trimEnd()}\n\n${journal}\n`;
86
+ }
@@ -0,0 +1,24 @@
1
+ import type { RunTextCommand } from '../gh-cli/exec.mts';
2
+ export type LivePullRequest = {
3
+ body: string;
4
+ headRefName: string;
5
+ baseRefName: string;
6
+ state: string;
7
+ };
8
+ /** The live pull request as gh reports it, rejected unless it is open. */
9
+ export declare function livePullRequest(gh: RunTextCommand, pr: number): Promise<LivePullRequest>;
10
+ /**
11
+ * The base to validate an existing pull request against: its live base. An explicit `base` that
12
+ * differs is rejected, because `gh pr edit` here changes only the title and body.
13
+ */
14
+ export declare function liveBase(pr: number, live: LivePullRequest, given: string | undefined): string;
15
+ /**
16
+ * Re-reads the live pull request just before the edit and aborts if its Shepherd Journal or base
17
+ * changed since the body was prepared and validated, so an entry appended in between is never
18
+ * overwritten and a retargeted PR never gets a body validated against its old base.
19
+ */
20
+ export declare function assertJournalUnchanged(gh: RunTextCommand, pr: number, preparedFrom: string, base: string): Promise<void>;
21
+ /** The server's credential values that must never reach the policy or GitHub, longest first. */
22
+ export declare function credentials(env: NodeJS.ProcessEnv): string[];
23
+ /** Replaces every server credential in every string of a JSON-shaped value. */
24
+ export declare function redactCredentials<T>(value: T, secrets: string[]): T;
@@ -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,6 @@
1
+ import type { Args } from './args.mts';
2
+ /**
3
+ * Checks `compose.facts`: either `{ status, reason }` (unassessed) or a live collection selector,
4
+ * never both. Returns whether live collection was requested.
5
+ */
6
+ export declare function checkFactsInput(facts: Args): boolean;
@@ -0,0 +1,25 @@
1
+ /** A plain branch name: no scheme or colon, no leading dash, no `..`. */
2
+ const BRANCH = /^(?!-)(?!.*\.\.)[A-Za-z0-9._/-]+$/;
3
+ /**
4
+ * Checks `compose.facts`: either `{ status, reason }` (unassessed) or a live collection selector,
5
+ * never both. Returns whether live collection was requested.
6
+ */
7
+ export function checkFactsInput(facts) {
8
+ const selectors = ['pr', 'branch', 'noPr'].filter((key) => key in facts);
9
+ if ('status' in facts) {
10
+ if (selectors.length)
11
+ throw new Error(`compose.facts: status is exclusive with ${selectors.join(', ')}`);
12
+ return false;
13
+ }
14
+ if ('reason' in facts)
15
+ throw new Error('compose.facts.reason requires a status');
16
+ // The collector trusts these types; a string "false" must not read as noPr: true.
17
+ // gh accepts a pull request URL for either, which would reach another repository.
18
+ if ('pr' in facts && (typeof facts.pr !== 'string' || !/^\d+$/.test(facts.pr)))
19
+ throw new Error('compose.facts.pr must be a pull request number string');
20
+ if ('branch' in facts && (typeof facts.branch !== 'string' || !BRANCH.test(facts.branch)))
21
+ throw new Error('compose.facts.branch must be a branch name');
22
+ if ('noPr' in facts && typeof facts.noPr !== 'boolean')
23
+ throw new Error('compose.facts.noPr must be a boolean');
24
+ return true;
25
+ }
@@ -0,0 +1,22 @@
1
+ import type { FeedbackReference } from '../agent-blackboard/index.mts';
2
+ /** The fields of the front matter `composeRetrospective` writes, with camelCase names. */
3
+ export type RetrospectiveFrontMatter = {
4
+ date: string;
5
+ issues: FeedbackReference[];
6
+ prs: FeedbackReference[];
7
+ sessionId?: string;
8
+ description?: string;
9
+ workOutcome?: string;
10
+ feedbackCoverage?: {
11
+ status: string;
12
+ sources: string[];
13
+ droppedCount: number;
14
+ };
15
+ repositories?: string[];
16
+ };
17
+ /**
18
+ * Parses the block from the leading `---` line to the next `---` line. Every line is
19
+ * `key: <JSON value>`; unknown keys, duplicates, malformed lines and ill-typed values are errors,
20
+ * and `date`, `issues`, and `prs` are required.
21
+ */
22
+ export declare function parseRetrospectiveFrontMatter(markdown: string): RetrospectiveFrontMatter;