@gajae-code/coding-agent 0.4.3 → 0.4.5
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/CHANGELOG.md +42 -0
- package/dist/types/async/job-manager.d.ts +19 -1
- package/dist/types/cli/fast-help.d.ts +1 -0
- package/dist/types/cli/setup-cli.d.ts +16 -1
- package/dist/types/commands/coordinator.d.ts +19 -0
- package/dist/types/commands/harness.d.ts +3 -0
- package/dist/types/commands/mcp-serve.d.ts +24 -0
- package/dist/types/commands/setup.d.ts +47 -0
- package/dist/types/config/model-registry.d.ts +3 -0
- package/dist/types/config/models-config-schema.d.ts +5 -0
- package/dist/types/coordinator/contract.d.ts +4 -0
- package/dist/types/coordinator-mcp/policy.d.ts +24 -0
- package/dist/types/coordinator-mcp/safety.d.ts +26 -0
- package/dist/types/coordinator-mcp/server.d.ts +58 -0
- package/dist/types/extensibility/extensions/types.d.ts +13 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
- package/dist/types/harness-control-plane/finalize.d.ts +5 -0
- package/dist/types/harness-control-plane/phase-rollup.d.ts +23 -0
- package/dist/types/harness-control-plane/receipt-ingest.d.ts +19 -0
- package/dist/types/harness-control-plane/receipts.d.ts +46 -0
- package/dist/types/harness-control-plane/rpc-adapter.d.ts +3 -0
- package/dist/types/harness-control-plane/types.d.ts +9 -1
- package/dist/types/main.d.ts +2 -2
- package/dist/types/modes/components/hook-selector.d.ts +11 -0
- package/dist/types/modes/utils/abort-message.d.ts +4 -0
- package/dist/types/session/session-manager.d.ts +8 -0
- package/dist/types/setup/hermes-setup.d.ts +78 -0
- package/dist/types/task/fork-context-advisory.d.ts +13 -0
- package/dist/types/task/receipt.d.ts +1 -0
- package/dist/types/task/render.d.ts +7 -1
- package/dist/types/task/roi-reconciliation.d.ts +27 -0
- package/dist/types/task/types.d.ts +10 -0
- package/dist/types/tools/subagent-render.d.ts +25 -0
- package/dist/types/tools/subagent.d.ts +5 -1
- package/package.json +8 -7
- package/scripts/build-binary.ts +4 -0
- package/src/async/job-manager.ts +43 -1
- package/src/cli/fast-help.ts +80 -0
- package/src/cli/setup-cli.ts +95 -2
- package/src/cli.ts +109 -16
- package/src/commands/coordinator.ts +113 -0
- package/src/commands/harness.ts +92 -9
- package/src/commands/mcp-serve.ts +63 -0
- package/src/commands/setup.ts +34 -1
- package/src/config/models-config-schema.ts +1 -0
- package/src/coordinator/contract.ts +21 -0
- package/src/coordinator-mcp/policy.ts +160 -0
- package/src/coordinator-mcp/safety.ts +80 -0
- package/src/coordinator-mcp/server.ts +1519 -0
- package/src/cursor.ts +30 -2
- package/src/extensibility/extensions/types.ts +13 -0
- package/src/gjc-runtime/launch-worktree.ts +12 -1
- package/src/gjc-runtime/session-state-sidecar.ts +117 -0
- package/src/harness-control-plane/finalize.ts +39 -5
- package/src/harness-control-plane/owner.ts +9 -1
- package/src/harness-control-plane/phase-rollup.ts +96 -0
- package/src/harness-control-plane/receipt-ingest.ts +127 -0
- package/src/harness-control-plane/receipts.ts +229 -1
- package/src/harness-control-plane/rpc-adapter.ts +8 -0
- package/src/harness-control-plane/types.ts +29 -1
- package/src/internal-urls/docs-index.generated.ts +6 -4
- package/src/main.ts +7 -3
- package/src/modes/components/hook-selector.ts +109 -5
- package/src/modes/components/status-line.ts +6 -6
- package/src/modes/controllers/event-controller.ts +5 -4
- package/src/modes/controllers/extension-ui-controller.ts +16 -1
- package/src/modes/interactive-mode.ts +4 -5
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/theme/theme.ts +2 -2
- package/src/modes/utils/abort-message.ts +41 -0
- package/src/modes/utils/context-usage.ts +15 -8
- package/src/modes/utils/ui-helpers.ts +5 -6
- package/src/prompts/agents/architect.md +6 -0
- package/src/prompts/agents/critic.md +6 -0
- package/src/prompts/agents/planner.md +8 -1
- package/src/sdk.ts +9 -4
- package/src/session/agent-session.ts +22 -5
- package/src/session/session-manager.ts +20 -0
- package/src/setup/hermes/templates/operator-instructions.v1.md +30 -0
- package/src/setup/hermes-setup.ts +484 -0
- package/src/task/fork-context-advisory.ts +99 -0
- package/src/task/index.ts +33 -2
- package/src/task/receipt.ts +2 -0
- package/src/task/render.ts +14 -0
- package/src/task/roi-reconciliation.ts +90 -0
- package/src/task/types.ts +7 -0
- package/src/tools/ask.ts +30 -10
- package/src/tools/index.ts +2 -2
- package/src/tools/renderers.ts +2 -0
- package/src/tools/subagent-render.ts +169 -0
- package/src/tools/subagent.ts +49 -7
- package/src/utils/title-generator.ts +16 -2
package/src/cursor.ts
CHANGED
|
@@ -161,7 +161,20 @@ function formatMcpToolErrorMessage(toolName: string, availableTools: string[]):
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
export class CursorExecHandlers implements ICursorExecHandlers {
|
|
164
|
-
constructor(private options: CursorExecBridgeOptions) {
|
|
164
|
+
constructor(private options: CursorExecBridgeOptions) {
|
|
165
|
+
// Bind every native handler so methods stay instance-safe when invoked
|
|
166
|
+
// detached/unbound by the Cursor provider (e.g. `const read = handlers.read`).
|
|
167
|
+
// Without this, `this.#optionsForCall()` throws "undefined is not an object".
|
|
168
|
+
this.read = this.read.bind(this);
|
|
169
|
+
this.ls = this.ls.bind(this);
|
|
170
|
+
this.grep = this.grep.bind(this);
|
|
171
|
+
this.write = this.write.bind(this);
|
|
172
|
+
this.delete = this.delete.bind(this);
|
|
173
|
+
this.shell = this.shell.bind(this);
|
|
174
|
+
this.shellStream = this.shellStream.bind(this);
|
|
175
|
+
this.diagnostics = this.diagnostics.bind(this);
|
|
176
|
+
this.mcp = this.mcp.bind(this);
|
|
177
|
+
}
|
|
165
178
|
|
|
166
179
|
#optionsForCall(): CursorExecBridgeOptions {
|
|
167
180
|
return {
|
|
@@ -185,9 +198,24 @@ export class CursorExecHandlers implements ICursorExecHandlers {
|
|
|
185
198
|
|
|
186
199
|
async grep(args: Parameters<NonNullable<ICursorExecHandlers["grep"]>>[0]) {
|
|
187
200
|
const toolCallId = decodeToolCallId(args.toolCallId);
|
|
201
|
+
// Cursor's native Glob tool arrives as a grep exec with a glob but no content
|
|
202
|
+
// pattern. The search tool requires a non-empty pattern, so an empty pattern
|
|
203
|
+
// means "list files matching this glob" — route that to find instead of
|
|
204
|
+
// throwing "Pattern must not be empty".
|
|
205
|
+
const pattern = typeof args.pattern === "string" ? args.pattern : "";
|
|
206
|
+
if (pattern.trim().length === 0) {
|
|
207
|
+
if (args.glob) {
|
|
208
|
+
const globPath = `${args.path || "."}/${args.glob}`;
|
|
209
|
+
return executeTool(this.#optionsForCall(), "find", toolCallId, { paths: [globPath] });
|
|
210
|
+
}
|
|
211
|
+
const result = buildToolErrorResult(
|
|
212
|
+
"Cursor grep request rejected: pattern must not be empty. Provide a non-empty search pattern.",
|
|
213
|
+
);
|
|
214
|
+
return createToolResultMessage(toolCallId, "search", result, true);
|
|
215
|
+
}
|
|
188
216
|
const searchPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || ".";
|
|
189
217
|
const toolResultMessage = await executeTool(this.#optionsForCall(), "search", toolCallId, {
|
|
190
|
-
pattern
|
|
218
|
+
pattern,
|
|
191
219
|
paths: [searchPath],
|
|
192
220
|
i: args.caseInsensitive || undefined,
|
|
193
221
|
});
|
|
@@ -122,6 +122,19 @@ export interface ExtensionUIDialogOptions {
|
|
|
122
122
|
* select-only rendering hint; non-TUI bridges drop it and do not serialize it.
|
|
123
123
|
*/
|
|
124
124
|
scrollTitleRows?: number;
|
|
125
|
+
/**
|
|
126
|
+
* For interactive TUI select dialogs, handle the option with `optionLabel`
|
|
127
|
+
* inline: selecting it keeps the title and option list on screen and opens
|
|
128
|
+
* a free-text input below the list. Submitting calls `onSubmit` with the
|
|
129
|
+
* typed text and resolves the select with `optionLabel`; Escape returns to
|
|
130
|
+
* option selection. Non-TUI bridges (RPC, ACP) drop it; callers must keep
|
|
131
|
+
* a fallback path for selects that resolve `optionLabel` without invoking
|
|
132
|
+
* `onSubmit`.
|
|
133
|
+
*/
|
|
134
|
+
customInput?: {
|
|
135
|
+
optionLabel: string;
|
|
136
|
+
onSubmit: (text: string) => void;
|
|
137
|
+
};
|
|
125
138
|
}
|
|
126
139
|
|
|
127
140
|
/** Raw terminal input listener for extensions. */
|
|
@@ -140,6 +140,17 @@ function readWorktreeEntryFromPath(repoRoot: string, worktreePath: string): GitW
|
|
|
140
140
|
return { path: path.resolve(worktreePath), head, branchRef, detached: !branchRef };
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
function resolveCanonicalRepoRoot(cwd: string): string {
|
|
144
|
+
const repoRoot = runGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
145
|
+
const commonDir = tryRunGit(repoRoot, ["rev-parse", "--git-common-dir"]);
|
|
146
|
+
if (!commonDir) return repoRoot;
|
|
147
|
+
const resolvedCommonDir = path.resolve(repoRoot, commonDir);
|
|
148
|
+
if (path.basename(resolvedCommonDir) !== ".git") return repoRoot;
|
|
149
|
+
const ownerRoot = path.dirname(resolvedCommonDir);
|
|
150
|
+
if (tryRunGit(ownerRoot, ["rev-parse", "--is-inside-work-tree"]) !== "true") return repoRoot;
|
|
151
|
+
return ownerRoot;
|
|
152
|
+
}
|
|
153
|
+
|
|
143
154
|
function isWorktreeDirty(worktreePath: string): boolean {
|
|
144
155
|
return runGit(worktreePath, ["status", "--porcelain"]).length > 0;
|
|
145
156
|
}
|
|
@@ -187,7 +198,7 @@ export function planLaunchWorktree(
|
|
|
187
198
|
mode: GjcLaunchWorktreeMode,
|
|
188
199
|
): GjcLaunchWorktreePlan | { enabled: false } {
|
|
189
200
|
if (!mode.enabled) return { enabled: false };
|
|
190
|
-
const repoRoot =
|
|
201
|
+
const repoRoot = resolveCanonicalRepoRoot(cwd);
|
|
191
202
|
const baseRef = runGit(repoRoot, ["rev-parse", "HEAD"]);
|
|
192
203
|
const branchName = mode.detached ? null : mode.name;
|
|
193
204
|
if (branchName) validateBranchName(repoRoot, branchName);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { AssistantMessage } from "@gajae-code/ai";
|
|
4
|
+
import { logger } from "@gajae-code/utils";
|
|
5
|
+
|
|
6
|
+
export const GJC_COORDINATOR_SESSION_STATE_FILE_ENV = "GJC_COORDINATOR_SESSION_STATE_FILE";
|
|
7
|
+
export const GJC_COORDINATOR_SESSION_ID_ENV = "GJC_COORDINATOR_SESSION_ID";
|
|
8
|
+
|
|
9
|
+
type RuntimeState = "ready_for_input" | "running" | "needs_user_input" | "completed" | "errored";
|
|
10
|
+
|
|
11
|
+
interface RuntimeStateEvent {
|
|
12
|
+
type: string;
|
|
13
|
+
messages?: unknown[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface RuntimeStateContext {
|
|
17
|
+
sessionId: string;
|
|
18
|
+
cwd: string;
|
|
19
|
+
sessionFile?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function lastAssistant(messages: unknown[] | undefined): AssistantMessage | undefined {
|
|
23
|
+
if (!messages) return undefined;
|
|
24
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
25
|
+
const message = messages[index];
|
|
26
|
+
if (message && typeof message === "object" && (message as { role?: unknown }).role === "assistant") {
|
|
27
|
+
return message as AssistantMessage;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function assistantText(assistant: AssistantMessage | undefined): string | null {
|
|
34
|
+
if (!assistant) return null;
|
|
35
|
+
const text = assistant.content
|
|
36
|
+
.filter(part => part.type === "text")
|
|
37
|
+
.map(part => part.text)
|
|
38
|
+
.join("\n")
|
|
39
|
+
.trim();
|
|
40
|
+
return text.length > 0 ? text : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function finalResponseForEvent(event: RuntimeStateEvent): {
|
|
44
|
+
text: string | null;
|
|
45
|
+
format: "markdown";
|
|
46
|
+
source: "agent_end";
|
|
47
|
+
artifact_path: null;
|
|
48
|
+
truncated: false;
|
|
49
|
+
} | null {
|
|
50
|
+
if (event.type !== "agent_end") return null;
|
|
51
|
+
return {
|
|
52
|
+
text: assistantText(lastAssistant(event.messages)),
|
|
53
|
+
format: "markdown",
|
|
54
|
+
source: "agent_end",
|
|
55
|
+
artifact_path: null,
|
|
56
|
+
truncated: false,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stateForEvent(event: RuntimeStateEvent): RuntimeState | null {
|
|
61
|
+
if (event.type === "agent_start" || event.type === "turn_start") return "running";
|
|
62
|
+
if (event.type === "agent_end") {
|
|
63
|
+
const assistant = lastAssistant(event.messages);
|
|
64
|
+
return assistant?.stopReason === "error" ? "errored" : "completed";
|
|
65
|
+
}
|
|
66
|
+
if (event.type === "notice") return null;
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function persistCoordinatorRuntimeStateFromEvent(
|
|
71
|
+
event: RuntimeStateEvent,
|
|
72
|
+
context: RuntimeStateContext,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
const stateFile = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim();
|
|
75
|
+
if (!stateFile) return;
|
|
76
|
+
const state = stateForEvent(event);
|
|
77
|
+
if (!state) return;
|
|
78
|
+
const now = new Date().toISOString();
|
|
79
|
+
let previous: Record<string, unknown> = {};
|
|
80
|
+
try {
|
|
81
|
+
previous = JSON.parse(await Bun.file(stateFile).text()) as Record<string, unknown>;
|
|
82
|
+
} catch {
|
|
83
|
+
previous = {};
|
|
84
|
+
}
|
|
85
|
+
const finalResponse = finalResponseForEvent(event);
|
|
86
|
+
const payload = {
|
|
87
|
+
schema_version: 1,
|
|
88
|
+
session_id: process.env[GJC_COORDINATOR_SESSION_ID_ENV]?.trim() || context.sessionId,
|
|
89
|
+
state,
|
|
90
|
+
ready_for_input: state === "completed" || state === "ready_for_input",
|
|
91
|
+
updated_at: now,
|
|
92
|
+
current_turn_id: typeof previous.current_turn_id === "string" ? previous.current_turn_id : null,
|
|
93
|
+
last_turn_id: typeof previous.last_turn_id === "string" ? previous.last_turn_id : null,
|
|
94
|
+
live: typeof previous.live === "boolean" ? previous.live : null,
|
|
95
|
+
reason: null,
|
|
96
|
+
source: "agent_session_event",
|
|
97
|
+
event: event.type,
|
|
98
|
+
cwd: context.cwd,
|
|
99
|
+
session_file: context.sessionFile ?? null,
|
|
100
|
+
...(finalResponse ? { final_response: finalResponse } : {}),
|
|
101
|
+
...(state === "errored"
|
|
102
|
+
? {
|
|
103
|
+
error: {
|
|
104
|
+
code: "agent_error",
|
|
105
|
+
message: lastAssistant(event.messages)?.errorMessage ?? "agent_error",
|
|
106
|
+
recoverable: true,
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
: {}),
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
await fs.mkdir(path.dirname(stateFile), { recursive: true });
|
|
113
|
+
await Bun.write(stateFile, `${JSON.stringify(payload, null, 2)}\n`);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
logger.warn("Failed to persist coordinator runtime state", { error: String(error), stateFile });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -19,11 +19,12 @@ import {
|
|
|
19
19
|
type ReceiptSubject,
|
|
20
20
|
type ReviewFailureEvidence,
|
|
21
21
|
type ReviewVerdictEvidence,
|
|
22
|
+
sha256Hex,
|
|
22
23
|
type ValidationEvidence,
|
|
23
24
|
validateReceipt,
|
|
24
25
|
} from "./receipts";
|
|
25
26
|
import { readReceiptIndex, writeReceiptImmutable } from "./storage";
|
|
26
|
-
import { isReviewVerdict, type ReviewVerdict } from "./types";
|
|
27
|
+
import { extractReviewVerdict, isReviewVerdict, type ReviewVerdict } from "./types";
|
|
27
28
|
|
|
28
29
|
export interface ValidationCommandSpec {
|
|
29
30
|
name: string;
|
|
@@ -56,6 +57,11 @@ export interface FinalizeOptions {
|
|
|
56
57
|
reviewOnly?: boolean;
|
|
57
58
|
/** Operator/loop-supplied terminal review verdict (closed vocabulary). */
|
|
58
59
|
verdict?: string | null;
|
|
60
|
+
/**
|
|
61
|
+
* Final assistant text from the live RPC owner, used to extract a closed-vocabulary verdict
|
|
62
|
+
* for review-only sessions when no explicit {@link verdict} is supplied. Never persisted raw.
|
|
63
|
+
*/
|
|
64
|
+
assistantText?: string | null;
|
|
59
65
|
/** Bounded PR/issue reference for the review target (e.g. "PR-414"). Never resolved from the live repo. */
|
|
60
66
|
prTarget?: string | null;
|
|
61
67
|
validationCommands?: ValidationCommandSpec[];
|
|
@@ -78,6 +84,14 @@ function receiptId(prefix: string): string {
|
|
|
78
84
|
return `${prefix}-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
|
79
85
|
}
|
|
80
86
|
|
|
87
|
+
/** Bound + whitespace-collapse assistant text into a redaction-safe digest summary (never a raw dump). */
|
|
88
|
+
function boundedAssistantSummary(text: string | null): string | null {
|
|
89
|
+
if (!text) return null;
|
|
90
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
91
|
+
if (collapsed.length === 0) return null;
|
|
92
|
+
return collapsed.length > 280 ? `${collapsed.slice(0, 280)}…` : collapsed;
|
|
93
|
+
}
|
|
94
|
+
|
|
81
95
|
export async function runFinalize(opts: FinalizeOptions): Promise<FinalizeResult> {
|
|
82
96
|
if (opts.reviewOnly) return runReviewFinalize(opts);
|
|
83
97
|
|
|
@@ -214,9 +228,27 @@ async function runReviewFinalize(opts: FinalizeOptions): Promise<FinalizeResult>
|
|
|
214
228
|
issueArtifact: null,
|
|
215
229
|
};
|
|
216
230
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
231
|
+
// Explicit operator/loop verdict always wins. Only when none is supplied do we fall back to
|
|
232
|
+
// extracting a closed-vocabulary verdict from the live RPC owner's final assistant text.
|
|
233
|
+
const explicitProvided = opts.verdict != null;
|
|
234
|
+
const explicitValid = isReviewVerdict(opts.verdict);
|
|
235
|
+
const assistantText = typeof opts.assistantText === "string" ? opts.assistantText : null;
|
|
236
|
+
const extracted = explicitProvided ? null : extractReviewVerdict(assistantText);
|
|
237
|
+
const verdict: ReviewVerdict | null = explicitValid ? (opts.verdict as ReviewVerdict) : extracted;
|
|
238
|
+
const verdictSource: "input" | "assistant" = explicitValid ? "input" : "assistant";
|
|
239
|
+
|
|
240
|
+
if (!verdict) {
|
|
241
|
+
const reason = explicitProvided ? "review-verdict-invalid" : "review-verdict-missing";
|
|
242
|
+
const assistantDigest = assistantText ? sha256Hex(assistantText) : null;
|
|
243
|
+
const assistantSummary = boundedAssistantSummary(assistantText);
|
|
244
|
+
const failure: ReviewFailureEvidence = {
|
|
245
|
+
reason,
|
|
246
|
+
prTarget,
|
|
247
|
+
failedAt: now(),
|
|
248
|
+
fallback: "operator-or-omx-review",
|
|
249
|
+
...(assistantDigest ? { assistantDigest } : {}),
|
|
250
|
+
...(assistantSummary ? { assistantSummary } : {}),
|
|
251
|
+
};
|
|
220
252
|
const receipt = buildReceipt<ReviewFailureEvidence>({
|
|
221
253
|
receiptId: receiptId("revfail"),
|
|
222
254
|
sessionId: opts.sessionId,
|
|
@@ -238,12 +270,14 @@ async function runReviewFinalize(opts: FinalizeOptions): Promise<FinalizeResult>
|
|
|
238
270
|
return { ...baseResult, completed: false, receiptPath: entry.path, verdict: null, blockers };
|
|
239
271
|
}
|
|
240
272
|
|
|
241
|
-
const
|
|
273
|
+
const assistantDigest = verdictSource === "assistant" && assistantText ? sha256Hex(assistantText) : null;
|
|
242
274
|
const evidence: ReviewVerdictEvidence = {
|
|
243
275
|
verdict,
|
|
244
276
|
prTarget,
|
|
245
277
|
finalizedAt: now(),
|
|
246
278
|
summaryRef: typeof opts.prTarget === "string" ? `verdict:${verdict}@${opts.prTarget}` : `verdict:${verdict}`,
|
|
279
|
+
verdictSource,
|
|
280
|
+
...(assistantDigest ? { assistantDigest } : {}),
|
|
247
281
|
};
|
|
248
282
|
const receipt = buildReceipt<ReviewVerdictEvidence>({
|
|
249
283
|
receiptId: receiptId("verdict"),
|
|
@@ -504,13 +504,21 @@ export class RuntimeOwner {
|
|
|
504
504
|
const workspace = state.handle.workspace;
|
|
505
505
|
const checks = this.#finalizeChecks ?? defaultFinalizeChecks(workspace);
|
|
506
506
|
const reviewOnly = state.handle.mode === "review";
|
|
507
|
+
const inputVerdict = reviewOnly ? (typeof input.verdict === "string" ? input.verdict : null) : undefined;
|
|
508
|
+
// Review-only finalize with no explicit verdict pulls the final assistant text from the live
|
|
509
|
+
// RPC owner so the verdict can be extracted deterministically instead of demanded from the operator.
|
|
510
|
+
let assistantText: string | null = null;
|
|
511
|
+
if (reviewOnly && inputVerdict == null && this.#opts.rpc.getLastAssistantText) {
|
|
512
|
+
assistantText = await this.#opts.rpc.getLastAssistantText().catch(() => null);
|
|
513
|
+
}
|
|
507
514
|
const fin = await runFinalize({
|
|
508
515
|
root: this.#opts.root,
|
|
509
516
|
sessionId: this.#opts.sessionId,
|
|
510
517
|
workspace,
|
|
511
518
|
branch: state.handle.branch ?? "",
|
|
512
519
|
reviewOnly,
|
|
513
|
-
verdict:
|
|
520
|
+
verdict: inputVerdict,
|
|
521
|
+
assistantText: reviewOnly ? assistantText : undefined,
|
|
514
522
|
prTarget: reviewOnly ? state.handle.issueOrPr : undefined,
|
|
515
523
|
requireTests: input.requireTests !== false,
|
|
516
524
|
requireCommit: input.requireCommit !== false,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase-boundary receipt rollup builder (receipt-of-receipts).
|
|
3
|
+
*
|
|
4
|
+
* At a harness lifecycle boundary, N child task receipts can be superseded by a
|
|
5
|
+
* single `phase-rollup` receipt that preserves per-child pointers (id, status,
|
|
6
|
+
* outputRef, sha256) plus aggregate ROI totals. The rollup is hash-sealed via
|
|
7
|
+
* the standard receipt envelope and validated fail-closed like every other
|
|
8
|
+
* family (see `validatePhaseRollup` in receipts.ts). Pure builder — no runtime
|
|
9
|
+
* injection behavior is changed here.
|
|
10
|
+
*/
|
|
11
|
+
import type { TaskResultReceipt } from "../task/receipt";
|
|
12
|
+
import {
|
|
13
|
+
type BuildReceiptInput,
|
|
14
|
+
buildReceipt,
|
|
15
|
+
canonicalJson,
|
|
16
|
+
type PhaseRollupChildPointer,
|
|
17
|
+
type PhaseRollupEvidence,
|
|
18
|
+
type ReceiptEnvelope,
|
|
19
|
+
sha256Hex,
|
|
20
|
+
} from "./receipts";
|
|
21
|
+
|
|
22
|
+
function childPointer(receipt: TaskResultReceipt): PhaseRollupChildPointer {
|
|
23
|
+
const ref = receipt.outputRef;
|
|
24
|
+
// Receipt-of-receipts integrity requires BOTH a pointer URI and its content
|
|
25
|
+
// hash. A URI without a verifiable hash cannot be integrity-checked, so we
|
|
26
|
+
// drop the (one-sided) pointer entirely rather than emit an unverifiable ref
|
|
27
|
+
// that the fail-closed validator would reject.
|
|
28
|
+
const hasVerifiableRef = Boolean(ref?.uri) && Boolean(ref?.sha256);
|
|
29
|
+
return {
|
|
30
|
+
id: receipt.id,
|
|
31
|
+
status: receipt.status,
|
|
32
|
+
outputUri: hasVerifiableRef ? (ref?.uri ?? null) : null,
|
|
33
|
+
outputSha256: hasVerifiableRef ? (ref?.sha256 ?? null) : null,
|
|
34
|
+
// Normalize through JSON first: in-memory task receipts carry optional
|
|
35
|
+
// fields with value `undefined`, which canonicalJson would hash as
|
|
36
|
+
// `null` while persisted/parsed receipts omit those keys entirely.
|
|
37
|
+
// JSON round-tripping drops undefined-valued keys so the hash is
|
|
38
|
+
// identical for in-memory and rehydrated copies of the same receipt.
|
|
39
|
+
receiptSha256: sha256Hex(canonicalJson(JSON.parse(JSON.stringify(receipt)))),
|
|
40
|
+
// Per-child ROI accounting so the rollup aggregate is recomputable from
|
|
41
|
+
// child evidence (see validatePhaseRollup). `tokens` falls back to the
|
|
42
|
+
// receipt's raw token count when no ROI proxy is present.
|
|
43
|
+
tokens: receipt.roi?.tokens ?? receipt.tokens,
|
|
44
|
+
costTotal: receipt.roi?.costTotal ?? null,
|
|
45
|
+
clonedTokens: receipt.roi?.clonedTokens ?? null,
|
|
46
|
+
lowRoi: receipt.roi?.lowRoi ?? false,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface BuildPhaseRollupInput {
|
|
51
|
+
receiptId: string;
|
|
52
|
+
sessionId: string;
|
|
53
|
+
source: string;
|
|
54
|
+
subject: BuildReceiptInput<PhaseRollupEvidence>["subject"];
|
|
55
|
+
phase: string;
|
|
56
|
+
children: readonly TaskResultReceipt[];
|
|
57
|
+
/** Supply for deterministic output; defaults to now. */
|
|
58
|
+
createdAt?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildPhaseRollupReceipt(input: BuildPhaseRollupInput): ReceiptEnvelope<PhaseRollupEvidence> {
|
|
62
|
+
// `null` means "no child reported this metric" — the canonical value the
|
|
63
|
+
// fail-closed validator reconciles against. Decide presence from whether a
|
|
64
|
+
// child carried the field at all (not from a >0 sum), so a legitimate
|
|
65
|
+
// all-zero total reconciles instead of collapsing to null and mismatching.
|
|
66
|
+
const anyCost = input.children.some(child => (child.roi?.costTotal ?? null) !== null);
|
|
67
|
+
const anyCloned = input.children.some(child => (child.roi?.clonedTokens ?? null) !== null);
|
|
68
|
+
const totalCostTotal = anyCost
|
|
69
|
+
? input.children.reduce((total, child) => total + (child.roi?.costTotal ?? 0), 0)
|
|
70
|
+
: null;
|
|
71
|
+
const totalClonedTokens = anyCloned
|
|
72
|
+
? input.children.reduce((total, child) => total + (child.roi?.clonedTokens ?? 0), 0)
|
|
73
|
+
: null;
|
|
74
|
+
const evidence: PhaseRollupEvidence = {
|
|
75
|
+
phase: input.phase,
|
|
76
|
+
children: input.children.map(childPointer),
|
|
77
|
+
aggregate: {
|
|
78
|
+
childCount: input.children.length,
|
|
79
|
+
completed: input.children.filter(child => child.status === "completed").length,
|
|
80
|
+
failed: input.children.filter(child => child.status === "failed" || child.status === "merge_failed").length,
|
|
81
|
+
totalTokens: input.children.reduce((total, child) => total + (child.roi?.tokens ?? child.tokens), 0),
|
|
82
|
+
totalCostTotal,
|
|
83
|
+
totalClonedTokens,
|
|
84
|
+
lowRoiChildIds: input.children.filter(child => child.roi?.lowRoi).map(child => child.id),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
return buildReceipt({
|
|
88
|
+
receiptId: input.receiptId,
|
|
89
|
+
sessionId: input.sessionId,
|
|
90
|
+
family: "phase-rollup",
|
|
91
|
+
source: input.source,
|
|
92
|
+
subject: input.subject,
|
|
93
|
+
evidence,
|
|
94
|
+
createdAt: input.createdAt,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { CompletionEvidence, ReceiptEnvelope, ReviewVerdictEvidence } from "./receipts";
|
|
2
|
+
import { validateReceipt } from "./receipts";
|
|
3
|
+
import { canTransition } from "./state-machine";
|
|
4
|
+
import type { HarnessLifecycle, ReceiptFamily, SessionState } from "./types";
|
|
5
|
+
|
|
6
|
+
export const RECEIPT_DIGEST_MAX_CHARS = 280;
|
|
7
|
+
|
|
8
|
+
export const RECEIPT_FAMILY_LIFECYCLE_TARGETS: Partial<Record<ReceiptFamily, HarnessLifecycle>> = {
|
|
9
|
+
completion: "completed",
|
|
10
|
+
// Review-only sessions terminate on a valid review verdict rather than a
|
|
11
|
+
// completion receipt; the finalizer treats valid verdicts as terminal.
|
|
12
|
+
"review-verdict": "completed",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Family-specific evidence consistency: the lifecycle target must agree with
|
|
17
|
+
* what the evidence itself claims. Hash validity alone is not enough — a
|
|
18
|
+
* semantically contradictory receipt must not drive the lifecycle.
|
|
19
|
+
*/
|
|
20
|
+
function evidenceContradiction(receipt: ReceiptEnvelope<unknown>, target: HarnessLifecycle): string | undefined {
|
|
21
|
+
if (receipt.family === "completion") {
|
|
22
|
+
const evidence = receipt.evidence as CompletionEvidence;
|
|
23
|
+
if (evidence.finalLifecycle !== target) {
|
|
24
|
+
return `evidence-lifecycle-mismatch:${evidence.finalLifecycle}`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (receipt.family === "review-verdict") {
|
|
28
|
+
const evidence = receipt.evidence as ReviewVerdictEvidence;
|
|
29
|
+
// Owner confirmation is not a terminal success verdict.
|
|
30
|
+
if (evidence.verdict === "OWNER_CONFIRMATION_REQUIRED") {
|
|
31
|
+
return "review-verdict-not-terminal";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ReceiptIngestResult {
|
|
38
|
+
accepted: ReceiptEnvelope<unknown>[];
|
|
39
|
+
rejected: { receipt: ReceiptEnvelope<unknown>; reasons: string[] }[];
|
|
40
|
+
transitions: { from: HarnessLifecycle; to: HarnessLifecycle; receiptId: string }[];
|
|
41
|
+
finalLifecycle: HarnessLifecycle;
|
|
42
|
+
digest: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ingestReceipts(
|
|
46
|
+
state: SessionState,
|
|
47
|
+
receipts: readonly ReceiptEnvelope<unknown>[],
|
|
48
|
+
): ReceiptIngestResult {
|
|
49
|
+
let lifecycle = state.lifecycle;
|
|
50
|
+
const accepted: ReceiptEnvelope<unknown>[] = [];
|
|
51
|
+
const rejected: { receipt: ReceiptEnvelope<unknown>; reasons: string[] }[] = [];
|
|
52
|
+
const transitions: { from: HarnessLifecycle; to: HarnessLifecycle; receiptId: string }[] = [];
|
|
53
|
+
|
|
54
|
+
for (const receipt of receipts) {
|
|
55
|
+
const validation = validateReceipt(receipt);
|
|
56
|
+
if (!validation.valid) {
|
|
57
|
+
rejected.push({ receipt, reasons: validation.reasons });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Fail closed on receipts the envelope itself marks invalid: the hash
|
|
62
|
+
// can be self-consistent while the issuer recorded the receipt as not
|
|
63
|
+
// proving its claim.
|
|
64
|
+
if (receipt.valid !== true) {
|
|
65
|
+
rejected.push({ receipt, reasons: ["receipt-marked-invalid"] });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Fail closed on cross-session receipts: a self-consistent receipt from
|
|
70
|
+
// another session must never drive this session's lifecycle.
|
|
71
|
+
if (receipt.sessionId !== state.sessionId) {
|
|
72
|
+
rejected.push({ receipt, reasons: [`session-mismatch:${receipt.sessionId}`] });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const target = RECEIPT_FAMILY_LIFECYCLE_TARGETS[receipt.family];
|
|
77
|
+
if (target) {
|
|
78
|
+
// Non-terminal review verdicts (OWNER_CONFIRMATION_REQUIRED) are
|
|
79
|
+
// valid receipts but do not complete the session: accept, no move.
|
|
80
|
+
if (receipt.family === "review-verdict" && evidenceContradiction(receipt, target) !== undefined) {
|
|
81
|
+
accepted.push(receipt);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// Other contradictions (e.g. completion evidence whose
|
|
85
|
+
// finalLifecycle disagrees with the target) reject fail-closed.
|
|
86
|
+
const contradiction = evidenceContradiction(receipt, target);
|
|
87
|
+
if (contradiction !== undefined) {
|
|
88
|
+
rejected.push({ receipt, reasons: [contradiction] });
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!canTransition(lifecycle, target)) {
|
|
92
|
+
rejected.push({ receipt, reasons: [`illegal-transition:${lifecycle}->${target}`] });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
transitions.push({ from: lifecycle, to: target, receiptId: receipt.receiptId });
|
|
97
|
+
lifecycle = target;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
accepted.push(receipt);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
accepted,
|
|
105
|
+
rejected,
|
|
106
|
+
transitions,
|
|
107
|
+
finalLifecycle: lifecycle,
|
|
108
|
+
digest: buildReceiptIngestDigest(receipts.length, accepted.length, rejected, state.lifecycle, lifecycle),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildReceiptIngestDigest(
|
|
113
|
+
total: number,
|
|
114
|
+
acceptedCount: number,
|
|
115
|
+
rejected: readonly { receipt: ReceiptEnvelope<unknown>; reasons: readonly string[] }[],
|
|
116
|
+
initialLifecycle: HarnessLifecycle,
|
|
117
|
+
finalLifecycle: HarnessLifecycle,
|
|
118
|
+
): string {
|
|
119
|
+
let digest = `ingested ${total} receipts: ${acceptedCount} accepted, ${rejected.length} rejected; lifecycle ${initialLifecycle}->${finalLifecycle}`;
|
|
120
|
+
if (rejected.length > 0) {
|
|
121
|
+
const rejectedSummary = rejected
|
|
122
|
+
.map(item => `${item.receipt?.receiptId ?? "<malformed>"}(${item.reasons.join("|")})`)
|
|
123
|
+
.join(",");
|
|
124
|
+
digest += `; rejected: ${rejectedSummary}`;
|
|
125
|
+
}
|
|
126
|
+
return digest.slice(0, RECEIPT_DIGEST_MAX_CHARS);
|
|
127
|
+
}
|