@linimin/pi-letscook 0.1.53 → 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.
@@ -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, loadCompletionDataForReminder } from "./state-store";
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
- "If canonical workflow context includes a /cook hint, treat it as a high-priority disambiguation signal, but do not let it bypass clear contradictory repo truth or approval-only confirmation.",
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 node
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
- for (const file of ['.agent/profile.json', '.agent/state.json', '.agent/plan.json', '.agent/active-slice.json', '.agent/verification-evidence.json']) {
350
- readJson(file);
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"] });