@bridge_gpt/mcp-server 0.2.18 → 0.2.20
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/CONDUCTOR.md +75 -0
- package/README.md +8 -5
- package/build/agent-capabilities/probe-context.js +13 -3
- package/build/agent-capabilities/probes.js +262 -11
- package/build/agent-capabilities/reporter.js +1 -0
- package/build/agents.generated.js +1 -1
- package/build/backend-warnings.js +44 -0
- package/build/claude-settings.js +129 -0
- package/build/commands.generated.js +5 -3
- package/build/conductor/bridge-api-client.js +7 -7
- package/build/conductor/cli.js +65 -12
- package/build/conductor/deny-enforcement-preflight.js +96 -0
- package/build/conductor/doctor.js +183 -2
- package/build/conductor/epic-reconcile.js +9 -1
- package/build/conductor/epic-runtime.js +403 -43
- package/build/conductor/epic-state.js +7 -0
- package/build/conductor/errors.js +115 -3
- package/build/conductor/event-accessors.js +28 -10
- package/build/conductor/local-merge.js +458 -95
- package/build/conductor/merge-ledger.js +6 -4
- package/build/conductor/pr-ci-producer.js +17 -2
- package/build/conductor/producer-ledger.js +1 -1
- package/build/conductor/store.js +161 -18
- package/build/conductor/supervisor-merge.js +32 -5
- package/build/conductor/taxonomy.js +8 -0
- package/build/conductor/tools.js +28 -6
- package/build/conductor/worker-ledger-cli.js +244 -0
- package/build/conductor-bin.js +1884 -6917
- package/build/doctor.js +8 -0
- package/build/estimate-epic.js +84 -0
- package/build/executor/cli.js +229 -0
- package/build/executor/credentials.js +65 -0
- package/build/executor/deps.js +117 -0
- package/build/executor/env.js +79 -0
- package/build/executor/heartbeat.js +59 -0
- package/build/executor/http-client.js +131 -0
- package/build/executor/index.js +10 -0
- package/build/executor/job-errors.js +55 -0
- package/build/executor/job-log-registry.js +110 -0
- package/build/executor/job-runner.js +822 -0
- package/build/executor/job-types.js +60 -0
- package/build/executor/merge-job.js +229 -0
- package/build/executor/observation.js +123 -0
- package/build/executor/permissions.js +79 -0
- package/build/executor/preflight.js +144 -0
- package/build/executor/process.js +81 -0
- package/build/executor/prompt-spec.js +235 -0
- package/build/executor/results.js +134 -0
- package/build/executor/resume-pre-spawn.js +179 -0
- package/build/executor/runner.js +98 -0
- package/build/executor/terminal-mutation.js +34 -0
- package/build/executor/test-clock.js +109 -0
- package/build/executor/types.js +18 -0
- package/build/executor/verdict-artifact.js +53 -0
- package/build/executor/viewer-tabs.js +78 -0
- package/build/executor/watch-cli.js +113 -0
- package/build/executor/worker-command.js +106 -0
- package/build/executor/worker-finalization.js +177 -0
- package/build/executor/worker-log.js +92 -0
- package/build/executor/worktree-gc.js +134 -0
- package/build/executor/worktree-inspection.js +86 -0
- package/build/executor/worktree.js +103 -0
- package/build/index.js +14534 -10416
- package/build/mcp-invoke.js +19 -3
- package/build/mcp-provisioning.js +31 -25
- package/build/mcp-registration-doctor.js +27 -7
- package/build/mcp-server-invocation.js +152 -0
- package/build/pipelines.generated.js +16 -20
- package/build/readme.generated.js +1 -1
- package/build/sfcc/client.js +192 -50
- package/build/sfcc/ocapi-write-faults.js +94 -0
- package/build/sfcc/permissions.js +7 -22
- package/build/sfcc/reads-site-preference.js +52 -19
- package/build/sfcc/register.js +9 -0
- package/build/sfcc/write-grants.js +80 -0
- package/build/sfcc/write-guard.js +39 -0
- package/build/sfcc/write-result.js +47 -0
- package/build/sfcc/write-tool-common.js +85 -0
- package/build/sfcc/writes-custom-object-def.js +141 -0
- package/build/sfcc/writes-object-attribute-payloads.js +97 -0
- package/build/sfcc/writes-site-preference-payloads.js +59 -0
- package/build/sfcc/writes-site-preference.js +96 -0
- package/build/sfcc/writes-system-object-payloads.js +213 -0
- package/build/sfcc/writes-system-object.js +348 -0
- package/build/sfcc/writes.js +66 -0
- package/build/start-tickets-conductor.js +25 -93
- package/build/start-tickets-prereqs.js +152 -1
- package/build/start-tickets.js +96 -158
- package/build/version.generated.js +1 -1
- package/build/visual-diff-worker.js +313 -0
- package/build/visual-diff.js +632 -0
- package/build/worktree-core.js +202 -0
- package/package.json +8 -4
- package/pipelines/idea-to-ticket.json +7 -0
- package/pipelines/review-ticket.json +5 -18
- package/public/css/main.min.css +1612 -107
- package/public/css/main.min.css.map +1 -1
- package/public/js/main.min.js +10267 -1
- package/public/js/main.min.js.map +1 -1
- package/smoke-test/SMOKE-TEST.md +2 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Default retained stdout/stderr excerpt bound (bytes). */
|
|
2
|
+
export const DEFAULT_EXCERPT_BYTES = 8_000;
|
|
3
|
+
/** Default SIGTERM→SIGKILL grace period (ms). */
|
|
4
|
+
export const DEFAULT_TERM_GRACE_MS = 10_000;
|
|
5
|
+
function byteLength(value) {
|
|
6
|
+
return Buffer.byteLength(value, "utf8");
|
|
7
|
+
}
|
|
8
|
+
function appendBounded(existing, chunk, limitBytes) {
|
|
9
|
+
if (byteLength(existing) >= limitBytes)
|
|
10
|
+
return existing;
|
|
11
|
+
const combined = existing + chunk;
|
|
12
|
+
if (byteLength(combined) <= limitBytes)
|
|
13
|
+
return combined;
|
|
14
|
+
return Buffer.from(combined, "utf8").subarray(0, limitBytes).toString("utf8");
|
|
15
|
+
}
|
|
16
|
+
async function pump(iterable, onChunk) {
|
|
17
|
+
if (!iterable)
|
|
18
|
+
return;
|
|
19
|
+
try {
|
|
20
|
+
for await (const chunk of iterable) {
|
|
21
|
+
onChunk(typeof chunk === "string" ? chunk : String(chunk));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
/* stream errors degrade observability, never correctness */
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Run an owned child process to completion, enforcing `timeoutSeconds` locally.
|
|
30
|
+
* On timeout: SIGTERM, then SIGKILL after the grace period if still alive;
|
|
31
|
+
* classification `timeout`. On normal exit: `clean_exit` (code 0) or `crashed`
|
|
32
|
+
* (non-zero). Retained stdout/stderr excerpts are bounded.
|
|
33
|
+
*/
|
|
34
|
+
export async function runProcessWithTimeout(proc, timeoutSeconds, deps, options = {}) {
|
|
35
|
+
const termGraceMs = options.termGraceMs ?? DEFAULT_TERM_GRACE_MS;
|
|
36
|
+
const limit = options.excerptLimitBytes ?? DEFAULT_EXCERPT_BYTES;
|
|
37
|
+
let stdoutExcerpt = "";
|
|
38
|
+
let stderrExcerpt = "";
|
|
39
|
+
const pumpStdout = pump(proc.stdout, (chunk) => {
|
|
40
|
+
options.onStdout?.(chunk);
|
|
41
|
+
stdoutExcerpt = appendBounded(stdoutExcerpt, chunk, limit);
|
|
42
|
+
});
|
|
43
|
+
const pumpStderr = pump(proc.stderr, (chunk) => {
|
|
44
|
+
stderrExcerpt = appendBounded(stderrExcerpt, chunk, limit);
|
|
45
|
+
});
|
|
46
|
+
let timedOut = false;
|
|
47
|
+
let graceTimer;
|
|
48
|
+
const timeoutTimer = deps.setTimer(() => {
|
|
49
|
+
timedOut = true;
|
|
50
|
+
proc.kill("SIGTERM");
|
|
51
|
+
graceTimer = deps.setTimer(() => {
|
|
52
|
+
// Still alive after the grace period — escalate.
|
|
53
|
+
proc.kill("SIGKILL");
|
|
54
|
+
}, termGraceMs);
|
|
55
|
+
}, timeoutSeconds * 1000);
|
|
56
|
+
const { exitCode, signal } = await proc.wait();
|
|
57
|
+
deps.clearTimer(timeoutTimer);
|
|
58
|
+
if (graceTimer !== undefined)
|
|
59
|
+
deps.clearTimer(graceTimer);
|
|
60
|
+
await pumpStdout;
|
|
61
|
+
await pumpStderr;
|
|
62
|
+
let classification;
|
|
63
|
+
if (timedOut) {
|
|
64
|
+
classification = "timeout";
|
|
65
|
+
}
|
|
66
|
+
else if (exitCode === 0) {
|
|
67
|
+
classification = "clean_exit";
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
classification = "crashed";
|
|
71
|
+
}
|
|
72
|
+
return { classification, exitCode, signal, stdoutExcerpt, stderrExcerpt };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Immediately SIGKILL an owned process for a stale-claim or dead-man abandonment.
|
|
76
|
+
* Returns a `killed` classification carrying the abandonment reason.
|
|
77
|
+
*/
|
|
78
|
+
export function killOwnedProcess(proc, reason) {
|
|
79
|
+
proc.kill("SIGKILL");
|
|
80
|
+
return { classification: "killed", reason };
|
|
81
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor-side structured prompt-spec renderer (BAPI-550, TDD §6/§7).
|
|
3
|
+
*
|
|
4
|
+
* The reconciler ships a server-authored prompt template in a `spec_review` job's
|
|
5
|
+
* `payload.prompt_spec` (from `src/python/llms/agents/conductor_jobs/prompt_specs.py`).
|
|
6
|
+
* This module validates that spec and renders it into the single `claude -p`
|
|
7
|
+
* prompt string, substituting each declared `{{PLACEHOLDER}}` marker from real
|
|
8
|
+
* runtime state (ticket spec, base/work branch, git log, prior revise reasons).
|
|
9
|
+
*
|
|
10
|
+
* Rendering happens in the executor's prepared-spawn path (`job-runner.ts`) —
|
|
11
|
+
* NOT in `worker-command.ts` — because resolving `{{GIT_LOG}}` needs the prepared
|
|
12
|
+
* worktree. Malformed or absent spec data fails loud with
|
|
13
|
+
* `ExecutorNamedError("ContractError.Prompt", ...)` so the runner posts `/fail`
|
|
14
|
+
* rather than spawning a worker with a stale or empty prompt.
|
|
15
|
+
*
|
|
16
|
+
* SECRET-FREE: thrown messages carry only safe identifiers (job id/type,
|
|
17
|
+
* placeholder name). This module NEVER logs prompt text, ticket specs, git logs,
|
|
18
|
+
* or reasons.
|
|
19
|
+
*/
|
|
20
|
+
import { ExecutorNamedError } from "./job-errors.js";
|
|
21
|
+
/** Stable named-error kind used for every prompt-spec contract failure. */
|
|
22
|
+
const PROMPT_CONTRACT_ERROR = "ContractError.Prompt";
|
|
23
|
+
/** The prompt-spec version this renderer understands. */
|
|
24
|
+
const SUPPORTED_PROMPT_SPEC_VERSION = 1;
|
|
25
|
+
/** Bound on the rendered `{{GIT_LOG}}` text inserted into the prompt. */
|
|
26
|
+
const GIT_LOG_MAX_CHARS = 8_000;
|
|
27
|
+
/** Explicit "no prior revise reasons" value — never an empty string. */
|
|
28
|
+
const REVISE_REASONS_NONE = "(none — this is the first review of the ticket spec)";
|
|
29
|
+
/** The `git log` argv used to resolve `{{GIT_LOG}}` (list-based, never shelled). */
|
|
30
|
+
const GIT_LOG_ARGS = ["log", "--oneline", "--decorate", "--max-count=50"];
|
|
31
|
+
/**
|
|
32
|
+
* The runtime placeholders this renderer knows how to resolve. A declared
|
|
33
|
+
* placeholder outside this set is an unsupported-marker contract failure rather
|
|
34
|
+
* than a silent empty substitution.
|
|
35
|
+
*/
|
|
36
|
+
const SUPPORTED_PLACEHOLDERS = new Set([
|
|
37
|
+
"TICKET_SPEC",
|
|
38
|
+
"BASE_BRANCH",
|
|
39
|
+
"WORK_BRANCH",
|
|
40
|
+
"GIT_LOG",
|
|
41
|
+
"REVISE_REASONS",
|
|
42
|
+
]);
|
|
43
|
+
function promptContractError(message) {
|
|
44
|
+
return new ExecutorNamedError(PROMPT_CONTRACT_ERROR, message);
|
|
45
|
+
}
|
|
46
|
+
function isNonBlankString(value) {
|
|
47
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Extract the ordered, de-duplicated set of literal `{{PLACEHOLDER}}` markers
|
|
51
|
+
* from a template. Markers are uppercase `[A-Z0-9_]` names; order is first
|
|
52
|
+
* appearance. Single braces (e.g. a literal `{"verdict": ...}` JSON snippet) are
|
|
53
|
+
* never matched.
|
|
54
|
+
*/
|
|
55
|
+
export function extractTemplatePlaceholders(template) {
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
const ordered = [];
|
|
58
|
+
const re = /\{\{([A-Z0-9_]+)\}\}/g;
|
|
59
|
+
let match;
|
|
60
|
+
while ((match = re.exec(template)) !== null) {
|
|
61
|
+
const name = match[1];
|
|
62
|
+
if (!seen.has(name)) {
|
|
63
|
+
seen.add(name);
|
|
64
|
+
ordered.push(name);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return ordered;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Validate an untrusted `payload.prompt_spec` value and return a typed spec.
|
|
71
|
+
* Throws `ContractError.Prompt` for a missing/malformed spec: wrong version,
|
|
72
|
+
* mismatched job type, blank prompts, a non-array/empty/duplicated placeholder
|
|
73
|
+
* list, a template marker that is not declared, or a declared placeholder that
|
|
74
|
+
* appears in neither prompt. Messages carry only safe identifiers.
|
|
75
|
+
*/
|
|
76
|
+
export function readPromptSpecPayload(value, expectedJobType) {
|
|
77
|
+
if (value === null || typeof value !== "object") {
|
|
78
|
+
throw promptContractError(`job_type '${expectedJobType}' has no usable payload.prompt_spec`);
|
|
79
|
+
}
|
|
80
|
+
const raw = value;
|
|
81
|
+
if (raw.version !== SUPPORTED_PROMPT_SPEC_VERSION) {
|
|
82
|
+
throw promptContractError(`prompt_spec for job_type '${expectedJobType}' has unsupported version ` +
|
|
83
|
+
`(expected ${SUPPORTED_PROMPT_SPEC_VERSION})`);
|
|
84
|
+
}
|
|
85
|
+
if (raw.job_type !== expectedJobType) {
|
|
86
|
+
throw promptContractError(`prompt_spec job_type mismatch: claimed job is '${expectedJobType}'`);
|
|
87
|
+
}
|
|
88
|
+
if (!isNonBlankString(raw.system_prompt)) {
|
|
89
|
+
throw promptContractError(`prompt_spec for job_type '${expectedJobType}' has a blank system_prompt`);
|
|
90
|
+
}
|
|
91
|
+
if (!isNonBlankString(raw.user_prompt_template)) {
|
|
92
|
+
throw promptContractError(`prompt_spec for job_type '${expectedJobType}' has a blank user_prompt_template`);
|
|
93
|
+
}
|
|
94
|
+
if (!Array.isArray(raw.placeholders) ||
|
|
95
|
+
raw.placeholders.length === 0 ||
|
|
96
|
+
!raw.placeholders.every((p) => isNonBlankString(p))) {
|
|
97
|
+
throw promptContractError(`prompt_spec for job_type '${expectedJobType}' has an invalid placeholders list`);
|
|
98
|
+
}
|
|
99
|
+
const declared = raw.placeholders;
|
|
100
|
+
const declaredSet = new Set();
|
|
101
|
+
for (const name of declared) {
|
|
102
|
+
if (declaredSet.has(name)) {
|
|
103
|
+
throw promptContractError(`prompt_spec for job_type '${expectedJobType}' declares duplicate placeholder '${name}'`);
|
|
104
|
+
}
|
|
105
|
+
declaredSet.add(name);
|
|
106
|
+
}
|
|
107
|
+
const systemPrompt = raw.system_prompt;
|
|
108
|
+
const userPromptTemplate = raw.user_prompt_template;
|
|
109
|
+
const used = new Set([
|
|
110
|
+
...extractTemplatePlaceholders(systemPrompt),
|
|
111
|
+
...extractTemplatePlaceholders(userPromptTemplate),
|
|
112
|
+
]);
|
|
113
|
+
// Every template marker must be declared (no undeclared substitution).
|
|
114
|
+
for (const name of used) {
|
|
115
|
+
if (!declaredSet.has(name)) {
|
|
116
|
+
throw promptContractError(`prompt_spec for job_type '${expectedJobType}' uses undeclared placeholder '${name}'`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Every declared placeholder must actually appear (no stale declarations).
|
|
120
|
+
for (const name of declaredSet) {
|
|
121
|
+
if (!used.has(name)) {
|
|
122
|
+
throw promptContractError(`prompt_spec for job_type '${expectedJobType}' declares unused placeholder '${name}'`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
version: SUPPORTED_PROMPT_SPEC_VERSION,
|
|
127
|
+
job_type: expectedJobType,
|
|
128
|
+
system_prompt: systemPrompt,
|
|
129
|
+
user_prompt_template: userPromptTemplate,
|
|
130
|
+
placeholders: declared,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/** Format `payload.reasons` as a deterministic bullet list, or the none value. */
|
|
134
|
+
function formatReviseReasons(reasons) {
|
|
135
|
+
if (!Array.isArray(reasons))
|
|
136
|
+
return REVISE_REASONS_NONE;
|
|
137
|
+
const bullets = reasons
|
|
138
|
+
.filter((r) => isNonBlankString(r))
|
|
139
|
+
.map((r) => `- ${r.trim()}`);
|
|
140
|
+
return bullets.length > 0 ? bullets.join("\n") : REVISE_REASONS_NONE;
|
|
141
|
+
}
|
|
142
|
+
/** Read `git log --oneline --decorate --max-count=50` in the worktree, bounded. */
|
|
143
|
+
async function resolveGitLog(context) {
|
|
144
|
+
const result = await context.runCommand("git", GIT_LOG_ARGS, {
|
|
145
|
+
cwd: context.worktreePath,
|
|
146
|
+
});
|
|
147
|
+
return result.stdout.slice(0, GIT_LOG_MAX_CHARS);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Resolve every declared placeholder to a concrete runtime value. Throws
|
|
151
|
+
* `ContractError.Prompt` for an unsupported placeholder name or a required value
|
|
152
|
+
* (ticket spec / work branch) that cannot be resolved — never a silent empty
|
|
153
|
+
* substitution.
|
|
154
|
+
*/
|
|
155
|
+
export async function buildPromptSpecPlaceholderValues(job, spec, context) {
|
|
156
|
+
const payload = job.payload && typeof job.payload === "object"
|
|
157
|
+
? job.payload
|
|
158
|
+
: {};
|
|
159
|
+
const values = {};
|
|
160
|
+
for (const name of spec.placeholders) {
|
|
161
|
+
if (!SUPPORTED_PLACEHOLDERS.has(name)) {
|
|
162
|
+
throw promptContractError(`prompt_spec for job ${job.id} declares unsupported placeholder '${name}'`);
|
|
163
|
+
}
|
|
164
|
+
switch (name) {
|
|
165
|
+
case "TICKET_SPEC": {
|
|
166
|
+
if (!isNonBlankString(payload.ticket_spec)) {
|
|
167
|
+
throw promptContractError(`job ${job.id} (${job.job_type}) is missing a usable ticket spec for {{TICKET_SPEC}}`);
|
|
168
|
+
}
|
|
169
|
+
values.TICKET_SPEC = payload.ticket_spec.trim();
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
case "BASE_BRANCH": {
|
|
173
|
+
values.BASE_BRANCH = isNonBlankString(payload.base_branch)
|
|
174
|
+
? payload.base_branch.trim()
|
|
175
|
+
: context.baseBranch;
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
case "WORK_BRANCH": {
|
|
179
|
+
if (!isNonBlankString(context.workBranch)) {
|
|
180
|
+
throw promptContractError(`job ${job.id} (${job.job_type}) has no work branch for {{WORK_BRANCH}}`);
|
|
181
|
+
}
|
|
182
|
+
values.WORK_BRANCH = context.workBranch.trim();
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case "GIT_LOG": {
|
|
186
|
+
values.GIT_LOG = await resolveGitLog(context);
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
case "REVISE_REASONS": {
|
|
190
|
+
values.REVISE_REASONS = formatReviseReasons(payload.reasons);
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return values;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Replace every declared `{{PLACEHOLDER}}` marker in a template (pure). A SINGLE
|
|
199
|
+
* left-to-right pass over the ORIGINAL template: each marker is substituted at
|
|
200
|
+
* most once, and substituted values are never re-scanned. This is deliberate —
|
|
201
|
+
* a sequential per-placeholder `.split().join()` would feed each iteration's
|
|
202
|
+
* output back in, so a `{{...}}`-shaped substring inside an already-substituted
|
|
203
|
+
* value (e.g. untrusted ticket text quoting `{{BASE_BRANCH}}`) would be
|
|
204
|
+
* clobbered by a later placeholder's value. A marker not present in `values`
|
|
205
|
+
* (should never happen — declared placeholders are all resolved) is left intact
|
|
206
|
+
* rather than replaced with an empty string.
|
|
207
|
+
*/
|
|
208
|
+
function substitutePlaceholders(template, values) {
|
|
209
|
+
return template.replace(/\{\{([A-Z0-9_]+)\}\}/g, (marker, name) => Object.prototype.hasOwnProperty.call(values, name) ? values[name] : marker);
|
|
210
|
+
}
|
|
211
|
+
/** Render the system prompt by substituting the resolved placeholder values. */
|
|
212
|
+
export function buildPromptSpecSystemPrompt(spec, values) {
|
|
213
|
+
return substitutePlaceholders(spec.system_prompt, values);
|
|
214
|
+
}
|
|
215
|
+
/** Render the user prompt by substituting the resolved placeholder values. */
|
|
216
|
+
export function buildPromptSpecUserPrompt(spec, values) {
|
|
217
|
+
return substitutePlaceholders(spec.user_prompt_template, values);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Render a claimed job's `payload.prompt_spec` into the single `claude -p` prompt
|
|
221
|
+
* string: validate the spec against the job type, resolve every declared
|
|
222
|
+
* placeholder from runtime state, substitute the system and user prompts
|
|
223
|
+
* separately, then concatenate them with a blank-line delimiter. Throws
|
|
224
|
+
* `ExecutorNamedError("ContractError.Prompt", ...)` on any contract failure.
|
|
225
|
+
*/
|
|
226
|
+
export async function renderPromptSpecPrompt(job, context) {
|
|
227
|
+
const payload = job.payload && typeof job.payload === "object"
|
|
228
|
+
? job.payload
|
|
229
|
+
: {};
|
|
230
|
+
const spec = readPromptSpecPayload(payload.prompt_spec, job.job_type);
|
|
231
|
+
const values = await buildPromptSpecPlaceholderValues(job, spec, context);
|
|
232
|
+
const systemPrompt = buildPromptSpecSystemPrompt(spec, values);
|
|
233
|
+
const userPrompt = buildPromptSpecUserPrompt(spec, values);
|
|
234
|
+
return `${systemPrompt}\n\n${userPrompt}`;
|
|
235
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor completion result + artifact helpers (BAPI-534, TDD §7 / §13).
|
|
3
|
+
*
|
|
4
|
+
* Result envelopes are size-bounded server-side (T6: smoke evidence ≤ 8000 bytes,
|
|
5
|
+
* whole envelope ≤ 64000 bytes), so evidence is truncated client-side rather than
|
|
6
|
+
* posting unbounded output. Full verdict-artifact behavior (`result.json`) is
|
|
7
|
+
* delegated to T3b — this ticket only reads the human-readable critique.
|
|
8
|
+
*/
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
/** Smoke evidence byte cap (mirrors server `SMOKE_EVIDENCE_MAX_BYTES`). */
|
|
11
|
+
export const SMOKE_EVIDENCE_MAX_BYTES = 8_000;
|
|
12
|
+
/** Whole-envelope byte cap (mirrors server `RESULT_JSONB_MAX_BYTES`). */
|
|
13
|
+
export const RESULT_JSONB_MAX_BYTES = 64_000;
|
|
14
|
+
/** Max bytes of a completion artifact (e.g. the critique) posted to the server. */
|
|
15
|
+
export const ARTIFACT_MAX_BYTES = 32_000;
|
|
16
|
+
/** Bounded generic-success `summary` length. */
|
|
17
|
+
export const SUMMARY_MAX_CHARS = 500;
|
|
18
|
+
function byteLength(value) {
|
|
19
|
+
return Buffer.byteLength(value, "utf8");
|
|
20
|
+
}
|
|
21
|
+
/** Slice a string to at most `maxBytes` UTF-8 bytes (never mid-splitting fatally). */
|
|
22
|
+
function sliceToBytes(value, maxBytes) {
|
|
23
|
+
if (maxBytes <= 0)
|
|
24
|
+
return "";
|
|
25
|
+
if (byteLength(value) <= maxBytes)
|
|
26
|
+
return value;
|
|
27
|
+
return Buffer.from(value, "utf8").subarray(0, maxBytes).toString("utf8");
|
|
28
|
+
}
|
|
29
|
+
function safeStringify(value) {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.stringify(value) ?? "null";
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return '"[unserializable]"';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Bound a JSON value to `maxBytes`. When the serialized value already fits it is
|
|
39
|
+
* returned unchanged; otherwise a bounded, clearly-truncated surrogate object is
|
|
40
|
+
* returned (the original is never posted unbounded).
|
|
41
|
+
*/
|
|
42
|
+
export function truncateJsonValueToBytes(value, maxBytes) {
|
|
43
|
+
const serialized = safeStringify(value);
|
|
44
|
+
if (byteLength(serialized) <= maxBytes)
|
|
45
|
+
return value;
|
|
46
|
+
// Reserve headroom for the surrogate object's own JSON overhead.
|
|
47
|
+
const previewBudget = Math.max(0, maxBytes - 128);
|
|
48
|
+
return {
|
|
49
|
+
truncated: true,
|
|
50
|
+
original_bytes: byteLength(serialized),
|
|
51
|
+
preview: sliceToBytes(serialized, previewBudget),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Build the smoke (no-op acceptance) result envelope: `{ result: "pass", evidence }`
|
|
56
|
+
* — the T6 registry shape, NOT the legacy `{ pass: true }`. Evidence is bounded.
|
|
57
|
+
*/
|
|
58
|
+
export function buildSmokeResult(evidence, result = "pass") {
|
|
59
|
+
return { result, evidence: truncateJsonValueToBytes(evidence, SMOKE_EVIDENCE_MAX_BYTES) };
|
|
60
|
+
}
|
|
61
|
+
/** Build a minimal, bounded generic success result for a real spawn job. */
|
|
62
|
+
export function buildGenericSuccessResult(input) {
|
|
63
|
+
const result = {};
|
|
64
|
+
if (typeof input.summary === "string") {
|
|
65
|
+
result.summary = input.summary.slice(0, SUMMARY_MAX_CHARS);
|
|
66
|
+
}
|
|
67
|
+
if (typeof input.branch === "string")
|
|
68
|
+
result.branch = input.branch;
|
|
69
|
+
if (typeof input.headSha === "string")
|
|
70
|
+
result.head_sha = input.headSha;
|
|
71
|
+
if (typeof input.localCommitCount === "number") {
|
|
72
|
+
result.local_commit_count = input.localCommitCount;
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Fail-open read of `<worktree>/.conductor/critique.md`, mapped to the
|
|
78
|
+
* `ticket-quality-critique.md` artifact key the server persists to `tickets_links`.
|
|
79
|
+
* Missing/unreadable → no artifacts (never throws). `result.json` is NOT parsed
|
|
80
|
+
* here (delegated to T3b).
|
|
81
|
+
*/
|
|
82
|
+
export async function readCompletionArtifacts(worktreePath, deps) {
|
|
83
|
+
if (!worktreePath)
|
|
84
|
+
return undefined;
|
|
85
|
+
const critiquePath = path.join(worktreePath, ".conductor", "critique.md");
|
|
86
|
+
let content;
|
|
87
|
+
try {
|
|
88
|
+
content = await deps.readFile(critiquePath);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
if (typeof content !== "string" || content.length === 0)
|
|
94
|
+
return undefined;
|
|
95
|
+
const bounded = sliceToBytes(content, ARTIFACT_MAX_BYTES);
|
|
96
|
+
return { "ticket-quality-critique.md": bounded };
|
|
97
|
+
}
|
|
98
|
+
function positiveInt(value) {
|
|
99
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Resolve a positive timeout in seconds. Prefers the job's local `timeout_seconds`,
|
|
103
|
+
* then `payload.timeout_seconds`, then the injected `fallbackSeconds` (from
|
|
104
|
+
* `ExecutorOptions.defaultJobTimeoutSeconds`). Returns a structured contract
|
|
105
|
+
* failure — never spawns — when no positive timeout can be resolved.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveJobTimeoutSeconds(job, fallbackSeconds) {
|
|
108
|
+
const topLevel = positiveInt(job.timeout_seconds);
|
|
109
|
+
if (topLevel !== null)
|
|
110
|
+
return { ok: true, timeoutSeconds: topLevel };
|
|
111
|
+
const payloadTimeout = positiveInt(job.payload && typeof job.payload === "object"
|
|
112
|
+
? job.payload.timeout_seconds
|
|
113
|
+
: undefined);
|
|
114
|
+
if (payloadTimeout !== null)
|
|
115
|
+
return { ok: true, timeoutSeconds: payloadTimeout };
|
|
116
|
+
const fallback = positiveInt(fallbackSeconds);
|
|
117
|
+
if (fallback !== null)
|
|
118
|
+
return { ok: true, timeoutSeconds: fallback };
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
error: "no positive timeout_seconds could be resolved for the job",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Build the top-level + telemetry residue commit fields from git telemetry. */
|
|
125
|
+
export function commitResidue(telemetry) {
|
|
126
|
+
const out = {};
|
|
127
|
+
if (typeof telemetry.local_commit_count === "number") {
|
|
128
|
+
out.local_commit_count = telemetry.local_commit_count;
|
|
129
|
+
}
|
|
130
|
+
if (typeof telemetry.last_commit_sha === "string") {
|
|
131
|
+
out.last_commit_sha = telemetry.last_commit_sha;
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume pre-spawn preparation protocol (BAPI-535, TDD §7).
|
|
3
|
+
*
|
|
4
|
+
* A `resume` job carries the same `worktree_name` and a server-side prompt
|
|
5
|
+
* template with `{{GIT_LOG}}` / `{{GIT_DIFF}}` placeholders. Before spawning the
|
|
6
|
+
* headless worker, the executor — in order — resolves (and, if gone, recreates
|
|
7
|
+
* ONLY from the pushed branch) the worktree, verifies it is on the expected
|
|
8
|
+
* branch, auto-commits any dirty state as a WIP checkpoint, reads the branch's
|
|
9
|
+
* git log/diff, and fills the prompt placeholders.
|
|
10
|
+
*
|
|
11
|
+
* PRESERVE WORK BY CONSTRUCTION: this module NEVER emits a destructive Git
|
|
12
|
+
* operation (`reset --hard`, `checkout --force`, `clean -fd`, forced ref update).
|
|
13
|
+
* Unpushed local commits are the checkpoint mechanism and must survive (TDD §7,
|
|
14
|
+
* §16 — the amended proposal explicitly rejected `fetch && reset --hard`).
|
|
15
|
+
*/
|
|
16
|
+
import { createWorktreeForTicket } from "../worktree-core.js";
|
|
17
|
+
import { ExecutorNamedError, WorktreeLostBeforePush } from "./job-errors.js";
|
|
18
|
+
import { assertWorktreeOnBranch, listGitWorktrees, findWorktreeByBranch, remoteBranchExists as remoteBranchExistsDefault, } from "./worktree-inspection.js";
|
|
19
|
+
/** The canonical WIP checkpoint commit message (executor-run, deterministic). */
|
|
20
|
+
export const RESUME_WIP_COMMIT_MESSAGE = "wip: auto-checkpoint before resume";
|
|
21
|
+
function asString(value) {
|
|
22
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
23
|
+
}
|
|
24
|
+
/** Extract the resume payload fields from a claimed job (pure). */
|
|
25
|
+
export function readResumePayloadFields(job) {
|
|
26
|
+
const payload = job.payload && typeof job.payload === "object" ? job.payload : {};
|
|
27
|
+
const promptTemplate = typeof payload.prompt_template === "string" ? payload.prompt_template : null;
|
|
28
|
+
return {
|
|
29
|
+
promptTemplate,
|
|
30
|
+
worktreePath: asString(payload.worktree_path),
|
|
31
|
+
worktreeName: asString(payload.worktree_name),
|
|
32
|
+
baseBranch: asString(payload.base_branch),
|
|
33
|
+
expectedBranch: asString(payload.expected_branch),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the expected branch for a resume job: prefer the top-level
|
|
38
|
+
* `expected_branch`, then a payload override. Fail with a named validation error
|
|
39
|
+
* (before any command runs) when neither is available — working on an unknown
|
|
40
|
+
* branch is never safe.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveResumeExpectedBranch(job, fields) {
|
|
43
|
+
const top = asString(job.expected_branch);
|
|
44
|
+
if (top)
|
|
45
|
+
return top;
|
|
46
|
+
if (fields.expectedBranch)
|
|
47
|
+
return fields.expectedBranch;
|
|
48
|
+
throw new ExecutorNamedError("ResumeMissingExpectedBranch", `resume job ${job.id} has no expected_branch (top-level or payload)`);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the server-provided prompt template, failing loud with a contract
|
|
52
|
+
* error when it is missing/empty. Validated BEFORE any worktree resolution or
|
|
53
|
+
* WIP-checkpoint work so a malformed `resume` payload never spawns a real
|
|
54
|
+
* `claude -p "" --dangerously-skip-permissions` worker that would burn the full
|
|
55
|
+
* job timeout before failing generically (mirrors the `implement`/`spec_review`
|
|
56
|
+
* `ContractError.Prompt` fail-fast in `resolveExecutorPrompt`).
|
|
57
|
+
*/
|
|
58
|
+
export function resolveResumePromptTemplate(job, fields) {
|
|
59
|
+
const template = fields.promptTemplate;
|
|
60
|
+
if (typeof template === "string" && template.trim().length > 0) {
|
|
61
|
+
return template;
|
|
62
|
+
}
|
|
63
|
+
throw new ExecutorNamedError("ContractError.Prompt", `resume job ${job.id} has no usable payload.prompt_template`);
|
|
64
|
+
}
|
|
65
|
+
/** Resolve the base branch: payload `base_branch` wins, else executor options. */
|
|
66
|
+
export function resolveResumeBaseBranch(fields, options) {
|
|
67
|
+
return fields.baseBranch ?? options.baseBranch;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolve the existing worktree path: use the payload `worktree_path` when it
|
|
71
|
+
* still exists, otherwise locate a worktree whose branch exactly equals the
|
|
72
|
+
* expected branch. Returns `null` when the worktree is gone (recreation needed).
|
|
73
|
+
*/
|
|
74
|
+
export async function resolveExistingResumeWorktreePath(fields, expectedBranch, seams) {
|
|
75
|
+
if (fields.worktreePath && (await seams.pathExists(fields.worktreePath))) {
|
|
76
|
+
return fields.worktreePath;
|
|
77
|
+
}
|
|
78
|
+
const listWorktrees = seams.listWorktrees ?? listGitWorktrees;
|
|
79
|
+
const entries = await listWorktrees(seams.runCommand, seams.cwd);
|
|
80
|
+
const found = findWorktreeByBranch(entries, expectedBranch);
|
|
81
|
+
return found ?? null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Recreate a missing worktree ONLY from the pushed branch. Verifies
|
|
85
|
+
* `origin/<expectedBranch>` exists first; if it does not, fails
|
|
86
|
+
* {@link WorktreeLostBeforePush} (the reconciler restarts `implement` within
|
|
87
|
+
* budget — TDD §7). When present, reuses the shared Worktrunk primitive with a
|
|
88
|
+
* branch override for the ticket key and `origin/<expectedBranch>` as the
|
|
89
|
+
* recreation start point.
|
|
90
|
+
*/
|
|
91
|
+
export async function recreateResumeWorktreeFromPushedBranch(job, expectedBranch, seams) {
|
|
92
|
+
const remoteExists = seams.remoteBranchExists ?? remoteBranchExistsDefault;
|
|
93
|
+
const pushed = await remoteExists(seams.runCommand, seams.cwd, expectedBranch);
|
|
94
|
+
if (!pushed) {
|
|
95
|
+
throw new ExecutorNamedError(WorktreeLostBeforePush, `resume worktree for '${expectedBranch}' is gone and origin/${expectedBranch} does not exist`);
|
|
96
|
+
}
|
|
97
|
+
const key = asString(job.ticket_key) ?? expectedBranch;
|
|
98
|
+
const coreDeps = {
|
|
99
|
+
runCommand: seams.runCommand,
|
|
100
|
+
platform: seams.platform,
|
|
101
|
+
env: seams.env,
|
|
102
|
+
cwd: seams.cwd,
|
|
103
|
+
};
|
|
104
|
+
const createWorktree = seams.createWorktree ?? createWorktreeForTicket;
|
|
105
|
+
const row = await createWorktree(coreDeps, key, { [key]: expectedBranch }, seams.worktrunkBinary,
|
|
106
|
+
// Cut/reuse from the pushed branch — never from base — so recreation restores
|
|
107
|
+
// the pushed work, not a fresh empty branch.
|
|
108
|
+
`origin/${expectedBranch}`,
|
|
109
|
+
// Never guard-refuse here: recreation of the SAME pushed branch is the goal.
|
|
110
|
+
false);
|
|
111
|
+
if (row.status === "created" && typeof row.path === "string") {
|
|
112
|
+
return row.path;
|
|
113
|
+
}
|
|
114
|
+
throw new ExecutorNamedError(WorktreeLostBeforePush, row.error ?? `failed to recreate worktree for '${expectedBranch}' from origin`);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Commit any dirty state as a deterministic WIP checkpoint so the injected diff
|
|
118
|
+
* is always clean and complete. No-op cleanly when the tree is already clean.
|
|
119
|
+
* NEVER discards work.
|
|
120
|
+
*/
|
|
121
|
+
export async function autoCheckpointDirtyState(worktreePath, seams) {
|
|
122
|
+
const status = await seams.runCommand("git", ["status", "--porcelain"], { cwd: worktreePath });
|
|
123
|
+
if (status.stdout.trim().length === 0) {
|
|
124
|
+
return { committed: false };
|
|
125
|
+
}
|
|
126
|
+
await seams.runCommand("git", ["add", "-A"], { cwd: worktreePath });
|
|
127
|
+
await seams.runCommand("git", ["commit", "-m", RESUME_WIP_COMMIT_MESSAGE], {
|
|
128
|
+
cwd: worktreePath,
|
|
129
|
+
});
|
|
130
|
+
return { committed: true };
|
|
131
|
+
}
|
|
132
|
+
/** Read `git log --oneline origin/<base>..HEAD` in the worktree. */
|
|
133
|
+
export async function readResumeGitLog(worktreePath, baseBranch, seams) {
|
|
134
|
+
const result = await seams.runCommand("git", ["log", "--oneline", `origin/${baseBranch}..HEAD`], { cwd: worktreePath });
|
|
135
|
+
return result.stdout;
|
|
136
|
+
}
|
|
137
|
+
/** Read `git diff origin/<base>...HEAD` in the worktree. */
|
|
138
|
+
export async function readResumeGitDiff(worktreePath, baseBranch, seams) {
|
|
139
|
+
const result = await seams.runCommand("git", ["diff", `origin/${baseBranch}...HEAD`], { cwd: worktreePath });
|
|
140
|
+
return result.stdout;
|
|
141
|
+
}
|
|
142
|
+
/** Replace EVERY literal `{{GIT_LOG}}` / `{{GIT_DIFF}}` occurrence (pure). */
|
|
143
|
+
export function fillResumePromptPlaceholders(template, gitLog, gitDiff) {
|
|
144
|
+
return template.split("{{GIT_LOG}}").join(gitLog).split("{{GIT_DIFF}}").join(gitDiff);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Orchestrate the resume pre-spawn protocol in the required order:
|
|
148
|
+
* 1. read payload fields
|
|
149
|
+
* 2. resolve expected branch (fail-fast validation, before any command)
|
|
150
|
+
* 3. resolve base branch
|
|
151
|
+
* 4. resolve existing worktree path; recreate-from-pushed when gone
|
|
152
|
+
* 5. verify the worktree is on the expected branch (BranchMismatch otherwise)
|
|
153
|
+
* 6. WIP-checkpoint any dirty state (no-op on a clean tree)
|
|
154
|
+
* 7. read git log/diff
|
|
155
|
+
* 8. fill the prompt placeholders
|
|
156
|
+
*
|
|
157
|
+
* Throws {@link ExecutorNamedError} (`WorktreeLostBeforePush` / `BranchMismatch`
|
|
158
|
+
* / validation) on the fail-loud conditions; the runner posts `/fail`.
|
|
159
|
+
*/
|
|
160
|
+
export async function prepareResumeSpawn(job, options, seams) {
|
|
161
|
+
const fields = readResumePayloadFields(job);
|
|
162
|
+
const expectedBranch = resolveResumeExpectedBranch(job, fields);
|
|
163
|
+
const baseBranch = resolveResumeBaseBranch(fields, options);
|
|
164
|
+
// Fail loud on a missing/empty prompt template BEFORE any worktree resolution
|
|
165
|
+
// or WIP-checkpoint commit — never spawn an empty-prompt worker.
|
|
166
|
+
const promptTemplate = resolveResumePromptTemplate(job, fields);
|
|
167
|
+
let worktreePath = await resolveExistingResumeWorktreePath(fields, expectedBranch, seams);
|
|
168
|
+
if (worktreePath === null) {
|
|
169
|
+
worktreePath = await recreateResumeWorktreeFromPushedBranch(job, expectedBranch, seams);
|
|
170
|
+
}
|
|
171
|
+
// Branch verification BEFORE any local mutation — never checkpoint/commit on
|
|
172
|
+
// the wrong branch.
|
|
173
|
+
await assertWorktreeOnBranch(seams.runCommand, worktreePath, expectedBranch);
|
|
174
|
+
await autoCheckpointDirtyState(worktreePath, seams);
|
|
175
|
+
const gitLog = await readResumeGitLog(worktreePath, baseBranch, seams);
|
|
176
|
+
const gitDiff = await readResumeGitDiff(worktreePath, baseBranch, seams);
|
|
177
|
+
const prompt = fillResumePromptPlaceholders(promptTemplate, gitLog, gitDiff);
|
|
178
|
+
return { worktreePath, branch: expectedBranch, prompt };
|
|
179
|
+
}
|