@linimin/pi-letscook 0.1.54 → 0.1.55
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 +2 -2
- package/README.md +12 -17
- package/extensions/completion/driver.ts +44 -110
- package/extensions/completion/index.ts +10 -70
- package/extensions/completion/prompt-surfaces.ts +53 -380
- package/extensions/completion/proposal.ts +5 -65
- package/extensions/completion/role-runner.ts +4 -311
- package/extensions/completion/state-store.ts +212 -5
- package/extensions/completion/transcription.ts +0 -8
- package/extensions/completion/types.ts +0 -114
- package/package.json +15 -4
- package/scripts/active-slice-contract-test.sh +61 -6
- package/scripts/context-proposal-test.sh +33 -29
- package/scripts/legacy-cleanup-test.sh +11 -0
- package/scripts/refocus-test.sh +10 -11
- package/scripts/release-check.sh +11 -11
- package/scripts/role-runner-contract-test.sh +1 -2
- package/scripts/rubric-contract-test.sh +0 -1
- package/scripts/smoke-test.sh +14 -10
- package/extensions/completion/input-routing.ts +0 -819
- package/scripts/cook-trigger-routing-test.sh +0 -1122
|
@@ -7,17 +7,11 @@ import { DynamicBorder, parseFrontmatter } from "@mariozechner/pi-coding-agent";
|
|
|
7
7
|
import { Container, Text } from "@mariozechner/pi-tui";
|
|
8
8
|
import {
|
|
9
9
|
buildContextProposalAnalystPromptFromEntries,
|
|
10
|
-
extractJsonObjectFromText,
|
|
11
10
|
parseContextProposalAnalystOutput,
|
|
12
|
-
serializeRecentDiscussionEntries,
|
|
13
11
|
type ContextProposal,
|
|
14
12
|
type RecentDiscussionEntry,
|
|
15
13
|
} from "./proposal";
|
|
16
|
-
import {
|
|
17
|
-
buildCookTriggerClassifierPrompt,
|
|
18
|
-
contextProposalAnalystProgressLines,
|
|
19
|
-
maybeWriteCookTriggerClassifierSnapshot,
|
|
20
|
-
} from "./prompt-surfaces";
|
|
14
|
+
import { contextProposalAnalystProgressLines } from "./prompt-surfaces";
|
|
21
15
|
import {
|
|
22
16
|
applyLiveRoleEvent,
|
|
23
17
|
buildInlineRunningLines,
|
|
@@ -29,15 +23,9 @@ import {
|
|
|
29
23
|
refreshCompletionStatus,
|
|
30
24
|
type RoleMessage,
|
|
31
25
|
} from "./status-surface";
|
|
32
|
-
import { completionRootKey, findCompletionRoot, findRepoRoot
|
|
26
|
+
import { completionRootKey, findCompletionRoot, findRepoRoot } from "./state-store";
|
|
33
27
|
import { parseReportFields, transcribeRoleOutput, type TranscriptionResult } from "./transcription";
|
|
34
|
-
import type {
|
|
35
|
-
AgentDefinition,
|
|
36
|
-
CompletionRole,
|
|
37
|
-
CookTriggerClassification,
|
|
38
|
-
JsonRecord,
|
|
39
|
-
LiveRoleActivity,
|
|
40
|
-
} from "./types";
|
|
28
|
+
import type { AgentDefinition, CompletionRole, JsonRecord, LiveRoleActivity } from "./types";
|
|
41
29
|
|
|
42
30
|
export type RunCompletionRoleParams = {
|
|
43
31
|
root: string;
|
|
@@ -79,21 +67,6 @@ export type AnalyzeContextProposalWithAgentParams = {
|
|
|
79
67
|
getCtxUi: <T extends { ui: any }>(ctx: T) => any | undefined;
|
|
80
68
|
};
|
|
81
69
|
|
|
82
|
-
export type ClassifyCookTriggerIntentWithAgentParams = {
|
|
83
|
-
ctx: { cwd: string; hasUI: boolean; ui: any; model?: any };
|
|
84
|
-
projectName: string;
|
|
85
|
-
inputText: string;
|
|
86
|
-
recentEntries: RecentDiscussionEntry[];
|
|
87
|
-
workflowContextLines?: string[];
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
export type CookTriggerClassifierResult = {
|
|
91
|
-
status: "classified" | "timeout" | "invalid_output" | "error";
|
|
92
|
-
classification?: CookTriggerClassification;
|
|
93
|
-
rawOutput?: string;
|
|
94
|
-
errorMessage?: string;
|
|
95
|
-
};
|
|
96
|
-
|
|
97
70
|
const AGENT_HOME = path.join(os.homedir(), ".pi", "agent");
|
|
98
71
|
const EXTENSION_DIR = typeof __dirname === "string" ? __dirname : process.cwd();
|
|
99
72
|
const PACKAGE_ROOT_CANDIDATE = path.resolve(EXTENSION_DIR, "..", "..");
|
|
@@ -106,7 +79,7 @@ const CONTEXT_PROPOSAL_ANALYST_SYSTEM_PROMPT = [
|
|
|
106
79
|
"You may additionally include optional keys alternate_missions, completed_topics, and negated_topics when they are clearly supported by the discussion and canonical workflow context.",
|
|
107
80
|
"mission must be a concise implementation mission anchor sentence.",
|
|
108
81
|
"Prefer the latest clear user implementation intent over older background context when they differ.",
|
|
109
|
-
"
|
|
82
|
+
"Use recent user/custom discussion plus canonical workflow context only; do not infer startup intent from slash-command arguments or let planning-only artifacts bypass approval-only confirmation.",
|
|
110
83
|
"Do not reopen work that the canonical workflow context says is done, completed, historical, or already covered unless the latest discussion clearly asks to revisit it.",
|
|
111
84
|
"Treat stale, weakly related, or explicitly negated topics as noise instead of mission scope.",
|
|
112
85
|
"scope must contain only work items that directly support the mission.",
|
|
@@ -120,28 +93,6 @@ const CONTEXT_PROPOSAL_ANALYST_SYSTEM_PROMPT = [
|
|
|
120
93
|
].join(" ");
|
|
121
94
|
const STARTUP_ANALYST_ROLE = "cook-proposal-analyst";
|
|
122
95
|
const ANALYST_HEARTBEAT_MS = 5_000;
|
|
123
|
-
const COOK_TRIGGER_CLASSIFIER_SYSTEM_PROMPT = [
|
|
124
|
-
"You classify whether the latest user input should stay in the main chat or be intercepted by the workflow-aware router into the canonical /cook workflow before the primary agent starts implementation work.",
|
|
125
|
-
"Assume router mode reviews every non-bypass normal user turn. Do not require short trigger phrases or explicit /cook text before choosing offer_workflow.",
|
|
126
|
-
"Do not emit markdown, code fences, or commentary.",
|
|
127
|
-
"Return exactly one JSON object with keys: decision, confidence, workflow_bias, reason, evidence, riskFlags, focusHint. You may also include optional keys requires_clarification, clarification_slots, and adopted_artifact when clearly supported.",
|
|
128
|
-
"decision must be exactly one of offer_workflow, normal_prompt, or unclear.",
|
|
129
|
-
"Use offer_workflow when the latest input is directly asking to start, resume, refocus, or continue workflow-worthy repo work through the completion boundary, or explicitly asking to let /cook take over.",
|
|
130
|
-
"Use normal_prompt for ordinary questions, explanations, analysis-only requests, or direct requests that should stay with the primary agent.",
|
|
131
|
-
"Use unclear for ambiguous approvals, acknowledgements, or mixed signals where false-positive routing risk is material.",
|
|
132
|
-
"workflow_bias must be exactly one of startup, resume, refocus, next_round, or unknown.",
|
|
133
|
-
"Use startup when there is no active workflow yet, resume when the user is clearly continuing the current workflow, refocus when the user is clearly switching the active workflow to a different goal, and next_round when the previous workflow is done and the user is starting a new round.",
|
|
134
|
-
"When decision is not offer_workflow, prefer workflow_bias=unknown unless a stronger routing hint would still aid debugging.",
|
|
135
|
-
"confidence must be a number from 0 to 1.",
|
|
136
|
-
"reason must be a single concise sentence.",
|
|
137
|
-
"evidence must be an array of short grounded strings.",
|
|
138
|
-
"riskFlags must be an array of short machine-readable strings such as ambiguous-approval, possible-normal-agent-request, or active-workflow-refocus-risk.",
|
|
139
|
-
"focusHint is optional, must stay short, and must never rewrite the workflow mission or invent scope.",
|
|
140
|
-
"When explicit user adoption of a recent plan or repo markdown artifact is evident, adopted_artifact may describe it with kind recent_plan|repo_markdown, path when known, and basis explicit_user_adoption.",
|
|
141
|
-
"requires_clarification may be true when chooser-style disambiguation is safer than guessing, and clarification_slots may list short needs such as goal, scope, or non_goal.",
|
|
142
|
-
"Short acknowledgements like 好, 可以, ok, sure, or 那就這樣 should usually be unclear unless the surrounding context makes the handoff explicit.",
|
|
143
|
-
].join(" ");
|
|
144
|
-
const COOK_TRIGGER_CLASSIFIER_TIMEOUT_MS = 10_000;
|
|
145
96
|
|
|
146
97
|
class StartupAnalystOverlay extends Container {
|
|
147
98
|
private readonly border: DynamicBorder;
|
|
@@ -373,263 +324,6 @@ export async function analyzeContextProposalWithAgent(params: AnalyzeContextProp
|
|
|
373
324
|
}
|
|
374
325
|
}
|
|
375
326
|
|
|
376
|
-
function uniqueStrings(items: string[]): string[] {
|
|
377
|
-
const seen = new Set<string>();
|
|
378
|
-
const result: string[] = [];
|
|
379
|
-
for (const item of items) {
|
|
380
|
-
const normalized = item.trim();
|
|
381
|
-
if (!normalized) continue;
|
|
382
|
-
const key = normalized.toLowerCase();
|
|
383
|
-
if (seen.has(key)) continue;
|
|
384
|
-
seen.add(key);
|
|
385
|
-
result.push(normalized);
|
|
386
|
-
}
|
|
387
|
-
return result;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function localAsStringArray(value: unknown): string[] {
|
|
391
|
-
return Array.isArray(value)
|
|
392
|
-
? uniqueStrings(value.filter((item): item is string => typeof item === "string" && item.trim().length > 0))
|
|
393
|
-
: [];
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
function confidenceFromUnknown(value: unknown): number {
|
|
397
|
-
const parsed =
|
|
398
|
-
typeof value === "number"
|
|
399
|
-
? value
|
|
400
|
-
: typeof value === "string" && value.trim().length > 0
|
|
401
|
-
? Number.parseFloat(value)
|
|
402
|
-
: Number.NaN;
|
|
403
|
-
if (!Number.isFinite(parsed)) return 0;
|
|
404
|
-
return Math.min(1, Math.max(0, parsed));
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function parseCookTriggerClassification(raw: string): CookTriggerClassification | undefined {
|
|
408
|
-
const jsonText = extractJsonObjectFromText(raw);
|
|
409
|
-
if (!jsonText) return undefined;
|
|
410
|
-
let parsed: unknown;
|
|
411
|
-
try {
|
|
412
|
-
parsed = JSON.parse(jsonText);
|
|
413
|
-
} catch {
|
|
414
|
-
return undefined;
|
|
415
|
-
}
|
|
416
|
-
if (!isRecord(parsed)) return undefined;
|
|
417
|
-
const rawDecision = asString(parsed.decision ?? parsed.intent);
|
|
418
|
-
const decision =
|
|
419
|
-
rawDecision === "offer_workflow" || rawDecision === "normal_prompt" || rawDecision === "unclear"
|
|
420
|
-
? rawDecision
|
|
421
|
-
: rawDecision === "route_to_cook"
|
|
422
|
-
? "offer_workflow"
|
|
423
|
-
: undefined;
|
|
424
|
-
if (!decision) return undefined;
|
|
425
|
-
const rawWorkflowBias = asString(parsed.workflow_bias ?? parsed.workflowBias ?? parsed.routing_bias ?? parsed.routingBias);
|
|
426
|
-
const workflowBias =
|
|
427
|
-
rawWorkflowBias === "startup" ||
|
|
428
|
-
rawWorkflowBias === "resume" ||
|
|
429
|
-
rawWorkflowBias === "refocus" ||
|
|
430
|
-
rawWorkflowBias === "next_round" ||
|
|
431
|
-
rawWorkflowBias === "unknown"
|
|
432
|
-
? rawWorkflowBias
|
|
433
|
-
: decision === "offer_workflow" && rawDecision === "route_to_cook"
|
|
434
|
-
? "unknown"
|
|
435
|
-
: "unknown";
|
|
436
|
-
const evidence = localAsStringArray(parsed.evidence);
|
|
437
|
-
const riskFlags = localAsStringArray(parsed.riskFlags ?? parsed.risk_flags);
|
|
438
|
-
const reason = asString(parsed.reason) ?? asString(parsed.rationale) ?? evidence[0] ?? `Classifier returned ${decision}.`;
|
|
439
|
-
const focusHint = asString(parsed.focusHint ?? parsed.focus_hint);
|
|
440
|
-
return {
|
|
441
|
-
decision,
|
|
442
|
-
confidence: confidenceFromUnknown(parsed.confidence),
|
|
443
|
-
workflowBias,
|
|
444
|
-
reason,
|
|
445
|
-
focusHint,
|
|
446
|
-
evidence: evidence.length > 0 ? evidence : [reason],
|
|
447
|
-
riskFlags,
|
|
448
|
-
};
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function triggerClassifierFailureModeFromEnv(): "timeout" | "error" | "invalid_output" | undefined {
|
|
452
|
-
const raw = asString(process.env.PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_FAILURE)?.toLowerCase();
|
|
453
|
-
return raw === "timeout" || raw === "error" || raw === "invalid_output" ? raw : undefined;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
function triggerClassifierSnapshotPath(): string | undefined {
|
|
457
|
-
return asString(process.env.PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_SNAPSHOT_PATH);
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
async function runCookTriggerClassifierSubprocess(
|
|
461
|
-
params: ClassifyCookTriggerIntentWithAgentParams & { prompt: string },
|
|
462
|
-
): Promise<CookTriggerClassifierResult> {
|
|
463
|
-
const cwd = params.ctx.cwd;
|
|
464
|
-
const runCwd = findCompletionRoot(cwd) ?? findRepoRoot(cwd) ?? cwd;
|
|
465
|
-
const modelArg = contextProposalAnalystModelArg(params.ctx.model);
|
|
466
|
-
const systemPromptTemp = await writeTempFile(runCwd, "pi-cook-trigger-classifier-", COOK_TRIGGER_CLASSIFIER_SYSTEM_PROMPT);
|
|
467
|
-
const args: string[] = ["--mode", "json", "-p", "--no-session", "--no-extensions", "--append-system-prompt", systemPromptTemp.filePath];
|
|
468
|
-
if (modelArg) args.push("--model", modelArg);
|
|
469
|
-
args.push(params.prompt);
|
|
470
|
-
const invocation = getPiInvocation(args);
|
|
471
|
-
const liveActivity = createLiveRoleActivity("cook-trigger-classifier");
|
|
472
|
-
const messages: RoleMessage[] = [];
|
|
473
|
-
let stderr = "";
|
|
474
|
-
let timedOut = false;
|
|
475
|
-
try {
|
|
476
|
-
const rawOutput = await new Promise<string | undefined>((resolve) => {
|
|
477
|
-
const proc = spawn(invocation.command, invocation.args, {
|
|
478
|
-
cwd: runCwd,
|
|
479
|
-
env: process.env,
|
|
480
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
481
|
-
shell: false,
|
|
482
|
-
});
|
|
483
|
-
let settled = false;
|
|
484
|
-
let buffer = "";
|
|
485
|
-
const resolveOnce = (value: string | undefined) => {
|
|
486
|
-
if (settled) return;
|
|
487
|
-
settled = true;
|
|
488
|
-
resolve(value);
|
|
489
|
-
};
|
|
490
|
-
const timeoutHandle = setTimeout(() => {
|
|
491
|
-
timedOut = true;
|
|
492
|
-
proc.kill("SIGTERM");
|
|
493
|
-
resolveOnce(undefined);
|
|
494
|
-
}, COOK_TRIGGER_CLASSIFIER_TIMEOUT_MS);
|
|
495
|
-
const processLine = (line: string) => {
|
|
496
|
-
if (!line.trim()) return;
|
|
497
|
-
try {
|
|
498
|
-
const event = JSON.parse(line) as JsonRecord;
|
|
499
|
-
applyLiveRoleEvent(liveActivity, event, messages);
|
|
500
|
-
} catch {
|
|
501
|
-
// ignore malformed lines from the subprocess event stream
|
|
502
|
-
}
|
|
503
|
-
};
|
|
504
|
-
proc.stdout.on("data", (chunk) => {
|
|
505
|
-
buffer += chunk.toString();
|
|
506
|
-
const lines = buffer.split("\n");
|
|
507
|
-
buffer = lines.pop() ?? "";
|
|
508
|
-
for (const line of lines) processLine(line);
|
|
509
|
-
});
|
|
510
|
-
proc.stderr.on("data", (chunk) => {
|
|
511
|
-
stderr += chunk.toString();
|
|
512
|
-
});
|
|
513
|
-
proc.on("close", (code) => {
|
|
514
|
-
clearTimeout(timeoutHandle);
|
|
515
|
-
if (buffer.trim()) processLine(buffer);
|
|
516
|
-
if (timedOut) return;
|
|
517
|
-
resolveOnce(code === 0 ? liveActivity.lastAssistantText?.trim() || undefined : undefined);
|
|
518
|
-
});
|
|
519
|
-
proc.on("error", () => {
|
|
520
|
-
clearTimeout(timeoutHandle);
|
|
521
|
-
resolveOnce(undefined);
|
|
522
|
-
});
|
|
523
|
-
});
|
|
524
|
-
if (!rawOutput) {
|
|
525
|
-
if (timedOut) {
|
|
526
|
-
return {
|
|
527
|
-
status: "timeout",
|
|
528
|
-
errorMessage: `Trigger classifier timed out after ${COOK_TRIGGER_CLASSIFIER_TIMEOUT_MS}ms.`,
|
|
529
|
-
};
|
|
530
|
-
}
|
|
531
|
-
return { status: "error", errorMessage: stderr.trim() || "Trigger classifier produced no assistant output." };
|
|
532
|
-
}
|
|
533
|
-
const classification = parseCookTriggerClassification(rawOutput);
|
|
534
|
-
if (!classification) {
|
|
535
|
-
return {
|
|
536
|
-
status: "invalid_output",
|
|
537
|
-
rawOutput,
|
|
538
|
-
errorMessage: "Trigger classifier returned invalid JSON output.",
|
|
539
|
-
};
|
|
540
|
-
}
|
|
541
|
-
return { status: "classified", classification, rawOutput };
|
|
542
|
-
} finally {
|
|
543
|
-
await fsp.rm(systemPromptTemp.dir, { recursive: true, force: true });
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
export async function classifyCookTriggerIntentWithAgent(
|
|
548
|
-
params: ClassifyCookTriggerIntentWithAgentParams,
|
|
549
|
-
): Promise<CookTriggerClassifierResult> {
|
|
550
|
-
const recentDiscussion = serializeRecentDiscussionEntries(params.recentEntries);
|
|
551
|
-
const prompt = buildCookTriggerClassifierPrompt({
|
|
552
|
-
projectName: params.projectName,
|
|
553
|
-
inputText: params.inputText,
|
|
554
|
-
recentDiscussion,
|
|
555
|
-
workflowContextLines: params.workflowContextLines,
|
|
556
|
-
});
|
|
557
|
-
const snapshotPath = triggerClassifierSnapshotPath();
|
|
558
|
-
const testFailureMode = triggerClassifierFailureModeFromEnv();
|
|
559
|
-
if (testFailureMode) {
|
|
560
|
-
const result: CookTriggerClassifierResult = {
|
|
561
|
-
status: testFailureMode,
|
|
562
|
-
errorMessage: `Forced trigger classifier ${testFailureMode} for deterministic tests.`,
|
|
563
|
-
};
|
|
564
|
-
maybeWriteCookTriggerClassifierSnapshot(
|
|
565
|
-
{
|
|
566
|
-
projectName: params.projectName,
|
|
567
|
-
inputText: params.inputText,
|
|
568
|
-
recentEntries: params.recentEntries,
|
|
569
|
-
workflowContextLines: params.workflowContextLines ?? [],
|
|
570
|
-
prompt,
|
|
571
|
-
result,
|
|
572
|
-
},
|
|
573
|
-
snapshotPath,
|
|
574
|
-
);
|
|
575
|
-
return result;
|
|
576
|
-
}
|
|
577
|
-
const testOutput = asString(process.env.PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_OUTPUT);
|
|
578
|
-
if (testOutput) {
|
|
579
|
-
const classification = parseCookTriggerClassification(testOutput);
|
|
580
|
-
const result: CookTriggerClassifierResult = classification
|
|
581
|
-
? { status: "classified", classification, rawOutput: testOutput }
|
|
582
|
-
: {
|
|
583
|
-
status: "invalid_output",
|
|
584
|
-
rawOutput: testOutput,
|
|
585
|
-
errorMessage: "Trigger classifier test override did not match the required JSON schema.",
|
|
586
|
-
};
|
|
587
|
-
maybeWriteCookTriggerClassifierSnapshot(
|
|
588
|
-
{
|
|
589
|
-
projectName: params.projectName,
|
|
590
|
-
inputText: params.inputText,
|
|
591
|
-
recentEntries: params.recentEntries,
|
|
592
|
-
workflowContextLines: params.workflowContextLines ?? [],
|
|
593
|
-
prompt,
|
|
594
|
-
result,
|
|
595
|
-
},
|
|
596
|
-
snapshotPath,
|
|
597
|
-
);
|
|
598
|
-
return result;
|
|
599
|
-
}
|
|
600
|
-
try {
|
|
601
|
-
const result = await runCookTriggerClassifierSubprocess({ ...params, prompt });
|
|
602
|
-
maybeWriteCookTriggerClassifierSnapshot(
|
|
603
|
-
{
|
|
604
|
-
projectName: params.projectName,
|
|
605
|
-
inputText: params.inputText,
|
|
606
|
-
recentEntries: params.recentEntries,
|
|
607
|
-
workflowContextLines: params.workflowContextLines ?? [],
|
|
608
|
-
prompt,
|
|
609
|
-
result,
|
|
610
|
-
},
|
|
611
|
-
snapshotPath,
|
|
612
|
-
);
|
|
613
|
-
return result;
|
|
614
|
-
} catch (error) {
|
|
615
|
-
const result: CookTriggerClassifierResult = {
|
|
616
|
-
status: "error",
|
|
617
|
-
errorMessage: error instanceof Error ? error.message : String(error),
|
|
618
|
-
};
|
|
619
|
-
maybeWriteCookTriggerClassifierSnapshot(
|
|
620
|
-
{
|
|
621
|
-
projectName: params.projectName,
|
|
622
|
-
inputText: params.inputText,
|
|
623
|
-
recentEntries: params.recentEntries,
|
|
624
|
-
workflowContextLines: params.workflowContextLines ?? [],
|
|
625
|
-
prompt,
|
|
626
|
-
result,
|
|
627
|
-
},
|
|
628
|
-
snapshotPath,
|
|
629
|
-
);
|
|
630
|
-
return result;
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
327
|
|
|
634
328
|
export async function loadAgentDefinition(cwd: string, role: CompletionRole): Promise<AgentDefinition> {
|
|
635
329
|
const projectAgent = walkUpForDir(cwd, [".pi", "agents", `${role}.md`]);
|
|
@@ -683,7 +377,6 @@ export function getPiInvocation(args: string[]): { command: string; args: string
|
|
|
683
377
|
|
|
684
378
|
export async function runCompletionRole(params: RunCompletionRoleParams): Promise<RunCompletionRoleResult> {
|
|
685
379
|
const agent = await loadAgentDefinition(params.root, params.role);
|
|
686
|
-
await loadCompletionDataForReminder(params.root);
|
|
687
380
|
const systemPromptTemp = await writeTempFile(params.root, "pi-completion-role-", agent.systemPrompt);
|
|
688
381
|
const taskLines = [...params.systemPromptPreamble];
|
|
689
382
|
if (params.evaluationContextLines?.length) taskLines.push("", ...params.evaluationContextLines);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
2
3
|
import { promises as fsp } from "node:fs";
|
|
3
4
|
import * as os from "node:os";
|
|
4
5
|
import * as path from "node:path";
|
|
@@ -7,6 +8,13 @@ import type { CompletionStateSnapshot, JsonRecord } from "./types";
|
|
|
7
8
|
const PROTOCOL_ID = "completion";
|
|
8
9
|
const DEFAULT_TASK_TYPE = "completion-workflow";
|
|
9
10
|
const DEFAULT_EVALUATION_PROFILE = "completion-rubric-v1";
|
|
11
|
+
const TRACKED_CONTRACT_FILES = [
|
|
12
|
+
".agent/README.md",
|
|
13
|
+
".agent/mission.md",
|
|
14
|
+
".agent/profile.json",
|
|
15
|
+
".agent/verify_completion_stop.sh",
|
|
16
|
+
".agent/verify_completion_control_plane.sh",
|
|
17
|
+
] as const;
|
|
10
18
|
|
|
11
19
|
function isRecord(value: unknown): value is JsonRecord {
|
|
12
20
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -223,10 +231,12 @@ export function buildProfileRecord(args: {
|
|
|
223
231
|
export function defaultState(
|
|
224
232
|
missionAnchor: string,
|
|
225
233
|
routing?: { taskType?: string; evaluationProfile?: string; continuationReason?: string },
|
|
234
|
+
advisoryStartupBrief?: JsonRecord,
|
|
226
235
|
): JsonRecord {
|
|
227
236
|
return {
|
|
228
237
|
schema_version: 1,
|
|
229
238
|
mission_anchor: missionAnchor,
|
|
239
|
+
advisory_startup_brief: advisoryStartupBrief ?? null,
|
|
230
240
|
current_phase: "reground",
|
|
231
241
|
continuation_policy: "continue",
|
|
232
242
|
continuation_reason: routing?.continuationReason ?? "Fresh completion bootstrap requires canonical re-ground",
|
|
@@ -330,8 +340,18 @@ export function buildVerifyControlPlaneScript(): string {
|
|
|
330
340
|
if (fs.existsSync(trackedScriptPath)) {
|
|
331
341
|
return fs.readFileSync(trackedScriptPath, "utf8");
|
|
332
342
|
}
|
|
333
|
-
return `#!/usr/bin/env
|
|
343
|
+
return `#!/usr/bin/env bash
|
|
344
|
+
':' //; exec node "$0" "$@"
|
|
334
345
|
const fs = require('node:fs');
|
|
346
|
+
const { spawnSync } = require('node:child_process');
|
|
347
|
+
|
|
348
|
+
const REQUIRED_TRACKED_CONTRACT_FILES = [
|
|
349
|
+
'.agent/README.md',
|
|
350
|
+
'.agent/mission.md',
|
|
351
|
+
'.agent/profile.json',
|
|
352
|
+
'.agent/verify_completion_stop.sh',
|
|
353
|
+
'.agent/verify_completion_control_plane.sh',
|
|
354
|
+
];
|
|
335
355
|
|
|
336
356
|
function fail(message) {
|
|
337
357
|
console.error(message);
|
|
@@ -346,8 +366,182 @@ function readJson(file) {
|
|
|
346
366
|
}
|
|
347
367
|
}
|
|
348
368
|
|
|
349
|
-
|
|
350
|
-
|
|
369
|
+
function asString(value) {
|
|
370
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function asNumber(value) {
|
|
374
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function asStringArray(value) {
|
|
378
|
+
return Array.isArray(value)
|
|
379
|
+
? value.filter((item) => typeof item === 'string' && item.trim().length > 0)
|
|
380
|
+
: [];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function sameStringArrays(left, right) {
|
|
384
|
+
return left.length === right.length && left.every((item, index) => item === right[index]);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function runGit(args, options = {}) {
|
|
388
|
+
const result = spawnSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
389
|
+
if (!options.allowFailure && result.status !== 0) {
|
|
390
|
+
const stderr = asString(result.stderr) ?? 'git command failed';
|
|
391
|
+
fail(\`git \${args.join(' ')} failed: \${stderr}\`);
|
|
392
|
+
}
|
|
393
|
+
return result;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function gitHeadSha() {
|
|
397
|
+
const result = runGit(['rev-parse', 'HEAD'], { allowFailure: true });
|
|
398
|
+
return result.status === 0 ? asString(result.stdout) : undefined;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function ensureTrackedContractFiles() {
|
|
402
|
+
for (const file of REQUIRED_TRACKED_CONTRACT_FILES) {
|
|
403
|
+
const result = runGit(['ls-files', '--error-unmatch', file], { allowFailure: true });
|
|
404
|
+
if (result.status !== 0) {
|
|
405
|
+
fail(\`Required tracked completion contract file is missing from git index: \${file}\`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function ensureCommitExists(commitish, label) {
|
|
411
|
+
const result = runGit(['rev-parse', '--verify', \`\${commitish}^{commit}\`], { allowFailure: true });
|
|
412
|
+
if (result.status !== 0) {
|
|
413
|
+
fail(\`\${label} must resolve to an existing commit: \${commitish}\`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function trackedDiffFiles(fromCommit, toCommit) {
|
|
418
|
+
const result = runGit(['diff', '--name-only', '--diff-filter=ACMR', \`\${fromCommit}..\${toCommit}\`]);
|
|
419
|
+
return result.stdout
|
|
420
|
+
.split(/\\r?\\n/)
|
|
421
|
+
.map((line) => line.trim())
|
|
422
|
+
.filter(Boolean);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const profile = readJson('.agent/profile.json');
|
|
426
|
+
const state = readJson('.agent/state.json');
|
|
427
|
+
const plan = readJson('.agent/plan.json');
|
|
428
|
+
const active = readJson('.agent/active-slice.json');
|
|
429
|
+
const evidence = readJson('.agent/verification-evidence.json');
|
|
430
|
+
|
|
431
|
+
ensureTrackedContractFiles();
|
|
432
|
+
|
|
433
|
+
for (const [file, record] of [
|
|
434
|
+
['.agent/profile.json', profile],
|
|
435
|
+
['.agent/state.json', state],
|
|
436
|
+
['.agent/plan.json', plan],
|
|
437
|
+
['.agent/active-slice.json', active],
|
|
438
|
+
]) {
|
|
439
|
+
if (!asString(record.task_type)) fail(file + ' is missing task_type');
|
|
440
|
+
if (!asString(record.evaluation_profile)) fail(file + ' is missing evaluation_profile');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const taskType = asString(profile.task_type);
|
|
444
|
+
const evaluationProfile = asString(profile.evaluation_profile);
|
|
445
|
+
if (asString(state.task_type) !== taskType) fail('.agent/state.json task_type must match .agent/profile.json task_type');
|
|
446
|
+
if (asString(plan.task_type) !== taskType) fail('.agent/plan.json task_type must match .agent/profile.json task_type');
|
|
447
|
+
if (asString(active.task_type) !== taskType) fail('.agent/active-slice.json task_type must match .agent/profile.json task_type');
|
|
448
|
+
if (asString(state.evaluation_profile) !== evaluationProfile) fail('.agent/state.json evaluation_profile must match .agent/profile.json evaluation_profile');
|
|
449
|
+
if (asString(plan.evaluation_profile) !== evaluationProfile) fail('.agent/plan.json evaluation_profile must match .agent/profile.json evaluation_profile');
|
|
450
|
+
if (asString(active.evaluation_profile) !== evaluationProfile) fail('.agent/active-slice.json evaluation_profile must match .agent/profile.json evaluation_profile');
|
|
451
|
+
|
|
452
|
+
if (asString(evidence.artifact_type) !== 'completion-verification-evidence') {
|
|
453
|
+
fail('.agent/verification-evidence.json artifact_type must be completion-verification-evidence');
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const exactStatuses = new Set(['selected', 'in_progress', 'committed', 'done']);
|
|
457
|
+
const activeStatus = asString(active.status);
|
|
458
|
+
const exactHandoff = exactStatuses.has(activeStatus || '');
|
|
459
|
+
const planSlices = Array.isArray(plan.candidate_slices) ? plan.candidate_slices : [];
|
|
460
|
+
const activeSliceId = asString(active.slice_id);
|
|
461
|
+
const planSlice = activeSliceId ? planSlices.find((slice) => asString(slice && slice.slice_id) === activeSliceId) : undefined;
|
|
462
|
+
|
|
463
|
+
if (exactHandoff && !planSlice) {
|
|
464
|
+
fail('slice_id must match a slice in .agent/plan.json when status carries an exact handoff');
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (exactHandoff) {
|
|
468
|
+
const requiredStringFields = ['goal', 'why_now', 'basis_commit'];
|
|
469
|
+
for (const field of requiredStringFields) {
|
|
470
|
+
if (!asString(active[field])) fail('.agent/active-slice.json is missing ' + field + ' when status carries an exact handoff');
|
|
471
|
+
}
|
|
472
|
+
const requiredArrayFields = ['contract_ids', 'acceptance_criteria', 'blocked_on', 'locked_notes', 'must_fix_findings', 'implementation_surfaces', 'verification_commands', 'remaining_contract_ids_before'];
|
|
473
|
+
for (const field of requiredArrayFields) {
|
|
474
|
+
if (!Array.isArray(active[field])) fail('.agent/active-slice.json is missing ' + field + ' when status carries an exact handoff');
|
|
475
|
+
}
|
|
476
|
+
const requiredNumberFields = ['priority', 'release_blocker_count_before', 'high_value_gap_count_before'];
|
|
477
|
+
for (const field of requiredNumberFields) {
|
|
478
|
+
if (asNumber(active[field]) === undefined) fail('.agent/active-slice.json is missing ' + field + ' when status carries an exact handoff');
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const mismatchFields = [];
|
|
482
|
+
if (asString(planSlice.slice_id) !== activeSliceId) mismatchFields.push('slice_id');
|
|
483
|
+
if (asString(planSlice.goal) !== asString(active.goal)) mismatchFields.push('goal');
|
|
484
|
+
if (!sameStringArrays(asStringArray(planSlice.contract_ids), asStringArray(active.contract_ids))) mismatchFields.push('contract_ids');
|
|
485
|
+
if (!sameStringArrays(asStringArray(planSlice.acceptance_criteria), asStringArray(active.acceptance_criteria))) mismatchFields.push('acceptance_criteria');
|
|
486
|
+
if (!sameStringArrays(asStringArray(planSlice.blocked_on), asStringArray(active.blocked_on))) mismatchFields.push('blocked_on');
|
|
487
|
+
if (asNumber(planSlice.priority) !== asNumber(active.priority)) mismatchFields.push('priority');
|
|
488
|
+
if (asString(planSlice.why_now) !== asString(active.why_now)) mismatchFields.push('why_now');
|
|
489
|
+
const planMirrorFields = ['locked_notes', 'must_fix_findings', 'implementation_surfaces', 'verification_commands', 'basis_commit', 'remaining_contract_ids_before', 'release_blocker_count_before', 'high_value_gap_count_before'];
|
|
490
|
+
for (const field of planMirrorFields) {
|
|
491
|
+
const planValue = planSlice[field];
|
|
492
|
+
const activeValue = active[field];
|
|
493
|
+
if (Array.isArray(planValue) || Array.isArray(activeValue)) {
|
|
494
|
+
if (!sameStringArrays(asStringArray(planValue), asStringArray(activeValue))) mismatchFields.push(field);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (typeof planValue === 'number' || typeof activeValue === 'number') {
|
|
498
|
+
if (asNumber(planValue) !== asNumber(activeValue)) mismatchFields.push(field);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (asString(planValue) !== asString(activeValue)) mismatchFields.push(field);
|
|
502
|
+
}
|
|
503
|
+
if (mismatchFields.length > 0) {
|
|
504
|
+
fail('.agent/active-slice.json must match the selected .agent/plan.json slice across: ' + mismatchFields.join(', '));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (asString(evidence.subject_type) !== 'selected_slice') {
|
|
508
|
+
fail('subject_type must be selected_slice when active slice exact handoff requires verification evidence');
|
|
509
|
+
}
|
|
510
|
+
if (asString(evidence.slice_id) !== activeSliceId) fail('.agent/verification-evidence.json slice_id must match .agent/active-slice.json slice_id');
|
|
511
|
+
if (asString(evidence.goal) !== asString(active.goal)) fail('.agent/verification-evidence.json goal must match .agent/active-slice.json goal');
|
|
512
|
+
if (!sameStringArrays(asStringArray(evidence.contract_ids), asStringArray(active.contract_ids))) fail('.agent/verification-evidence.json contract_ids must match .agent/active-slice.json contract_ids');
|
|
513
|
+
if (asString(evidence.basis_commit) !== asString(active.basis_commit)) fail('.agent/verification-evidence.json basis_commit must match .agent/active-slice.json basis_commit');
|
|
514
|
+
if (!sameStringArrays(asStringArray(evidence.verification_commands), asStringArray(active.verification_commands))) {
|
|
515
|
+
fail('.agent/verification-evidence.json verification_commands must match .agent/active-slice.json verification_commands');
|
|
516
|
+
}
|
|
517
|
+
if (!asString(evidence.recorded_at)) fail('.agent/verification-evidence.json recorded_at must be present for selected-slice evidence');
|
|
518
|
+
if (asString(evidence.outcome) === 'not_recorded') fail('.agent/verification-evidence.json outcome must not be not_recorded for selected-slice evidence');
|
|
519
|
+
const headSha = gitHeadSha();
|
|
520
|
+
if (headSha && asString(evidence.head_sha) !== headSha) {
|
|
521
|
+
fail('.agent/verification-evidence.json head_sha must match current HEAD');
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const basisCommit = asString(active.basis_commit);
|
|
525
|
+
if (basisCommit && headSha) {
|
|
526
|
+
ensureCommitExists(basisCommit, '.agent/active-slice.json basis_commit');
|
|
527
|
+
const ancestorCheck = runGit(['merge-base', '--is-ancestor', basisCommit, headSha], { allowFailure: true });
|
|
528
|
+
if (ancestorCheck.status !== 0) {
|
|
529
|
+
fail(\`.agent/active-slice.json basis_commit must be an ancestor of current HEAD: \${basisCommit} -> \${headSha}\`);
|
|
530
|
+
}
|
|
531
|
+
const changedFiles = trackedDiffFiles(basisCommit, headSha);
|
|
532
|
+
const implementationSurfaces = new Set(asStringArray(active.implementation_surfaces));
|
|
533
|
+
const missingSurfaces = changedFiles.filter((file) => !implementationSurfaces.has(file));
|
|
534
|
+
if (missingSurfaces.length > 0) {
|
|
535
|
+
fail('.agent/active-slice.json implementation_surfaces must cover every tracked file changed from basis_commit to current HEAD; missing: ' + missingSurfaces.join(', '));
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
} else {
|
|
539
|
+
const subjectType = asString(evidence.subject_type);
|
|
540
|
+
if (subjectType === 'none') {
|
|
541
|
+
if (asString(evidence.outcome) && asString(evidence.outcome) !== 'not_recorded') {
|
|
542
|
+
fail('.agent/verification-evidence.json outcome must stay not_recorded when subject_type=none');
|
|
543
|
+
}
|
|
544
|
+
}
|
|
351
545
|
}
|
|
352
546
|
`;
|
|
353
547
|
}
|
|
@@ -379,6 +573,18 @@ async function ensureGitignore(root: string): Promise<boolean> {
|
|
|
379
573
|
return true;
|
|
380
574
|
}
|
|
381
575
|
|
|
576
|
+
async function stageTrackedContractFiles(root: string): Promise<void> {
|
|
577
|
+
if (!(await pathExists(path.join(root, ".git")))) return;
|
|
578
|
+
const result = spawnSync("git", ["-C", root, "add", "--", ...TRACKED_CONTRACT_FILES], {
|
|
579
|
+
encoding: "utf8",
|
|
580
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
581
|
+
});
|
|
582
|
+
if (result.status !== 0) {
|
|
583
|
+
const stderr = asString(result.stderr) ?? "git add failed while staging completion contract files";
|
|
584
|
+
throw new Error(stderr);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
382
588
|
export type ScaffoldResult = {
|
|
383
589
|
root: string;
|
|
384
590
|
created: string[];
|
|
@@ -389,7 +595,7 @@ export type ScaffoldResult = {
|
|
|
389
595
|
export async function scaffoldCompletionFiles(
|
|
390
596
|
root: string,
|
|
391
597
|
missionAnchor: string,
|
|
392
|
-
options?: { analysis?: { taskType?: string; evaluationProfile?: string }; continuationReason?: string },
|
|
598
|
+
options?: { analysis?: { taskType?: string; evaluationProfile?: string }; continuationReason?: string; advisoryStartupBrief?: JsonRecord },
|
|
393
599
|
): Promise<ScaffoldResult> {
|
|
394
600
|
const files = resolveFiles(root);
|
|
395
601
|
const created: string[] = [];
|
|
@@ -410,7 +616,7 @@ export async function scaffoldCompletionFiles(
|
|
|
410
616
|
{ path: path.join(files.agentDir, "verify_completion_control_plane.sh"), content: buildVerifyControlPlaneScript(), executable: true },
|
|
411
617
|
{
|
|
412
618
|
path: files.statePath,
|
|
413
|
-
content: `${JSON.stringify(defaultState(missionAnchor, { taskType: options?.analysis?.taskType, evaluationProfile: options?.analysis?.evaluationProfile, continuationReason: options?.continuationReason }), null, 2)}\n`,
|
|
619
|
+
content: `${JSON.stringify(defaultState(missionAnchor, { taskType: options?.analysis?.taskType, evaluationProfile: options?.analysis?.evaluationProfile, continuationReason: options?.continuationReason }, options?.advisoryStartupBrief), null, 2)}\n`,
|
|
414
620
|
},
|
|
415
621
|
{ path: files.planPath, content: `${JSON.stringify(defaultPlan(missionAnchor, { taskType: options?.analysis?.taskType, evaluationProfile: options?.analysis?.evaluationProfile }), null, 2)}\n` },
|
|
416
622
|
{ path: files.activePath, content: `${JSON.stringify(defaultActiveSlice(missionAnchor, { taskType: options?.analysis?.taskType, evaluationProfile: options?.analysis?.evaluationProfile }), null, 2)}\n` },
|
|
@@ -425,6 +631,7 @@ export async function scaffoldCompletionFiles(
|
|
|
425
631
|
created.push(path.relative(root, file.path));
|
|
426
632
|
}
|
|
427
633
|
if (await ensureGitignore(root)) updated.push(".gitignore");
|
|
634
|
+
await stageTrackedContractFiles(root);
|
|
428
635
|
return { root, created, updated, missionAnchor };
|
|
429
636
|
}
|
|
430
637
|
|
|
@@ -17,14 +17,6 @@ export function parseReportFields(text: string): Record<string, string> {
|
|
|
17
17
|
return roleReporting.parseReportFields(text);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export function parseYesNo(value: string | undefined): boolean | undefined {
|
|
21
|
-
return roleReporting.parseYesNo(value);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function parseFirstNumber(value: string | undefined): number | undefined {
|
|
25
|
-
return roleReporting.parseFirstNumber(value);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
20
|
async function gitHeadSha(cwd: string): Promise<string | undefined> {
|
|
29
21
|
return await new Promise((resolve) => {
|
|
30
22
|
const proc = spawn("git", ["rev-parse", "HEAD"], { cwd, stdio: ["ignore", "pipe", "ignore"] });
|