vouchington-tooling 0.44.0 → 0.46.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 +10 -52
- package/dist/agent-blackboard/client.d.mts +6 -0
- package/dist/agent-blackboard/feedback-audit-source.d.mts +4 -10
- package/dist/agent-blackboard/feedback-audit-source.mjs +7 -18
- package/dist/agent-blackboard/feedback-compose-audit.mjs +6 -14
- package/dist/agent-blackboard/feedback-compose.d.mts +1 -4
- package/dist/agent-blackboard/feedback-compose.mjs +2 -5
- package/dist/agent-blackboard/feedback-fields.mjs +1 -1
- package/dist/agent-blackboard/feedback-identity.mjs +1 -1
- package/dist/agent-blackboard/feedback-journal-audit.d.mts +6 -8
- package/dist/agent-blackboard/feedback-journal-audit.mjs +9 -10
- package/dist/agent-blackboard/feedback-journal-sandbox.d.mts +2 -2
- package/dist/agent-blackboard/feedback-journal-sandbox.mjs +2 -2
- package/dist/agent-blackboard/feedback-outbox-rejected.mjs +1 -1
- package/dist/agent-blackboard/feedback-outbox.mjs +2 -2
- package/dist/agent-blackboard/index.d.mts +1 -1
- package/dist/audit-journal/ci-failures.d.mts +6 -0
- package/dist/{session-friction → audit-journal}/ci-failures.mjs +3 -9
- package/dist/{session-friction → audit-journal}/journal.d.mts +0 -1
- package/dist/{session-friction → audit-journal}/journal.mjs +1 -1
- package/dist/audit-journal/session-id.d.mts +1 -0
- package/dist/{session-friction → audit-journal}/session-id.mjs +0 -5
- package/dist/{session-friction → audit-journal}/text.d.mts +1 -4
- package/dist/{session-friction → audit-journal}/text.mjs +4 -22
- package/dist/audit-journal/types.d.mts +17 -0
- package/dist/cli/commands/gha-runtime-audit.mjs +2 -0
- package/dist/cli/usage.d.mts +1 -1
- package/dist/cli/usage.mjs +1 -1
- package/dist/gh-cli/env.d.mts +9 -0
- package/dist/gh-cli/env.mjs +12 -0
- package/dist/gh-cli/exec.d.mts +7 -4
- package/dist/gh-cli/exec.mjs +7 -5
- package/dist/gh-cli/index.d.mts +1 -0
- package/dist/gh-cli/index.mjs +1 -0
- package/dist/gha-post-review/github.mjs +2 -1
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +2 -2
- package/dist/mcp-server/context.d.mts +5 -0
- package/dist/mcp-server/dispatch.d.mts +5 -0
- package/dist/mcp-server/dispatch.mjs +8 -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 +3 -0
- package/dist/mcp-server/harness.test-helpers.mjs +2 -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-agent.d.mts +12 -0
- package/dist/mcp-server/pr-agent.mjs +34 -0
- package/dist/mcp-server/pr-body.d.mts +6 -2
- package/dist/mcp-server/pr-body.mjs +16 -5
- package/dist/mcp-server/pr-defaults.d.mts +3 -0
- package/dist/mcp-server/pr-defaults.mjs +7 -2
- package/dist/mcp-server/pr-gh.mjs +2 -1
- package/dist/mcp-server/pr-input.d.mts +14 -6
- package/dist/mcp-server/pr-input.mjs +22 -5
- package/dist/mcp-server/pr-live.d.mts +4 -1
- package/dist/mcp-server/pr-live.mjs +8 -3
- 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 +124 -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/server.mjs +1 -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 +28 -16
- package/dist/mcp-server/tools-retrospective.mjs +25 -5
- package/dist/mcp-server/tools.mjs +6 -0
- package/dist/{session-friction → private-files}/lock.mjs +3 -2
- package/dist/private-files/log-file.d.mts +3 -0
- package/dist/private-files/log-file.mjs +53 -0
- package/dist/{session-friction → private-files}/utf8.mjs +1 -1
- package/dist/retrospective-facts/exec.mjs +2 -1
- package/docs/mcp-server.md +112 -26
- package/package.json +3 -8
- package/skills/retrospective/SKILL.md +3 -1
- package/skills/retrospective-distill/SKILL.md +5 -0
- package/dist/session-friction/ci-failures.d.mts +0 -13
- package/dist/session-friction/classify.d.mts +0 -2
- package/dist/session-friction/classify.mjs +0 -98
- package/dist/session-friction/command-segments.d.mts +0 -3
- package/dist/session-friction/command-segments.mjs +0 -94
- package/dist/session-friction/coverage-ledger.d.mts +0 -10
- package/dist/session-friction/coverage-ledger.mjs +0 -75
- package/dist/session-friction/directory.d.mts +0 -1
- package/dist/session-friction/directory.mjs +0 -71
- package/dist/session-friction/index.d.mts +0 -6
- package/dist/session-friction/index.mjs +0 -5
- package/dist/session-friction/log-file.d.mts +0 -7
- package/dist/session-friction/log-file.mjs +0 -95
- package/dist/session-friction/log.d.mts +0 -8
- package/dist/session-friction/log.mjs +0 -163
- package/dist/session-friction/normalize.d.mts +0 -1
- package/dist/session-friction/normalize.mjs +0 -81
- package/dist/session-friction/report.d.mts +0 -16
- package/dist/session-friction/report.mjs +0 -91
- package/dist/session-friction/sandbox.d.mts +0 -3
- package/dist/session-friction/sandbox.mjs +0 -43
- package/dist/session-friction/session-id.d.mts +0 -2
- package/dist/session-friction/types.d.mts +0 -80
- /package/dist/{session-friction → audit-journal}/audit-block.d.mts +0 -0
- /package/dist/{session-friction → audit-journal}/audit-block.mjs +0 -0
- /package/dist/{session-friction → audit-journal}/audit-fit.d.mts +0 -0
- /package/dist/{session-friction → audit-journal}/audit-fit.mjs +0 -0
- /package/dist/{session-friction → audit-journal}/types.mjs +0 -0
- /package/dist/{session-friction → private-files}/lock.d.mts +0 -0
- /package/dist/{session-friction → private-files}/utf8.d.mts +0 -0
|
@@ -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,124 @@
|
|
|
1
|
+
import { hostname, userInfo } from 'node:os';
|
|
2
|
+
import { validateGitHubBodyLength } from "../gh-cli/body-length.mjs";
|
|
3
|
+
import { assertPolicyAllowed } from "./context.mjs";
|
|
4
|
+
import { agentLabel, deriveAgent } from "./pr-agent.mjs";
|
|
5
|
+
import { renderPullRequestBody, worktreeLabel } from "./pr-body.mjs";
|
|
6
|
+
import { createGhRunner, ghEnvironment } from "./pr-gh.mjs";
|
|
7
|
+
import { checkTitleLength, readPullRequestInput } from "./pr-input.mjs";
|
|
8
|
+
import { preserveShepherdJournal } from "./pr-journal.mjs";
|
|
9
|
+
import { credentials, liveBase, livePullRequest, readLivePullRequest, redactCredentials, } from "./pr-live.mjs";
|
|
10
|
+
import { noSourceStatement } from "./pr-no-source.mjs";
|
|
11
|
+
import { runValidator } from "./policy-run.mjs";
|
|
12
|
+
import { parseWorktreeList } from "./worktree.mjs";
|
|
13
|
+
export function gitIn(context) {
|
|
14
|
+
return (args) => context.runGit(['-C', context.worktree, ...args]);
|
|
15
|
+
}
|
|
16
|
+
export function ghIn(context) {
|
|
17
|
+
const run = context.runGh ?? createGhRunner(context.env);
|
|
18
|
+
const env = ghEnvironment(context.env);
|
|
19
|
+
return (args) => run(args, { cwd: context.worktree, env });
|
|
20
|
+
}
|
|
21
|
+
export function policyFor(context) {
|
|
22
|
+
return {
|
|
23
|
+
worktree: context.worktree,
|
|
24
|
+
env: context.env,
|
|
25
|
+
...(context.spawnPolicy === undefined ? {} : { spawn: context.spawnPolicy }),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
async function currentBranch(git) {
|
|
29
|
+
const branch = (await git(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
30
|
+
if (branch === 'HEAD')
|
|
31
|
+
throw new Error('the worktree has a detached HEAD: check out a branch');
|
|
32
|
+
return branch;
|
|
33
|
+
}
|
|
34
|
+
async function label(context, git) {
|
|
35
|
+
const listing = await git(['worktree', 'list', '--porcelain', '-z']);
|
|
36
|
+
return worktreeLabel(context.worktree, parseWorktreeList(listing)[0]);
|
|
37
|
+
}
|
|
38
|
+
async function liveFor(kind, context, pr) {
|
|
39
|
+
if (pr === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
return kind === 'update'
|
|
42
|
+
? livePullRequest(ghIn(context), pr)
|
|
43
|
+
: readLivePullRequest(ghIn(context), pr);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The head to validate. An update accepts the worktree branch, or an alias branch whose HEAD is
|
|
47
|
+
* the live head commit, and then uses the live head name; anything else is rejected.
|
|
48
|
+
*/
|
|
49
|
+
async function headFor(kind, git, live, pr, worktreeHead) {
|
|
50
|
+
if (worktreeHead === undefined)
|
|
51
|
+
return live.headRefName;
|
|
52
|
+
if (live === undefined || kind !== 'update' || live.headRefName === worktreeHead)
|
|
53
|
+
return worktreeHead;
|
|
54
|
+
const sha = (await git(['rev-parse', 'HEAD'])).trim();
|
|
55
|
+
if (sha !== live.headRefOid)
|
|
56
|
+
throw new Error(`pull request ${pr} has head ${live.headRefName} at ${live.headRefOid}, not the worktree branch ${worktreeHead} at ${sha}`);
|
|
57
|
+
return live.headRefName;
|
|
58
|
+
}
|
|
59
|
+
/** Renders the body, preserves the live journal for an update, and runs the policy validator. */
|
|
60
|
+
export async function prepare(args, context, kind, pr) {
|
|
61
|
+
assertPolicyAllowed(context);
|
|
62
|
+
// No server credential may reach the policy or gh, whatever the caller wrote: every field is
|
|
63
|
+
// redacted before anything is rendered or forwarded.
|
|
64
|
+
const secrets = credentials(context.env);
|
|
65
|
+
const given = redactCredentials(readPullRequestInput(args, context.env), secrets);
|
|
66
|
+
checkTitleLength(given.title);
|
|
67
|
+
const git = gitIn(context);
|
|
68
|
+
const live = await liveFor(kind, context, pr);
|
|
69
|
+
// A read-only validation of an existing pull request judges the head that pull request has.
|
|
70
|
+
const worktreeHead = live !== undefined && kind === 'validate' ? undefined : await currentBranch(git);
|
|
71
|
+
// The local branch is checked before any diagnostic or alias resolution can echo or drop it.
|
|
72
|
+
if (worktreeHead !== undefined && secrets.some((secret) => worktreeHead.includes(secret)))
|
|
73
|
+
throw new Error('the worktree branch name contains a server credential: rename the branch');
|
|
74
|
+
const head = await headFor(kind, git, live, pr, worktreeHead);
|
|
75
|
+
// Branch names are refs passed to gh as is: redacting one would name a branch that does not exist.
|
|
76
|
+
if (secrets.some((secret) => head.includes(secret)))
|
|
77
|
+
throw new Error('the worktree branch name contains a server credential: rename the branch');
|
|
78
|
+
if (secrets.some((secret) => args.base?.includes(secret)))
|
|
79
|
+
throw new Error('base contains a server credential: use another base branch');
|
|
80
|
+
const base = live === undefined
|
|
81
|
+
? given.base
|
|
82
|
+
: liveBase(pr, live, redactCredentials(args.base, secrets));
|
|
83
|
+
const input = { ...given, base };
|
|
84
|
+
// A live pull request may come from a fork whose branch shares the base's name.
|
|
85
|
+
if (head === base && !(kind === 'validate' && live !== undefined))
|
|
86
|
+
throw new Error(`head branch ${head} must differ from base ${base}`);
|
|
87
|
+
const device = (context.device ?? (() => ({ user: userInfo().username, host: hostname() })))();
|
|
88
|
+
const statement = await noSourceStatement(policyFor(context), input);
|
|
89
|
+
let body = renderPullRequestBody(input, {
|
|
90
|
+
sessionId: context.sessionId,
|
|
91
|
+
...device,
|
|
92
|
+
worktree: await label(context, git),
|
|
93
|
+
agent: agentLabel(deriveAgent(context.env, context.clientName?.()), input.agent, input.model),
|
|
94
|
+
}, statement);
|
|
95
|
+
if (live !== undefined)
|
|
96
|
+
body = preserveShepherdJournal(body, live.body);
|
|
97
|
+
body = redactCredentials(body, secrets);
|
|
98
|
+
const length = validateGitHubBodyLength(body);
|
|
99
|
+
if (!length.ok)
|
|
100
|
+
throw new Error(`the rendered body has ${length.characterCount} characters; GitHub allows ${length.maxCharacterCount}`);
|
|
101
|
+
const report = await runValidator({
|
|
102
|
+
...policyFor(context),
|
|
103
|
+
export: 'validatePullRequest',
|
|
104
|
+
input: {
|
|
105
|
+
title: input.title,
|
|
106
|
+
body,
|
|
107
|
+
base: input.base,
|
|
108
|
+
head,
|
|
109
|
+
mode: input.mode,
|
|
110
|
+
...(input.noSourceIssue === undefined ? {} : { noSourceIssue: input.noSourceIssue }),
|
|
111
|
+
...(pr === undefined ? {} : { pr }),
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
const errors = 'errors' in report ? report.errors : [];
|
|
115
|
+
const warnings = 'warnings' in report ? report.warnings : [];
|
|
116
|
+
return {
|
|
117
|
+
input,
|
|
118
|
+
head,
|
|
119
|
+
body,
|
|
120
|
+
errors,
|
|
121
|
+
warnings,
|
|
122
|
+
...(live === undefined ? {} : { liveBody: live.body }),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -15,6 +15,7 @@ const INSTRUCTIONS = 'Journals agent-blackboard feedback for a vouchington consu
|
|
|
15
15
|
*/
|
|
16
16
|
export function createMcpServer(sdk, environment, version) {
|
|
17
17
|
const mcp = new sdk.McpServer({ name: SERVER_NAME, version }, { instructions: INSTRUCTIONS });
|
|
18
|
+
environment.clientName ??= () => mcp.server.getClientVersion()?.name;
|
|
18
19
|
mcp.server.registerCapabilities({ tools: {} });
|
|
19
20
|
mcp.server.setRequestHandler(sdk.ListToolsRequestSchema, async () => ({ tools: [...TOOLS] }));
|
|
20
21
|
mcp.server.setRequestHandler(sdk.CallToolRequestSchema, async (request) => callTool(request.params.name, request.params.arguments, environment));
|
|
@@ -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
|
+
}
|
|
@@ -12,7 +12,8 @@ const FIELDS = {
|
|
|
12
12
|
impact: text('Who or what is affected, as markdown.'),
|
|
13
13
|
relatedIssues: {
|
|
14
14
|
type: 'array',
|
|
15
|
-
description: 'Issues this pull request closes or refs;
|
|
15
|
+
description: 'Issues this pull request closes or refs. Empty when it has none; with no source issue ' +
|
|
16
|
+
'at all, give noSourceIssue as well (it is rejected beside a non-empty list).',
|
|
16
17
|
items: {
|
|
17
18
|
type: 'object',
|
|
18
19
|
properties: {
|
|
@@ -24,6 +25,15 @@ const FIELDS = {
|
|
|
24
25
|
additionalProperties: false,
|
|
25
26
|
},
|
|
26
27
|
},
|
|
28
|
+
noSourceIssue: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
description: 'Declares a pull request with no source issue, mutually exclusive with a non-empty ' +
|
|
31
|
+
'relatedIssues. Rendered inside Related issues (the policy may supply the wording) and ' +
|
|
32
|
+
'passed to the policy, which may allow it only in interactive mode.',
|
|
33
|
+
properties: { reason: text('Single-line reason there is no source issue.') },
|
|
34
|
+
required: ['reason'],
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
},
|
|
27
37
|
details: {
|
|
28
38
|
type: 'array',
|
|
29
39
|
minItems: 1,
|
|
@@ -31,21 +41,21 @@ const FIELDS = {
|
|
|
31
41
|
description: 'Markdown paragraphs or bullets, joined by blank lines.',
|
|
32
42
|
},
|
|
33
43
|
base: { type: 'string', minLength: 1, description: 'Base branch. Default: main.' },
|
|
34
|
-
agent: text('
|
|
35
|
-
|
|
36
|
-
|
|
44
|
+
agent: text('Optional, and never trusted. The server derives the Agent line from its own launch ' +
|
|
45
|
+
'environment (harness markers) and the MCP client name; a value that does not name the ' +
|
|
46
|
+
'derived agent is rendered as "caller-claimed", and with nothing derivable the agent is ' +
|
|
47
|
+
'"unknown".'),
|
|
48
|
+
model: text('Optional, and never trusted. The server cannot derive the model, so it is rendered as ' +
|
|
49
|
+
'"caller-claimed model: <model>" in parentheses after the agent.'),
|
|
50
|
+
mode: {
|
|
51
|
+
enum: ['interactive', 'autonomous'],
|
|
52
|
+
description: 'Optional. The server derives the mode from its own launch environment and fails closed: ' +
|
|
53
|
+
'an automation marker (CI, GITHUB_ACTIONS and similar) means autonomous. This argument ' +
|
|
54
|
+
'can only narrow interactive to autonomous, never widen it.',
|
|
55
|
+
},
|
|
37
56
|
workspaceSetup: text('Text for the Workspace setup line, such as how the worktree was prepared.'),
|
|
38
57
|
};
|
|
39
|
-
const REQUIRED = [
|
|
40
|
-
'title',
|
|
41
|
-
'summary',
|
|
42
|
-
'impact',
|
|
43
|
-
'relatedIssues',
|
|
44
|
-
'details',
|
|
45
|
-
'agent',
|
|
46
|
-
'mode',
|
|
47
|
-
'workspaceSetup',
|
|
48
|
-
];
|
|
58
|
+
const REQUIRED = ['title', 'summary', 'impact', 'relatedIssues', 'details', 'workspaceSetup'];
|
|
49
59
|
const RENDERING = 'The server renders the body from the structured input (Summary, Impact, Related issues, ' +
|
|
50
60
|
'Details, then Workspace setup, Agent, Device and Worktree lines) and runs the checkout ' +
|
|
51
61
|
'policy validatePullRequest export in a child process; the head branch is the ' +
|
|
@@ -54,7 +64,9 @@ export const PR_TOOLS = [
|
|
|
54
64
|
tool('pr_validate', 'Renders the pull request body and validates it with the checkout policy; writes nothing. ' +
|
|
55
65
|
RENDERING +
|
|
56
66
|
'Pass pr to validate an update of that pull request: the live body is read and its Shepherd ' +
|
|
57
|
-
'Journal preserved
|
|
67
|
+
'Journal preserved; the pull request may be in any state and on any head (the head given to ' +
|
|
68
|
+
'the policy is its own, and no liveness check applies, unlike pr_update). Returns ' +
|
|
69
|
+
'{ body, errors, warnings }.', openWorld(READ_ONLY), { ...FIELDS, pr: PR_NUMBER }, REQUIRED),
|
|
58
70
|
tool('pr_create', 'Creates a draft pull request from the worktree branch. ' +
|
|
59
71
|
RENDERING +
|
|
60
72
|
'Validation errors block creation. The policy pullRequestDefaults export may add labels, a ' +
|
|
@@ -63,7 +75,7 @@ export const PR_TOOLS = [
|
|
|
63
75
|
"repository's stack tooling. Returns { number, url, warnings }.", openWorld(ADDITIVE), FIELDS, REQUIRED),
|
|
64
76
|
tool('pr_update', "Replaces an open pull request's title and body. " +
|
|
65
77
|
RENDERING +
|
|
66
|
-
"The pull request's head branch must be the
|
|
78
|
+
"The pull request's head must be the worktree branch, or the worktree HEAD must be the pull request's head commit (an alias branch); the live head name is then used. The live Shepherd Journal is " +
|
|
67
79
|
'preserved and nothing else is carried over from the live body. Validation errors block ' +
|
|
68
80
|
'the update. Returns { number, url, warnings }.', openWorld(MUTATING), { ...FIELDS, pr: PR_NUMBER }, [...REQUIRED, 'pr']),
|
|
69
81
|
];
|
|
@@ -15,9 +15,15 @@ export const RETROSPECTIVE_TOOLS = [
|
|
|
15
15
|
'takes only { sessionId?, harnessSessionId? } (sessionId must be the call sessionId; the ' +
|
|
16
16
|
'transcript is found by harnessSessionId, the harness session UUID, else by sessionId when ' +
|
|
17
17
|
'that is a UUID, in the default session roots of the worktree) or { status, reason } to report it ' +
|
|
18
|
-
'unassessed; any path override is rejected. The server redacts its blackboard token ' +
|
|
19
|
-
'
|
|
20
|
-
'
|
|
18
|
+
'unassessed; any path override is rejected. The server redacts its blackboard token and ' +
|
|
19
|
+
'knownSensitiveValues (also accepted beside markdown, not beside compose) from the ' +
|
|
20
|
+
'document, category and feedbackCoverage before the policy sees them. dryRun returns ' +
|
|
21
|
+
'{ markdown, previewToken, policy: { warnings } } and writes nothing; the previewToken is ' +
|
|
22
|
+
'opaque, single use, held in server memory (a restart drops it) and bound to the sessionId, ' +
|
|
23
|
+
'worktree and sourceEventId. To save exactly the previewed content, call again with ' +
|
|
24
|
+
'previewToken, the identity, mode and the same sourceEventId, and no markdown, compose, ' +
|
|
25
|
+
'workOutcome, feedbackCoverage, repositories, category or knownSensitiveValues. The ' +
|
|
26
|
+
'policy is not run again for a token save. The worktree package.json may ' +
|
|
21
27
|
'declare "vouchington-tooling": { "policy": "<module>" }; its validateRetrospective export ' +
|
|
22
28
|
'runs in a child process, and any error it reports rejects the save with nothing written. ' +
|
|
23
29
|
'Policy tools need a server launched inside a repository worktree. The server owns the ' +
|
|
@@ -29,7 +35,10 @@ export const RETROSPECTIVE_TOOLS = [
|
|
|
29
35
|
'follow journal_append.', ADDITIVE, {
|
|
30
36
|
...IDENTITY,
|
|
31
37
|
mode: { enum: ['interactive', 'autonomous'], description: 'Delivery mode.' },
|
|
32
|
-
workOutcome: {
|
|
38
|
+
workOutcome: {
|
|
39
|
+
enum: OUTCOMES,
|
|
40
|
+
description: 'Outcome of the work being reported; required without previewToken.',
|
|
41
|
+
},
|
|
33
42
|
feedbackCoverage: COVERAGE,
|
|
34
43
|
repositories: {
|
|
35
44
|
type: 'array',
|
|
@@ -44,5 +53,16 @@ export const RETROSPECTIVE_TOOLS = [
|
|
|
44
53
|
sourceEventId: { type: 'string', pattern: ID_PATTERN, description: 'Stable event id.' },
|
|
45
54
|
category: { type: 'string', description: 'Optional context; never changes entry type.' },
|
|
46
55
|
dryRun: { type: 'boolean', description: 'Compose and validate only; write nothing.' },
|
|
47
|
-
|
|
56
|
+
knownSensitiveValues: {
|
|
57
|
+
type: 'array',
|
|
58
|
+
items: { type: 'string', minLength: 4 },
|
|
59
|
+
description: 'Values redacted from every argument of a markdown save.',
|
|
60
|
+
},
|
|
61
|
+
previewToken: {
|
|
62
|
+
type: 'string',
|
|
63
|
+
description: 'Token from a dry run; saves exactly that content. Exclusive with the content.',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
// workOutcome and feedbackCoverage are required unless previewToken carries them.
|
|
67
|
+
[...IDENTITY_REQUIRED, 'mode']),
|
|
48
68
|
];
|
|
@@ -58,5 +58,11 @@ export const TOOLS = [
|
|
|
58
58
|
dataArrayContains: { type: 'object', description: 'Exact string membership in arrays.' },
|
|
59
59
|
inactiveForHours: { type: 'number', exclusiveMinimum: 0 },
|
|
60
60
|
}),
|
|
61
|
+
tool('snapshot_cleanup', 'Deletes the snapshot file (and partition directory, when one was recorded) that ' +
|
|
62
|
+
'snapshot_export produced. Takes only the cleanupToken it returned: the server resolves the ' +
|
|
63
|
+
'paths from its own record, never from the caller. The token is bound to the sessionId and ' +
|
|
64
|
+
'worktree that exported, is single-use, and is forgotten when the server restarts (the ' +
|
|
65
|
+
'file is then left for the OS to reap). A failed cleanup keeps the token valid and names ' +
|
|
66
|
+
'any retained tombstone so the same call can retry. Call it as a finally step.', { ...MUTATING, idempotentHint: false }, { cleanupToken: { type: 'string', description: 'The cleanupToken snapshot_export returned.' } }, ['cleanupToken']),
|
|
61
67
|
tool('session_archive', 'Marks a session as distilled. Its metadata becomes immutable; entries stay appendable.', MUTATING),
|
|
62
68
|
];
|
|
@@ -131,6 +131,7 @@ function removeStaleLock(lockPath) {
|
|
|
131
131
|
return true;
|
|
132
132
|
}
|
|
133
133
|
catch {
|
|
134
|
+
/* v8 ignore next -- requires an unexpected filesystem failure during stale-lock recovery. */
|
|
134
135
|
return false;
|
|
135
136
|
}
|
|
136
137
|
});
|
|
@@ -174,7 +175,7 @@ export function withFileLock(path, action) {
|
|
|
174
175
|
throw statusError;
|
|
175
176
|
}
|
|
176
177
|
if (!lockStatus.isFile())
|
|
177
|
-
throw new Error('
|
|
178
|
+
throw new Error('log lock must be a regular file');
|
|
178
179
|
if (removeStaleLock(lockPath))
|
|
179
180
|
continue;
|
|
180
181
|
waitForLock();
|
|
@@ -189,5 +190,5 @@ export function withFileLock(path, action) {
|
|
|
189
190
|
unlinkSync(lockPath);
|
|
190
191
|
}
|
|
191
192
|
}
|
|
192
|
-
throw new Error(`could not acquire
|
|
193
|
+
throw new Error(`could not acquire file lock for ${basename(path)}`);
|
|
193
194
|
}
|