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
|
@@ -1,154 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { createFeedbackEnvelope, validateFeedbackEnvelope, writeFeedback, } from "../agent-blackboard/index.mjs";
|
|
3
|
-
import { optionalRecord, optionalString, requiredChoice, requiredString, requiredStringArray, } from "./args.mjs";
|
|
4
|
-
import { assertPolicyAllowed, outboxDirectory, readIdentity } from "./context.mjs";
|
|
5
|
-
import { byCreatedAt, deliveryResponse, MODES, OUTCOMES, readCoverage, } from "./handlers-journal.mjs";
|
|
6
|
-
import { readEntriesIfPresent } from "./journal-read.mjs";
|
|
7
|
-
import { runValidator } from "./policy-run.mjs";
|
|
8
|
-
import { parseRetrospectiveFrontMatter } from "./retrospective-front-matter.mjs";
|
|
9
|
-
import { FEEDBACK_MARKDOWN_MAX_BYTES, redactFeedbackText, } from "../agent-blackboard/feedback-codec.mjs";
|
|
10
|
-
import { composeFromArgs, sensitiveValues } from "./retrospective-input.mjs";
|
|
11
|
-
import { checkFrontMatter, resolveRepositories } from "./retrospective-resolve.mjs";
|
|
12
|
-
function retrospectiveData(entry) {
|
|
13
|
-
const data = entry?.data;
|
|
14
|
-
return data?.type === 'retrospective' ? data : undefined;
|
|
15
|
-
}
|
|
16
|
-
/** Whether the session already holds a retrospective, and which (sourceEventId, issues, prs). */
|
|
17
|
-
export const retrospectiveCheck = async (args, context) => {
|
|
18
|
-
const sourceEventId = optionalString(args, 'sourceEventId');
|
|
19
|
-
const entries = await readEntriesIfPresent(context.sessionId, context.env, context.dependencies);
|
|
20
|
-
const found = (entries ?? [])
|
|
21
|
-
.toSorted(byCreatedAt)
|
|
22
|
-
.flatMap((entry) => {
|
|
23
|
-
const data = retrospectiveData(entry);
|
|
24
|
-
return data === undefined ? [] : [data];
|
|
25
|
-
})
|
|
26
|
-
.filter((data) => sourceEventId === undefined || data.sourceEventId === sourceEventId)
|
|
27
|
-
.map((data) => ({
|
|
28
|
-
sourceEventId: data.sourceEventId,
|
|
29
|
-
timestamp: data.timestamp,
|
|
30
|
-
issues: data.issues ?? [],
|
|
31
|
-
prs: data.prs ?? [],
|
|
32
|
-
}));
|
|
33
|
-
return { sessionId: context.sessionId, saved: found.length > 0, entries: found };
|
|
34
|
-
};
|
|
35
|
-
function readMarkdownSource(args) {
|
|
36
|
-
const compose = optionalRecord(args, 'compose');
|
|
37
|
-
if ((args.markdown === undefined) === (compose === undefined))
|
|
38
|
-
throw new Error('pass exactly one of markdown or compose');
|
|
39
|
-
return compose === undefined ? { markdown: requiredString(args, 'markdown') } : { compose };
|
|
40
|
-
}
|
|
41
|
-
/** The default event id, hashed when the session id would push it past the 256-byte limit. */
|
|
42
|
-
export function defaultSourceEventId(sessionId) {
|
|
43
|
-
const id = `retrospective-${sessionId}`;
|
|
44
|
-
return Buffer.byteLength(id) <= 256
|
|
45
|
-
? id
|
|
46
|
-
: `retrospective-${createHash('sha256').update(sessionId).digest('hex')}`;
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* Saves the session's retrospective: composes it when `compose` is given, checks the arguments
|
|
50
|
-
* against the document's front matter, runs the checkout's `validateRetrospective` policy (in a
|
|
51
|
-
* child process) and writes nothing if it reports errors, then writes through the same verified
|
|
52
|
-
* path as `journal_append`.
|
|
53
|
-
*/
|
|
54
|
-
export const retrospectiveSave = async (args, context) => {
|
|
55
|
-
assertPolicyAllowed(context);
|
|
56
|
-
const identity = readIdentity(args, context.sessionId);
|
|
57
|
-
const mode = requiredChoice(args, 'mode', MODES);
|
|
58
|
-
const workOutcome = requiredChoice(args, 'workOutcome', OUTCOMES);
|
|
59
|
-
const feedbackCoverage = readCoverage(args);
|
|
60
|
-
const argRepositories = args.repositories === undefined ? undefined : requiredStringArray(args, 'repositories');
|
|
61
|
-
const sourceEventId = optionalString(args, 'sourceEventId') ?? defaultSourceEventId(context.sessionId);
|
|
62
|
-
const category = optionalString(args, 'category');
|
|
63
|
-
if (args.dryRun !== undefined && typeof args.dryRun !== 'boolean')
|
|
64
|
-
throw new Error('dryRun must be a boolean');
|
|
65
|
-
const policy = {
|
|
66
|
-
worktree: context.worktree,
|
|
67
|
-
env: context.env,
|
|
68
|
-
...(context.spawnPolicy === undefined ? {} : { spawn: context.spawnPolicy }),
|
|
69
|
-
};
|
|
70
|
-
const { markdown: given, compose } = readMarkdownSource(args);
|
|
71
|
-
const secrets = sensitiveValues(compose, context.env);
|
|
72
|
-
// Everything the policy sees is derived from the redacted document, never the raw input.
|
|
73
|
-
const redactedGiven = given === undefined ? undefined : redactFeedbackText(given, secrets);
|
|
74
|
-
// Reject a document that can never be stored before any policy export runs for it.
|
|
75
|
-
if (redactedGiven !== undefined && Buffer.byteLength(redactedGiven) > FEEDBACK_MARKDOWN_MAX_BYTES)
|
|
76
|
-
throw new Error(`markdown must be at most ${FEEDBACK_MARKDOWN_MAX_BYTES} bytes`);
|
|
77
|
-
const early = redactedGiven === undefined ? undefined : parseRetrospectiveFrontMatter(redactedGiven);
|
|
78
|
-
const repositories = await resolveRepositories(argRepositories, early?.repositories, policy);
|
|
79
|
-
const markdown = redactedGiven ??
|
|
80
|
-
redactFeedbackText(await composeFromArgs(compose, {
|
|
81
|
-
sessionId: context.sessionId,
|
|
82
|
-
repositories,
|
|
83
|
-
workOutcome,
|
|
84
|
-
feedbackCoverage,
|
|
85
|
-
worktree: context.worktree,
|
|
86
|
-
env: context.env,
|
|
87
|
-
dependencies: context.dependencies,
|
|
88
|
-
}), secrets);
|
|
89
|
-
const front = early ?? parseRetrospectiveFrontMatter(markdown);
|
|
90
|
-
checkFrontMatter(front, {
|
|
91
|
-
sessionId: context.sessionId,
|
|
92
|
-
workOutcome,
|
|
93
|
-
// The document was redacted before parsing, so compare it with equally redacted sources.
|
|
94
|
-
feedbackCoverage: {
|
|
95
|
-
...feedbackCoverage,
|
|
96
|
-
sources: feedbackCoverage.sources.map((source) => redactFeedbackText(source, secrets)),
|
|
97
|
-
},
|
|
98
|
-
repositories: argRepositories,
|
|
99
|
-
});
|
|
100
|
-
// Building the envelope first rejects empty or oversized markdown before any policy runs.
|
|
101
|
-
const envelope = createFeedbackEnvelope({
|
|
102
|
-
schemaVersion: 1,
|
|
103
|
-
type: 'retrospective',
|
|
104
|
-
sourceEventId,
|
|
105
|
-
// The server owns the timestamp, as in journal_append.
|
|
106
|
-
timestamp: new Date().toISOString(),
|
|
107
|
-
date: front.date,
|
|
108
|
-
issues: front.issues,
|
|
109
|
-
prs: front.prs,
|
|
110
|
-
repositories,
|
|
111
|
-
markdown,
|
|
112
|
-
workOutcome,
|
|
113
|
-
feedbackCoverage,
|
|
114
|
-
...(category === undefined ? {} : { category }),
|
|
115
|
-
}, { knownSensitiveValues: secrets });
|
|
116
|
-
const redactedFront = parseRetrospectiveFrontMatter(envelope.markdown);
|
|
117
|
-
validateFeedbackEnvelope(envelope);
|
|
118
|
-
const report = await runValidator({
|
|
119
|
-
...policy,
|
|
120
|
-
export: 'validateRetrospective',
|
|
121
|
-
input: {
|
|
122
|
-
sessionId: context.sessionId,
|
|
123
|
-
parentSessionId: identity.parentSessionId,
|
|
124
|
-
mode,
|
|
125
|
-
workOutcome,
|
|
126
|
-
// The envelope's copy, which is redacted like the markdown.
|
|
127
|
-
feedbackCoverage: envelope.feedbackCoverage,
|
|
128
|
-
repositories,
|
|
129
|
-
markdown: envelope.markdown,
|
|
130
|
-
sourceEventId,
|
|
131
|
-
metadata: {
|
|
132
|
-
date: redactedFront.date,
|
|
133
|
-
issues: redactedFront.issues,
|
|
134
|
-
prs: redactedFront.prs,
|
|
135
|
-
description: redactedFront.description,
|
|
136
|
-
},
|
|
137
|
-
},
|
|
138
|
-
});
|
|
139
|
-
if ('errors' in report && report.errors.length)
|
|
140
|
-
throw new Error(`policy rejected the retrospective:\n- ${report.errors.join('\n- ')}`);
|
|
141
|
-
const warnings = 'warnings' in report ? report.warnings : [];
|
|
142
|
-
if (args.dryRun === true)
|
|
143
|
-
return { markdown: envelope.markdown, policy: { warnings } };
|
|
144
|
-
const directory = mode === 'interactive' ? outboxDirectory(context.worktree) : undefined;
|
|
145
|
-
const result = await writeFeedback({
|
|
146
|
-
identity,
|
|
147
|
-
envelope,
|
|
148
|
-
mode,
|
|
149
|
-
...(directory === undefined ? {} : { outboxDirectory: directory }),
|
|
150
|
-
env: context.env,
|
|
151
|
-
dependencies: context.dependencies,
|
|
152
|
-
});
|
|
153
|
-
return { ...deliveryResponse(result, context, mode), policy: { warnings } };
|
|
154
|
-
};
|
|
1
|
+
export { defaultSourceEventId, retrospectiveSave } from "./handlers-retrospective-save.mjs";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { archiveBlackboardSession, ensureBlackboardSession, exportSnapshot, } from "../agent-blackboard/index.mjs";
|
|
2
2
|
import { optionalRecord, optionalString, requiredNullableString } from "./args.mjs";
|
|
3
|
+
import { isObject } from "../agent-blackboard/snapshot-partition-guards.mjs";
|
|
3
4
|
import { readIdentity } from "./context.mjs";
|
|
4
5
|
function readSelection(args) {
|
|
5
6
|
const data = optionalRecord(args, 'data');
|
|
@@ -33,15 +34,33 @@ export const sessionArchive = async (_args, context) => archiveBlackboardSession
|
|
|
33
34
|
env: context.env,
|
|
34
35
|
dependencies: context.dependencies,
|
|
35
36
|
});
|
|
37
|
+
/** A partition directory and signed receipt, only when the client itself reported both. */
|
|
38
|
+
function recordedPartitions(outcome) {
|
|
39
|
+
const { directory, cleanupReceipt } = outcome;
|
|
40
|
+
return typeof directory === 'string' && isObject(cleanupReceipt)
|
|
41
|
+
? { directory, receipt: cleanupReceipt }
|
|
42
|
+
: {};
|
|
43
|
+
}
|
|
36
44
|
export const snapshotExport = async (args, context) => {
|
|
37
45
|
const selection = readSelection(args);
|
|
46
|
+
const outcome = await exportSnapshot({
|
|
47
|
+
// An empty selection means "everything"; leave it unset rather than send an empty filter.
|
|
48
|
+
...(Object.keys(selection).length === 0 ? {} : { selection }),
|
|
49
|
+
env: context.env,
|
|
50
|
+
dependencies: context.dependencies,
|
|
51
|
+
});
|
|
52
|
+
// The client's own token stays server-side: the server records the export and mints a token bound to
|
|
53
|
+
// the caller's sessionId and worktree, which `snapshot_cleanup` checks before touching disk.
|
|
54
|
+
const { cleanupToken: clientToken, ...reported } = outcome;
|
|
38
55
|
return {
|
|
39
56
|
sessionId: context.sessionId,
|
|
40
|
-
...
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
57
|
+
...reported,
|
|
58
|
+
cleanupToken: context.snapshots.register({
|
|
59
|
+
sessionId: context.sessionId,
|
|
60
|
+
worktree: context.worktree,
|
|
61
|
+
path: outcome.path,
|
|
62
|
+
...(typeof clientToken === 'string' ? { clientToken } : {}),
|
|
63
|
+
...recordedPartitions(outcome),
|
|
64
|
+
}),
|
|
46
65
|
};
|
|
47
66
|
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ToolHandler } from './context.mts';
|
|
2
|
+
/**
|
|
3
|
+
* Deletes the files one `snapshot_export` recorded. The caller supplies only the token: the paths
|
|
4
|
+
* and the signed receipt come from the server's own record, and the record is checked against the
|
|
5
|
+
* caller's sessionId and resolved worktree before any filesystem call.
|
|
6
|
+
*/
|
|
7
|
+
export declare const snapshotCleanup: ToolHandler;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { cleanupSnapshotPartitions } from "../agent-blackboard/index.mjs";
|
|
2
|
+
import { loadClient } from "../agent-blackboard/client.mjs";
|
|
3
|
+
import { requiredString } from "./args.mjs";
|
|
4
|
+
/**
|
|
5
|
+
* Deletes the files one `snapshot_export` recorded. The caller supplies only the token: the paths
|
|
6
|
+
* and the signed receipt come from the server's own record, and the record is checked against the
|
|
7
|
+
* caller's sessionId and resolved worktree before any filesystem call.
|
|
8
|
+
*/
|
|
9
|
+
export const snapshotCleanup = async (args, context) => {
|
|
10
|
+
const token = requiredString(args, 'cleanupToken');
|
|
11
|
+
// Taking the record is synchronous, so a concurrent second call finds the token already spent.
|
|
12
|
+
const record = context.snapshots.take(token, context.sessionId, context.worktree);
|
|
13
|
+
try {
|
|
14
|
+
const client = await loadClient(context.dependencies);
|
|
15
|
+
// A client that wrote an ownership marker must clean with its own capability, or the marker
|
|
16
|
+
// outlives the file. Older clients fall back to the in-tree receipt-based cleanup.
|
|
17
|
+
if (client.cleanupSnapshotPartitions && record.clientToken !== undefined)
|
|
18
|
+
await client.cleanupSnapshotPartitions({
|
|
19
|
+
path: record.path,
|
|
20
|
+
...(record.directory === undefined ? {} : { directory: record.directory }),
|
|
21
|
+
cleanupToken: record.clientToken,
|
|
22
|
+
});
|
|
23
|
+
else
|
|
24
|
+
await cleanupSnapshotPartitions({
|
|
25
|
+
path: record.path,
|
|
26
|
+
...(record.directory === undefined ? {} : { directory: record.directory }),
|
|
27
|
+
...(record.receipt === undefined ? {} : { receipt: record.receipt }),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
context.snapshots.restore(token, record);
|
|
32
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
33
|
+
throw new Error(`snapshot cleanup failed and the cleanupToken is still valid; retry the same call. ${reason}`, { cause: error });
|
|
34
|
+
}
|
|
35
|
+
return { sessionId: context.sessionId, cleaned: true };
|
|
36
|
+
};
|
|
@@ -3,6 +3,7 @@ import { type FakeBlackboard, type FakeOptions } from './fake-blackboard.test-he
|
|
|
3
3
|
import { type RepoFixture } from './git-fixture.test-helpers.mts';
|
|
4
4
|
import type { GhRunner } from './pr-gh.mts';
|
|
5
5
|
import type { PolicySpawner } from './policy-spawn.mts';
|
|
6
|
+
import type { SnapshotRegistry } from './snapshot-registry.mts';
|
|
6
7
|
/** A fresh pair of real repositories for every test. */
|
|
7
8
|
export declare function useRepoFixture(): () => RepoFixture;
|
|
8
9
|
export declare function outboxPath(worktree: string): string;
|
|
@@ -21,6 +22,8 @@ export declare function harness(fixture: RepoFixture, options?: FakeOptions & {
|
|
|
21
22
|
user: string;
|
|
22
23
|
host: string;
|
|
23
24
|
};
|
|
25
|
+
snapshots?: SnapshotRegistry;
|
|
26
|
+
clientName?: () => string | undefined;
|
|
24
27
|
}): Harness;
|
|
25
28
|
export declare function textOf(result: ToolCallResult): string;
|
|
26
29
|
export declare function jsonOf(result: ToolCallResult): Record<string, unknown>;
|
|
@@ -27,6 +27,8 @@ export function harness(fixture, options = {}) {
|
|
|
27
27
|
...(options.spawnPolicy === undefined ? {} : { spawnPolicy: options.spawnPolicy }),
|
|
28
28
|
...(options.runGh === undefined ? {} : { runGh: options.runGh }),
|
|
29
29
|
...(options.device === undefined ? {} : { device: options.device }),
|
|
30
|
+
...(options.snapshots === undefined ? {} : { snapshots: options.snapshots }),
|
|
31
|
+
...(options.clientName === undefined ? {} : { clientName: options.clientName }),
|
|
30
32
|
};
|
|
31
33
|
return { fake, call: (name, args) => callTool(name, args, environment) };
|
|
32
34
|
}
|
|
@@ -4,6 +4,7 @@ export declare const POLICY_TIMEOUTS_MS: {
|
|
|
4
4
|
readonly validateRetrospective: 30000;
|
|
5
5
|
readonly validatePullRequest: 120000;
|
|
6
6
|
readonly pullRequestDefaults: 120000;
|
|
7
|
+
readonly renderNoSourceIssue: 30000;
|
|
7
8
|
};
|
|
8
9
|
type PolicyExport = keyof typeof POLICY_TIMEOUTS_MS;
|
|
9
10
|
export type PolicyResult = {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type HarnessId } from '../agent-harness-identity/index.mts';
|
|
2
|
+
/**
|
|
3
|
+
* The harness that launched this server, from the launch environment first and the MCP client's
|
|
4
|
+
* `initialize` `clientInfo.name` second. Neither comes from a tool argument.
|
|
5
|
+
*/
|
|
6
|
+
export declare function deriveAgent(env: NodeJS.ProcessEnv, clientName?: string): HarnessId | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* The text of the `Agent:` line before the session. The agent is the derived harness; a caller
|
|
9
|
+
* `agent` that does not name it, or any `model`, is shown as caller-claimed and never replaces it.
|
|
10
|
+
* With nothing derivable the agent is `unknown`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function agentLabel(derived: HarnessId | undefined, claimedAgent: string | undefined, claimedModel: string | undefined): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { inspectHarnessEnvironment } from "../agent-harness-identity/index.mjs";
|
|
2
|
+
const PRECEDENCE = ['codex', 'claude', 'cursor', 'grok'];
|
|
3
|
+
/**
|
|
4
|
+
* The harness that launched this server, from the launch environment first and the MCP client's
|
|
5
|
+
* `initialize` `clientInfo.name` second. Neither comes from a tool argument.
|
|
6
|
+
*/
|
|
7
|
+
export function deriveAgent(env, clientName) {
|
|
8
|
+
const found = inspectHarnessEnvironment(env);
|
|
9
|
+
const present = {
|
|
10
|
+
codex: Boolean(found.codex.sessionId),
|
|
11
|
+
claude: found.claude.compatMode || Boolean(found.claude.sessionId),
|
|
12
|
+
cursor: found.cursor.agentMarker || Boolean(found.cursor.sessionId),
|
|
13
|
+
grok: found.grok.agentMarker || found.grok.hookEvent || Boolean(found.grok.sessionId),
|
|
14
|
+
};
|
|
15
|
+
const fromEnv = PRECEDENCE.find((id) => present[id]);
|
|
16
|
+
if (fromEnv !== undefined)
|
|
17
|
+
return fromEnv;
|
|
18
|
+
const name = clientName?.toLowerCase() ?? '';
|
|
19
|
+
return PRECEDENCE.find((id) => name.includes(id));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The text of the `Agent:` line before the session. The agent is the derived harness; a caller
|
|
23
|
+
* `agent` that does not name it, or any `model`, is shown as caller-claimed and never replaces it.
|
|
24
|
+
* With nothing derivable the agent is `unknown`.
|
|
25
|
+
*/
|
|
26
|
+
export function agentLabel(derived, claimedAgent, claimedModel) {
|
|
27
|
+
const notes = [];
|
|
28
|
+
const claim = claimedAgent?.trim();
|
|
29
|
+
if (claim && !(derived !== undefined && claim.toLowerCase().includes(derived)))
|
|
30
|
+
notes.push(`caller-claimed agent: ${claim}`);
|
|
31
|
+
if (claimedModel)
|
|
32
|
+
notes.push(`caller-claimed model: ${claimedModel}`);
|
|
33
|
+
return `${derived ?? 'unknown'}${notes.length ? ` (${notes.join('; ')})` : ''}`;
|
|
34
|
+
}
|
|
@@ -7,12 +7,16 @@ export type PullRequestProvenance = {
|
|
|
7
7
|
host: string;
|
|
8
8
|
/** The checkout label, see {@link worktreeLabel}. */
|
|
9
9
|
worktree: string;
|
|
10
|
+
/** The Agent line text before the session, see `agentLabel`. */
|
|
11
|
+
agent: string;
|
|
10
12
|
};
|
|
11
13
|
/**
|
|
12
14
|
* The pull request body: the Summary, Impact, Related issues and Details sections followed by the
|
|
13
|
-
* provenance lines.
|
|
15
|
+
* provenance lines. With `noSourceIssue` the Related issues section holds `noSourceStatement`
|
|
16
|
+
* (default {@link defaultNoSourceStatement}) instead of `- None`. Pure; the caller supplies the
|
|
17
|
+
* device and worktree facts.
|
|
14
18
|
*/
|
|
15
|
-
export declare function renderPullRequestBody(input: PullRequestInput, provenance: PullRequestProvenance): string;
|
|
19
|
+
export declare function renderPullRequestBody(input: PullRequestInput, provenance: PullRequestProvenance, noSourceStatement?: string): string;
|
|
16
20
|
/**
|
|
17
21
|
* The worktree label: the path after `/worktrees/` when present, `main` for the repository's main
|
|
18
22
|
* worktree, else the directory name.
|
|
@@ -3,13 +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
|
|
12
|
-
|
|
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
|
+
];
|
|
13
24
|
return [
|
|
14
25
|
'## Summary',
|
|
15
26
|
'',
|
|
@@ -28,7 +39,7 @@ export function renderPullRequestBody(input, provenance) {
|
|
|
28
39
|
input.details.join('\n\n'),
|
|
29
40
|
'',
|
|
30
41
|
`Workspace setup: ${input.workspaceSetup}`,
|
|
31
|
-
`Agent: ${agent} session ${provenance.sessionId}`,
|
|
42
|
+
`Agent: ${provenance.agent} session ${provenance.sessionId}`,
|
|
32
43
|
`Device: ${provenance.user}@${provenance.host.toLowerCase()}`,
|
|
33
44
|
`Worktree: ${provenance.worktree}`,
|
|
34
45
|
'',
|
|
@@ -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: [] };
|
|
@@ -3,6 +3,7 @@ import { constants } from 'node:fs';
|
|
|
3
3
|
import { access, stat } from 'node:fs/promises';
|
|
4
4
|
import { delimiter, isAbsolute, join } from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
+
import { ghSpawnEnv } from "../gh-cli/env.mjs";
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
const FALLBACKS = ['/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh'];
|
|
8
9
|
const MAX_BUFFER_BYTES = 16_000_000;
|
|
@@ -36,7 +37,7 @@ export async function locateGh(env, fallbacks = FALLBACKS) {
|
|
|
36
37
|
* GH_REPO, which would point gh at a repository other than the validated worktree's.
|
|
37
38
|
*/
|
|
38
39
|
export function ghEnvironment(env) {
|
|
39
|
-
const { AGENT_BLACKBOARD_URL: _url, AGENT_BLACKBOARD_TOKEN: _token, GH_REPO: _repo, ...rest } = env;
|
|
40
|
+
const { AGENT_BLACKBOARD_URL: _url, AGENT_BLACKBOARD_TOKEN: _token, GH_REPO: _repo, ...rest } = ghSpawnEnv(env);
|
|
40
41
|
return rest;
|
|
41
42
|
}
|
|
42
43
|
/** A runner that locates gh once, on first use, from the server's environment. */
|
|
@@ -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,25 @@ 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
|
+
/** The caller's claim only; the rendered Agent line comes from `agentLabel`. */
|
|
20
|
+
agent?: string;
|
|
16
21
|
model?: string;
|
|
17
|
-
mode
|
|
22
|
+
/** The trusted mode, see {@link trustedMode}; never the caller's word alone. */
|
|
23
|
+
mode: PullRequestMode;
|
|
18
24
|
workspaceSetup: string;
|
|
19
25
|
};
|
|
20
26
|
/** A positive integer argument, such as a pull request number or an issue number. */
|
|
21
27
|
export declare function positiveInteger(value: unknown, label: string): number;
|
|
22
28
|
/** Rechecked after redaction, which can lengthen a title. */
|
|
23
29
|
export declare function checkTitleLength(title: string): void;
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Reads and validates the structured pull request fields shared by the three pr_* tools. The mode
|
|
32
|
+
* comes from the server's `env`; the caller's optional `mode` can only narrow it.
|
|
33
|
+
*/
|
|
34
|
+
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
|
-
agent: singleLine(args, 'agent'),
|
|
73
|
+
...(args.agent === undefined ? {} : { 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
|
};
|
|
@@ -2,10 +2,13 @@ import type { RunTextCommand } from '../gh-cli/exec.mts';
|
|
|
2
2
|
export type LivePullRequest = {
|
|
3
3
|
body: string;
|
|
4
4
|
headRefName: string;
|
|
5
|
+
headRefOid: string;
|
|
5
6
|
baseRefName: string;
|
|
6
7
|
state: string;
|
|
7
8
|
};
|
|
8
|
-
/** The live pull request as gh reports it,
|
|
9
|
+
/** The live pull request as gh reports it, whatever its state. */
|
|
10
|
+
export declare function readLivePullRequest(gh: RunTextCommand, pr: number): Promise<LivePullRequest>;
|
|
11
|
+
/** The live pull request, rejected unless it is open. */
|
|
9
12
|
export declare function livePullRequest(gh: RunTextCommand, pr: number): Promise<LivePullRequest>;
|
|
10
13
|
/**
|
|
11
14
|
* 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
|
-
const FIELDS = ['body', 'headRefName', 'baseRefName', 'state'];
|
|
3
|
-
/** The live pull request as gh reports it,
|
|
4
|
-
export async function
|
|
2
|
+
const FIELDS = ['body', 'headRefName', 'headRefOid', 'baseRefName', 'state'];
|
|
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>;
|