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.
- package/README.md +45 -1
- package/dist/agent-blackboard/client.d.mts +6 -0
- package/dist/cli/commands/fresh-base.d.mts +6 -0
- package/dist/cli/commands/fresh-base.mjs +14 -0
- package/dist/cli/index.mjs +3 -0
- package/dist/cli/parse-fresh-base.d.mts +2 -0
- package/dist/cli/parse-fresh-base.mjs +15 -0
- package/dist/cli/parse.d.mts +5 -0
- package/dist/cli/parse.mjs +3 -0
- package/dist/cli/usage.d.mts +1 -1
- package/dist/cli/usage.mjs +3 -1
- package/dist/fresh-base/classify.d.mts +21 -0
- package/dist/fresh-base/classify.mjs +29 -0
- package/dist/fresh-base/index.d.mts +4 -0
- package/dist/fresh-base/index.mjs +2 -0
- package/dist/fresh-base/run.d.mts +25 -0
- package/dist/fresh-base/run.mjs +66 -0
- package/dist/gh-cli/closing-refs.d.mts +24 -0
- package/dist/gh-cli/closing-refs.mjs +37 -0
- package/dist/gh-cli/index.d.mts +3 -0
- package/dist/gh-cli/index.mjs +2 -0
- package/dist/gh-cli/non-closing-refs.d.mts +11 -0
- package/dist/gh-cli/non-closing-refs.mjs +24 -0
- package/dist/index.d.mts +6 -0
- package/dist/index.mjs +3 -0
- package/dist/mcp-server/context.d.mts +3 -0
- package/dist/mcp-server/dispatch.d.mts +3 -0
- package/dist/mcp-server/dispatch.mjs +7 -1
- package/dist/mcp-server/fake-blackboard.test-helpers.d.mts +2 -0
- package/dist/mcp-server/fake-blackboard.test-helpers.mjs +3 -0
- package/dist/mcp-server/handlers-pr.d.mts +1 -1
- package/dist/mcp-server/handlers-pr.mjs +6 -97
- package/dist/mcp-server/handlers-retrospective-save.d.mts +10 -0
- package/dist/mcp-server/handlers-retrospective-save.mjs +138 -0
- package/dist/mcp-server/handlers-retrospective.d.mts +1 -12
- package/dist/mcp-server/handlers-retrospective.mjs +1 -154
- package/dist/mcp-server/handlers-session.mjs +25 -6
- package/dist/mcp-server/handlers-snapshot-cleanup.d.mts +7 -0
- package/dist/mcp-server/handlers-snapshot-cleanup.mjs +36 -0
- package/dist/mcp-server/harness.test-helpers.d.mts +2 -0
- package/dist/mcp-server/harness.test-helpers.mjs +1 -0
- package/dist/mcp-server/policy-run.d.mts +1 -0
- package/dist/mcp-server/policy-run.mjs +1 -0
- package/dist/mcp-server/pr-body.d.mts +4 -2
- package/dist/mcp-server/pr-body.mjs +15 -3
- package/dist/mcp-server/pr-defaults.d.mts +3 -0
- package/dist/mcp-server/pr-defaults.mjs +7 -2
- package/dist/mcp-server/pr-input.d.mts +12 -5
- package/dist/mcp-server/pr-input.mjs +21 -4
- package/dist/mcp-server/pr-live.d.mts +3 -1
- package/dist/mcp-server/pr-live.mjs +7 -2
- package/dist/mcp-server/pr-mode.d.mts +7 -0
- package/dist/mcp-server/pr-mode.mjs +34 -0
- package/dist/mcp-server/pr-no-source.d.mts +9 -0
- package/dist/mcp-server/pr-no-source.mjs +22 -0
- package/dist/mcp-server/pr-prepare.d.mts +25 -0
- package/dist/mcp-server/pr-prepare.mjs +103 -0
- package/dist/mcp-server/preview-registry.d.mts +25 -0
- package/dist/mcp-server/preview-registry.mjs +38 -0
- package/dist/mcp-server/retrospective-check.d.mts +3 -0
- package/dist/mcp-server/retrospective-check.mjs +38 -0
- package/dist/mcp-server/retrospective-input.mjs +2 -2
- package/dist/mcp-server/retrospective-preview.d.mts +17 -0
- package/dist/mcp-server/retrospective-preview.mjs +60 -0
- package/dist/mcp-server/snapshot-registry.d.mts +25 -0
- package/dist/mcp-server/snapshot-registry.mjs +32 -0
- package/dist/mcp-server/tools-pr.mjs +20 -4
- package/dist/mcp-server/tools-retrospective.mjs +25 -5
- package/dist/mcp-server/tools.mjs +6 -0
- package/dist/pull-request-diff/diff-size.d.mts +29 -0
- package/dist/pull-request-diff/diff-size.mjs +35 -0
- package/dist/pull-request-diff/index.d.mts +2 -0
- package/dist/pull-request-diff/index.mjs +1 -0
- package/dist/retrospective-distill/args.d.mts +13 -0
- package/dist/retrospective-distill/args.mjs +27 -0
- package/dist/retrospective-distill/checkpoint-entry.d.mts +2 -0
- package/dist/retrospective-distill/checkpoint-entry.mjs +11 -0
- package/dist/retrospective-distill/fixtures.test-helpers.d.mts +3 -0
- package/dist/retrospective-distill/fixtures.test-helpers.mjs +21 -0
- package/dist/retrospective-distill/flag-args.d.mts +5 -0
- package/dist/retrospective-distill/flag-args.mjs +31 -0
- package/dist/retrospective-distill/index.d.mts +10 -0
- package/dist/retrospective-distill/index.mjs +6 -0
- package/dist/retrospective-distill/partition-records.d.mts +6 -0
- package/dist/retrospective-distill/partition-records.mjs +49 -0
- package/dist/retrospective-distill/retrospective-entry.d.mts +3 -0
- package/dist/retrospective-distill/retrospective-entry.mjs +4 -0
- package/dist/retrospective-distill/run.d.mts +5 -0
- package/dist/retrospective-distill/run.mjs +28 -0
- package/dist/retrospective-distill/session-types.d.mts +17 -0
- package/dist/retrospective-distill/session-types.mjs +1 -0
- package/dist/retrospective-distill/shape.d.mts +29 -0
- package/dist/retrospective-distill/shape.mjs +111 -0
- package/docs/mcp-server.md +95 -23
- package/package.json +20 -5
- package/skills/agent-workflow/SKILL.md +20 -0
- package/skills/agent-workflow/references/four-refs.md +74 -0
- package/skills/blackboard/SKILL.md +21 -1
- package/skills/github-issue/SKILL.md +6 -0
- package/skills/manifest.json +11 -5
- package/skills/postgres-node-performance-tuning/SKILL.md +2 -0
- package/skills/postgres-node-performance-tuning/references/performance-patterns.md +76 -0
- package/skills/postgres-node-performance-tuning/references/reading-plans.md +111 -0
- package/skills/postgres-schema-design/references/relations.md +18 -0
- package/skills/retrospective/SKILL.md +3 -1
- package/skills/retrospective-distill/SKILL.md +72 -4
- package/skills/revisit-followups/SKILL.md +17 -0
- package/skills/stacked-prs/SKILL.md +47 -16
- package/skills/stacked-prs/references/drain.md +168 -0
- package/skills/stacked-prs/references/gh-stack.md +143 -0
|
@@ -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.
|
|
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
|
|
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',
|
|
@@ -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: {
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
/**
|
|
45
|
-
|
|
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
|
|
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:
|
|
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,
|
|
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,
|
|
4
|
-
export async function
|
|
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,38 @@
|
|
|
1
|
+
import { validateFeedbackEnvelope } from "../agent-blackboard/index.mjs";
|
|
2
|
+
import { optionalString } from "./args.mjs";
|
|
3
|
+
import { byCreatedAt } from "./handlers-journal.mjs";
|
|
4
|
+
import { readEntriesIfPresent } from "./journal-read.mjs";
|
|
5
|
+
function retrospectiveData(entry) {
|
|
6
|
+
const data = entry?.data;
|
|
7
|
+
if (data?.type !== 'retrospective')
|
|
8
|
+
return undefined;
|
|
9
|
+
// A schema-versioned entry must be a valid envelope; unversioned legacy entries stay accepted.
|
|
10
|
+
if (data.schemaVersion !== undefined) {
|
|
11
|
+
try {
|
|
12
|
+
validateFeedbackEnvelope(data);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return data;
|
|
19
|
+
}
|
|
20
|
+
/** Whether the session already holds a retrospective, and which (sourceEventId, issues, prs). */
|
|
21
|
+
export const retrospectiveCheck = async (args, context) => {
|
|
22
|
+
const sourceEventId = optionalString(args, 'sourceEventId');
|
|
23
|
+
const entries = await readEntriesIfPresent(context.sessionId, context.env, context.dependencies);
|
|
24
|
+
const found = (entries ?? [])
|
|
25
|
+
.toSorted(byCreatedAt)
|
|
26
|
+
.flatMap((entry) => {
|
|
27
|
+
const data = retrospectiveData(entry);
|
|
28
|
+
return data === undefined ? [] : [data];
|
|
29
|
+
})
|
|
30
|
+
.filter((data) => sourceEventId === undefined || data.sourceEventId === sourceEventId)
|
|
31
|
+
.map((data) => ({
|
|
32
|
+
sourceEventId: data.sourceEventId,
|
|
33
|
+
timestamp: data.timestamp,
|
|
34
|
+
issues: data.issues ?? [],
|
|
35
|
+
prs: data.prs ?? [],
|
|
36
|
+
}));
|
|
37
|
+
return { sessionId: context.sessionId, saved: found.length > 0, entries: found };
|
|
38
|
+
};
|
|
@@ -73,10 +73,10 @@ export function worktreeExecutor(worktree, env, timeoutMs = FACTS_TIMEOUT_MS) {
|
|
|
73
73
|
export function sensitiveValues(compose, env) {
|
|
74
74
|
const given = compose?.knownSensitiveValues;
|
|
75
75
|
if (given !== undefined && (!Array.isArray(given) || given.some((v) => typeof v !== 'string')))
|
|
76
|
-
throw new Error('
|
|
76
|
+
throw new Error('knownSensitiveValues must be an array of strings');
|
|
77
77
|
// Redaction ignores values under four characters, so promising to redact one would be false.
|
|
78
78
|
if (given?.some((value) => value.length < 4))
|
|
79
|
-
throw new Error('
|
|
79
|
+
throw new Error('knownSensitiveValues entries must be at least 4 characters');
|
|
80
80
|
// Facts commands inherit the GitHub tokens, so their output is redacted of those values too.
|
|
81
81
|
const credentials = [env.AGENT_BLACKBOARD_TOKEN, env.GH_TOKEN, env.GITHUB_TOKEN].filter((value) => Boolean(value));
|
|
82
82
|
const values = [...(given ?? []), ...credentials];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Args } from './args.mts';
|
|
2
|
+
import { type ToolContext } from './context.mts';
|
|
3
|
+
import type { FeedbackEnvelope, FeedbackIdentity } from '../agent-blackboard/feedback-types.mts';
|
|
4
|
+
type Mode = 'interactive' | 'autonomous';
|
|
5
|
+
/** Writes a validated envelope through the same verified path as `journal_append`. */
|
|
6
|
+
export declare function deliver(envelope: FeedbackEnvelope, identity: FeedbackIdentity, mode: Mode, context: ToolContext): Promise<Record<string, unknown>>;
|
|
7
|
+
/**
|
|
8
|
+
* Saves exactly the content a dry run previewed. The token is single use and bound to the
|
|
9
|
+
* session, the worktree and the event id; an unknown, spent, restarted or mismatched token is
|
|
10
|
+
* one error. The policy accepted this content at preview time, so it is not run again.
|
|
11
|
+
*/
|
|
12
|
+
export declare function saveFromPreview(args: Args, context: ToolContext, identity: FeedbackIdentity, mode: Mode, sourceEventId: string): Promise<{
|
|
13
|
+
policy: {
|
|
14
|
+
warnings: string[];
|
|
15
|
+
};
|
|
16
|
+
}>;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { rejectUnknown, requiredString } from "./args.mjs";
|
|
2
|
+
import { outboxDirectory } from "./context.mjs";
|
|
3
|
+
import { deliveryResponse } from "./handlers-journal.mjs";
|
|
4
|
+
import { previewRegistry } from "./preview-registry.mjs";
|
|
5
|
+
import { validateFeedbackEnvelope, writeFeedback } from "../agent-blackboard/index.mjs";
|
|
6
|
+
/** Writes a validated envelope through the same verified path as `journal_append`. */
|
|
7
|
+
export async function deliver(envelope, identity, mode, context) {
|
|
8
|
+
const directory = mode === 'interactive' ? outboxDirectory(context.worktree) : undefined;
|
|
9
|
+
const result = await writeFeedback({
|
|
10
|
+
identity,
|
|
11
|
+
envelope,
|
|
12
|
+
mode,
|
|
13
|
+
...(directory === undefined ? {} : { outboxDirectory: directory }),
|
|
14
|
+
env: context.env,
|
|
15
|
+
dependencies: context.dependencies,
|
|
16
|
+
});
|
|
17
|
+
return deliveryResponse(result, context, mode);
|
|
18
|
+
}
|
|
19
|
+
/** The only arguments a token save takes: everything else was fixed by the dry run. */
|
|
20
|
+
const TOKEN_SAVE_ARGS = [
|
|
21
|
+
'sessionId',
|
|
22
|
+
'worktree',
|
|
23
|
+
'parentSessionId',
|
|
24
|
+
'agent',
|
|
25
|
+
'version',
|
|
26
|
+
'mode',
|
|
27
|
+
'sourceEventId',
|
|
28
|
+
'previewToken',
|
|
29
|
+
];
|
|
30
|
+
/**
|
|
31
|
+
* Saves exactly the content a dry run previewed. The token is single use and bound to the
|
|
32
|
+
* session, the worktree and the event id; an unknown, spent, restarted or mismatched token is
|
|
33
|
+
* one error. The policy accepted this content at preview time, so it is not run again.
|
|
34
|
+
*/
|
|
35
|
+
export async function saveFromPreview(args, context, identity, mode, sourceEventId) {
|
|
36
|
+
rejectUnknown(args, TOKEN_SAVE_ARGS);
|
|
37
|
+
const token = requiredString(args, 'previewToken');
|
|
38
|
+
const preview = previewRegistry.take(token, {
|
|
39
|
+
sessionId: context.sessionId,
|
|
40
|
+
parentSessionId: identity.parentSessionId,
|
|
41
|
+
mode,
|
|
42
|
+
worktree: context.worktree,
|
|
43
|
+
sourceEventId,
|
|
44
|
+
});
|
|
45
|
+
if (preview === undefined)
|
|
46
|
+
throw new Error('previewToken is unknown, already used, expired by a restart, or bound to a different ' +
|
|
47
|
+
'session, worktree or sourceEventId: run the dry run again');
|
|
48
|
+
// The server owns the timestamp, as for every other write.
|
|
49
|
+
const envelope = { ...preview.envelope, timestamp: new Date().toISOString() };
|
|
50
|
+
try {
|
|
51
|
+
validateFeedbackEnvelope(envelope);
|
|
52
|
+
const response = await deliver(envelope, identity, mode, context);
|
|
53
|
+
return { ...response, policy: { warnings: preview.warnings } };
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
// Nothing was stored, so the caller may retry with the same token.
|
|
57
|
+
previewRegistry.restore(token, preview);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { SnapshotCleanupReceipt } from '../agent-blackboard/snapshot-types.mts';
|
|
2
|
+
/** What the server itself recorded when it exported a snapshot. Never read from a caller. */
|
|
3
|
+
export type SnapshotRecord = {
|
|
4
|
+
sessionId: string;
|
|
5
|
+
worktree: string;
|
|
6
|
+
path: string;
|
|
7
|
+
/** The exporting client's own capability; kept server-side and never returned to a caller. */
|
|
8
|
+
clientToken?: string;
|
|
9
|
+
directory?: string;
|
|
10
|
+
receipt?: SnapshotCleanupReceipt;
|
|
11
|
+
};
|
|
12
|
+
export type SnapshotRegistry = {
|
|
13
|
+
/** Records an export and returns the server-minted, unguessable cleanup token. */
|
|
14
|
+
register: (record: SnapshotRecord) => string;
|
|
15
|
+
/** Removes and returns the record when the token is known and bound to this caller. */
|
|
16
|
+
take: (token: string, sessionId: string, worktree: string) => SnapshotRecord;
|
|
17
|
+
/** Puts a taken record back after a failed cleanup so the same token can retry. */
|
|
18
|
+
restore: (token: string, record: SnapshotRecord) => void;
|
|
19
|
+
};
|
|
20
|
+
export declare const MAX_RECORDS = 256;
|
|
21
|
+
/**
|
|
22
|
+
* Tokens live in memory only. A server restart forgets every binding, so an old token is then
|
|
23
|
+
* rejected as unknown and the file is left for the operating system to reap from its temp directory.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createSnapshotRegistry(): SnapshotRegistry;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
export const MAX_RECORDS = 256;
|
|
3
|
+
const REJECTED = 'cleanupToken is unknown, already used, or bound to another session or worktree; nothing was deleted';
|
|
4
|
+
/**
|
|
5
|
+
* Tokens live in memory only. A server restart forgets every binding, so an old token is then
|
|
6
|
+
* rejected as unknown and the file is left for the operating system to reap from its temp directory.
|
|
7
|
+
*/
|
|
8
|
+
export function createSnapshotRegistry() {
|
|
9
|
+
const records = new Map();
|
|
10
|
+
const store = (token, record) => {
|
|
11
|
+
records.set(token, record);
|
|
12
|
+
// A long-lived server must not grow without bound; the oldest export is forgotten first.
|
|
13
|
+
while (records.size > MAX_RECORDS)
|
|
14
|
+
records.delete(records.keys().next().value);
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
register(record) {
|
|
18
|
+
const token = randomBytes(32).toString('base64url');
|
|
19
|
+
store(token, record);
|
|
20
|
+
return token;
|
|
21
|
+
},
|
|
22
|
+
take(token, sessionId, worktree) {
|
|
23
|
+
const record = records.get(token);
|
|
24
|
+
// One message for every rejection, so a probe learns nothing about other sessions' tokens.
|
|
25
|
+
if (!record || record.sessionId !== sessionId || record.worktree !== worktree)
|
|
26
|
+
throw new Error(REJECTED);
|
|
27
|
+
records.delete(token);
|
|
28
|
+
return record;
|
|
29
|
+
},
|
|
30
|
+
restore: store,
|
|
31
|
+
};
|
|
32
|
+
}
|