@linimin/pi-letscook 0.1.26
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 +196 -0
- package/LICENSE +21 -0
- package/PUBLISHING.md +60 -0
- package/README.md +219 -0
- package/agents/completion-auditor.md +43 -0
- package/agents/completion-bootstrapper.md +56 -0
- package/agents/completion-implementer.md +83 -0
- package/agents/completion-regrounder.md +66 -0
- package/agents/completion-reviewer.md +46 -0
- package/agents/completion-stop-judge.md +50 -0
- package/extensions/completion/index.ts +2572 -0
- package/package.json +38 -0
- package/scripts/context-proposal-test.sh +235 -0
- package/scripts/observability-status-test.sh +237 -0
- package/scripts/refocus-test.sh +77 -0
- package/scripts/release-check.sh +13 -0
- package/scripts/smoke-test.sh +74 -0
- package/skills/completion-protocol/SKILL.md +168 -0
- package/skills/completion-protocol/references/completion.md +287 -0
|
@@ -0,0 +1,2572 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { promises as fsp } from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
7
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
8
|
+
import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
|
|
12
|
+
const PROTOCOL_ID = "completion";
|
|
13
|
+
const ROLE_NAMES = [
|
|
14
|
+
"completion-bootstrapper",
|
|
15
|
+
"completion-regrounder",
|
|
16
|
+
"completion-implementer",
|
|
17
|
+
"completion-reviewer",
|
|
18
|
+
"completion-auditor",
|
|
19
|
+
"completion-stop-judge",
|
|
20
|
+
] as const;
|
|
21
|
+
const AGENT_HOME = path.join(os.homedir(), ".pi", "agent");
|
|
22
|
+
const COMPLETION_STATUS_KEY = "completion";
|
|
23
|
+
const EXTENSION_DIR = typeof __dirname === "string" ? __dirname : process.cwd();
|
|
24
|
+
const PACKAGE_ROOT_CANDIDATE = path.resolve(EXTENSION_DIR, "..", "..");
|
|
25
|
+
const PACKAGE_ROOT = fs.existsSync(path.join(PACKAGE_ROOT_CANDIDATE, "package.json")) ? PACKAGE_ROOT_CANDIDATE : undefined;
|
|
26
|
+
const PACKAGE_SKILL_PATH = PACKAGE_ROOT ? path.join(PACKAGE_ROOT, "skills", "completion-protocol", "SKILL.md") : undefined;
|
|
27
|
+
const PACKAGE_REFERENCE_PATH = PACKAGE_ROOT
|
|
28
|
+
? path.join(PACKAGE_ROOT, "skills", "completion-protocol", "references", "completion.md")
|
|
29
|
+
: undefined;
|
|
30
|
+
const PACKAGE_AGENTS_DIR = PACKAGE_ROOT ? path.join(PACKAGE_ROOT, "agents") : undefined;
|
|
31
|
+
const SKILL_PATH = PACKAGE_SKILL_PATH ?? path.join(AGENT_HOME, "skills", "completion-protocol", "SKILL.md");
|
|
32
|
+
const REFERENCE_PATH = PACKAGE_REFERENCE_PATH ?? path.join(AGENT_HOME, "skills", "completion-protocol", "references", "completion.md");
|
|
33
|
+
|
|
34
|
+
type CompletionRole = (typeof ROLE_NAMES)[number];
|
|
35
|
+
type JsonRecord = Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
type CompletionFiles = {
|
|
38
|
+
root: string;
|
|
39
|
+
agentDir: string;
|
|
40
|
+
tmpDir: string;
|
|
41
|
+
profilePath: string;
|
|
42
|
+
statePath: string;
|
|
43
|
+
planPath: string;
|
|
44
|
+
activePath: string;
|
|
45
|
+
sliceHistoryPath: string;
|
|
46
|
+
stopHistoryPath: string;
|
|
47
|
+
compactionMarkerPath: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type CompletionStateSnapshot = {
|
|
51
|
+
files: CompletionFiles;
|
|
52
|
+
profile?: JsonRecord;
|
|
53
|
+
state?: JsonRecord;
|
|
54
|
+
plan?: JsonRecord;
|
|
55
|
+
active?: JsonRecord;
|
|
56
|
+
activeSlice?: JsonRecord;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type AgentDefinition = {
|
|
60
|
+
name: string;
|
|
61
|
+
description?: string;
|
|
62
|
+
tools?: string[];
|
|
63
|
+
model?: string;
|
|
64
|
+
systemPrompt: string;
|
|
65
|
+
filePath: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type LiveRoleActivity = {
|
|
69
|
+
role: string;
|
|
70
|
+
status: "running" | "ok" | "error";
|
|
71
|
+
currentAction?: string;
|
|
72
|
+
toolActivity?: string;
|
|
73
|
+
toolRecentActivity: string[];
|
|
74
|
+
recentActivity: string[];
|
|
75
|
+
assistantSummary?: string;
|
|
76
|
+
lastAssistantText?: string;
|
|
77
|
+
progress?: string;
|
|
78
|
+
rationale?: string;
|
|
79
|
+
nextStep?: string;
|
|
80
|
+
verifying?: string;
|
|
81
|
+
stateDeltas: string[];
|
|
82
|
+
startedAt: number;
|
|
83
|
+
updatedAt: number;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
type CompletionStatusSurface = {
|
|
87
|
+
snapshotPresent: boolean;
|
|
88
|
+
statusText?: string;
|
|
89
|
+
widgetLines: string[];
|
|
90
|
+
currentPhase?: string;
|
|
91
|
+
sliceId?: string;
|
|
92
|
+
nextMandatoryRole?: string;
|
|
93
|
+
remainingContractCount?: number;
|
|
94
|
+
releaseBlockerCount?: number;
|
|
95
|
+
highValueGapCount?: number;
|
|
96
|
+
remainingStopJudgeCount?: number;
|
|
97
|
+
activeRole?: string;
|
|
98
|
+
livePreview?: string;
|
|
99
|
+
liveState?: "active" | "waiting" | "stalled";
|
|
100
|
+
liveIdleMs?: number;
|
|
101
|
+
liveToolActivity?: string;
|
|
102
|
+
liveAssistantSummary?: string;
|
|
103
|
+
liveProgress?: string;
|
|
104
|
+
liveRationale?: string;
|
|
105
|
+
liveNextStep?: string;
|
|
106
|
+
liveVerifying?: string;
|
|
107
|
+
liveStateDeltas?: string[];
|
|
108
|
+
liveDetailsLines?: string[];
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
type ContextProposal = {
|
|
112
|
+
mission: string;
|
|
113
|
+
scope: string[];
|
|
114
|
+
constraints: string[];
|
|
115
|
+
acceptance: string[];
|
|
116
|
+
goalText: string;
|
|
117
|
+
basisPreview: string;
|
|
118
|
+
source: "session";
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
type ContextProposalDecision = {
|
|
122
|
+
missionAnchor: string;
|
|
123
|
+
goalText: string;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
type ContextProposalConfirmOptions = {
|
|
127
|
+
title: string;
|
|
128
|
+
nonInteractiveBehavior?: "accept" | "cancel";
|
|
129
|
+
editorPrompt?: string;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const liveRoleActivityByRoot = new Map<string, LiveRoleActivity>();
|
|
133
|
+
const LIVE_ROLE_WAITING_MS = 15_000;
|
|
134
|
+
const LIVE_ROLE_STALLED_MS = 45_000;
|
|
135
|
+
const LIVE_ROLE_HEARTBEAT_MS = 5_000;
|
|
136
|
+
|
|
137
|
+
function isRecord(value: unknown): value is JsonRecord {
|
|
138
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function asString(value: unknown): string | undefined {
|
|
142
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function asBoolean(value: unknown): boolean | undefined {
|
|
146
|
+
return typeof value === "boolean" ? value : undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function asNumber(value: unknown): number | undefined {
|
|
150
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function asStringArray(value: unknown): string[] {
|
|
154
|
+
return Array.isArray(value)
|
|
155
|
+
? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
|
156
|
+
: [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function roleFromEnv(): string | undefined {
|
|
160
|
+
return asString(process.env.PI_COMPLETION_ROLE);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function resolveFiles(root: string): CompletionFiles {
|
|
164
|
+
const agentDir = path.join(root, ".agent");
|
|
165
|
+
const tmpDir = path.join(agentDir, "tmp");
|
|
166
|
+
return {
|
|
167
|
+
root,
|
|
168
|
+
agentDir,
|
|
169
|
+
tmpDir,
|
|
170
|
+
profilePath: path.join(agentDir, "profile.json"),
|
|
171
|
+
statePath: path.join(agentDir, "state.json"),
|
|
172
|
+
planPath: path.join(agentDir, "plan.json"),
|
|
173
|
+
activePath: path.join(agentDir, "active-slice.json"),
|
|
174
|
+
sliceHistoryPath: path.join(agentDir, "slice-history.jsonl"),
|
|
175
|
+
stopHistoryPath: path.join(agentDir, "stop-check-history.jsonl"),
|
|
176
|
+
compactionMarkerPath: path.join(tmpDir, "post-compaction-recovery.json"),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function walkUpForDir(startCwd: string, segments: string[]): string | undefined {
|
|
181
|
+
let current = path.resolve(startCwd);
|
|
182
|
+
while (true) {
|
|
183
|
+
const candidate = path.join(current, ...segments);
|
|
184
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
185
|
+
const parent = path.dirname(current);
|
|
186
|
+
if (parent === current) return undefined;
|
|
187
|
+
current = parent;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function findCompletionRoot(startCwd: string): string | undefined {
|
|
192
|
+
const profilePath = walkUpForDir(startCwd, [".agent", "profile.json"]);
|
|
193
|
+
return profilePath ? path.dirname(path.dirname(profilePath)) : undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function findRepoRoot(startCwd: string): string | undefined {
|
|
197
|
+
const gitPath = walkUpForDir(startCwd, [".git"]);
|
|
198
|
+
return gitPath ? path.dirname(gitPath) : undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function readJson(filePath: string): Promise<JsonRecord | undefined> {
|
|
202
|
+
try {
|
|
203
|
+
const raw = await fsp.readFile(filePath, "utf8");
|
|
204
|
+
const parsed = JSON.parse(raw);
|
|
205
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
206
|
+
} catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function readJsonl(filePath: string): Promise<JsonRecord[]> {
|
|
212
|
+
try {
|
|
213
|
+
const raw = await fsp.readFile(filePath, "utf8");
|
|
214
|
+
return raw
|
|
215
|
+
.split("\n")
|
|
216
|
+
.map((line) => line.trim())
|
|
217
|
+
.filter(Boolean)
|
|
218
|
+
.flatMap((line) => {
|
|
219
|
+
try {
|
|
220
|
+
const parsed = JSON.parse(line);
|
|
221
|
+
return isRecord(parsed) ? [parsed] : [];
|
|
222
|
+
} catch {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
} catch {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function writeJsonFile(filePath: string, value: JsonRecord): Promise<void> {
|
|
232
|
+
await fsp.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function candidateSlices(plan: JsonRecord | undefined): JsonRecord[] {
|
|
236
|
+
const slices = plan?.candidate_slices;
|
|
237
|
+
return Array.isArray(slices) ? slices.filter(isRecord) : [];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function findActiveSlice(plan: JsonRecord | undefined, active: JsonRecord | undefined): JsonRecord | undefined {
|
|
241
|
+
const sliceId = asString(active?.slice_id);
|
|
242
|
+
if (!sliceId) return undefined;
|
|
243
|
+
return candidateSlices(plan).find((slice) => asString(slice.slice_id) === sliceId);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function loadCompletionSnapshot(startCwd: string): Promise<CompletionStateSnapshot | undefined> {
|
|
247
|
+
const root = findCompletionRoot(startCwd);
|
|
248
|
+
if (!root) return undefined;
|
|
249
|
+
const files = resolveFiles(root);
|
|
250
|
+
const profile = await readJson(files.profilePath);
|
|
251
|
+
if (asString(profile?.protocol_id) !== PROTOCOL_ID) return undefined;
|
|
252
|
+
const state = await readJson(files.statePath);
|
|
253
|
+
const plan = await readJson(files.planPath);
|
|
254
|
+
const active = await readJson(files.activePath);
|
|
255
|
+
return {
|
|
256
|
+
files,
|
|
257
|
+
profile,
|
|
258
|
+
state,
|
|
259
|
+
plan,
|
|
260
|
+
active,
|
|
261
|
+
activeSlice: findActiveSlice(plan, active),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function loadCompletionDataForReminder(startCwd: string) {
|
|
266
|
+
const snapshot = await loadCompletionSnapshot(startCwd);
|
|
267
|
+
if (!snapshot) return undefined;
|
|
268
|
+
const sliceHistory = await readJsonl(snapshot.files.sliceHistoryPath);
|
|
269
|
+
const stopHistory = await readJsonl(snapshot.files.stopHistoryPath);
|
|
270
|
+
return { snapshot, sliceHistory, stopHistory };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function pathExists(targetPath: string): Promise<boolean> {
|
|
274
|
+
try {
|
|
275
|
+
await fsp.access(targetPath);
|
|
276
|
+
return true;
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function readText(filePath: string): Promise<string | undefined> {
|
|
283
|
+
try {
|
|
284
|
+
return await fsp.readFile(filePath, "utf8");
|
|
285
|
+
} catch {
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function detectDocsSurfaces(root: string): Promise<string[]> {
|
|
291
|
+
const candidates = ["README.md", "docs/", "docs", "CHANGELOG.md"];
|
|
292
|
+
const found: string[] = [];
|
|
293
|
+
for (const candidate of candidates) {
|
|
294
|
+
if (await pathExists(path.join(root, candidate))) found.push(candidate.endsWith("/") ? candidate : candidate.replace(/\/$/, ""));
|
|
295
|
+
}
|
|
296
|
+
return found.length > 0 ? found : ["README.md"];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function detectVerifierCommand(root: string): Promise<string | undefined> {
|
|
300
|
+
const packageJsonPath = path.join(root, "package.json");
|
|
301
|
+
const packageJson = await readJson(packageJsonPath);
|
|
302
|
+
if (packageJson) {
|
|
303
|
+
const scripts = isRecord(packageJson.scripts) ? packageJson.scripts : undefined;
|
|
304
|
+
const packageManager = asString((packageJson as JsonRecord).packageManager) ?? "";
|
|
305
|
+
const runner = packageManager.startsWith("pnpm") ? "pnpm" : packageManager.startsWith("yarn") ? "yarn" : packageManager.startsWith("bun") ? "bun" : "npm";
|
|
306
|
+
if (scripts && asString(scripts.test)) return runner === "npm" ? "npm test" : `${runner} test`;
|
|
307
|
+
if (scripts && asString(scripts.check)) return runner === "npm" ? "npm run check" : `${runner} check`;
|
|
308
|
+
if (scripts && asString(scripts.lint)) return runner === "npm" ? "npm run lint" : `${runner} lint`;
|
|
309
|
+
}
|
|
310
|
+
if (await pathExists(path.join(root, "pnpm-lock.yaml"))) return "pnpm test";
|
|
311
|
+
if (await pathExists(path.join(root, "bun.lockb")) || await pathExists(path.join(root, "bun.lock"))) return "bun test";
|
|
312
|
+
if (await pathExists(path.join(root, "yarn.lock"))) return "yarn test";
|
|
313
|
+
if (await pathExists(path.join(root, "Cargo.toml"))) return "cargo test";
|
|
314
|
+
if (await pathExists(path.join(root, "pyproject.toml")) || await pathExists(path.join(root, "pytest.ini"))) return "pytest";
|
|
315
|
+
if (await pathExists(path.join(root, "go.mod"))) return "go test ./...";
|
|
316
|
+
if (await pathExists(path.join(root, "Makefile"))) return "make test";
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function normalizeMissionAnchorText(value: string): string {
|
|
321
|
+
return value
|
|
322
|
+
.replace(/^\/(?:cook|complete)\s+/i, "")
|
|
323
|
+
.replace(/^["'“”‘’]+|["'“”‘’]+$/g, "")
|
|
324
|
+
.replace(/^\s*(please|pls|can you|could you|help me|i want to|we need to|let'?s|continue to|continue|resume)\s+/i, "")
|
|
325
|
+
.replace(/\s+/g, " ")
|
|
326
|
+
.replace(/[。!?.!?]+$/u, "")
|
|
327
|
+
.trim();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function isWeakMissionAnchor(value: string): boolean {
|
|
331
|
+
const normalized = value.trim().toLowerCase();
|
|
332
|
+
if (normalized.length < 8) return true;
|
|
333
|
+
if (["continue", "resume", "fix", "fix it", "work on this", "help", "do it", "try again"].includes(normalized)) return true;
|
|
334
|
+
if (/^(continue|resume|fix|help|work on)(\s+.*)?$/i.test(normalized) && normalized.split(/\s+/).length <= 3) return true;
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
type MissionAnchorAssessment = {
|
|
339
|
+
derived: string;
|
|
340
|
+
needsConfirmation: boolean;
|
|
341
|
+
reason?: string;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
function assessMissionAnchor(rawGoal: string, projectName: string): MissionAnchorAssessment {
|
|
345
|
+
const normalized = normalizeMissionAnchorText(rawGoal);
|
|
346
|
+
const derived = deriveMissionAnchor(rawGoal, projectName);
|
|
347
|
+
if (!normalized) {
|
|
348
|
+
return {
|
|
349
|
+
derived,
|
|
350
|
+
needsConfirmation: true,
|
|
351
|
+
reason: "No meaningful goal text was provided.",
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
if (isWeakMissionAnchor(normalized)) {
|
|
355
|
+
return {
|
|
356
|
+
derived,
|
|
357
|
+
needsConfirmation: true,
|
|
358
|
+
reason: "The goal is too short or vague for stable canonical workflow state.",
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
const vaguePronouns = /\b(this|that|it|things|stuff|something)\b/i.test(normalized);
|
|
362
|
+
const fallback = derived === `Drive ${projectName} to truthful, verifiable completion.`;
|
|
363
|
+
if (fallback || vaguePronouns) {
|
|
364
|
+
return {
|
|
365
|
+
derived,
|
|
366
|
+
needsConfirmation: true,
|
|
367
|
+
reason: fallback
|
|
368
|
+
? "The initial goal was too ambiguous, so the workflow fell back to a generic repo-based mission."
|
|
369
|
+
: "The goal still contains ambiguous references that are better confirmed before writing canonical state.",
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
return { derived, needsConfirmation: false };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function confirmMissionAnchor(
|
|
376
|
+
ctx: { hasUI: boolean; ui: any },
|
|
377
|
+
assessment: MissionAnchorAssessment,
|
|
378
|
+
): Promise<string | undefined> {
|
|
379
|
+
if (!getCtxHasUI(ctx)) return assessment.derived;
|
|
380
|
+
const ui = getCtxUi(ctx);
|
|
381
|
+
if (!ui) return assessment.derived;
|
|
382
|
+
if (!assessment.needsConfirmation) return assessment.derived;
|
|
383
|
+
const title = "Confirm mission anchor";
|
|
384
|
+
const reason = assessment.reason ? `${assessment.reason}\n\n` : "";
|
|
385
|
+
const choice = await ui.select(
|
|
386
|
+
title,
|
|
387
|
+
[
|
|
388
|
+
`${reason}Proposed mission anchor:\n${assessment.derived}\n\nUse proposed mission anchor`,
|
|
389
|
+
"Edit mission anchor",
|
|
390
|
+
"Cancel",
|
|
391
|
+
],
|
|
392
|
+
);
|
|
393
|
+
if (!choice || choice === "Cancel") return undefined;
|
|
394
|
+
if (choice === "Edit mission anchor") {
|
|
395
|
+
const edited = await ui.editor(title, assessment.derived);
|
|
396
|
+
return edited?.trim() ? edited.trim() : undefined;
|
|
397
|
+
}
|
|
398
|
+
return assessment.derived;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
type ExistingWorkflowDecision =
|
|
402
|
+
| { action: "continue"; currentMissionAnchor: string }
|
|
403
|
+
| { action: "refocus"; currentMissionAnchor: string; missionAnchor: string };
|
|
404
|
+
|
|
405
|
+
function completionTestWorkflowActionOverride(): "continue" | "refocus" | undefined {
|
|
406
|
+
const raw = process.env.PI_COMPLETION_EXISTING_WORKFLOW_ACTION?.trim().toLowerCase();
|
|
407
|
+
return raw === "continue" || raw === "refocus" ? raw : undefined;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function shouldSkipDriverKickoffForTests(): boolean {
|
|
411
|
+
return process.env.PI_COMPLETION_SKIP_DRIVER_KICKOFF === "1";
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function completionTestContextProposalActionOverride(): "accept" | "edit" | "cancel" | undefined {
|
|
415
|
+
const raw = process.env.PI_COMPLETION_CONTEXT_PROPOSAL_ACTION?.trim().toLowerCase();
|
|
416
|
+
return raw === "accept" || raw === "edit" || raw === "cancel" ? raw : undefined;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function completionTestContextProposalEditText(): string | undefined {
|
|
420
|
+
return asString(process.env.PI_COMPLETION_CONTEXT_PROPOSAL_EDIT_TEXT);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function isWorkflowDone(snapshot: CompletionStateSnapshot | undefined): boolean {
|
|
424
|
+
return asString(snapshot?.state?.continuation_policy) === "done";
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function extractTextFromMessageContent(content: unknown): string {
|
|
428
|
+
if (typeof content === "string") return content.trim();
|
|
429
|
+
if (!Array.isArray(content)) return "";
|
|
430
|
+
return content
|
|
431
|
+
.map((item) => {
|
|
432
|
+
if (!isRecord(item)) return "";
|
|
433
|
+
if (item.type !== "text") return "";
|
|
434
|
+
return asString(item.text) ?? "";
|
|
435
|
+
})
|
|
436
|
+
.filter((item) => item.length > 0)
|
|
437
|
+
.join("\n")
|
|
438
|
+
.trim();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function stripCodeBlocks(text: string): string {
|
|
442
|
+
return text.replace(/```[\s\S]*?```/g, " ");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function normalizeProposalLine(line: string): string {
|
|
446
|
+
return line
|
|
447
|
+
.replace(/^[-*+]\s+/, "")
|
|
448
|
+
.replace(/^\d+[.)]\s+/, "")
|
|
449
|
+
.replace(/^\[.?\]\s+/, "")
|
|
450
|
+
.replace(/^>\s*/, "")
|
|
451
|
+
.replace(/^[`*_~]+|[`*_~]+$/g, "")
|
|
452
|
+
.replace(/^\*\*(.+)\*\*$/u, "$1")
|
|
453
|
+
.replace(/^__([^_]+)__$/u, "$1")
|
|
454
|
+
.trim();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function detectProposalSection(line: string): "mission" | "scope" | "constraints" | "acceptance" | undefined {
|
|
458
|
+
const normalized = normalizeProposalLine(line)
|
|
459
|
+
.toLowerCase()
|
|
460
|
+
.replace(/[::]$/, "")
|
|
461
|
+
.trim();
|
|
462
|
+
if (!normalized) return undefined;
|
|
463
|
+
if (["mission", "goal", "objective", "summary", "目標", "任務", "計劃", "计划", "方案"].includes(normalized)) return "mission";
|
|
464
|
+
if (["scope", "plan", "steps", "implementation", "範圍", "范围", "實作", "实现", "步驟", "步骤"].includes(normalized)) return "scope";
|
|
465
|
+
if (["constraints", "constraint", "guardrails", "non-goals", "限制", "約束", "约束", "非目標", "非目标"].includes(normalized)) return "constraints";
|
|
466
|
+
if (["acceptance", "acceptance criteria", "deliverables", "verification", "驗收", "验收", "交付", "驗證", "验证"].includes(normalized)) return "acceptance";
|
|
467
|
+
return undefined;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function matchInlineProposalSection(
|
|
471
|
+
line: string,
|
|
472
|
+
): { section: "mission" | "scope" | "constraints" | "acceptance"; content: string } | undefined {
|
|
473
|
+
const normalized = normalizeProposalLine(line);
|
|
474
|
+
const match = normalized.match(/^([^::]+)[::]\s*(.+)$/u);
|
|
475
|
+
if (!match) return undefined;
|
|
476
|
+
const [, rawLabel, rawContent] = match;
|
|
477
|
+
const section = detectProposalSection(rawLabel);
|
|
478
|
+
const content = rawContent.trim();
|
|
479
|
+
if (!section || !content) return undefined;
|
|
480
|
+
return { section, content };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function bulletText(line: string): string | undefined {
|
|
484
|
+
if (!/^\s*(?:[-*+]\s+|\d+[.)]\s+)/.test(line)) return undefined;
|
|
485
|
+
const normalized = normalizeProposalLine(line);
|
|
486
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function looksLikeConstraint(text: string): boolean {
|
|
490
|
+
return /(do not|don't|must not|avoid|without|keep\b|preserve|retain|remain|不要|不可|不能|不應|不应|保持|保留|避免)/i.test(text);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function looksLikeAcceptance(text: string): boolean {
|
|
494
|
+
return /(test|tests|testing|verify|verification|validated|README|docs?|documentation|regression|observability|驗證|验证|測試|测试|文件|文檔|文档|回歸|回归)/i.test(text);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function uniqueProposalItems(items: string[]): string[] {
|
|
498
|
+
const seen = new Set<string>();
|
|
499
|
+
const result: string[] = [];
|
|
500
|
+
for (const item of items) {
|
|
501
|
+
const normalized = normalizeProposalLine(item).replace(/\s+/g, " ").trim();
|
|
502
|
+
if (!normalized) continue;
|
|
503
|
+
const key = normalized.toLowerCase();
|
|
504
|
+
if (seen.has(key)) continue;
|
|
505
|
+
seen.add(key);
|
|
506
|
+
result.push(normalized);
|
|
507
|
+
}
|
|
508
|
+
return result;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function buildContextProposalGoalText(proposal: {
|
|
512
|
+
mission: string;
|
|
513
|
+
scope: string[];
|
|
514
|
+
constraints: string[];
|
|
515
|
+
acceptance: string[];
|
|
516
|
+
}): string {
|
|
517
|
+
const lines = [`Mission: ${proposal.mission}`];
|
|
518
|
+
if (proposal.scope.length > 0) {
|
|
519
|
+
lines.push("", "Scope:");
|
|
520
|
+
for (const item of proposal.scope) lines.push(`- ${item}`);
|
|
521
|
+
}
|
|
522
|
+
if (proposal.constraints.length > 0) {
|
|
523
|
+
lines.push("", "Constraints:");
|
|
524
|
+
for (const item of proposal.constraints) lines.push(`- ${item}`);
|
|
525
|
+
}
|
|
526
|
+
if (proposal.acceptance.length > 0) {
|
|
527
|
+
lines.push("", "Acceptance:");
|
|
528
|
+
for (const item of proposal.acceptance) lines.push(`- ${item}`);
|
|
529
|
+
}
|
|
530
|
+
return lines.join("\n");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function buildContextProposalDisplayText(proposal: ContextProposal): string {
|
|
534
|
+
const lines = ["Mission", proposal.mission];
|
|
535
|
+
if (proposal.scope.length > 0) {
|
|
536
|
+
lines.push("", "Scope");
|
|
537
|
+
for (const item of proposal.scope) lines.push(`- ${item}`);
|
|
538
|
+
}
|
|
539
|
+
if (proposal.constraints.length > 0) {
|
|
540
|
+
lines.push("", "Constraints");
|
|
541
|
+
for (const item of proposal.constraints) lines.push(`- ${item}`);
|
|
542
|
+
}
|
|
543
|
+
if (proposal.acceptance.length > 0) {
|
|
544
|
+
lines.push("", "Acceptance");
|
|
545
|
+
for (const item of proposal.acceptance) lines.push(`- ${item}`);
|
|
546
|
+
}
|
|
547
|
+
return lines.join("\n");
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function buildContextProposalSelectionText(proposal: ContextProposal): string {
|
|
551
|
+
return [
|
|
552
|
+
"I found a likely implementation plan in the recent discussion.",
|
|
553
|
+
"Confirm it before /cook writes canonical workflow state.",
|
|
554
|
+
"",
|
|
555
|
+
buildContextProposalDisplayText(proposal),
|
|
556
|
+
"",
|
|
557
|
+
"Start the workflow with this proposal.",
|
|
558
|
+
].join("\n");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function buildContextProposalEditChoiceText(): string {
|
|
562
|
+
return [
|
|
563
|
+
"Edit this proposal before starting.",
|
|
564
|
+
"",
|
|
565
|
+
"Use this when the mission is right but the scope, constraints, or acceptance details need cleanup.",
|
|
566
|
+
].join("\n");
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function buildContextProposalCancelChoiceText(): string {
|
|
570
|
+
return ["Cancel", "", "Do not start a workflow yet."].join("\n");
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function buildContextProposalEditorText(proposal: ContextProposal): string {
|
|
574
|
+
return buildContextProposalGoalText(proposal);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function parseContextProposal(text: string, projectName: string): ContextProposal | undefined {
|
|
578
|
+
const cleaned = stripCodeBlocks(text).replace(/\r/g, "").trim();
|
|
579
|
+
if (!cleaned) return undefined;
|
|
580
|
+
const lines = cleaned
|
|
581
|
+
.split("\n")
|
|
582
|
+
.map((line) => line.trim())
|
|
583
|
+
.filter((line) => line.length > 0);
|
|
584
|
+
if (lines.length === 0) return undefined;
|
|
585
|
+
|
|
586
|
+
let section: "mission" | "scope" | "constraints" | "acceptance" | undefined;
|
|
587
|
+
let missionLine: string | undefined;
|
|
588
|
+
const scope: string[] = [];
|
|
589
|
+
const constraints: string[] = [];
|
|
590
|
+
const acceptance: string[] = [];
|
|
591
|
+
let structuredSignalCount = 0;
|
|
592
|
+
|
|
593
|
+
for (const rawLine of lines) {
|
|
594
|
+
const inlineSection = matchInlineProposalSection(rawLine);
|
|
595
|
+
if (inlineSection) {
|
|
596
|
+
section = inlineSection.section;
|
|
597
|
+
structuredSignalCount += 1;
|
|
598
|
+
if (inlineSection.section === "mission" && !missionLine) {
|
|
599
|
+
missionLine = inlineSection.content;
|
|
600
|
+
} else if (inlineSection.section === "constraints") {
|
|
601
|
+
constraints.push(inlineSection.content);
|
|
602
|
+
} else if (inlineSection.section === "acceptance") {
|
|
603
|
+
acceptance.push(inlineSection.content);
|
|
604
|
+
} else if (inlineSection.section === "scope") {
|
|
605
|
+
scope.push(inlineSection.content);
|
|
606
|
+
}
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
const headerSection = detectProposalSection(rawLine);
|
|
610
|
+
if (headerSection) {
|
|
611
|
+
section = headerSection;
|
|
612
|
+
structuredSignalCount += 1;
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
const bullet = bulletText(rawLine);
|
|
616
|
+
if (bullet) {
|
|
617
|
+
structuredSignalCount += 1;
|
|
618
|
+
if (section === "mission" && !missionLine) {
|
|
619
|
+
missionLine = bullet;
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
if (section === "constraints") {
|
|
623
|
+
constraints.push(bullet);
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
if (section === "acceptance") {
|
|
627
|
+
acceptance.push(bullet);
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (section === "scope") {
|
|
631
|
+
scope.push(bullet);
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
if (!missionLine) {
|
|
635
|
+
missionLine = bullet;
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
if (looksLikeAcceptance(bullet)) acceptance.push(bullet);
|
|
639
|
+
else if (looksLikeConstraint(bullet)) constraints.push(bullet);
|
|
640
|
+
else scope.push(bullet);
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
const normalized = normalizeProposalLine(rawLine);
|
|
644
|
+
if (!normalized) continue;
|
|
645
|
+
if (!missionLine) {
|
|
646
|
+
missionLine = normalized;
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
if (section === "constraints" || looksLikeConstraint(normalized)) {
|
|
650
|
+
constraints.push(normalized);
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
if (section === "acceptance" || looksLikeAcceptance(normalized)) {
|
|
654
|
+
acceptance.push(normalized);
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
if (section === "scope") {
|
|
658
|
+
scope.push(normalized);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const basisPreview = cleaned.replace(/\s+/g, " ").trim();
|
|
663
|
+
const missionSource = missionLine ?? scope[0] ?? acceptance[0] ?? constraints[0] ?? basisPreview;
|
|
664
|
+
const assessment = assessMissionAnchor(missionSource, projectName);
|
|
665
|
+
const normalizedMission = normalizeMissionAnchorText(missionSource);
|
|
666
|
+
const itemCount = scope.length + constraints.length + acceptance.length;
|
|
667
|
+
const hasStrongStructure = structuredSignalCount >= 2 || itemCount >= 2;
|
|
668
|
+
if (!normalizedMission || isWeakMissionAnchor(normalizedMission)) return undefined;
|
|
669
|
+
if (!hasStrongStructure && basisPreview.length < 140) return undefined;
|
|
670
|
+
const mission = assessment.derived;
|
|
671
|
+
const goalText = buildContextProposalGoalText({ mission, scope, constraints, acceptance });
|
|
672
|
+
return {
|
|
673
|
+
mission,
|
|
674
|
+
scope,
|
|
675
|
+
constraints,
|
|
676
|
+
acceptance,
|
|
677
|
+
goalText,
|
|
678
|
+
basisPreview,
|
|
679
|
+
source: "session",
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function extractContextProposalFromSession(ctx: { sessionManager: any }, projectName: string): ContextProposal | undefined {
|
|
684
|
+
let branch: any[] = [];
|
|
685
|
+
try {
|
|
686
|
+
branch = ctx.sessionManager?.getBranch?.() ?? [];
|
|
687
|
+
} catch (error) {
|
|
688
|
+
if (isStaleContextError(error)) return undefined;
|
|
689
|
+
throw error;
|
|
690
|
+
}
|
|
691
|
+
const candidates: string[] = [];
|
|
692
|
+
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
693
|
+
const entry = branch[index];
|
|
694
|
+
if (!isRecord(entry) || entry.type !== "message" || !isRecord(entry.message)) continue;
|
|
695
|
+
const message = entry.message as JsonRecord;
|
|
696
|
+
let text = "";
|
|
697
|
+
const role = asString(message.role);
|
|
698
|
+
if (role === "user" || role === "assistant" || role === "custom") {
|
|
699
|
+
text = extractTextFromMessageContent(message.content);
|
|
700
|
+
} else if (role === "branchSummary" || role === "compactionSummary") {
|
|
701
|
+
text = asString(message.summary) ?? "";
|
|
702
|
+
}
|
|
703
|
+
if (!text) continue;
|
|
704
|
+
const trimmed = text.trim();
|
|
705
|
+
if (!trimmed || /^\/(?:cook|complete)\b/i.test(trimmed)) continue;
|
|
706
|
+
candidates.push(trimmed);
|
|
707
|
+
}
|
|
708
|
+
for (const candidate of candidates) {
|
|
709
|
+
const parsed = parseContextProposal(candidate, projectName);
|
|
710
|
+
if (parsed) return parsed;
|
|
711
|
+
}
|
|
712
|
+
if (candidates.length > 1) {
|
|
713
|
+
const combined = candidates.slice(0, 4).reverse().join("\n\n");
|
|
714
|
+
return parseContextProposal(combined, projectName);
|
|
715
|
+
}
|
|
716
|
+
return undefined;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function buildGoalAnchoredContextProposal(
|
|
720
|
+
ctx: { sessionManager: any },
|
|
721
|
+
goal: string,
|
|
722
|
+
projectName: string,
|
|
723
|
+
): ContextProposal {
|
|
724
|
+
const explicit = parseContextProposal(goal, projectName);
|
|
725
|
+
const sessionProposal = extractContextProposalFromSession(ctx, projectName);
|
|
726
|
+
const missionSource = explicit?.mission ?? goal;
|
|
727
|
+
const assessment = assessMissionAnchor(missionSource, projectName);
|
|
728
|
+
const mission = assessment.derived;
|
|
729
|
+
const scope = uniqueProposalItems([...(explicit?.scope ?? []), ...(sessionProposal?.scope ?? [])]);
|
|
730
|
+
const constraints = uniqueProposalItems([...(explicit?.constraints ?? []), ...(sessionProposal?.constraints ?? [])]);
|
|
731
|
+
const acceptance = uniqueProposalItems([...(explicit?.acceptance ?? []), ...(sessionProposal?.acceptance ?? [])]);
|
|
732
|
+
const goalText = buildContextProposalGoalText({ mission, scope, constraints, acceptance });
|
|
733
|
+
return {
|
|
734
|
+
mission,
|
|
735
|
+
scope,
|
|
736
|
+
constraints,
|
|
737
|
+
acceptance,
|
|
738
|
+
goalText,
|
|
739
|
+
basisPreview: sessionProposal?.basisPreview ?? explicit?.basisPreview ?? goal,
|
|
740
|
+
source: "session",
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async function confirmContextProposal(
|
|
745
|
+
ctx: { hasUI: boolean; ui: any },
|
|
746
|
+
proposal: ContextProposal,
|
|
747
|
+
projectName: string,
|
|
748
|
+
options: ContextProposalConfirmOptions,
|
|
749
|
+
): Promise<ContextProposalDecision | undefined> {
|
|
750
|
+
const actionOverride = completionTestContextProposalActionOverride();
|
|
751
|
+
if (actionOverride === "cancel") return undefined;
|
|
752
|
+
if (actionOverride === "accept") {
|
|
753
|
+
return { missionAnchor: proposal.mission, goalText: proposal.goalText };
|
|
754
|
+
}
|
|
755
|
+
if (actionOverride === "edit") {
|
|
756
|
+
const editedText = completionTestContextProposalEditText();
|
|
757
|
+
if (!editedText) return undefined;
|
|
758
|
+
const editedProposal = parseContextProposal(editedText, projectName);
|
|
759
|
+
if (editedProposal) return { missionAnchor: editedProposal.mission, goalText: editedProposal.goalText };
|
|
760
|
+
const assessment = assessMissionAnchor(editedText, projectName);
|
|
761
|
+
return { missionAnchor: assessment.derived, goalText: editedText.trim() };
|
|
762
|
+
}
|
|
763
|
+
if (!getCtxHasUI(ctx)) {
|
|
764
|
+
return options.nonInteractiveBehavior === "accept"
|
|
765
|
+
? { missionAnchor: proposal.mission, goalText: proposal.goalText }
|
|
766
|
+
: undefined;
|
|
767
|
+
}
|
|
768
|
+
const ui = getCtxUi(ctx);
|
|
769
|
+
if (!ui) {
|
|
770
|
+
return options.nonInteractiveBehavior === "accept"
|
|
771
|
+
? { missionAnchor: proposal.mission, goalText: proposal.goalText }
|
|
772
|
+
: undefined;
|
|
773
|
+
}
|
|
774
|
+
const useChoice = buildContextProposalSelectionText(proposal);
|
|
775
|
+
const editChoice = buildContextProposalEditChoiceText();
|
|
776
|
+
const cancelChoice = buildContextProposalCancelChoiceText();
|
|
777
|
+
const choice = await ui.select(options.title, [useChoice, editChoice, cancelChoice]);
|
|
778
|
+
if (!choice || choice === cancelChoice) return undefined;
|
|
779
|
+
if (choice === editChoice) {
|
|
780
|
+
const editedText = await ui.editor(
|
|
781
|
+
options.editorPrompt ?? `${options.title}\n\nEdit the proposed mission, scope, constraints, and acceptance details below.`,
|
|
782
|
+
buildContextProposalEditorText(proposal),
|
|
783
|
+
);
|
|
784
|
+
if (!editedText?.trim()) return undefined;
|
|
785
|
+
const editedProposal = parseContextProposal(editedText, projectName);
|
|
786
|
+
if (editedProposal) return { missionAnchor: editedProposal.mission, goalText: editedProposal.goalText };
|
|
787
|
+
const assessment = assessMissionAnchor(editedText, projectName);
|
|
788
|
+
const missionAnchor = await confirmMissionAnchor(ctx, assessment);
|
|
789
|
+
if (!missionAnchor) return undefined;
|
|
790
|
+
return { missionAnchor, goalText: editedText.trim() };
|
|
791
|
+
}
|
|
792
|
+
return { missionAnchor: proposal.mission, goalText: proposal.goalText };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function currentMissionAnchor(snapshot: CompletionStateSnapshot): string {
|
|
796
|
+
return (
|
|
797
|
+
asString(snapshot.state?.mission_anchor) ??
|
|
798
|
+
asString(snapshot.plan?.mission_anchor) ??
|
|
799
|
+
asString(snapshot.active?.mission_anchor) ??
|
|
800
|
+
path.basename(snapshot.files.root)
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
async function confirmExistingWorkflowGoal(
|
|
805
|
+
ctx: { hasUI: boolean; ui: any },
|
|
806
|
+
snapshot: CompletionStateSnapshot,
|
|
807
|
+
goal: string,
|
|
808
|
+
): Promise<ExistingWorkflowDecision | undefined> {
|
|
809
|
+
const currentMission = currentMissionAnchor(snapshot);
|
|
810
|
+
const assessment = assessMissionAnchor(goal, path.basename(snapshot.files.root));
|
|
811
|
+
const normalizedCurrent = normalizeMissionAnchorText(currentMission);
|
|
812
|
+
const normalizedGoal = normalizeMissionAnchorText(goal);
|
|
813
|
+
const normalizedProposed = normalizeMissionAnchorText(assessment.derived);
|
|
814
|
+
if (!normalizedGoal || normalizedGoal === normalizedCurrent || normalizedProposed === normalizedCurrent) {
|
|
815
|
+
return { action: "continue", currentMissionAnchor: currentMission };
|
|
816
|
+
}
|
|
817
|
+
const actionOverride = completionTestWorkflowActionOverride();
|
|
818
|
+
if (actionOverride === "continue") {
|
|
819
|
+
return { action: "continue", currentMissionAnchor: currentMission };
|
|
820
|
+
}
|
|
821
|
+
if (actionOverride === "refocus") {
|
|
822
|
+
return { action: "refocus", currentMissionAnchor: currentMission, missionAnchor: assessment.derived };
|
|
823
|
+
}
|
|
824
|
+
if (!getCtxHasUI(ctx)) {
|
|
825
|
+
return { action: "continue", currentMissionAnchor: currentMission };
|
|
826
|
+
}
|
|
827
|
+
const ui = getCtxUi(ctx);
|
|
828
|
+
if (!ui) {
|
|
829
|
+
return { action: "continue", currentMissionAnchor: currentMission };
|
|
830
|
+
}
|
|
831
|
+
const title = [
|
|
832
|
+
"Existing completion workflow found",
|
|
833
|
+
"",
|
|
834
|
+
"A workflow is already in progress. Choose how /cook should proceed:",
|
|
835
|
+
"",
|
|
836
|
+
"Current mission",
|
|
837
|
+
currentMission,
|
|
838
|
+
"",
|
|
839
|
+
"New proposed mission",
|
|
840
|
+
assessment.derived,
|
|
841
|
+
].join("\n");
|
|
842
|
+
const continueChoice = "Continue current workflow\n\nKeep the current mission and treat the new goal as extra direction only.";
|
|
843
|
+
const refocusChoice = "Abandon current workflow and start this new one\n\nReplace the current mission with the new goal, then rebuild canonical state from that new direction.";
|
|
844
|
+
const cancelChoice = "Cancel\n\nExit without changing the current workflow.";
|
|
845
|
+
const choice = await ui.select(title, [continueChoice, refocusChoice, cancelChoice]);
|
|
846
|
+
if (!choice || choice === cancelChoice) return undefined;
|
|
847
|
+
if (choice === refocusChoice) {
|
|
848
|
+
const missionAnchor = await confirmMissionAnchor(ctx, assessment);
|
|
849
|
+
if (!missionAnchor) return undefined;
|
|
850
|
+
return { action: "refocus", currentMissionAnchor: currentMission, missionAnchor };
|
|
851
|
+
}
|
|
852
|
+
return { action: "continue", currentMissionAnchor: currentMission };
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
async function refocusCompletionMission(snapshot: CompletionStateSnapshot, missionAnchor: string, rawGoal: string): Promise<void> {
|
|
856
|
+
const requiredStopJudges = asNumber(snapshot.profile?.required_stop_judges) ?? 3;
|
|
857
|
+
const root = snapshot.files.root;
|
|
858
|
+
const nextState = {
|
|
859
|
+
...defaultState(missionAnchor),
|
|
860
|
+
remaining_stop_judges: requiredStopJudges,
|
|
861
|
+
continuation_reason: `User refocused workflow via /cook: ${truncateInline(rawGoal, 160)}`,
|
|
862
|
+
next_mandatory_action: "Reconcile canonical state from current repo truth for the refocused mission",
|
|
863
|
+
};
|
|
864
|
+
const nextPlan = {
|
|
865
|
+
...defaultPlan(missionAnchor),
|
|
866
|
+
plan_basis: "user_refocus",
|
|
867
|
+
};
|
|
868
|
+
const nextActive = defaultActiveSlice(missionAnchor);
|
|
869
|
+
await Promise.all([
|
|
870
|
+
fsp.writeFile(path.join(snapshot.files.agentDir, "mission.md"), buildMission(path.basename(root), missionAnchor), "utf8"),
|
|
871
|
+
writeJsonFile(snapshot.files.statePath, nextState),
|
|
872
|
+
writeJsonFile(snapshot.files.planPath, nextPlan),
|
|
873
|
+
writeJsonFile(snapshot.files.activePath, nextActive),
|
|
874
|
+
]);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function deriveMissionAnchor(rawGoal: string, projectName: string): string {
|
|
878
|
+
const normalized = normalizeMissionAnchorText(rawGoal);
|
|
879
|
+
if (!normalized || isWeakMissionAnchor(normalized)) {
|
|
880
|
+
return `Drive ${projectName} to truthful, verifiable completion.`;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
let mission = normalized
|
|
884
|
+
.replace(/\b(end[- ]to[- ]end|for me|thanks|thank you)\b/gi, "")
|
|
885
|
+
.replace(/\s+/g, " ")
|
|
886
|
+
.trim();
|
|
887
|
+
|
|
888
|
+
mission = mission
|
|
889
|
+
.replace(/\bwith tests and docs\b/gi, "with tests and docs parity")
|
|
890
|
+
.replace(/\bwith tests and documentation\b/gi, "with tests and docs parity")
|
|
891
|
+
.replace(/\bwith docs\b/gi, "with docs parity")
|
|
892
|
+
.trim();
|
|
893
|
+
|
|
894
|
+
if (mission.length > 120) {
|
|
895
|
+
mission = `${mission.slice(0, 117).trimEnd()}...`;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
if (!/[.!?。!?]$/u.test(mission)) mission += ".";
|
|
899
|
+
return mission;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function defaultState(missionAnchor: string): JsonRecord {
|
|
903
|
+
return {
|
|
904
|
+
schema_version: 1,
|
|
905
|
+
mission_anchor: missionAnchor,
|
|
906
|
+
current_phase: "reground",
|
|
907
|
+
continuation_policy: "continue",
|
|
908
|
+
continuation_reason: "Fresh completion bootstrap requires canonical re-ground",
|
|
909
|
+
project_done: false,
|
|
910
|
+
requires_reground: true,
|
|
911
|
+
slices_since_last_reground: 0,
|
|
912
|
+
remaining_release_blockers: null,
|
|
913
|
+
remaining_high_value_gaps: null,
|
|
914
|
+
unsatisfied_contract_ids: [],
|
|
915
|
+
release_blocker_ids: [],
|
|
916
|
+
next_mandatory_action: "Reconcile canonical state from current repo truth",
|
|
917
|
+
next_mandatory_role: "completion-regrounder",
|
|
918
|
+
remaining_stop_judges: 3,
|
|
919
|
+
last_reground_at: null,
|
|
920
|
+
last_auditor_verdict: null,
|
|
921
|
+
contract_status: "unknown",
|
|
922
|
+
latest_completed_slice: null,
|
|
923
|
+
latest_verified_slice: null,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function defaultPlan(missionAnchor: string): JsonRecord {
|
|
928
|
+
return {
|
|
929
|
+
schema_version: 1,
|
|
930
|
+
mission_anchor: missionAnchor,
|
|
931
|
+
last_reground_at: null,
|
|
932
|
+
plan_basis: "bootstrap",
|
|
933
|
+
candidate_slices: [],
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function defaultActiveSlice(missionAnchor: string): JsonRecord {
|
|
938
|
+
return {
|
|
939
|
+
schema_version: 1,
|
|
940
|
+
mission_anchor: missionAnchor,
|
|
941
|
+
status: "idle",
|
|
942
|
+
slice_id: null,
|
|
943
|
+
goal: null,
|
|
944
|
+
contract_ids: [],
|
|
945
|
+
acceptance_criteria: [],
|
|
946
|
+
priority: null,
|
|
947
|
+
why_now: null,
|
|
948
|
+
blocked_on: [],
|
|
949
|
+
locked_notes: [],
|
|
950
|
+
must_fix_findings: [],
|
|
951
|
+
basis_commit: null,
|
|
952
|
+
remaining_contract_ids_before: [],
|
|
953
|
+
release_blocker_count_before: null,
|
|
954
|
+
high_value_gap_count_before: null,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function buildAgentReadme(projectName: string): string {
|
|
959
|
+
return `# Completion Control Plane\n\nThis repository uses the \`completion\` workflow for long-running coding tasks.\n\n## Canonical tracked contract files\n\n- \`.agent/README.md\`\n- \`.agent/mission.md\`\n- \`.agent/profile.json\`\n- \`.agent/verify_completion_stop.sh\`\n- \`.agent/verify_completion_control_plane.sh\`\n\n## Ignored canonical execution state\n\n- \`.agent/state.json\`\n- \`.agent/plan.json\`\n- \`.agent/active-slice.json\`\n- \`.agent/slice-history.jsonl\`\n- \`.agent/stop-check-history.jsonl\`\n- \`.agent/*.log\`\n- \`.agent/tmp/\`\n\nThe source of truth for long-running completion work is canonical \`.agent/**\` state plus current repo truth.\n\nProject: ${projectName}\n`;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function buildMission(projectName: string, missionAnchor: string): string {
|
|
963
|
+
return `# Mission\n\nProject: ${projectName}\n\nMission anchor:\n${missionAnchor}\n\nThis file is a tracked human-readable statement of the repo's completion mission. Re-grounders may refine this file when repo truth becomes clearer, but it must stay truthful to shipped behavior and the active completion objective.\n`;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function buildVerifyStopScript(verifierCommand?: string): string {
|
|
967
|
+
const repoCheck = verifierCommand
|
|
968
|
+
? `echo "[completion] running repo-level verification: ${verifierCommand}"\n${verifierCommand}`
|
|
969
|
+
: `echo "[completion] no repo-specific verifier auto-detected; control-plane verification only"`;
|
|
970
|
+
return `#!/usr/bin/env bash\nset -euo pipefail\n\nbash .agent/verify_completion_control_plane.sh\n${repoCheck}\n`;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function buildVerifyControlPlaneScript(): string {
|
|
974
|
+
return `#!/usr/bin/env bash\nset -euo pipefail\n\nfor file in \\
|
|
975
|
+
.agent/README.md \\
|
|
976
|
+
.agent/mission.md \\
|
|
977
|
+
.agent/profile.json \\
|
|
978
|
+
.agent/verify_completion_stop.sh \\
|
|
979
|
+
.agent/verify_completion_control_plane.sh \\
|
|
980
|
+
.agent/state.json \\
|
|
981
|
+
.agent/plan.json \\
|
|
982
|
+
.agent/active-slice.json; do\n [[ -e "$file" ]] || { echo "missing required file: $file"; exit 1; }\ndone\n\nnode <<'NODE'\nconst fs = require('node:fs');\n\nconst readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));\nconst assert = (condition, message) => {\n if (!condition) {\n console.error(message);\n process.exit(1);\n }\n};\nconst isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);\nconst isString = (value) => typeof value === 'string';\nconst isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string');\nconst hasOnlyKeys = (object, allowed, label) => {\n const unknown = Object.keys(object).filter((key) => !allowed.includes(key));\n assert(unknown.length === 0, label + ': unknown keys: ' + unknown.join(', '));\n};\nconst requireKeys = (object, required, label) => {\n for (const key of required) {\n assert(Object.prototype.hasOwnProperty.call(object, key), label + ': missing required field: ' + key);\n }\n};\n\nfor (const file of ['.agent/profile.json', '.agent/state.json', '.agent/plan.json', '.agent/active-slice.json']) {\n readJson(file);\n}\n\nconst profile = readJson('.agent/profile.json');\nconst state = readJson('.agent/state.json');\nconst plan = readJson('.agent/plan.json');\nconst active = readJson('.agent/active-slice.json');\n\nassert(isObject(profile), '.agent/profile.json must be an object');\nassert(isObject(state), '.agent/state.json must be an object');\nassert(isObject(plan), '.agent/plan.json must be an object');\nassert(isObject(active), '.agent/active-slice.json must be an object');\n\nconst requiredProfile = ['schema_version', 'protocol_id', 'project_name', 'required_stop_judges', 'priority_policy_id', 'docs_surfaces'];\nrequireKeys(profile, requiredProfile, '.agent/profile.json');\nhasOnlyKeys(profile, requiredProfile, '.agent/profile.json');\nassert(profile.protocol_id === 'completion', '.agent/profile.json: protocol_id must be completion');\nassert(Array.isArray(profile.docs_surfaces), '.agent/profile.json: docs_surfaces must be an array');\n\nconst requiredState = [\n 'schema_version','mission_anchor','current_phase','continuation_policy','continuation_reason','project_done',\n 'requires_reground','slices_since_last_reground','remaining_release_blockers','remaining_high_value_gaps',\n 'unsatisfied_contract_ids','release_blocker_ids','next_mandatory_action','next_mandatory_role',\n 'remaining_stop_judges','last_reground_at','last_auditor_verdict','contract_status','latest_completed_slice','latest_verified_slice'\n];\nconst continuationPolicies = ['continue', 'await_user_input', 'blocked', 'paused', 'done'];\nconst workflowRoles = ['completion-bootstrapper', 'completion-regrounder', 'completion-implementer', 'completion-reviewer', 'completion-auditor', 'completion-stop-judge', null];\nconst workflowPhases = ['reground', 'implement', 'post_commit_review', 'post_commit_audit', 'post_commit_reconcile', 'stop_wave', 'awaiting_user', 'blocked', 'done'];\nrequireKeys(state, requiredState, '.agent/state.json');\nhasOnlyKeys(state, requiredState, '.agent/state.json');\nassert(continuationPolicies.includes(state.continuation_policy), '.agent/state.json: invalid continuation_policy');\nassert(workflowRoles.includes(state.next_mandatory_role), '.agent/state.json: invalid next_mandatory_role');\nassert(workflowPhases.includes(state.current_phase), '.agent/state.json: invalid current_phase');\nassert(isStringArray(state.unsatisfied_contract_ids), '.agent/state.json: unsatisfied_contract_ids must be an array of strings');\nassert(isStringArray(state.release_blocker_ids), '.agent/state.json: release_blocker_ids must be an array of strings');\n\nconst requiredPlan = ['schema_version', 'mission_anchor', 'last_reground_at', 'plan_basis', 'candidate_slices'];\nconst requiredSlice = ['slice_id', 'goal', 'acceptance_criteria', 'contract_ids', 'priority', 'status', 'why_now', 'blocked_on', 'evidence'];\nconst sliceStatuses = ['planned', 'selected', 'in_progress', 'blocked', 'done', 'cancelled'];\nrequireKeys(plan, requiredPlan, '.agent/plan.json');\nhasOnlyKeys(plan, requiredPlan, '.agent/plan.json');\nassert(Array.isArray(plan.candidate_slices), '.agent/plan.json: candidate_slices must be an array');\nfor (const [index, slice] of plan.candidate_slices.entries()) {\n const label = '.agent/plan.json candidate_slices[' + index + ']';\n assert(isObject(slice), label + ' must be an object');\n requireKeys(slice, requiredSlice, label);\n hasOnlyKeys(slice, requiredSlice, label);\n assert(isString(slice.slice_id) && slice.slice_id.length > 0, label + ': slice_id must be a non-empty string');\n assert(isString(slice.goal) && slice.goal.length > 0, label + ': goal must be a non-empty string');\n assert(Array.isArray(slice.acceptance_criteria) && slice.acceptance_criteria.length > 0 && slice.acceptance_criteria.every((item) => typeof item === 'string' && item.length > 0), label + ': acceptance_criteria must be a non-empty array of strings');\n assert(isStringArray(slice.contract_ids), label + ': contract_ids must be an array of strings');\n assert(typeof slice.priority === 'number' && Number.isFinite(slice.priority), label + ': priority must be a finite number');\n assert(sliceStatuses.includes(slice.status), label + ': invalid status');\n assert(isString(slice.why_now) && slice.why_now.length > 0, label + ': why_now must be a non-empty string');\n assert(isStringArray(slice.blocked_on), label + ': blocked_on must be an array of strings');\n assert(isStringArray(slice.evidence), label + ': evidence must be an array of strings');\n}\n\nconst requiredActiveBase = ['schema_version', 'mission_anchor', 'status', 'slice_id', 'goal', 'contract_ids', 'acceptance_criteria', 'blocked_on', 'locked_notes', 'must_fix_findings', 'basis_commit', 'remaining_contract_ids_before', 'release_blocker_count_before', 'high_value_gap_count_before'];\nconst allowedActive = [...requiredActiveBase, 'priority', 'why_now'];\nconst activeStatuses = ['idle', 'selected', 'in_progress', 'committed', 'done'];\nrequireKeys(active, requiredActiveBase, '.agent/active-slice.json');\nhasOnlyKeys(active, allowedActive, '.agent/active-slice.json');\nassert(activeStatuses.includes(active.status), '.agent/active-slice.json: invalid status');\nassert(isStringArray(active.contract_ids), '.agent/active-slice.json: contract_ids must be an array of strings');\nassert(Array.isArray(active.acceptance_criteria), '.agent/active-slice.json: acceptance_criteria must be an array');\nassert(isStringArray(active.blocked_on), '.agent/active-slice.json: blocked_on must be an array of strings');\nassert(isStringArray(active.locked_notes), '.agent/active-slice.json: locked_notes must be an array of strings');\nassert(isStringArray(active.must_fix_findings), '.agent/active-slice.json: must_fix_findings must be an array of strings');\nassert(isStringArray(active.remaining_contract_ids_before), '.agent/active-slice.json: remaining_contract_ids_before must be an array of strings');\n\nconst requiresExactHandoff = ['selected', 'in_progress', 'committed', 'done'].includes(active.status);\nif (requiresExactHandoff) {\n assert(Array.isArray(active.acceptance_criteria) && active.acceptance_criteria.length > 0 && active.acceptance_criteria.every((item) => typeof item === 'string' && item.length > 0), '.agent/active-slice.json: acceptance_criteria must be a non-empty array of strings when status carries an exact handoff');\n assert(typeof active.priority === 'number' && Number.isFinite(active.priority), '.agent/active-slice.json: priority must be a finite number when status carries an exact handoff');\n assert(isString(active.why_now) && active.why_now.length > 0, '.agent/active-slice.json: why_now must be a non-empty string when status carries an exact handoff');\n assert(isString(active.basis_commit) && active.basis_commit.length > 0, '.agent/active-slice.json: basis_commit must be a non-empty string when status carries an exact handoff');\n assert(typeof active.release_blocker_count_before === 'number' && Number.isFinite(active.release_blocker_count_before), '.agent/active-slice.json: release_blocker_count_before must be a finite number when status carries an exact handoff');\n assert(typeof active.high_value_gap_count_before === 'number' && Number.isFinite(active.high_value_gap_count_before), '.agent/active-slice.json: high_value_gap_count_before must be a finite number when status carries an exact handoff');\n} else {\n assert(active.priority === null || active.priority === undefined || (typeof active.priority === 'number' && Number.isFinite(active.priority)), '.agent/active-slice.json: idle priority must be null/undefined or a finite number');\n assert(active.why_now === null || active.why_now === undefined || typeof active.why_now === 'string', '.agent/active-slice.json: idle why_now must be null/undefined or a string');\n}\nNODE\n`;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async function ensureGitignore(root: string): Promise<boolean> {
|
|
986
|
+
const gitignorePath = path.join(root, ".gitignore");
|
|
987
|
+
const blockLines = [
|
|
988
|
+
"# completion protocol",
|
|
989
|
+
".agent/*",
|
|
990
|
+
"!.agent/README.md",
|
|
991
|
+
"!.agent/mission.md",
|
|
992
|
+
"!.agent/profile.json",
|
|
993
|
+
"!.agent/verify_completion_stop.sh",
|
|
994
|
+
"!.agent/verify_completion_control_plane.sh",
|
|
995
|
+
".agent/tmp/",
|
|
996
|
+
];
|
|
997
|
+
const block = blockLines.join("\n");
|
|
998
|
+
const existing = (await pathExists(gitignorePath)) ? await fsp.readFile(gitignorePath, "utf8") : "";
|
|
999
|
+
const filteredLines = existing
|
|
1000
|
+
.split(/\r?\n/)
|
|
1001
|
+
.filter((line) => !blockLines.includes(line.trim()));
|
|
1002
|
+
while (filteredLines.length > 0 && filteredLines[filteredLines.length - 1]?.trim() === "") {
|
|
1003
|
+
filteredLines.pop();
|
|
1004
|
+
}
|
|
1005
|
+
const base = filteredLines.join("\n").trimEnd();
|
|
1006
|
+
const content = base.length > 0 ? `${base}\n\n${block}\n` : `${block}\n`;
|
|
1007
|
+
if (content === existing) return false;
|
|
1008
|
+
await fsp.writeFile(gitignorePath, content, "utf8");
|
|
1009
|
+
return true;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
type ScaffoldResult = {
|
|
1013
|
+
root: string;
|
|
1014
|
+
created: string[];
|
|
1015
|
+
updated: string[];
|
|
1016
|
+
missionAnchor: string;
|
|
1017
|
+
};
|
|
1018
|
+
|
|
1019
|
+
async function scaffoldCompletionFiles(root: string, missionAnchor: string): Promise<ScaffoldResult> {
|
|
1020
|
+
const files = resolveFiles(root);
|
|
1021
|
+
const created: string[] = [];
|
|
1022
|
+
const updated: string[] = [];
|
|
1023
|
+
await fsp.mkdir(files.agentDir, { recursive: true });
|
|
1024
|
+
await fsp.mkdir(path.join(files.agentDir, "tmp"), { recursive: true });
|
|
1025
|
+
const projectName = path.basename(root);
|
|
1026
|
+
const docsSurfaces = await detectDocsSurfaces(root);
|
|
1027
|
+
const verifierCommand = await detectVerifierCommand(root);
|
|
1028
|
+
const trackedFiles: Array<{ path: string; content: string; executable?: boolean }> = [
|
|
1029
|
+
{ path: path.join(files.agentDir, "README.md"), content: buildAgentReadme(projectName) },
|
|
1030
|
+
{ path: path.join(files.agentDir, "mission.md"), content: buildMission(projectName, missionAnchor) },
|
|
1031
|
+
{
|
|
1032
|
+
path: files.profilePath,
|
|
1033
|
+
content: `${JSON.stringify({ schema_version: 1, protocol_id: PROTOCOL_ID, project_name: projectName, required_stop_judges: 3, priority_policy_id: "completion-default", docs_surfaces: docsSurfaces }, null, 2)}\n`,
|
|
1034
|
+
},
|
|
1035
|
+
{ path: path.join(files.agentDir, "verify_completion_stop.sh"), content: buildVerifyStopScript(verifierCommand), executable: true },
|
|
1036
|
+
{ path: path.join(files.agentDir, "verify_completion_control_plane.sh"), content: buildVerifyControlPlaneScript(), executable: true },
|
|
1037
|
+
{ path: files.statePath, content: `${JSON.stringify(defaultState(missionAnchor), null, 2)}\n` },
|
|
1038
|
+
{ path: files.planPath, content: `${JSON.stringify(defaultPlan(missionAnchor), null, 2)}\n` },
|
|
1039
|
+
{ path: files.activePath, content: `${JSON.stringify(defaultActiveSlice(missionAnchor), null, 2)}\n` },
|
|
1040
|
+
{ path: files.sliceHistoryPath, content: "" },
|
|
1041
|
+
{ path: files.stopHistoryPath, content: "" },
|
|
1042
|
+
];
|
|
1043
|
+
for (const file of trackedFiles) {
|
|
1044
|
+
if (await pathExists(file.path)) continue;
|
|
1045
|
+
await fsp.writeFile(file.path, file.content, "utf8");
|
|
1046
|
+
if (file.executable) await fsp.chmod(file.path, 0o755);
|
|
1047
|
+
created.push(path.relative(root, file.path));
|
|
1048
|
+
}
|
|
1049
|
+
if (await ensureGitignore(root)) updated.push(".gitignore");
|
|
1050
|
+
return { root, created, updated, missionAnchor };
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function remainingSliceCount(plan: JsonRecord | undefined): number {
|
|
1054
|
+
return candidateSlices(plan).filter((slice) => {
|
|
1055
|
+
const status = asString(slice.status);
|
|
1056
|
+
return status !== "done" && status !== "cancelled";
|
|
1057
|
+
}).length;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function historyCounts(sliceHistory: JsonRecord[], stopHistory: JsonRecord[]) {
|
|
1061
|
+
return {
|
|
1062
|
+
reviewed: sliceHistory.filter((item) => asString(item.type) === "reviewed").length,
|
|
1063
|
+
audited: sliceHistory.filter((item) => asString(item.type) === "audited").length,
|
|
1064
|
+
accepted: sliceHistory.filter((item) => asString(item.type) === "accepted").length,
|
|
1065
|
+
reopened: sliceHistory.filter((item) => asString(item.type) === "reopened").length,
|
|
1066
|
+
judgments: stopHistory.filter((item) => asString(item.type) === "judgment").length,
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function activeSliceMatchesPlan(snapshot: CompletionStateSnapshot): "yes" | "no" | "unknown" {
|
|
1071
|
+
const activeId = asString(snapshot.active?.slice_id);
|
|
1072
|
+
if (!activeId) return "unknown";
|
|
1073
|
+
return snapshot.activeSlice ? "yes" : "no";
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function handoffSnapshotState(active: JsonRecord | undefined): "present" | "missing_or_unclear" {
|
|
1077
|
+
const required = [
|
|
1078
|
+
active?.acceptance_criteria,
|
|
1079
|
+
active?.priority,
|
|
1080
|
+
active?.why_now,
|
|
1081
|
+
active?.blocked_on,
|
|
1082
|
+
active?.locked_notes,
|
|
1083
|
+
active?.must_fix_findings,
|
|
1084
|
+
active?.basis_commit,
|
|
1085
|
+
active?.remaining_contract_ids_before,
|
|
1086
|
+
active?.release_blocker_count_before,
|
|
1087
|
+
active?.high_value_gap_count_before,
|
|
1088
|
+
];
|
|
1089
|
+
return required.every((value) => value !== undefined && value !== null) ? "present" : "missing_or_unclear";
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function buildSystemReminder(snapshot: CompletionStateSnapshot, sliceHistory: JsonRecord[], stopHistory: JsonRecord[]): string {
|
|
1093
|
+
const history = historyCounts(sliceHistory, stopHistory);
|
|
1094
|
+
return [
|
|
1095
|
+
"Completion workflow detected.",
|
|
1096
|
+
"Canonical truth lives in .agent/state.json, .agent/plan.json, .agent/active-slice.json, .agent/slice-history.jsonl, and .agent/stop-check-history.jsonl.",
|
|
1097
|
+
`Mission anchor: ${asString(snapshot.state?.mission_anchor) ?? "(unknown)"}`,
|
|
1098
|
+
`Current phase: ${asString(snapshot.state?.current_phase) ?? "unknown"}`,
|
|
1099
|
+
`Continuation policy: ${asString(snapshot.state?.continuation_policy) ?? "unknown"}`,
|
|
1100
|
+
`Continuation reason: ${asString(snapshot.state?.continuation_reason) ?? "(unknown)"}`,
|
|
1101
|
+
`Next mandatory role: ${asString(snapshot.state?.next_mandatory_role) ?? "unknown"}`,
|
|
1102
|
+
`Next mandatory action: ${asString(snapshot.state?.next_mandatory_action) ?? "unknown"}`,
|
|
1103
|
+
`Remaining slice count: ${remainingSliceCount(snapshot.plan)}`,
|
|
1104
|
+
`Remaining stop judges: ${asNumber(snapshot.state?.remaining_stop_judges) ?? "(unknown)"}`,
|
|
1105
|
+
`History counts: reviewed=${history.reviewed}, audited=${history.audited}, accepted=${history.accepted}, reopened=${history.reopened}, judgments=${history.judgments}.`,
|
|
1106
|
+
"Re-read canonical .agent state after compaction or recovery instead of relying on conversation memory.",
|
|
1107
|
+
"If continuation_policy == continue, do not stop after a slice or ask whether to continue; dispatch the next mandatory role directly.",
|
|
1108
|
+
"Only stop for the user when continuation_policy is await_user_input, blocked, paused, or done.",
|
|
1109
|
+
"If canonical state is stale, invalid, ambiguous, or missing, route to completion-regrounder.",
|
|
1110
|
+
"When recovering from compaction, prefer a deterministic restart from canonical files over conversational inference.",
|
|
1111
|
+
].join(" ");
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function buildPostCompactionDriverInstructions(snapshot: CompletionStateSnapshot, marker: JsonRecord | undefined): string {
|
|
1115
|
+
const markerAt = typeof marker?.recorded_at === "number" ? new Date(marker.recorded_at).toISOString() : "(unknown time)";
|
|
1116
|
+
const nextRole = asString(snapshot.state?.next_mandatory_role) ?? "unknown";
|
|
1117
|
+
const nextAction = asString(snapshot.state?.next_mandatory_action) ?? "unknown";
|
|
1118
|
+
const continuation = asString(snapshot.state?.continuation_policy) ?? "unknown";
|
|
1119
|
+
const activeSliceId = asString(snapshot.active?.slice_id) ?? asString(snapshot.activeSlice?.slice_id) ?? "(none)";
|
|
1120
|
+
return [
|
|
1121
|
+
"POST-COMPACTION RECOVERY MODE is active.",
|
|
1122
|
+
`Compaction marker time: ${markerAt}`,
|
|
1123
|
+
"Treat the previous conversation as lossy continuity support only.",
|
|
1124
|
+
"Before taking any substantive action, re-read .agent/state.json, .agent/plan.json, .agent/active-slice.json, .agent/slice-history.jsonl, and .agent/stop-check-history.jsonl from disk.",
|
|
1125
|
+
`Canonical next mandatory role is currently: ${nextRole}`,
|
|
1126
|
+
`Canonical next mandatory action is currently: ${nextAction}`,
|
|
1127
|
+
`Canonical continuation policy is currently: ${continuation}`,
|
|
1128
|
+
`Canonical active slice is currently: ${activeSliceId}`,
|
|
1129
|
+
"Do not trust pre-compaction memory over canonical files.",
|
|
1130
|
+
"If the canonical state is ambiguous, inconsistent, missing, or stale after re-reading it, your first mandatory action is to dispatch completion-regrounder rather than guessing.",
|
|
1131
|
+
"If continuation_policy == continue and canonical state is coherent, continue dispatching the mandatory role directly without asking the user whether to continue.",
|
|
1132
|
+
"If you are about to implement after compaction, confirm the active slice snapshot still matches .agent/plan.json before doing any work.",
|
|
1133
|
+
].join(" ");
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function isStaleContextError(error: unknown): boolean {
|
|
1137
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1138
|
+
return message.includes("This extension ctx is stale after session replacement or reload");
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function safeUiCall(action: () => void) {
|
|
1142
|
+
try {
|
|
1143
|
+
action();
|
|
1144
|
+
} catch (error) {
|
|
1145
|
+
if (isStaleContextError(error)) return;
|
|
1146
|
+
throw error;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function getCtxCwd(ctx: { cwd: string }): string {
|
|
1151
|
+
try {
|
|
1152
|
+
return ctx.cwd;
|
|
1153
|
+
} catch (error) {
|
|
1154
|
+
if (isStaleContextError(error)) return process.cwd();
|
|
1155
|
+
throw error;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function getCtxHasUI(ctx: { hasUI: boolean }): boolean {
|
|
1160
|
+
try {
|
|
1161
|
+
return ctx.hasUI;
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
if (isStaleContextError(error)) return false;
|
|
1164
|
+
throw error;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function getCtxUi<T extends { ui: any }>(ctx: T): any | undefined {
|
|
1169
|
+
try {
|
|
1170
|
+
return ctx.ui;
|
|
1171
|
+
} catch (error) {
|
|
1172
|
+
if (isStaleContextError(error)) return undefined;
|
|
1173
|
+
throw error;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function getSystemPromptSafe(ctx: { getSystemPrompt: () => string }): string | undefined {
|
|
1178
|
+
try {
|
|
1179
|
+
return ctx.getSystemPrompt();
|
|
1180
|
+
} catch (error) {
|
|
1181
|
+
if (isStaleContextError(error)) return undefined;
|
|
1182
|
+
throw error;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function emitCommandText(ctx: { hasUI: boolean; ui: any }, text: string, level: "info" | "success" | "warning" | "error" = "info") {
|
|
1187
|
+
if (getCtxHasUI(ctx)) {
|
|
1188
|
+
const ui = getCtxUi(ctx);
|
|
1189
|
+
if (ui) safeUiCall(() => ui.notify(text, level));
|
|
1190
|
+
else console.log(text);
|
|
1191
|
+
} else {
|
|
1192
|
+
console.log(text);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function buildResumeCapsule(snapshot: CompletionStateSnapshot, sliceHistory: JsonRecord[], stopHistory: JsonRecord[]): string {
|
|
1197
|
+
const history = historyCounts(sliceHistory, stopHistory);
|
|
1198
|
+
const acceptance = asStringArray(snapshot.active?.acceptance_criteria).length > 0
|
|
1199
|
+
? asStringArray(snapshot.active?.acceptance_criteria)
|
|
1200
|
+
: asStringArray(snapshot.activeSlice?.acceptance_criteria);
|
|
1201
|
+
const contractIds = asStringArray(snapshot.active?.contract_ids).length > 0
|
|
1202
|
+
? asStringArray(snapshot.active?.contract_ids)
|
|
1203
|
+
: asStringArray(snapshot.activeSlice?.contract_ids);
|
|
1204
|
+
const blockedOn = asStringArray(snapshot.active?.blocked_on).length > 0
|
|
1205
|
+
? asStringArray(snapshot.active?.blocked_on)
|
|
1206
|
+
: asStringArray(snapshot.activeSlice?.blocked_on);
|
|
1207
|
+
const lockedNotes = asStringArray(snapshot.active?.locked_notes);
|
|
1208
|
+
const mustFixFindings = asStringArray(snapshot.active?.must_fix_findings);
|
|
1209
|
+
const remainingBefore = asStringArray(snapshot.active?.remaining_contract_ids_before);
|
|
1210
|
+
const lines = [
|
|
1211
|
+
"Authoritative completion resume capsule:",
|
|
1212
|
+
"",
|
|
1213
|
+
"<completion-state>",
|
|
1214
|
+
`mission_anchor: ${asString(snapshot.state?.mission_anchor) ?? "(unknown)"}`,
|
|
1215
|
+
`current_phase: ${asString(snapshot.state?.current_phase) ?? "unknown"}`,
|
|
1216
|
+
`continuation_policy: ${asString(snapshot.state?.continuation_policy) ?? "unknown"}`,
|
|
1217
|
+
`continuation_reason: ${asString(snapshot.state?.continuation_reason) ?? "(unknown)"}`,
|
|
1218
|
+
`requires_reground: ${asBoolean(snapshot.state?.requires_reground) ?? "unknown"}`,
|
|
1219
|
+
`next_mandatory_role: ${asString(snapshot.state?.next_mandatory_role) ?? "unknown"}`,
|
|
1220
|
+
`next_mandatory_action: ${asString(snapshot.state?.next_mandatory_action) ?? "unknown"}`,
|
|
1221
|
+
`remaining_slice_count: ${remainingSliceCount(snapshot.plan)}`,
|
|
1222
|
+
`remaining_stop_judges: ${asNumber(snapshot.state?.remaining_stop_judges) ?? "(unknown)"}`,
|
|
1223
|
+
`active_slice_matches_plan: ${activeSliceMatchesPlan(snapshot)}`,
|
|
1224
|
+
`implementer_handoff_snapshot: ${handoffSnapshotState(snapshot.active)}`,
|
|
1225
|
+
`history_counts: reviewed=${history.reviewed}, audited=${history.audited}, accepted=${history.accepted}, reopened=${history.reopened}, judgments=${history.judgments}`,
|
|
1226
|
+
"",
|
|
1227
|
+
"active_slice:",
|
|
1228
|
+
`- slice_id: ${asString(snapshot.active?.slice_id) ?? asString(snapshot.activeSlice?.slice_id) ?? "(none)"}`,
|
|
1229
|
+
`- status: ${asString(snapshot.active?.status) ?? asString(snapshot.activeSlice?.status) ?? "unknown"}`,
|
|
1230
|
+
`- goal: ${asString(snapshot.active?.goal) ?? asString(snapshot.activeSlice?.goal) ?? "(unknown)"}`,
|
|
1231
|
+
`- contract_ids: ${contractIds.length > 0 ? contractIds.join(", ") : "(none)"}`,
|
|
1232
|
+
];
|
|
1233
|
+
if (blockedOn.length > 0) lines.push(`- blocked_on: ${blockedOn.join(", ")}`);
|
|
1234
|
+
if (lockedNotes.length > 0) lines.push(`- locked_notes: ${lockedNotes.join(" | ")}`);
|
|
1235
|
+
if (mustFixFindings.length > 0) lines.push(`- must_fix_findings: ${mustFixFindings.join(" | ")}`);
|
|
1236
|
+
lines.push(`- basis_commit: ${asString(snapshot.active?.basis_commit) ?? "(none)"}`);
|
|
1237
|
+
lines.push(`- remaining_contract_ids_before: ${remainingBefore.length > 0 ? remainingBefore.join(", ") : "(none)"}`);
|
|
1238
|
+
lines.push(`- release_blocker_count_before: ${asNumber(snapshot.active?.release_blocker_count_before) ?? "(unknown)"}`);
|
|
1239
|
+
lines.push(`- high_value_gap_count_before: ${asNumber(snapshot.active?.high_value_gap_count_before) ?? "(unknown)"}`);
|
|
1240
|
+
lines.push("", "acceptance_criteria:");
|
|
1241
|
+
if (acceptance.length === 0) lines.push("- (none)");
|
|
1242
|
+
else lines.push(...acceptance.map((item) => `- ${item}`));
|
|
1243
|
+
lines.push(
|
|
1244
|
+
"",
|
|
1245
|
+
"Rules:",
|
|
1246
|
+
"- Treat this block as continuity support derived from canonical .agent state.",
|
|
1247
|
+
"- Preserve exact slice_id, contract_ids, acceptance criteria, locked notes, and must-fix findings where still true.",
|
|
1248
|
+
"- After compaction, re-read .agent/state.json, .agent/plan.json, .agent/active-slice.json, .agent/slice-history.jsonl, and .agent/stop-check-history.jsonl before resuming long-running completion work.",
|
|
1249
|
+
"- Invoke completion-regrounder before continuing when requires_reground is true or unknown.",
|
|
1250
|
+
"- Invoke completion-regrounder before continuing when next_mandatory_role or next_mandatory_action is unknown or ambiguous.",
|
|
1251
|
+
"- Invoke completion-regrounder before continuing when active_slice_matches_plan is no or implementer_handoff_snapshot is missing_or_unclear.",
|
|
1252
|
+
"- If continuation_policy is continue, do not stop after a slice or ask whether to continue. Dispatch the next mandatory role directly.",
|
|
1253
|
+
"- Only stop for the user when continuation_policy is await_user_input, blocked, paused, or done.",
|
|
1254
|
+
"- If you are completion-implementer after compaction, resume from the canonical active-slice handoff instead of asking the user to resend the original caller payload.",
|
|
1255
|
+
"- Do not replace canonical .agent state with summary inference.",
|
|
1256
|
+
"</completion-state>",
|
|
1257
|
+
);
|
|
1258
|
+
return lines.join("\n");
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function formatCount(count: number, singular: string, plural = `${singular}s`): string {
|
|
1262
|
+
return `${count} ${count === 1 ? singular : plural}`;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function completionRemainingSummary(surface: {
|
|
1266
|
+
remainingContractCount: number;
|
|
1267
|
+
releaseBlockerCount: number;
|
|
1268
|
+
highValueGapCount: number;
|
|
1269
|
+
remainingStopJudgeCount: number;
|
|
1270
|
+
}): string {
|
|
1271
|
+
return [
|
|
1272
|
+
formatCount(surface.remainingContractCount, "contract"),
|
|
1273
|
+
formatCount(surface.releaseBlockerCount, "blocker"),
|
|
1274
|
+
formatCount(surface.highValueGapCount, "gap"),
|
|
1275
|
+
formatCount(surface.remainingStopJudgeCount, "stop judge", "stop judges"),
|
|
1276
|
+
].join(" · ");
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function envNumber(name: string): number | undefined {
|
|
1280
|
+
const raw = asString(process.env[name]);
|
|
1281
|
+
if (!raw) return undefined;
|
|
1282
|
+
const parsed = Number(raw);
|
|
1283
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function nowMs(): number {
|
|
1287
|
+
return envNumber("PI_COMPLETION_TEST_NOW") ?? Date.now();
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
type LiveActivitySignal = {
|
|
1291
|
+
state: "active" | "waiting" | "stalled";
|
|
1292
|
+
idleMs: number;
|
|
1293
|
+
};
|
|
1294
|
+
|
|
1295
|
+
function liveActivitySignal(activity: { status?: string; startedAt?: number; updatedAt?: number } | undefined): LiveActivitySignal | undefined {
|
|
1296
|
+
if (!activity || activity.status !== "running") return undefined;
|
|
1297
|
+
const anchor = activity.updatedAt ?? activity.startedAt;
|
|
1298
|
+
if (anchor === undefined) return undefined;
|
|
1299
|
+
const idleMs = Math.max(0, nowMs() - anchor);
|
|
1300
|
+
return {
|
|
1301
|
+
state: idleMs >= LIVE_ROLE_STALLED_MS ? "stalled" : idleMs >= LIVE_ROLE_WAITING_MS ? "waiting" : "active",
|
|
1302
|
+
idleMs,
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function formatLiveActivitySignal(signal: LiveActivitySignal | undefined): string | undefined {
|
|
1307
|
+
if (!signal) return undefined;
|
|
1308
|
+
if (signal.state === "active") return "activity: active";
|
|
1309
|
+
return `activity: ${signal.state} (${formatElapsed(signal.idleMs)} since update)`;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
function livePreviewForStatus(activity: LiveRoleActivity | undefined): string | undefined {
|
|
1313
|
+
if (!activity || activity.status !== "running") return undefined;
|
|
1314
|
+
return truncateInline(
|
|
1315
|
+
activity.progress ?? activity.verifying ?? activity.toolActivity ?? activity.assistantSummary ?? activity.currentAction ?? activity.lastAssistantText ?? "",
|
|
1316
|
+
120,
|
|
1317
|
+
) || undefined;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function completionRootKey(snapshot: CompletionStateSnapshot | undefined, cwd: string): string {
|
|
1321
|
+
return snapshot?.files.root ?? findCompletionRoot(cwd) ?? findRepoRoot(cwd) ?? path.resolve(cwd);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function cloneLiveRoleActivity(activity: LiveRoleActivity, overrides: Partial<LiveRoleActivity> = {}): LiveRoleActivity {
|
|
1325
|
+
return {
|
|
1326
|
+
...activity,
|
|
1327
|
+
...overrides,
|
|
1328
|
+
toolRecentActivity: [...(overrides.toolRecentActivity ?? activity.toolRecentActivity)],
|
|
1329
|
+
recentActivity: [...(overrides.recentActivity ?? activity.recentActivity)],
|
|
1330
|
+
stateDeltas: [...(overrides.stateDeltas ?? activity.stateDeltas)],
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function createLiveRoleActivity(role: string, startedAt = nowMs()): LiveRoleActivity {
|
|
1335
|
+
const currentAction = "Starting role subprocess";
|
|
1336
|
+
return {
|
|
1337
|
+
role,
|
|
1338
|
+
status: "running",
|
|
1339
|
+
currentAction,
|
|
1340
|
+
toolActivity: currentAction,
|
|
1341
|
+
toolRecentActivity: [currentAction],
|
|
1342
|
+
recentActivity: [currentAction],
|
|
1343
|
+
stateDeltas: [],
|
|
1344
|
+
startedAt,
|
|
1345
|
+
updatedAt: startedAt,
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
type RoleMessage = {
|
|
1350
|
+
role: string;
|
|
1351
|
+
content: Array<{ type: string; text?: string }>;
|
|
1352
|
+
};
|
|
1353
|
+
|
|
1354
|
+
function activityTimestampMs(event: JsonRecord | undefined): number | undefined {
|
|
1355
|
+
return asNumber(event?.updatedAt) ?? asNumber(event?.timestampMs) ?? asNumber(event?.timestamp) ?? asNumber(event?.at);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function asRoleMessage(value: unknown): RoleMessage | undefined {
|
|
1359
|
+
if (!isRecord(value)) return undefined;
|
|
1360
|
+
const role = asString(value.role);
|
|
1361
|
+
const content = Array.isArray(value.content)
|
|
1362
|
+
? value.content.flatMap((item) => {
|
|
1363
|
+
if (!isRecord(item)) return [];
|
|
1364
|
+
const type = asString(item.type);
|
|
1365
|
+
if (!type) return [];
|
|
1366
|
+
return [{ type, text: asString(item.text) }];
|
|
1367
|
+
})
|
|
1368
|
+
: [];
|
|
1369
|
+
if (!role) return undefined;
|
|
1370
|
+
return { role, content };
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function applyAssistantTextToLiveRoleActivity(activity: LiveRoleActivity, text: string, activityAt = nowMs()): boolean {
|
|
1374
|
+
if (!text) return false;
|
|
1375
|
+
activity.lastAssistantText = text;
|
|
1376
|
+
const parsed = parseStructuredProgress(text);
|
|
1377
|
+
if (parsed.progress) activity.progress = parsed.progress;
|
|
1378
|
+
if (parsed.rationale) activity.rationale = parsed.rationale;
|
|
1379
|
+
if (parsed.nextStep) activity.nextStep = parsed.nextStep;
|
|
1380
|
+
if (parsed.verifying) activity.verifying = parsed.verifying;
|
|
1381
|
+
if (parsed.stateDeltas.length > 0) activity.stateDeltas = parsed.stateDeltas;
|
|
1382
|
+
const preview = truncateInline(text, 140);
|
|
1383
|
+
activity.assistantSummary = activity.progress ?? activity.verifying ?? preview;
|
|
1384
|
+
activity.currentAction = activity.assistantSummary;
|
|
1385
|
+
if (activity.assistantSummary) activity.recentActivity = pushRecentActivity(activity.recentActivity, `assistant: ${activity.assistantSummary}`);
|
|
1386
|
+
activity.updatedAt = activityAt;
|
|
1387
|
+
return true;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function applyLiveRoleEvent(activity: LiveRoleActivity, event: JsonRecord, messages: RoleMessage[]): boolean {
|
|
1391
|
+
const eventType = asString(event.type);
|
|
1392
|
+
if (!eventType) return false;
|
|
1393
|
+
const activityAt = activityTimestampMs(event) ?? nowMs();
|
|
1394
|
+
if (eventType === "tool_execution_start") {
|
|
1395
|
+
const toolName = asString(event.toolName) ?? "tool";
|
|
1396
|
+
const toolArgs = isRecord(event.args) ? event.args : isRecord(event.input) ? event.input : {};
|
|
1397
|
+
activity.toolActivity = formatToolActivity(toolName, toolArgs);
|
|
1398
|
+
activity.currentAction = activity.toolActivity;
|
|
1399
|
+
activity.toolRecentActivity = pushRecentActivity(activity.toolRecentActivity, activity.toolActivity, 6);
|
|
1400
|
+
activity.recentActivity = pushRecentActivity(activity.recentActivity, activity.toolActivity);
|
|
1401
|
+
activity.updatedAt = activityAt;
|
|
1402
|
+
return true;
|
|
1403
|
+
}
|
|
1404
|
+
if (eventType === "tool_execution_end" || eventType === "tool_result_end") {
|
|
1405
|
+
activity.updatedAt = activityAt;
|
|
1406
|
+
return true;
|
|
1407
|
+
}
|
|
1408
|
+
if ((eventType === "message_update" || eventType === "message_end") && isRecord(event.message)) {
|
|
1409
|
+
const message = asRoleMessage(event.message);
|
|
1410
|
+
if (message && eventType === "message_end") messages.push(message);
|
|
1411
|
+
const nextOutput = message ? lastAssistantText(eventType === "message_end" ? messages : [message]) : "";
|
|
1412
|
+
if (nextOutput) return applyAssistantTextToLiveRoleActivity(activity, nextOutput, activityAt);
|
|
1413
|
+
activity.updatedAt = activityAt;
|
|
1414
|
+
return true;
|
|
1415
|
+
}
|
|
1416
|
+
return false;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function maybeInjectTestLiveRoleActivity(rootKey: string): void {
|
|
1420
|
+
const raw = asString(process.env.PI_COMPLETION_TEST_LIVE_ROLE_ACTIVITY_JSON);
|
|
1421
|
+
if (!raw) return;
|
|
1422
|
+
try {
|
|
1423
|
+
const parsed = JSON.parse(raw);
|
|
1424
|
+
if (!isRecord(parsed)) return;
|
|
1425
|
+
const currentAction = asString(parsed.currentAction);
|
|
1426
|
+
const recentActivity = asStringArray(parsed.recentActivity).length > 0 ? asStringArray(parsed.recentActivity) : currentAction ? [currentAction] : [];
|
|
1427
|
+
const toolActivity =
|
|
1428
|
+
asString(parsed.toolActivity) ??
|
|
1429
|
+
(currentAction && !currentAction.startsWith("assistant:") && !currentAction.startsWith("progress:") ? currentAction : undefined);
|
|
1430
|
+
const assistantSummary =
|
|
1431
|
+
asString(parsed.assistantSummary) ??
|
|
1432
|
+
(currentAction?.startsWith("assistant:") ? currentAction.slice("assistant:".length).trim() : undefined);
|
|
1433
|
+
liveRoleActivityByRoot.set(rootKey, {
|
|
1434
|
+
role: asString(parsed.role) ?? "completion-implementer",
|
|
1435
|
+
status: asString(parsed.status) === "ok" ? "ok" : asString(parsed.status) === "error" ? "error" : "running",
|
|
1436
|
+
currentAction,
|
|
1437
|
+
toolActivity,
|
|
1438
|
+
toolRecentActivity: asStringArray(parsed.toolRecentActivity).length > 0 ? asStringArray(parsed.toolRecentActivity) : toolActivity ? [toolActivity] : [],
|
|
1439
|
+
recentActivity,
|
|
1440
|
+
assistantSummary,
|
|
1441
|
+
lastAssistantText: asString(parsed.lastAssistantText),
|
|
1442
|
+
progress: asString(parsed.progress),
|
|
1443
|
+
rationale: asString(parsed.rationale),
|
|
1444
|
+
nextStep: asString(parsed.nextStep),
|
|
1445
|
+
verifying: asString(parsed.verifying),
|
|
1446
|
+
stateDeltas: asStringArray(parsed.stateDeltas),
|
|
1447
|
+
startedAt: asNumber(parsed.startedAt) ?? nowMs(),
|
|
1448
|
+
updatedAt: asNumber(parsed.updatedAt) ?? nowMs(),
|
|
1449
|
+
});
|
|
1450
|
+
} catch {
|
|
1451
|
+
// ignore malformed test override
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function maybeReplayTestLiveRoleEvents(rootKey: string): void {
|
|
1456
|
+
const raw = asString(process.env.PI_COMPLETION_TEST_ROLE_EVENT_STREAM_JSON);
|
|
1457
|
+
if (!raw) return;
|
|
1458
|
+
try {
|
|
1459
|
+
const parsed = JSON.parse(raw);
|
|
1460
|
+
let role = "completion-implementer";
|
|
1461
|
+
let status: LiveRoleActivity["status"] = "running";
|
|
1462
|
+
let startedAt = nowMs();
|
|
1463
|
+
let events: JsonRecord[] = [];
|
|
1464
|
+
if (Array.isArray(parsed)) {
|
|
1465
|
+
events = parsed.filter(isRecord);
|
|
1466
|
+
} else if (isRecord(parsed)) {
|
|
1467
|
+
role = asString(parsed.role) ?? role;
|
|
1468
|
+
status = asString(parsed.status) === "ok" ? "ok" : asString(parsed.status) === "error" ? "error" : "running";
|
|
1469
|
+
startedAt = asNumber(parsed.startedAt) ?? asNumber(parsed.started_at) ?? startedAt;
|
|
1470
|
+
events = Array.isArray(parsed.events) ? parsed.events.filter(isRecord) : [];
|
|
1471
|
+
} else {
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
const activity = createLiveRoleActivity(role, startedAt);
|
|
1475
|
+
const messages: RoleMessage[] = [];
|
|
1476
|
+
for (const event of events) applyLiveRoleEvent(activity, event, messages);
|
|
1477
|
+
liveRoleActivityByRoot.set(rootKey, cloneLiveRoleActivity(activity, { status }));
|
|
1478
|
+
} catch {
|
|
1479
|
+
// ignore malformed event stream override
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function buildCompletionStatusSurface(
|
|
1484
|
+
snapshot: CompletionStateSnapshot | undefined,
|
|
1485
|
+
liveActivity: LiveRoleActivity | undefined,
|
|
1486
|
+
): CompletionStatusSurface {
|
|
1487
|
+
if (!snapshot) return { snapshotPresent: false, widgetLines: [] };
|
|
1488
|
+
const currentPhase = asString(snapshot.state?.current_phase) ?? "unknown";
|
|
1489
|
+
const sliceId = asString(snapshot.active?.slice_id) ?? asString(snapshot.activeSlice?.slice_id) ?? "(none)";
|
|
1490
|
+
const sliceGoal = truncateInline(asString(snapshot.active?.goal) ?? asString(snapshot.activeSlice?.goal) ?? "(unknown)", 140);
|
|
1491
|
+
const nextMandatoryRole = asString(snapshot.state?.next_mandatory_role) ?? "unknown";
|
|
1492
|
+
const remainingContractCount = asStringArray(snapshot.state?.unsatisfied_contract_ids).length;
|
|
1493
|
+
const releaseBlockerCount = asNumber(snapshot.state?.remaining_release_blockers) ?? 0;
|
|
1494
|
+
const highValueGapCount = asNumber(snapshot.state?.remaining_high_value_gaps) ?? 0;
|
|
1495
|
+
const remainingStopJudgeCount = asNumber(snapshot.state?.remaining_stop_judges) ?? 0;
|
|
1496
|
+
const activeRole = liveActivity?.status === "running" ? liveActivity.role : undefined;
|
|
1497
|
+
const liveSignal = liveActivitySignal(liveActivity);
|
|
1498
|
+
const livePreview = livePreviewForStatus(liveActivity);
|
|
1499
|
+
const liveDetailsLines = activeRole
|
|
1500
|
+
? buildInlineRunningLines({
|
|
1501
|
+
role: activeRole,
|
|
1502
|
+
currentAction: liveActivity?.currentAction,
|
|
1503
|
+
toolActivity: liveActivity?.toolActivity,
|
|
1504
|
+
toolRecentActivity: liveActivity?.toolRecentActivity,
|
|
1505
|
+
recentActivity: liveActivity?.recentActivity,
|
|
1506
|
+
assistantSummary: liveActivity?.assistantSummary,
|
|
1507
|
+
progress: liveActivity?.progress,
|
|
1508
|
+
rationale: liveActivity?.rationale,
|
|
1509
|
+
nextStep: liveActivity?.nextStep,
|
|
1510
|
+
verifying: liveActivity?.verifying,
|
|
1511
|
+
stateDeltas: liveActivity?.stateDeltas,
|
|
1512
|
+
startedAt: liveActivity?.startedAt,
|
|
1513
|
+
updatedAt: liveActivity?.updatedAt,
|
|
1514
|
+
})
|
|
1515
|
+
: [];
|
|
1516
|
+
const remainingSummary = completionRemainingSummary({
|
|
1517
|
+
remainingContractCount,
|
|
1518
|
+
releaseBlockerCount,
|
|
1519
|
+
highValueGapCount,
|
|
1520
|
+
remainingStopJudgeCount,
|
|
1521
|
+
});
|
|
1522
|
+
const widgetLines = activeRole
|
|
1523
|
+
? []
|
|
1524
|
+
: [
|
|
1525
|
+
"completion workflow",
|
|
1526
|
+
`phase: ${currentPhase}`,
|
|
1527
|
+
`slice: ${sliceId}`,
|
|
1528
|
+
`goal: ${sliceGoal}`,
|
|
1529
|
+
`next: ${nextMandatoryRole}`,
|
|
1530
|
+
`remaining: ${remainingSummary}`,
|
|
1531
|
+
];
|
|
1532
|
+
return {
|
|
1533
|
+
snapshotPresent: true,
|
|
1534
|
+
widgetLines,
|
|
1535
|
+
currentPhase,
|
|
1536
|
+
sliceId,
|
|
1537
|
+
nextMandatoryRole,
|
|
1538
|
+
remainingContractCount,
|
|
1539
|
+
releaseBlockerCount,
|
|
1540
|
+
highValueGapCount,
|
|
1541
|
+
remainingStopJudgeCount,
|
|
1542
|
+
activeRole,
|
|
1543
|
+
livePreview,
|
|
1544
|
+
liveState: liveSignal?.state,
|
|
1545
|
+
liveIdleMs: liveSignal?.idleMs,
|
|
1546
|
+
liveToolActivity: liveActivity?.toolActivity,
|
|
1547
|
+
liveAssistantSummary: liveActivity?.assistantSummary,
|
|
1548
|
+
liveProgress: liveActivity?.progress,
|
|
1549
|
+
liveRationale: liveActivity?.rationale,
|
|
1550
|
+
liveNextStep: liveActivity?.nextStep,
|
|
1551
|
+
liveVerifying: liveActivity?.verifying,
|
|
1552
|
+
liveStateDeltas: liveActivity?.stateDeltas ?? [],
|
|
1553
|
+
liveDetailsLines,
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
async function writeCompletionStatusProbe(surface: CompletionStatusSurface): Promise<void> {
|
|
1558
|
+
const outputPath = asString(process.env.PI_COMPLETION_STATUS_SNAPSHOT_FILE);
|
|
1559
|
+
if (!outputPath) return;
|
|
1560
|
+
await fsp.mkdir(path.dirname(outputPath), { recursive: true });
|
|
1561
|
+
await fsp.writeFile(outputPath, `${JSON.stringify(surface, null, 2)}\n`, "utf8");
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
async function refreshStatus(ctx: { cwd: string; hasUI: boolean; ui: any }) {
|
|
1565
|
+
const snapshot = await loadCompletionSnapshot(getCtxCwd(ctx));
|
|
1566
|
+
const rootKey = completionRootKey(snapshot, getCtxCwd(ctx));
|
|
1567
|
+
maybeInjectTestLiveRoleActivity(rootKey);
|
|
1568
|
+
maybeReplayTestLiveRoleEvents(rootKey);
|
|
1569
|
+
const surface = buildCompletionStatusSurface(snapshot, liveRoleActivityByRoot.get(rootKey));
|
|
1570
|
+
await writeCompletionStatusProbe(surface);
|
|
1571
|
+
if (!getCtxHasUI(ctx)) return;
|
|
1572
|
+
const ui = getCtxUi(ctx);
|
|
1573
|
+
if (!ui) return;
|
|
1574
|
+
safeUiCall(() => {
|
|
1575
|
+
ui.setWidget(COMPLETION_STATUS_KEY, surface.widgetLines.length > 0 ? surface.widgetLines : undefined);
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function parseReportFields(text: string): Record<string, string> {
|
|
1580
|
+
const fields: Record<string, string> = {};
|
|
1581
|
+
for (const rawLine of text.split("\n")) {
|
|
1582
|
+
const line = rawLine.trim();
|
|
1583
|
+
if (!line) continue;
|
|
1584
|
+
const normalized = line.replace(/^-\s*/, "").replace(/^`/, "").replace(/`$/, "");
|
|
1585
|
+
const match = normalized.match(/^([A-Za-z][A-Za-z0-9 _\/-]*?):\s*(.*)$/);
|
|
1586
|
+
if (!match) continue;
|
|
1587
|
+
const [, key, value] = match;
|
|
1588
|
+
fields[key.trim()] = value.trim();
|
|
1589
|
+
}
|
|
1590
|
+
return fields;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
function parseYesNo(value: string | undefined): boolean | undefined {
|
|
1594
|
+
if (!value) return undefined;
|
|
1595
|
+
const normalized = value.trim().toLowerCase();
|
|
1596
|
+
if (normalized.startsWith("yes")) return true;
|
|
1597
|
+
if (normalized.startsWith("no")) return false;
|
|
1598
|
+
return undefined;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
function parseFirstNumber(value: string | undefined): number | undefined {
|
|
1602
|
+
if (!value) return undefined;
|
|
1603
|
+
const match = value.match(/-?\d+/);
|
|
1604
|
+
if (!match) return undefined;
|
|
1605
|
+
const parsed = Number.parseInt(match[0], 10);
|
|
1606
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
async function gitHeadSha(cwd: string): Promise<string | undefined> {
|
|
1610
|
+
return await new Promise((resolve) => {
|
|
1611
|
+
const proc = spawn("git", ["rev-parse", "HEAD"], { cwd, stdio: ["ignore", "pipe", "ignore"] });
|
|
1612
|
+
let stdout = "";
|
|
1613
|
+
proc.stdout.on("data", (chunk) => {
|
|
1614
|
+
stdout += chunk.toString();
|
|
1615
|
+
});
|
|
1616
|
+
proc.on("close", (code) => {
|
|
1617
|
+
resolve(code === 0 ? asString(stdout) : undefined);
|
|
1618
|
+
});
|
|
1619
|
+
proc.on("error", () => resolve(undefined));
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
type TranscriptionResult = {
|
|
1624
|
+
appended: string[];
|
|
1625
|
+
skipped: string[];
|
|
1626
|
+
errors: string[];
|
|
1627
|
+
};
|
|
1628
|
+
|
|
1629
|
+
async function appendJsonlRecord(filePath: string, record: JsonRecord): Promise<void> {
|
|
1630
|
+
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
|
1631
|
+
await fsp.appendFile(filePath, `${JSON.stringify(record)}\n`, "utf8");
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
function formatElapsed(ms: number | undefined): string {
|
|
1636
|
+
if (!ms || ms < 0) return "00:00";
|
|
1637
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
1638
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
1639
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
1640
|
+
const seconds = totalSeconds % 60;
|
|
1641
|
+
if (hours > 0) return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
|
1642
|
+
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
function truncateInline(text: string, maxLength = 120): string {
|
|
1646
|
+
const singleLine = text.replace(/\s+/g, " ").trim();
|
|
1647
|
+
return singleLine.length > maxLength ? `${singleLine.slice(0, maxLength - 3)}...` : singleLine;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
function formatToolActivity(toolName: string, args: JsonRecord): string {
|
|
1652
|
+
if (toolName === "bash") return `$ ${truncateInline(asString(args.command) ?? "...")}`;
|
|
1653
|
+
if (toolName === "read") return `read ${asString(args.filePath) ?? asString(args.path) ?? "..."}`;
|
|
1654
|
+
if (toolName === "write") return `write ${asString(args.filePath) ?? asString(args.path) ?? "..."}`;
|
|
1655
|
+
if (toolName === "edit") return `edit ${asString(args.filePath) ?? asString(args.path) ?? "..."}`;
|
|
1656
|
+
if (toolName === "grep") return `grep ${asString(args.pattern) ?? "..."}`;
|
|
1657
|
+
if (toolName === "find") return `find ${asString(args.pattern) ?? "..."}`;
|
|
1658
|
+
if (toolName === "ls") return `ls ${asString(args.path) ?? "."}`;
|
|
1659
|
+
return `${toolName} ${truncateInline(JSON.stringify(args))}`;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function pushRecentActivity(items: string[], line: string, maxItems = 8): string[] {
|
|
1663
|
+
const normalized = truncateInline(line, 160);
|
|
1664
|
+
if (!normalized) return items;
|
|
1665
|
+
if (items[items.length - 1] === normalized) return items;
|
|
1666
|
+
const next = [...items, normalized];
|
|
1667
|
+
return next.slice(-maxItems);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
function collapseRecentActivity(items: string[], maxItems = 4): string[] {
|
|
1671
|
+
const collapsed: string[] = [];
|
|
1672
|
+
for (const rawItem of items) {
|
|
1673
|
+
const item = truncateInline(rawItem, 120);
|
|
1674
|
+
if (!item || item.startsWith("done ") || item.startsWith("result ")) continue;
|
|
1675
|
+
if (item.startsWith("assistant:")) continue;
|
|
1676
|
+
if (collapsed[collapsed.length - 1] === item) continue;
|
|
1677
|
+
collapsed.push(item);
|
|
1678
|
+
}
|
|
1679
|
+
return collapsed.slice(-maxItems);
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
function buildInlineRunningLines(details: {
|
|
1683
|
+
role?: string;
|
|
1684
|
+
startedAt?: number;
|
|
1685
|
+
updatedAt?: number;
|
|
1686
|
+
currentAction?: string;
|
|
1687
|
+
toolActivity?: string;
|
|
1688
|
+
toolRecentActivity?: string[];
|
|
1689
|
+
recentActivity?: string[];
|
|
1690
|
+
assistantSummary?: string;
|
|
1691
|
+
progress?: string;
|
|
1692
|
+
rationale?: string;
|
|
1693
|
+
nextStep?: string;
|
|
1694
|
+
verifying?: string;
|
|
1695
|
+
stateDeltas?: string[];
|
|
1696
|
+
}): string[] {
|
|
1697
|
+
const lines: string[] = [];
|
|
1698
|
+
let header = "running completion role";
|
|
1699
|
+
if (details.role) header += ` ${details.role}`;
|
|
1700
|
+
lines.push(header);
|
|
1701
|
+
if (details.startedAt !== undefined) lines.push(`elapsed: ${formatElapsed(nowMs() - details.startedAt)}`);
|
|
1702
|
+
const signalLine = formatLiveActivitySignal(
|
|
1703
|
+
liveActivitySignal({ status: "running", startedAt: details.startedAt, updatedAt: details.updatedAt }),
|
|
1704
|
+
);
|
|
1705
|
+
if (signalLine) lines.push(signalLine);
|
|
1706
|
+
const toolLine = details.toolActivity;
|
|
1707
|
+
if (toolLine) lines.push(`tool: ${toolLine}`);
|
|
1708
|
+
if (details.progress) lines.push(`progress: ${details.progress}`);
|
|
1709
|
+
else if (details.assistantSummary) lines.push(`assistant: ${details.assistantSummary}`);
|
|
1710
|
+
else if (details.currentAction && details.currentAction !== toolLine) {
|
|
1711
|
+
lines.push(`assistant: ${details.currentAction.replace(/^assistant:\s*/, "")}`);
|
|
1712
|
+
}
|
|
1713
|
+
if (details.rationale) lines.push(`rationale: ${details.rationale}`);
|
|
1714
|
+
if (details.nextStep) lines.push(`next: ${details.nextStep}`);
|
|
1715
|
+
if (details.verifying) lines.push(`verifying: ${details.verifying}`);
|
|
1716
|
+
for (const delta of (details.stateDeltas ?? []).slice(-4)) lines.push(`state-delta: ${delta}`);
|
|
1717
|
+
const recentTools = collapseRecentActivity(details.toolRecentActivity ?? details.recentActivity ?? []);
|
|
1718
|
+
const recentWithoutCurrent = recentTools.filter((item) => item !== toolLine);
|
|
1719
|
+
if (recentWithoutCurrent.length > 0) {
|
|
1720
|
+
lines.push("recent tools:");
|
|
1721
|
+
for (const item of recentWithoutCurrent) lines.push(`- ${item}`);
|
|
1722
|
+
}
|
|
1723
|
+
return lines;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
function parseStructuredProgress(text: string): {
|
|
1727
|
+
progress?: string;
|
|
1728
|
+
rationale?: string;
|
|
1729
|
+
nextStep?: string;
|
|
1730
|
+
verifying?: string;
|
|
1731
|
+
stateDeltas: string[];
|
|
1732
|
+
} {
|
|
1733
|
+
const result: { progress?: string; rationale?: string; nextStep?: string; verifying?: string; stateDeltas: string[] } = {
|
|
1734
|
+
stateDeltas: [],
|
|
1735
|
+
};
|
|
1736
|
+
for (const rawLine of text.split("\n")) {
|
|
1737
|
+
const line = rawLine.trim();
|
|
1738
|
+
if (!line) continue;
|
|
1739
|
+
const match = line.match(/^(PROGRESS|RATIONALE|NEXT|VERIFYING|STATE-DELTA):\s*(.+)$/i);
|
|
1740
|
+
if (!match) continue;
|
|
1741
|
+
const [, rawKey, rawValue] = match;
|
|
1742
|
+
const key = rawKey.toUpperCase();
|
|
1743
|
+
const value = rawValue.trim();
|
|
1744
|
+
if (!value) continue;
|
|
1745
|
+
if (key === "PROGRESS") result.progress = value;
|
|
1746
|
+
else if (key === "RATIONALE") result.rationale = value;
|
|
1747
|
+
else if (key === "NEXT") result.nextStep = value;
|
|
1748
|
+
else if (key === "VERIFYING") result.verifying = value;
|
|
1749
|
+
else if (key === "STATE-DELTA") result.stateDeltas.push(value);
|
|
1750
|
+
}
|
|
1751
|
+
if (result.stateDeltas.length > 6) result.stateDeltas = result.stateDeltas.slice(-6);
|
|
1752
|
+
return result;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
async function transcribeRoleOutput(role: CompletionRole, cwd: string, output: string, reportFields: Record<string, string>): Promise<TranscriptionResult> {
|
|
1756
|
+
const result: TranscriptionResult = { appended: [], skipped: [], errors: [] };
|
|
1757
|
+
const snapshot = await loadCompletionSnapshot(cwd);
|
|
1758
|
+
if (!snapshot) {
|
|
1759
|
+
result.skipped.push("No canonical completion snapshot found.");
|
|
1760
|
+
return result;
|
|
1761
|
+
}
|
|
1762
|
+
const headSha = await gitHeadSha(snapshot.files.root);
|
|
1763
|
+
if (!headSha) {
|
|
1764
|
+
result.errors.push("Could not resolve git HEAD for transcription.");
|
|
1765
|
+
return result;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
const sliceId =
|
|
1769
|
+
asString(snapshot.active?.slice_id) ??
|
|
1770
|
+
asString(snapshot.activeSlice?.slice_id) ??
|
|
1771
|
+
asString(snapshot.state?.latest_completed_slice);
|
|
1772
|
+
|
|
1773
|
+
if (role === "completion-reviewer" || role === "completion-auditor") {
|
|
1774
|
+
if (!sliceId) {
|
|
1775
|
+
result.errors.push(`Missing slice_id for ${role} transcription.`);
|
|
1776
|
+
return result;
|
|
1777
|
+
}
|
|
1778
|
+
const type = role === "completion-reviewer" ? "reviewed" : "audited";
|
|
1779
|
+
const history = await readJsonl(snapshot.files.sliceHistoryPath);
|
|
1780
|
+
const duplicate = history.some((entry) => {
|
|
1781
|
+
return (
|
|
1782
|
+
asString(entry.type) === type &&
|
|
1783
|
+
asString(entry.slice_id) === sliceId &&
|
|
1784
|
+
asString(entry.head_sha) === headSha &&
|
|
1785
|
+
asString(entry.report_text) === output.trim()
|
|
1786
|
+
);
|
|
1787
|
+
});
|
|
1788
|
+
if (duplicate) {
|
|
1789
|
+
result.skipped.push(`Skipped duplicate ${type} record for slice ${sliceId} at ${headSha.slice(0, 12)}.`);
|
|
1790
|
+
return result;
|
|
1791
|
+
}
|
|
1792
|
+
await appendJsonlRecord(snapshot.files.sliceHistoryPath, {
|
|
1793
|
+
schema_version: 1,
|
|
1794
|
+
type,
|
|
1795
|
+
recorded_at: Date.now(),
|
|
1796
|
+
slice_id: sliceId,
|
|
1797
|
+
commit_sha: headSha,
|
|
1798
|
+
head_sha: headSha,
|
|
1799
|
+
role,
|
|
1800
|
+
report_fields: reportFields,
|
|
1801
|
+
report_text: output.trim(),
|
|
1802
|
+
});
|
|
1803
|
+
result.appended.push(`${type}:${sliceId}`);
|
|
1804
|
+
return result;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
if (role === "completion-stop-judge") {
|
|
1808
|
+
const canStop = parseYesNo(reportFields["Can the project stop now"]);
|
|
1809
|
+
const blockerCount = parseFirstNumber(reportFields["Blocker count"]);
|
|
1810
|
+
const highValueGapCount = parseFirstNumber(reportFields["High-value gap count"]);
|
|
1811
|
+
if (canStop === undefined || blockerCount === undefined || highValueGapCount === undefined) {
|
|
1812
|
+
result.errors.push("Missing required stop-judge fields for canonical judgment transcription.");
|
|
1813
|
+
return result;
|
|
1814
|
+
}
|
|
1815
|
+
const history = await readJsonl(snapshot.files.stopHistoryPath);
|
|
1816
|
+
const duplicate = history.some((entry) => {
|
|
1817
|
+
return asString(entry.type) === "judgment" && asString(entry.head_sha) === headSha && asString(entry.report_text) === output.trim();
|
|
1818
|
+
});
|
|
1819
|
+
if (duplicate) {
|
|
1820
|
+
result.skipped.push(`Skipped duplicate judgment record at ${headSha.slice(0, 12)}.`);
|
|
1821
|
+
return result;
|
|
1822
|
+
}
|
|
1823
|
+
await appendJsonlRecord(snapshot.files.stopHistoryPath, {
|
|
1824
|
+
schema_version: 1,
|
|
1825
|
+
type: "judgment",
|
|
1826
|
+
recorded_at: Date.now(),
|
|
1827
|
+
head_sha: headSha,
|
|
1828
|
+
can_stop: canStop,
|
|
1829
|
+
blocker_count: blockerCount,
|
|
1830
|
+
high_value_gap_count: highValueGapCount,
|
|
1831
|
+
role,
|
|
1832
|
+
report_fields: reportFields,
|
|
1833
|
+
report_text: output.trim(),
|
|
1834
|
+
});
|
|
1835
|
+
result.appended.push(`judgment:${headSha.slice(0, 12)}`);
|
|
1836
|
+
return result;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
if (role === "completion-regrounder") {
|
|
1840
|
+
const rawDecision = asString(reportFields["Reconciliation decision"])?.toLowerCase();
|
|
1841
|
+
const decision = rawDecision?.match(/\b(accepted|reopened|none)\b/)?.[1];
|
|
1842
|
+
if (!decision || decision === "none") {
|
|
1843
|
+
result.skipped.push("No reconciliation decision emitted by completion-regrounder.");
|
|
1844
|
+
return result;
|
|
1845
|
+
}
|
|
1846
|
+
const reconciledSliceId =
|
|
1847
|
+
asString(reportFields["Reconciled slice ID"]) ??
|
|
1848
|
+
asString(reportFields["Current selected slice"]) ??
|
|
1849
|
+
sliceId;
|
|
1850
|
+
if (!reconciledSliceId || reconciledSliceId === "none" || reconciledSliceId === "(none)") {
|
|
1851
|
+
result.errors.push("Missing reconciled slice id for completion-regrounder transcription.");
|
|
1852
|
+
return result;
|
|
1853
|
+
}
|
|
1854
|
+
const history = await readJsonl(snapshot.files.sliceHistoryPath);
|
|
1855
|
+
const duplicate = history.some((entry) => {
|
|
1856
|
+
return (
|
|
1857
|
+
asString(entry.type) === decision &&
|
|
1858
|
+
asString(entry.slice_id) === reconciledSliceId &&
|
|
1859
|
+
asString(entry.head_sha) === headSha &&
|
|
1860
|
+
asString(entry.report_text) === output.trim()
|
|
1861
|
+
);
|
|
1862
|
+
});
|
|
1863
|
+
if (duplicate) {
|
|
1864
|
+
result.skipped.push(`Skipped duplicate ${decision} record for slice ${reconciledSliceId} at ${headSha.slice(0, 12)}.`);
|
|
1865
|
+
return result;
|
|
1866
|
+
}
|
|
1867
|
+
await appendJsonlRecord(snapshot.files.sliceHistoryPath, {
|
|
1868
|
+
schema_version: 1,
|
|
1869
|
+
type: decision,
|
|
1870
|
+
recorded_at: Date.now(),
|
|
1871
|
+
slice_id: reconciledSliceId,
|
|
1872
|
+
commit_sha: headSha,
|
|
1873
|
+
head_sha: headSha,
|
|
1874
|
+
role,
|
|
1875
|
+
report_fields: reportFields,
|
|
1876
|
+
report_text: output.trim(),
|
|
1877
|
+
});
|
|
1878
|
+
result.appended.push(`${decision}:${reconciledSliceId}`);
|
|
1879
|
+
return result;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
result.skipped.push(`No automatic transcription configured for ${role}.`);
|
|
1883
|
+
return result;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
function isPathInside(root: string, candidatePath: string): boolean {
|
|
1887
|
+
const resolvedRoot = path.resolve(root);
|
|
1888
|
+
const resolvedCandidate = path.resolve(candidatePath);
|
|
1889
|
+
return resolvedCandidate === resolvedRoot || resolvedCandidate.startsWith(`${resolvedRoot}${path.sep}`);
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
function resolveToolPath(cwd: string, rawPath: string): string {
|
|
1893
|
+
return path.isAbsolute(rawPath) ? rawPath : path.resolve(cwd, rawPath);
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
function isAllowedControlPlanePath(root: string, rawPath: string): boolean {
|
|
1897
|
+
const resolved = resolveToolPath(root, rawPath);
|
|
1898
|
+
if (path.basename(resolved) === ".gitignore") return true;
|
|
1899
|
+
return isPathInside(path.join(root, ".agent"), resolved);
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
function startsWithAny(value: string, prefixes: string[]): boolean {
|
|
1903
|
+
return prefixes.some((prefix) => value.startsWith(prefix));
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function normalizeCommand(command: string): string {
|
|
1907
|
+
return command.trim().replace(/\s+/g, " ");
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
function isMutatingBash(command: string): boolean {
|
|
1911
|
+
const normalized = normalizeCommand(command);
|
|
1912
|
+
return startsWithAny(normalized, [
|
|
1913
|
+
"git add",
|
|
1914
|
+
"git commit",
|
|
1915
|
+
"git push",
|
|
1916
|
+
"rm ",
|
|
1917
|
+
"mv ",
|
|
1918
|
+
"cp ",
|
|
1919
|
+
"mkdir ",
|
|
1920
|
+
"touch ",
|
|
1921
|
+
"chmod ",
|
|
1922
|
+
"chown ",
|
|
1923
|
+
"sed -i",
|
|
1924
|
+
"perl -pi",
|
|
1925
|
+
"python -c",
|
|
1926
|
+
"python3 -c",
|
|
1927
|
+
"node -e",
|
|
1928
|
+
"bun -e",
|
|
1929
|
+
"tee ",
|
|
1930
|
+
]) || normalized.includes(">") || normalized.includes("| tee") || normalized.includes("apply_patch");
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
async function loadAgentDefinition(cwd: string, role: CompletionRole): Promise<AgentDefinition> {
|
|
1934
|
+
const projectAgent = walkUpForDir(cwd, [".pi", "agents", `${role}.md`]);
|
|
1935
|
+
const packageAgent = PACKAGE_AGENTS_DIR ? path.join(PACKAGE_AGENTS_DIR, `${role}.md`) : undefined;
|
|
1936
|
+
const candidates = [projectAgent, packageAgent, path.join(AGENT_HOME, "agents", `${role}.md`)].filter(
|
|
1937
|
+
(candidate): candidate is string => Boolean(candidate),
|
|
1938
|
+
);
|
|
1939
|
+
for (const candidate of candidates) {
|
|
1940
|
+
if (!fs.existsSync(candidate)) continue;
|
|
1941
|
+
const raw = await fsp.readFile(candidate, "utf8");
|
|
1942
|
+
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(raw);
|
|
1943
|
+
return {
|
|
1944
|
+
name: frontmatter.name ?? role,
|
|
1945
|
+
description: frontmatter.description,
|
|
1946
|
+
tools: frontmatter.tools?.split(",").map((tool) => tool.trim()).filter(Boolean),
|
|
1947
|
+
model: frontmatter.model,
|
|
1948
|
+
systemPrompt: body.trim(),
|
|
1949
|
+
filePath: candidate,
|
|
1950
|
+
};
|
|
1951
|
+
}
|
|
1952
|
+
throw new Error(`Missing completion agent definition for ${role}`);
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
async function writeTempFile(prefix: string, content: string): Promise<{ dir: string; filePath: string }> {
|
|
1956
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
1957
|
+
const filePath = path.join(dir, "prompt.md");
|
|
1958
|
+
await fsp.writeFile(filePath, content, { encoding: "utf8", mode: 0o600 });
|
|
1959
|
+
return { dir, filePath };
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
1963
|
+
const currentScript = process.argv[1];
|
|
1964
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
1965
|
+
if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
|
|
1966
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
1967
|
+
}
|
|
1968
|
+
const execName = path.basename(process.execPath).toLowerCase();
|
|
1969
|
+
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
|
|
1970
|
+
if (!isGenericRuntime) return { command: process.execPath, args };
|
|
1971
|
+
return { command: "pi", args };
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
function lastAssistantText(messages: Array<{ role: string; content: Array<{ type: string; text?: string }> }>): string {
|
|
1975
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1976
|
+
const message = messages[i];
|
|
1977
|
+
if (message.role !== "assistant") continue;
|
|
1978
|
+
const texts = message.content
|
|
1979
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
1980
|
+
.map((part) => part.text?.trim())
|
|
1981
|
+
.filter((part): part is string => Boolean(part));
|
|
1982
|
+
if (texts.length > 0) return texts.join("\n\n");
|
|
1983
|
+
}
|
|
1984
|
+
return "";
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
function completionKickoff(goal: string, intent: "auto" | "continue" | "refocus" = "auto", missionAnchor?: string): string {
|
|
1988
|
+
const intentBlock =
|
|
1989
|
+
intent === "continue" && missionAnchor
|
|
1990
|
+
? `Existing canonical mission anchor:\n${missionAnchor}\n\nWorkflow intent:\n- Continue the existing workflow.\n- Treat the new user text as supplemental direction unless canonical reconciliation proves the mission itself must change.\n\n`
|
|
1991
|
+
: intent === "refocus" && missionAnchor
|
|
1992
|
+
? `Updated canonical mission anchor:\n${missionAnchor}\n\nWorkflow intent:\n- The user explicitly refocused the workflow before this kickoff.\n- Re-read canonical .agent/** state and continue from the refocused mission.\n\n`
|
|
1993
|
+
: "";
|
|
1994
|
+
return `/skill:completion-protocol Start or continue the completion workflow for this repo.\n\nBefore acting, read:\n- ${SKILL_PATH}\n- ${REFERENCE_PATH}\n\nUser goal:\n${goal}\n\n${intentBlock}Driver instructions:\n- Canonical truth is in .agent/**. Re-read .agent/state.json, .agent/plan.json, and .agent/active-slice.json before acting when they exist.\n- If tracked completion contract files are missing or onboarding is required, invoke completion_role with role completion-bootstrapper.\n- Otherwise follow the mandatory dispatch rules from completion-protocol.\n- Use completion_role for all completion-* role work. Do not directly implement tracked product changes yourself.\n- Continue dispatching mandatory roles while continuation_policy == continue.\n- Only stop for the user when continuation_policy is await_user_input, blocked, paused, or done.`;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
function completionResumePrompt(): string {
|
|
1998
|
+
return `/skill:completion-protocol Resume the completion workflow from canonical state.\n\nBefore acting, read:\n- ${SKILL_PATH}\n- ${REFERENCE_PATH}\n\nResume instructions:\n- Re-read .agent/state.json, .agent/plan.json, and .agent/active-slice.json before acting.\n- If canonical state is missing, invalid, contradictory, stale, or ambiguous, route to completion-regrounder first.\n- Continue from next_mandatory_role and next_mandatory_action.\n- Use completion_role for all completion-* role work.\n- Continue dispatching mandatory roles while continuation_policy == continue.\n- Only stop for the user when continuation_policy is await_user_input, blocked, paused, or done.`;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
export default function completionExtension(pi: ExtensionAPI) {
|
|
2002
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
2003
|
+
await refreshStatus(ctx);
|
|
2004
|
+
});
|
|
2005
|
+
|
|
2006
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
2007
|
+
await refreshStatus(ctx);
|
|
2008
|
+
});
|
|
2009
|
+
|
|
2010
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
2011
|
+
const snapshot = await loadCompletionSnapshot(getCtxCwd(ctx));
|
|
2012
|
+
if (snapshot && (await pathExists(snapshot.files.compactionMarkerPath))) {
|
|
2013
|
+
await fsp.rm(snapshot.files.compactionMarkerPath, { force: true });
|
|
2014
|
+
}
|
|
2015
|
+
await refreshStatus(ctx);
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
2019
|
+
const loaded = await loadCompletionDataForReminder(getCtxCwd(ctx));
|
|
2020
|
+
if (!loaded) return;
|
|
2021
|
+
const markerText = await readText(loaded.snapshot.files.compactionMarkerPath);
|
|
2022
|
+
let marker: JsonRecord | undefined;
|
|
2023
|
+
if (markerText) {
|
|
2024
|
+
try {
|
|
2025
|
+
const parsed = JSON.parse(markerText);
|
|
2026
|
+
marker = isRecord(parsed) ? parsed : undefined;
|
|
2027
|
+
} catch {
|
|
2028
|
+
marker = undefined;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
const additions = [buildSystemReminder(loaded.snapshot, loaded.sliceHistory, loaded.stopHistory)];
|
|
2032
|
+
if (marker) additions.push(buildPostCompactionDriverInstructions(loaded.snapshot, marker));
|
|
2033
|
+
const systemPrompt = getSystemPromptSafe(ctx);
|
|
2034
|
+
if (!systemPrompt) return;
|
|
2035
|
+
return {
|
|
2036
|
+
systemPrompt: `${systemPrompt}\n\n${additions.join("\n\n")}`,
|
|
2037
|
+
};
|
|
2038
|
+
});
|
|
2039
|
+
|
|
2040
|
+
pi.on("session_before_compact", async (event, ctx) => {
|
|
2041
|
+
const loaded = await loadCompletionDataForReminder(getCtxCwd(ctx));
|
|
2042
|
+
if (!loaded) return;
|
|
2043
|
+
const { preparation } = event;
|
|
2044
|
+
const summary = buildResumeCapsule(loaded.snapshot, loaded.sliceHistory, loaded.stopHistory);
|
|
2045
|
+
await fsp.mkdir(loaded.snapshot.files.tmpDir, { recursive: true });
|
|
2046
|
+
await fsp.writeFile(
|
|
2047
|
+
loaded.snapshot.files.compactionMarkerPath,
|
|
2048
|
+
`${JSON.stringify({
|
|
2049
|
+
recorded_at: Date.now(),
|
|
2050
|
+
mission_anchor: asString(loaded.snapshot.state?.mission_anchor) ?? null,
|
|
2051
|
+
next_mandatory_role: asString(loaded.snapshot.state?.next_mandatory_role) ?? null,
|
|
2052
|
+
next_mandatory_action: asString(loaded.snapshot.state?.next_mandatory_action) ?? null,
|
|
2053
|
+
continuation_policy: asString(loaded.snapshot.state?.continuation_policy) ?? null,
|
|
2054
|
+
active_slice_id: asString(loaded.snapshot.active?.slice_id) ?? asString(loaded.snapshot.activeSlice?.slice_id) ?? null,
|
|
2055
|
+
}, null, 2)}\n`,
|
|
2056
|
+
"utf8",
|
|
2057
|
+
);
|
|
2058
|
+
emitCommandText(ctx, "Completion continuity capsule injected for compaction", "info");
|
|
2059
|
+
return {
|
|
2060
|
+
compaction: {
|
|
2061
|
+
summary,
|
|
2062
|
+
firstKeptEntryId: preparation.firstKeptEntryId,
|
|
2063
|
+
tokensBefore: preparation.tokensBefore,
|
|
2064
|
+
details: preparation.fileOps,
|
|
2065
|
+
},
|
|
2066
|
+
};
|
|
2067
|
+
});
|
|
2068
|
+
|
|
2069
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
2070
|
+
const role = roleFromEnv();
|
|
2071
|
+
const cwd = getCtxCwd(ctx);
|
|
2072
|
+
const snapshot = await loadCompletionSnapshot(cwd);
|
|
2073
|
+
const completionActive = Boolean(snapshot) && asString(snapshot?.state?.continuation_policy) !== "done";
|
|
2074
|
+
const root = snapshot?.files.root ?? findRepoRoot(cwd) ?? cwd;
|
|
2075
|
+
|
|
2076
|
+
if (event.toolName === "completion_role" && role) {
|
|
2077
|
+
return { block: true, reason: `Nested completion role dispatch is forbidden for ${role}.` };
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
if (event.toolName === "edit" || event.toolName === "write") {
|
|
2081
|
+
const rawPath = asString((event.input as JsonRecord).path);
|
|
2082
|
+
if (!rawPath) return;
|
|
2083
|
+
|
|
2084
|
+
if (role === "completion-reviewer" || role === "completion-auditor" || role === "completion-stop-judge") {
|
|
2085
|
+
return { block: true, reason: `${role} is read-only.` };
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
if ((role === "completion-bootstrapper" || role === "completion-regrounder") && !isAllowedControlPlanePath(root, rawPath)) {
|
|
2089
|
+
return { block: true, reason: `${role} may only edit .agent/** or .gitignore.` };
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
if (!role && completionActive && !isAllowedControlPlanePath(root, rawPath)) {
|
|
2093
|
+
return { block: true, reason: "The workflow driver may not edit tracked product files directly during completion." };
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
if (event.toolName !== "bash") return;
|
|
2098
|
+
const command = asString((event.input as JsonRecord).command);
|
|
2099
|
+
if (!command) return;
|
|
2100
|
+
const normalized = normalizeCommand(command);
|
|
2101
|
+
|
|
2102
|
+
if (["completion-reviewer", "completion-auditor", "completion-stop-judge"].includes(role ?? "") && isMutatingBash(normalized)) {
|
|
2103
|
+
return { block: true, reason: `${role} is read-only and cannot run mutating bash.` };
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
if ((role === "completion-bootstrapper" || role === "completion-regrounder") && startsWithAny(normalized, ["git add", "git commit"])) {
|
|
2107
|
+
return { block: true, reason: `${role} may not create commits.` };
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
if (!role && completionActive && startsWithAny(normalized, ["git add", "git commit"])) {
|
|
2111
|
+
return { block: true, reason: "The workflow driver may not create commits directly during completion." };
|
|
2112
|
+
}
|
|
2113
|
+
});
|
|
2114
|
+
|
|
2115
|
+
pi.registerTool({
|
|
2116
|
+
name: "completion_role",
|
|
2117
|
+
label: "Completion Role",
|
|
2118
|
+
description: "Run one completion workflow role in an isolated pi subprocess. Only the main workflow driver should call this tool.",
|
|
2119
|
+
promptSnippet: "Dispatch one completion workflow role in isolated context.",
|
|
2120
|
+
promptGuidelines: [
|
|
2121
|
+
"Use completion_role when driving the completion workflow and a mandatory completion role must act next.",
|
|
2122
|
+
"Use completion_role only for completion-bootstrapper, completion-regrounder, completion-implementer, completion-reviewer, completion-auditor, or completion-stop-judge.",
|
|
2123
|
+
"Do not use completion_role from inside a completion role; only the workflow driver may dispatch roles.",
|
|
2124
|
+
],
|
|
2125
|
+
parameters: Type.Object({
|
|
2126
|
+
role: StringEnum(ROLE_NAMES, { description: "The completion role to invoke." }),
|
|
2127
|
+
task: Type.Optional(Type.String({ description: "Optional extra task context for the selected role." })),
|
|
2128
|
+
}),
|
|
2129
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
2130
|
+
const role = params.role as CompletionRole;
|
|
2131
|
+
const cwd = getCtxCwd(ctx);
|
|
2132
|
+
const runCwd = findCompletionRoot(cwd) ?? findRepoRoot(cwd) ?? cwd;
|
|
2133
|
+
const rootKey = runCwd;
|
|
2134
|
+
const agent = await loadAgentDefinition(runCwd, role);
|
|
2135
|
+
type RunningDetails = {
|
|
2136
|
+
role: string;
|
|
2137
|
+
status: "running" | "ok" | "error";
|
|
2138
|
+
currentAction?: string;
|
|
2139
|
+
toolActivity?: string;
|
|
2140
|
+
toolRecentActivity?: string[];
|
|
2141
|
+
recentActivity?: string[];
|
|
2142
|
+
assistantSummary?: string;
|
|
2143
|
+
lastAssistantText?: string;
|
|
2144
|
+
progress?: string;
|
|
2145
|
+
rationale?: string;
|
|
2146
|
+
nextStep?: string;
|
|
2147
|
+
verifying?: string;
|
|
2148
|
+
stateDeltas?: string[];
|
|
2149
|
+
startedAt?: number;
|
|
2150
|
+
updatedAt?: number;
|
|
2151
|
+
stderr?: string;
|
|
2152
|
+
reportFields?: Record<string, string>;
|
|
2153
|
+
transcription?: TranscriptionResult;
|
|
2154
|
+
exitCode?: number;
|
|
2155
|
+
};
|
|
2156
|
+
const systemPromptTemp = await writeTempFile("pi-completion-role-", agent.systemPrompt);
|
|
2157
|
+
const taskLines = [
|
|
2158
|
+
`Completion role: ${role}`,
|
|
2159
|
+
"Before acting, read the completion protocol skill and reference:",
|
|
2160
|
+
`- ${SKILL_PATH}`,
|
|
2161
|
+
`- ${REFERENCE_PATH}`,
|
|
2162
|
+
"Use canonical .agent/** state as the source of truth.",
|
|
2163
|
+
];
|
|
2164
|
+
if (params.task?.trim()) {
|
|
2165
|
+
taskLines.push("", "Supplemental task context:", params.task.trim());
|
|
2166
|
+
}
|
|
2167
|
+
const prompt = taskLines.join("\n");
|
|
2168
|
+
const args: string[] = ["--mode", "json", "-p", "--no-session", "--append-system-prompt", systemPromptTemp.filePath];
|
|
2169
|
+
if (agent.model) args.push("--model", agent.model);
|
|
2170
|
+
if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
|
|
2171
|
+
args.push(prompt);
|
|
2172
|
+
|
|
2173
|
+
const invocation = getPiInvocation(args);
|
|
2174
|
+
let stderr = "";
|
|
2175
|
+
const messages: RoleMessage[] = [];
|
|
2176
|
+
const liveActivity = createLiveRoleActivity(role);
|
|
2177
|
+
const emitRunningUpdate = (freshActivity = false) => {
|
|
2178
|
+
if (freshActivity) liveActivity.updatedAt = nowMs();
|
|
2179
|
+
const details: RunningDetails = {
|
|
2180
|
+
role,
|
|
2181
|
+
status: "running",
|
|
2182
|
+
currentAction: liveActivity.currentAction,
|
|
2183
|
+
toolActivity: liveActivity.toolActivity,
|
|
2184
|
+
toolRecentActivity: liveActivity.toolRecentActivity,
|
|
2185
|
+
recentActivity: liveActivity.recentActivity,
|
|
2186
|
+
assistantSummary: liveActivity.assistantSummary,
|
|
2187
|
+
lastAssistantText: liveActivity.lastAssistantText,
|
|
2188
|
+
progress: liveActivity.progress,
|
|
2189
|
+
rationale: liveActivity.rationale,
|
|
2190
|
+
nextStep: liveActivity.nextStep,
|
|
2191
|
+
verifying: liveActivity.verifying,
|
|
2192
|
+
stateDeltas: liveActivity.stateDeltas,
|
|
2193
|
+
startedAt: liveActivity.startedAt,
|
|
2194
|
+
updatedAt: liveActivity.updatedAt,
|
|
2195
|
+
};
|
|
2196
|
+
liveRoleActivityByRoot.set(rootKey, cloneLiveRoleActivity(liveActivity, { status: "running" }));
|
|
2197
|
+
void refreshStatus(ctx as { cwd: string; hasUI: boolean; ui: any });
|
|
2198
|
+
onUpdate?.({
|
|
2199
|
+
content: [{ type: "text", text: liveActivity.lastAssistantText || liveActivity.currentAction || `Running ${role}...` }],
|
|
2200
|
+
details,
|
|
2201
|
+
});
|
|
2202
|
+
};
|
|
2203
|
+
emitRunningUpdate(true);
|
|
2204
|
+
const heartbeat = setInterval(() => emitRunningUpdate(false), LIVE_ROLE_HEARTBEAT_MS);
|
|
2205
|
+
|
|
2206
|
+
try {
|
|
2207
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
2208
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
2209
|
+
cwd: runCwd,
|
|
2210
|
+
env: { ...process.env, PI_COMPLETION_ROLE: role },
|
|
2211
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2212
|
+
shell: false,
|
|
2213
|
+
});
|
|
2214
|
+
let buffer = "";
|
|
2215
|
+
|
|
2216
|
+
const processLine = (line: string) => {
|
|
2217
|
+
if (!line.trim()) return;
|
|
2218
|
+
try {
|
|
2219
|
+
const event = JSON.parse(line) as JsonRecord;
|
|
2220
|
+
if (applyLiveRoleEvent(liveActivity, event, messages)) emitRunningUpdate(true);
|
|
2221
|
+
} catch {
|
|
2222
|
+
// ignore malformed lines
|
|
2223
|
+
}
|
|
2224
|
+
};
|
|
2225
|
+
|
|
2226
|
+
proc.stdout.on("data", (chunk) => {
|
|
2227
|
+
buffer += chunk.toString();
|
|
2228
|
+
const lines = buffer.split("\n");
|
|
2229
|
+
buffer = lines.pop() ?? "";
|
|
2230
|
+
for (const line of lines) processLine(line);
|
|
2231
|
+
});
|
|
2232
|
+
|
|
2233
|
+
proc.stderr.on("data", (chunk) => {
|
|
2234
|
+
stderr += chunk.toString();
|
|
2235
|
+
});
|
|
2236
|
+
|
|
2237
|
+
proc.on("close", (code) => {
|
|
2238
|
+
if (buffer.trim()) processLine(buffer);
|
|
2239
|
+
resolve(code ?? 0);
|
|
2240
|
+
});
|
|
2241
|
+
|
|
2242
|
+
proc.on("error", () => resolve(1));
|
|
2243
|
+
|
|
2244
|
+
if (signal) {
|
|
2245
|
+
const abort = () => proc.kill("SIGTERM");
|
|
2246
|
+
if (signal.aborted) abort();
|
|
2247
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
2248
|
+
}
|
|
2249
|
+
});
|
|
2250
|
+
|
|
2251
|
+
const output = liveActivity.lastAssistantText || stderr.trim() || `${role} finished with no text output.`;
|
|
2252
|
+
const reportFields = parseReportFields(output);
|
|
2253
|
+
const transcription = exitCode === 0 ? await transcribeRoleOutput(role, runCwd, output, reportFields) : undefined;
|
|
2254
|
+
if (transcription?.appended.length) {
|
|
2255
|
+
emitCommandText(ctx, `Completion transcription appended: ${transcription.appended.join(", ")}`, "info");
|
|
2256
|
+
}
|
|
2257
|
+
if (transcription?.errors.length) {
|
|
2258
|
+
emitCommandText(ctx, `Completion transcription warning: ${transcription.errors.join(" | ")}`, "warning");
|
|
2259
|
+
}
|
|
2260
|
+
liveRoleActivityByRoot.set(rootKey, cloneLiveRoleActivity(liveActivity, { status: exitCode === 0 ? "ok" : "error" }));
|
|
2261
|
+
await refreshStatus(ctx as { cwd: string; hasUI: boolean; ui: any });
|
|
2262
|
+
return {
|
|
2263
|
+
content: [{ type: "text", text: output }],
|
|
2264
|
+
details: {
|
|
2265
|
+
role,
|
|
2266
|
+
status: exitCode === 0 ? "ok" : "error",
|
|
2267
|
+
exitCode,
|
|
2268
|
+
stderr: stderr.trim(),
|
|
2269
|
+
reportFields,
|
|
2270
|
+
transcription,
|
|
2271
|
+
currentAction: liveActivity.currentAction,
|
|
2272
|
+
toolActivity: liveActivity.toolActivity,
|
|
2273
|
+
toolRecentActivity: liveActivity.toolRecentActivity,
|
|
2274
|
+
recentActivity: liveActivity.recentActivity,
|
|
2275
|
+
assistantSummary: liveActivity.assistantSummary,
|
|
2276
|
+
lastAssistantText: liveActivity.lastAssistantText,
|
|
2277
|
+
progress: liveActivity.progress,
|
|
2278
|
+
rationale: liveActivity.rationale,
|
|
2279
|
+
nextStep: liveActivity.nextStep,
|
|
2280
|
+
verifying: liveActivity.verifying,
|
|
2281
|
+
stateDeltas: liveActivity.stateDeltas,
|
|
2282
|
+
startedAt: liveActivity.startedAt,
|
|
2283
|
+
updatedAt: liveActivity.updatedAt,
|
|
2284
|
+
},
|
|
2285
|
+
isError: exitCode !== 0,
|
|
2286
|
+
};
|
|
2287
|
+
} finally {
|
|
2288
|
+
clearInterval(heartbeat);
|
|
2289
|
+
setTimeout(() => {
|
|
2290
|
+
const current = liveRoleActivityByRoot.get(rootKey);
|
|
2291
|
+
if (current && current.role === role && current.status !== "running") {
|
|
2292
|
+
liveRoleActivityByRoot.delete(rootKey);
|
|
2293
|
+
}
|
|
2294
|
+
}, 10_000);
|
|
2295
|
+
await fsp.rm(systemPromptTemp.dir, { recursive: true, force: true });
|
|
2296
|
+
}
|
|
2297
|
+
},
|
|
2298
|
+
renderCall(args, theme) {
|
|
2299
|
+
const role = args.role || "completion-role";
|
|
2300
|
+
const task = typeof args.task === "string" ? args.task.trim() : "";
|
|
2301
|
+
let text = theme.fg("toolTitle", theme.bold("completion_role ")) + theme.fg("accent", role);
|
|
2302
|
+
if (task) {
|
|
2303
|
+
text += `\n${theme.fg("dim", task)}`;
|
|
2304
|
+
}
|
|
2305
|
+
return new Text(text, 0, 0);
|
|
2306
|
+
},
|
|
2307
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
2308
|
+
const details = (result.details ?? {}) as {
|
|
2309
|
+
role?: string;
|
|
2310
|
+
status?: string;
|
|
2311
|
+
exitCode?: number;
|
|
2312
|
+
stderr?: string;
|
|
2313
|
+
reportFields?: Record<string, string>;
|
|
2314
|
+
transcription?: TranscriptionResult;
|
|
2315
|
+
currentAction?: string;
|
|
2316
|
+
toolActivity?: string;
|
|
2317
|
+
toolRecentActivity?: string[];
|
|
2318
|
+
recentActivity?: string[];
|
|
2319
|
+
assistantSummary?: string;
|
|
2320
|
+
lastAssistantText?: string;
|
|
2321
|
+
progress?: string;
|
|
2322
|
+
rationale?: string;
|
|
2323
|
+
nextStep?: string;
|
|
2324
|
+
verifying?: string;
|
|
2325
|
+
stateDeltas?: string[];
|
|
2326
|
+
startedAt?: number;
|
|
2327
|
+
updatedAt?: number;
|
|
2328
|
+
};
|
|
2329
|
+
if (isPartial) {
|
|
2330
|
+
const lines = buildInlineRunningLines(details);
|
|
2331
|
+
let text = "";
|
|
2332
|
+
for (const [index, line] of lines.entries()) {
|
|
2333
|
+
if (index > 0) text += "\n";
|
|
2334
|
+
if (index === 0) {
|
|
2335
|
+
const [prefix, ...rest] = line.split(" ");
|
|
2336
|
+
text += theme.fg("warning", prefix);
|
|
2337
|
+
if (rest.length > 0) text += ` ${theme.fg("accent", rest.join(" "))}`;
|
|
2338
|
+
continue;
|
|
2339
|
+
}
|
|
2340
|
+
if (line.startsWith("tool:") || line.startsWith("progress:")) {
|
|
2341
|
+
text += theme.fg("toolOutput", line);
|
|
2342
|
+
continue;
|
|
2343
|
+
}
|
|
2344
|
+
if (line.startsWith("activity:")) {
|
|
2345
|
+
text += theme.fg(line.includes("stalled") ? "warning" : "dim", line);
|
|
2346
|
+
continue;
|
|
2347
|
+
}
|
|
2348
|
+
if (line === "recent tools:") {
|
|
2349
|
+
text += theme.fg("dim", line);
|
|
2350
|
+
continue;
|
|
2351
|
+
}
|
|
2352
|
+
if (line.startsWith("- ")) {
|
|
2353
|
+
text += `${theme.fg("muted", "- ")}${theme.fg("dim", line.slice(2))}`;
|
|
2354
|
+
continue;
|
|
2355
|
+
}
|
|
2356
|
+
text += theme.fg("dim", line);
|
|
2357
|
+
}
|
|
2358
|
+
return new Text(text, 0, 0);
|
|
2359
|
+
}
|
|
2360
|
+
const role = details.role ?? "completion-role";
|
|
2361
|
+
const ok = details.status === "ok" && !result.isError;
|
|
2362
|
+
let text = `${theme.fg(ok ? "success" : "error", ok ? "done" : "error")} ${theme.fg("toolTitle", theme.bold(role))}`;
|
|
2363
|
+
if (details.startedAt !== undefined) text += `\n${theme.fg("dim", `elapsed: ${formatElapsed(nowMs() - details.startedAt)}`)}`;
|
|
2364
|
+
if (details.toolActivity) text += `\n${theme.fg("toolOutput", `tool: ${details.toolActivity}`)}`;
|
|
2365
|
+
if (details.progress) text += `\n${theme.fg("toolOutput", `progress: ${details.progress}`)}`;
|
|
2366
|
+
else if (details.assistantSummary) text += `\n${theme.fg("dim", `assistant: ${details.assistantSummary}`)}`;
|
|
2367
|
+
if (details.rationale) text += `\n${theme.fg("dim", `rationale: ${details.rationale}`)}`;
|
|
2368
|
+
if (details.nextStep) text += `\n${theme.fg("dim", `next: ${details.nextStep}`)}`;
|
|
2369
|
+
if (details.verifying) text += `\n${theme.fg("dim", `verifying: ${details.verifying}`)}`;
|
|
2370
|
+
if (details.stateDeltas?.length) {
|
|
2371
|
+
for (const delta of details.stateDeltas.slice(-4)) text += `\n${theme.fg("dim", `state-delta: ${delta}`)}`;
|
|
2372
|
+
}
|
|
2373
|
+
if (details.transcription?.appended?.length) {
|
|
2374
|
+
text += `\n${theme.fg("success", `transcribed: ${details.transcription.appended.join(", ")}`)}`;
|
|
2375
|
+
}
|
|
2376
|
+
if (details.transcription?.skipped?.length && expanded) {
|
|
2377
|
+
text += `\n${theme.fg("dim", `skipped: ${details.transcription.skipped.join(" | ")}`)}`;
|
|
2378
|
+
}
|
|
2379
|
+
if (details.transcription?.errors?.length) {
|
|
2380
|
+
text += `\n${theme.fg("warning", `warnings: ${details.transcription.errors.join(" | ")}`)}`;
|
|
2381
|
+
}
|
|
2382
|
+
const reportFields = details.reportFields ?? {};
|
|
2383
|
+
const summaryKeys = [
|
|
2384
|
+
"MISSION ANCHOR",
|
|
2385
|
+
"Remaining contract IDs",
|
|
2386
|
+
"Next role to invoke",
|
|
2387
|
+
"Reconciliation decision",
|
|
2388
|
+
"Can the project stop now",
|
|
2389
|
+
"Acceptable as-is",
|
|
2390
|
+
"Plan adjustment required",
|
|
2391
|
+
];
|
|
2392
|
+
for (const key of summaryKeys) {
|
|
2393
|
+
const value = reportFields[key];
|
|
2394
|
+
if (!value) continue;
|
|
2395
|
+
text += `\n${theme.fg("dim", `${key}: `)}${value}`;
|
|
2396
|
+
}
|
|
2397
|
+
const body = result.content.find((item) => item.type === "text");
|
|
2398
|
+
if (expanded && body?.type === "text") {
|
|
2399
|
+
text += `\n\n${body.text}`;
|
|
2400
|
+
} else if (!expanded && body?.type === "text") {
|
|
2401
|
+
const preview = body.text.split("\n").slice(0, 4).join("\n");
|
|
2402
|
+
text += `\n${theme.fg("dim", preview)}`;
|
|
2403
|
+
}
|
|
2404
|
+
if (details.stderr && expanded) text += `\n${theme.fg("error", details.stderr)}`;
|
|
2405
|
+
return new Text(text, 0, 0);
|
|
2406
|
+
},
|
|
2407
|
+
});
|
|
2408
|
+
|
|
2409
|
+
pi.registerCommand("cook", {
|
|
2410
|
+
description: "Start or continue the completion workflow for a repo",
|
|
2411
|
+
handler: async (args, ctx) => {
|
|
2412
|
+
const explicitGoal = args.trim();
|
|
2413
|
+
let goal = explicitGoal;
|
|
2414
|
+
const cwd = getCtxCwd(ctx);
|
|
2415
|
+
let snapshot = await loadCompletionSnapshot(cwd);
|
|
2416
|
+
const hadSnapshot = Boolean(snapshot);
|
|
2417
|
+
const workflowDone = isWorkflowDone(snapshot);
|
|
2418
|
+
let kickoffIntent: "auto" | "continue" | "refocus" = "auto";
|
|
2419
|
+
let kickoffMissionAnchor = snapshot ? currentMissionAnchor(snapshot) : undefined;
|
|
2420
|
+
|
|
2421
|
+
if (!snapshot) {
|
|
2422
|
+
const root = findRepoRoot(cwd) ?? cwd;
|
|
2423
|
+
const projectName = path.basename(root);
|
|
2424
|
+
if (!goal) {
|
|
2425
|
+
const proposal = extractContextProposalFromSession(ctx, projectName);
|
|
2426
|
+
if (!proposal) {
|
|
2427
|
+
emitCommandText(
|
|
2428
|
+
ctx,
|
|
2429
|
+
"Usage: /cook <goal> (or finish shaping the plan in discussion, then rerun /cook to confirm the proposed workflow)",
|
|
2430
|
+
"error",
|
|
2431
|
+
);
|
|
2432
|
+
return;
|
|
2433
|
+
}
|
|
2434
|
+
const decision = await confirmContextProposal(ctx, proposal, projectName, {
|
|
2435
|
+
title: "Start a completion workflow from the recent discussion?",
|
|
2436
|
+
editorPrompt:
|
|
2437
|
+
"Start a completion workflow from the recent discussion?\n\nEdit the proposed mission, scope, constraints, and acceptance details below.",
|
|
2438
|
+
});
|
|
2439
|
+
if (!decision) {
|
|
2440
|
+
emitCommandText(ctx, "Cancelled recent-discussion workflow proposal", "info");
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2443
|
+
goal = decision.goalText;
|
|
2444
|
+
kickoffMissionAnchor = decision.missionAnchor;
|
|
2445
|
+
} else {
|
|
2446
|
+
const proposal = buildGoalAnchoredContextProposal(ctx, goal, projectName);
|
|
2447
|
+
const decision = await confirmContextProposal(ctx, proposal, projectName, {
|
|
2448
|
+
title: "Start a completion workflow from this goal?",
|
|
2449
|
+
nonInteractiveBehavior: "accept",
|
|
2450
|
+
editorPrompt:
|
|
2451
|
+
"Start a completion workflow from this goal?\n\nEdit the proposed mission, scope, constraints, and acceptance details below.",
|
|
2452
|
+
});
|
|
2453
|
+
if (!decision) {
|
|
2454
|
+
emitCommandText(ctx, "Cancelled workflow startup proposal", "info");
|
|
2455
|
+
return;
|
|
2456
|
+
}
|
|
2457
|
+
goal = decision.goalText;
|
|
2458
|
+
kickoffMissionAnchor = decision.missionAnchor;
|
|
2459
|
+
}
|
|
2460
|
+
const created = await scaffoldCompletionFiles(root, kickoffMissionAnchor ?? projectName);
|
|
2461
|
+
emitCommandText(
|
|
2462
|
+
ctx,
|
|
2463
|
+
`Initialized completion control plane in ${created.root}${created.created.length > 0 ? ` (${created.created.length} files created)` : ""}`,
|
|
2464
|
+
"info",
|
|
2465
|
+
);
|
|
2466
|
+
snapshot = await loadCompletionSnapshot(root);
|
|
2467
|
+
}
|
|
2468
|
+
if (!snapshot) {
|
|
2469
|
+
emitCommandText(ctx, "Failed to load completion workflow state", "error");
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2472
|
+
if (!goal) {
|
|
2473
|
+
if (workflowDone) {
|
|
2474
|
+
const projectName = path.basename(snapshot.files.root);
|
|
2475
|
+
const proposal = extractContextProposalFromSession(ctx, projectName);
|
|
2476
|
+
if (!proposal) {
|
|
2477
|
+
emitCommandText(
|
|
2478
|
+
ctx,
|
|
2479
|
+
"The previous completion workflow is already done. Shape the next plan in discussion or provide a new goal, then rerun /cook to start the next round.",
|
|
2480
|
+
"info",
|
|
2481
|
+
);
|
|
2482
|
+
return;
|
|
2483
|
+
}
|
|
2484
|
+
const decision = await confirmContextProposal(ctx, proposal, projectName, {
|
|
2485
|
+
title: "The previous completion workflow is done. Start the next workflow round from the recent discussion?",
|
|
2486
|
+
editorPrompt:
|
|
2487
|
+
"The previous completion workflow is done. Start the next workflow round from the recent discussion?\n\nEdit the proposed mission, scope, constraints, and acceptance details below.",
|
|
2488
|
+
});
|
|
2489
|
+
if (!decision) {
|
|
2490
|
+
emitCommandText(ctx, "Cancelled next workflow round proposal", "info");
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
goal = decision.goalText;
|
|
2494
|
+
kickoffIntent = "refocus";
|
|
2495
|
+
kickoffMissionAnchor = decision.missionAnchor;
|
|
2496
|
+
await refocusCompletionMission(snapshot, decision.missionAnchor, decision.goalText);
|
|
2497
|
+
snapshot = (await loadCompletionSnapshot(snapshot.files.root)) ?? snapshot;
|
|
2498
|
+
emitCommandText(ctx, `Started a new completion workflow round from recent discussion: ${decision.missionAnchor}`, "info");
|
|
2499
|
+
} else {
|
|
2500
|
+
const mission = currentMissionAnchor(snapshot);
|
|
2501
|
+
pi.setSessionName(`completion: ${mission.slice(0, 60)}`);
|
|
2502
|
+
if (shouldSkipDriverKickoffForTests()) {
|
|
2503
|
+
emitCommandText(ctx, "Skipped completion workflow resume kickoff (test mode)", "info");
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
pi.sendUserMessage(completionResumePrompt());
|
|
2507
|
+
emitCommandText(ctx, "Queued completion workflow resume", "info");
|
|
2508
|
+
return;
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
kickoffMissionAnchor = kickoffMissionAnchor ?? currentMissionAnchor(snapshot);
|
|
2512
|
+
if (hadSnapshot && explicitGoal) {
|
|
2513
|
+
if (workflowDone) {
|
|
2514
|
+
const projectName = path.basename(snapshot.files.root);
|
|
2515
|
+
const proposal = buildGoalAnchoredContextProposal(ctx, goal, projectName);
|
|
2516
|
+
const decision = await confirmContextProposal(ctx, proposal, projectName, {
|
|
2517
|
+
title: "Start the next workflow round from this goal?",
|
|
2518
|
+
nonInteractiveBehavior: "accept",
|
|
2519
|
+
editorPrompt:
|
|
2520
|
+
"Start the next workflow round from this goal?\n\nEdit the proposed mission, scope, constraints, and acceptance details below.",
|
|
2521
|
+
});
|
|
2522
|
+
if (!decision) {
|
|
2523
|
+
emitCommandText(ctx, "Cancelled next workflow round proposal", "info");
|
|
2524
|
+
return;
|
|
2525
|
+
}
|
|
2526
|
+
goal = decision.goalText;
|
|
2527
|
+
kickoffIntent = "refocus";
|
|
2528
|
+
kickoffMissionAnchor = decision.missionAnchor;
|
|
2529
|
+
await refocusCompletionMission(snapshot, decision.missionAnchor, decision.goalText);
|
|
2530
|
+
snapshot = (await loadCompletionSnapshot(snapshot.files.root)) ?? snapshot;
|
|
2531
|
+
emitCommandText(ctx, `Started a new completion workflow round from explicit goal: ${decision.missionAnchor}`, "info");
|
|
2532
|
+
} else {
|
|
2533
|
+
const decision = await confirmExistingWorkflowGoal(ctx, snapshot, goal);
|
|
2534
|
+
if (!decision) {
|
|
2535
|
+
emitCommandText(ctx, "Cancelled existing workflow confirmation", "info");
|
|
2536
|
+
return;
|
|
2537
|
+
}
|
|
2538
|
+
kickoffIntent = decision.action;
|
|
2539
|
+
kickoffMissionAnchor = decision.currentMissionAnchor;
|
|
2540
|
+
if (decision.action === "refocus") {
|
|
2541
|
+
const projectName = path.basename(snapshot.files.root);
|
|
2542
|
+
const proposal = buildGoalAnchoredContextProposal(ctx, goal, projectName);
|
|
2543
|
+
const proposalDecision = await confirmContextProposal(ctx, proposal, projectName, {
|
|
2544
|
+
title: "Start the replacement workflow from this goal?",
|
|
2545
|
+
nonInteractiveBehavior: "accept",
|
|
2546
|
+
editorPrompt:
|
|
2547
|
+
"Start the replacement workflow from this goal?\n\nEdit the proposed mission, scope, constraints, and acceptance details below.",
|
|
2548
|
+
});
|
|
2549
|
+
if (!proposalDecision) {
|
|
2550
|
+
emitCommandText(ctx, "Cancelled replacement workflow proposal", "info");
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
2553
|
+
goal = proposalDecision.goalText;
|
|
2554
|
+
await refocusCompletionMission(snapshot, proposalDecision.missionAnchor, proposalDecision.goalText);
|
|
2555
|
+
snapshot = (await loadCompletionSnapshot(snapshot.files.root)) ?? snapshot;
|
|
2556
|
+
kickoffMissionAnchor = proposalDecision.missionAnchor;
|
|
2557
|
+
emitCommandText(ctx, `Refocused completion mission to: ${proposalDecision.missionAnchor}`, "info");
|
|
2558
|
+
} else if (normalizeMissionAnchorText(goal) !== normalizeMissionAnchorText(decision.currentMissionAnchor)) {
|
|
2559
|
+
emitCommandText(ctx, `Continuing existing workflow without changing mission anchor: ${decision.currentMissionAnchor}`, "info");
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
pi.setSessionName(`completion: ${kickoffMissionAnchor.slice(0, 60)}`);
|
|
2564
|
+
if (shouldSkipDriverKickoffForTests()) {
|
|
2565
|
+
emitCommandText(ctx, "Skipped completion workflow kickoff (test mode)", "info");
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
pi.sendUserMessage(completionKickoff(goal, kickoffIntent, kickoffMissionAnchor));
|
|
2569
|
+
emitCommandText(ctx, "Queued completion workflow kickoff", "info");
|
|
2570
|
+
},
|
|
2571
|
+
});
|
|
2572
|
+
}
|