specpi 0.10.0 → 0.11.2

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.
@@ -33,11 +33,21 @@ import {
33
33
  renderChallengeMarkdown,
34
34
  validateChallengeSubmission,
35
35
  } from "./challenge.mjs";
36
+ import {
37
+ TASK_CONTRACT_ENTRY,
38
+ createTaskContract,
39
+ markdownPathLabel,
40
+ readTaskContract,
41
+ renderTaskContract,
42
+ taskContractScopeViolations,
43
+ validateTaskContract,
44
+ } from "./task-contract.mjs";
36
45
 
37
46
  const agentDir = path.resolve(process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent"));
38
47
  const stateDir = path.join(agentDir, "specpi");
39
48
  const SCOPE_ENTRY = "specpi-scope-state";
40
49
  const CHALLENGE_ENTRY = "specpi-completion-challenge";
50
+ const TASK_HANDOFF_ENTRY = "specpi-task-handoff";
41
51
  const SCOPE_STATUS = "specpi-scope";
42
52
  const MAX_PENDING_SCOPE = 40;
43
53
  // `read` is the one documented Pi seam that cannot mutate the worktree. Every other tool, including extension-provided
@@ -57,11 +67,14 @@ interface ScopeState {
57
67
  observed: string[];
58
68
  indeterminate: boolean;
59
69
  generation: number;
70
+ taskDigest?: string;
60
71
  }
61
72
 
62
73
  interface ActiveChallenge {
63
74
  generation: string;
64
75
  sessionId: string;
76
+ root: string;
77
+ taskContractDigest?: string;
65
78
  facts: any;
66
79
  prompt: string;
67
80
  delivered: boolean;
@@ -76,6 +89,21 @@ interface ChallengeEntryData {
76
89
  createdAt?: string;
77
90
  }
78
91
 
92
+ interface TaskContractEntryData {
93
+ kind: "set" | "cleared";
94
+ contract?: any;
95
+ createdAt?: string;
96
+ reason?: string;
97
+ }
98
+
99
+ interface TaskHandoffEntryData {
100
+ markdown: string;
101
+ contractId: string;
102
+ contractDigest: string;
103
+ indeterminate: boolean;
104
+ createdAt: string;
105
+ }
106
+
79
107
  function validScopeEntry(value: any): value is ScopeItem {
80
108
  return (
81
109
  value &&
@@ -96,6 +124,7 @@ function emptyScope(root: string): ScopeState {
96
124
  observed: [],
97
125
  indeterminate: false,
98
126
  generation: 0,
127
+ taskDigest: undefined,
99
128
  };
100
129
  }
101
130
 
@@ -114,6 +143,128 @@ function parseExperimentCard(source: string, fallbackName: string) {
114
143
  };
115
144
  }
116
145
 
146
+ function taskContractEditorText(contract: any = {}) {
147
+ const requirements = Array.isArray(contract.requirements) ? contract.requirements : [];
148
+ const paths = Array.isArray(contract.paths) ? contract.paths : [];
149
+ const nonGoals = Array.isArray(contract.nonGoals) ? contract.nonGoals : [];
150
+ const requirementLines =
151
+ requirements.length > 0
152
+ ? requirements.flatMap((item: any, index: number) => [
153
+ `- ${item.id ?? `R${index + 1}`}: ${item.description ?? ""}`,
154
+ ` Acceptance: ${item.acceptance ?? ""}`,
155
+ ])
156
+ : ["- R1: ", " Acceptance: "];
157
+
158
+ return [
159
+ `Objective: ${contract.objective ?? ""}`,
160
+ `Hypothesis: ${contract.hypothesis ?? ""}`,
161
+ "Requirements:",
162
+ ...requirementLines,
163
+ "Paths:",
164
+ ...(paths.length > 0 ? paths.map((item: string) => `- ${markdownPathLabel(item)}`) : ["- "]),
165
+ `Rollback: ${contract.rollback ?? ""}`,
166
+ "Non-goals:",
167
+ ...(nonGoals.length > 0 ? nonGoals.map((item: string) => `- ${item}`) : ["- "]),
168
+ ].join("\n");
169
+ }
170
+
171
+ function parseTaskContractCard(source: string) {
172
+ const fields: Record<string, string> = {};
173
+ const requirements: any[] = [];
174
+ const paths: string[] = [];
175
+ const nonGoals: string[] = [];
176
+ let section = "";
177
+ let currentRequirement: any;
178
+
179
+ for (const rawLine of String(source).split(/\r?\n/u)) {
180
+ const line = rawLine.trimEnd();
181
+ const field = line.match(/^\s*(Objective|Hypothesis|Rollback|Requirements|Paths|Non-goals):\s*(.*)$/iu);
182
+ if (field) {
183
+ const name = field[1].toLowerCase();
184
+ const value = field[2].trim();
185
+ if (["objective", "hypothesis", "rollback"].includes(name)) {
186
+ fields[name] = value;
187
+ section = "";
188
+ } else {
189
+ section = name;
190
+ if (value) {
191
+ if (name === "paths") {
192
+ paths.push(decodeDisplayedPath(value.replace(/^[-*]\s*/u, "").trim()));
193
+ } else if (name === "non-goals") {
194
+ nonGoals.push(value.replace(/^[-*]\s*/u, "").trim());
195
+ }
196
+ }
197
+ }
198
+
199
+ continue;
200
+ }
201
+
202
+ if (!line.trim()) {
203
+ continue;
204
+ }
205
+
206
+ if (section === "requirements") {
207
+ const requirement = line.match(/^\s*[-*]\s*(?:\[?([A-Za-z][A-Za-z0-9_-]*)\]?\s*(?::|[-–—])\s*)?(.+?)\s*$/u);
208
+ const acceptance = line.match(/^\s*(?:Acceptance|Accept):\s*(.*)$/iu);
209
+ if (requirement) {
210
+ currentRequirement = {
211
+ ...(requirement[1] ? { id: requirement[1] } : {}),
212
+ description: requirement[2].trim(),
213
+ acceptance: "",
214
+ };
215
+ requirements.push(currentRequirement);
216
+ } else if (acceptance && currentRequirement) {
217
+ currentRequirement.acceptance = acceptance[1].trim();
218
+ }
219
+ } else if (section === "paths") {
220
+ const value = line.replace(/^\s*[-*]\s*/u, "").trim();
221
+ if (value) {
222
+ paths.push(decodeDisplayedPath(value));
223
+ }
224
+ } else if (section === "non-goals") {
225
+ const value = line.replace(/^\s*[-*]\s*/u, "").trim();
226
+ if (value) {
227
+ nonGoals.push(value);
228
+ }
229
+ }
230
+ }
231
+
232
+ return {
233
+ objective: fields.objective ?? "",
234
+ hypothesis: fields.hypothesis ?? "",
235
+ requirements,
236
+ paths,
237
+ rollback: fields.rollback ?? "",
238
+ nonGoals,
239
+ };
240
+ }
241
+
242
+ function decodeDisplayedPath(value: string) {
243
+ try {
244
+ return decodeURIComponent(value);
245
+ } catch {
246
+ return value;
247
+ }
248
+ }
249
+
250
+ function experimentCardEditorText(contract: any, fallbackName: string) {
251
+ if (!contract) {
252
+ return `Name: ${fallbackName}\nHypothesis: \nAcceptance: \nNon-goals:\n- `;
253
+ }
254
+
255
+ const acceptance = contract.requirements.map((item: any) => `${item.id}: ${item.acceptance}`).join("; ");
256
+ const nonGoals =
257
+ contract.nonGoals.length > 0 ? contract.nonGoals.map((item: string) => `- ${item}`).join("\n") : "- ";
258
+
259
+ return [
260
+ `Name: ${contract.objective}`,
261
+ `Hypothesis: ${contract.hypothesis}`,
262
+ `Acceptance: ${acceptance}`,
263
+ "Non-goals:",
264
+ nonGoals,
265
+ ].join("\n");
266
+ }
267
+
117
268
  function safeMessage(error: unknown) {
118
269
  return (error instanceof Error ? error.message : String(error))
119
270
  .replace(/[\u0000-\u001f\u007f]+/gu, " ")
@@ -122,11 +273,14 @@ function safeMessage(error: unknown) {
122
273
 
123
274
  export default function workflowControls(pi: ExtensionAPI) {
124
275
  let scope = emptyScope(canonicalRoot(process.cwd()));
276
+ let latestTaskContract: any | undefined;
125
277
  let activeChallenge: ActiveChallenge | undefined;
126
278
  let latestChallenge: ChallengeEntryData | undefined;
279
+ let taskContractError: string | undefined;
127
280
  let observedToolFailures = 0;
128
281
  let experimentBusy = false;
129
282
  let latestSnapshot: any;
283
+ let sessionGeneration = 0;
130
284
  const snapshots = new Map<string, any>();
131
285
  const supportsEntryRenderer = typeof pi.registerEntryRenderer === "function";
132
286
 
@@ -145,16 +299,125 @@ export default function workflowControls(pi: ExtensionAPI) {
145
299
  return canonicalRoot(cwd);
146
300
  };
147
301
 
148
- const emitScopeStatus = (ctx: ExtensionContext) => {
302
+ const branchEntries = (ctx: ExtensionContext) => ctx.sessionManager.getBranch?.() ?? [];
303
+
304
+ // Pi contexts expose the live session through getters. Retaining `ctx` across an await does not retain its
305
+ // original branch, and /tree can change that branch without reloading this extension or changing its session ID.
306
+ const captureSession = (ctx: ExtensionContext) => ({
307
+ generation: sessionGeneration,
308
+ cwd: ctx.cwd,
309
+ sessionId: ctx.sessionManager.getSessionId(),
310
+ });
311
+ const sessionIsCurrent = (origin: ReturnType<typeof captureSession>, ctx: ExtensionContext) =>
312
+ origin.generation === sessionGeneration &&
313
+ origin.cwd === ctx.cwd &&
314
+ origin.sessionId === ctx.sessionManager.getSessionId();
315
+
316
+ const emitTaskContractChanged = (contract: any | undefined, previousDigest?: string) => {
317
+ pi.events.emit("specpi:task-contract-changed", {
318
+ active: Boolean(contract),
319
+ previousDigest,
320
+ digest: contract?.digest,
321
+ id: contract?.id,
322
+ objective: contract?.objective,
323
+ requirements: contract?.requirements?.length ?? 0,
324
+ });
325
+ };
326
+
327
+ const invalidateChallenge = (ctx: ExtensionContext, reason = "task contract changed") => {
328
+ const hadEvidence = Boolean(activeChallenge || latestChallenge);
329
+ activeChallenge = undefined;
330
+ latestChallenge = undefined;
331
+ if (hadEvidence) {
332
+ pi.appendEntry<ChallengeEntryData>(CHALLENGE_ENTRY, {
333
+ kind: "cleared",
334
+ generation: randomUUID(),
335
+ createdAt: new Date().toISOString(),
336
+ reason,
337
+ });
338
+ }
339
+
340
+ return hadEvidence;
341
+ };
342
+
343
+ const readCurrentTaskContract = (ctx: ExtensionContext, root: string) => {
344
+ const current = readTaskContract(branchEntries(ctx), root);
345
+ taskContractError = undefined;
346
+
347
+ return current;
348
+ };
349
+
350
+ const refreshTaskContract = (ctx: ExtensionContext, root: string, { invalidate = true } = {}) => {
351
+ let current;
352
+ try {
353
+ current = readCurrentTaskContract(ctx, root);
354
+ } catch (error) {
355
+ taskContractError = safeMessage(error);
356
+ throw error;
357
+ }
358
+
359
+ const previousDigest = latestTaskContract?.digest;
360
+ const changed = previousDigest !== current?.digest;
361
+ latestTaskContract = current;
362
+ if (changed) {
363
+ if (
364
+ invalidate &&
365
+ previousDigest !== current?.digest &&
366
+ (previousDigest !== undefined || activeChallenge || latestChallenge)
367
+ ) {
368
+ invalidateChallenge(ctx);
369
+ }
370
+
371
+ emitTaskContractChanged(current, previousDigest);
372
+ emitScopeStatus(ctx);
373
+ }
374
+
375
+ return current;
376
+ };
377
+
378
+ const persistTaskContract = (contract: any | undefined, ctx: ExtensionContext, reason?: string) => {
379
+ const previousDigest = latestTaskContract?.digest;
380
+ if (contract) {
381
+ const validated = validateTaskContract(contract);
382
+ pi.appendEntry<TaskContractEntryData>(TASK_CONTRACT_ENTRY, {
383
+ kind: "set",
384
+ contract: validated,
385
+ createdAt: new Date().toISOString(),
386
+ });
387
+ latestTaskContract = validated;
388
+ } else {
389
+ pi.appendEntry<TaskContractEntryData>(TASK_CONTRACT_ENTRY, {
390
+ kind: "cleared",
391
+ createdAt: new Date().toISOString(),
392
+ reason,
393
+ });
394
+ latestTaskContract = undefined;
395
+ }
396
+
397
+ const changed = previousDigest !== latestTaskContract?.digest;
398
+ if (changed) {
399
+ invalidateChallenge(ctx, reason ?? "task contract changed");
400
+ }
401
+
402
+ emitTaskContractChanged(latestTaskContract, previousDigest);
403
+ if (changed || !contract) {
404
+ emitScopeStatus(ctx);
405
+ }
406
+ };
407
+
408
+ const emitScopeStatus = (ctx: ExtensionContext, { taskReviewChanged = false } = {}) => {
409
+ const taskStale = scope.taskDigest !== undefined && latestTaskContract?.digest !== scope.taskDigest;
149
410
  const summary = scope.active
150
411
  ? {
151
412
  active: true,
152
413
  pending: scope.pending.length,
153
414
  entries: scope.entries.length,
154
415
  indeterminate: scope.indeterminate,
416
+ taskBound: scope.taskDigest !== undefined,
417
+ taskStale,
155
418
  }
156
- : { active: false, pending: 0, entries: 0, indeterminate: false };
157
- pi.events.emit("specpi:workflow-status", summary);
419
+ : { active: false, pending: 0, entries: 0, indeterminate: false, taskBound: false, taskStale: false };
420
+ pi.events.emit("specpi:workflow-status", taskReviewChanged ? { ...summary, taskReviewChanged: true } : summary);
158
421
  if (!scope.active) {
159
422
  ctx.ui.setStatus(SCOPE_STATUS, undefined);
160
423
  ctx.ui.setWidget(SCOPE_STATUS, undefined);
@@ -162,19 +425,20 @@ export default function workflowControls(pi: ExtensionAPI) {
162
425
  return;
163
426
  }
164
427
 
165
- const label = scope.pending.length > 0 || scope.indeterminate ? "scope: review" : "scope: clean";
428
+ const label = scope.pending.length > 0 || scope.indeterminate || taskStale ? "scope: review" : "scope: clean";
166
429
  ctx.ui.setStatus(SCOPE_STATUS, label);
167
430
  ctx.ui.setWidget(SCOPE_STATUS, (_tui, theme) => ({
168
431
  invalidate() {},
169
432
  render(width: number): string[] {
170
433
  const pending = scope.pending.length > 0 ? ` · ${scope.pending.length} pending` : "";
171
434
  const uncertain = scope.indeterminate ? " · snapshot uncertain" : "";
435
+ const stale = taskStale ? " · task stale" : "";
172
436
 
173
437
  return [
174
438
  truncateToWidth(
175
439
  theme.fg(
176
440
  scope.pending.length > 0 || scope.indeterminate ? "warning" : "dim",
177
- `scope · ${scope.entries.length} paths · ${scope.observed.length} changed${pending}${uncertain}`,
441
+ `scope · ${scope.entries.length} paths · ${scope.observed.length} changed${pending}${uncertain}${stale}`,
178
442
  ),
179
443
  width,
180
444
  "",
@@ -195,6 +459,7 @@ export default function workflowControls(pi: ExtensionAPI) {
195
459
  observed: [...scope.observed],
196
460
  indeterminate: scope.indeterminate,
197
461
  generation: scope.generation,
462
+ taskDigest: scope.taskDigest,
198
463
  });
199
464
  emitScopeStatus(ctx);
200
465
  };
@@ -233,27 +498,37 @@ export default function workflowControls(pi: ExtensionAPI) {
233
498
  return undefined;
234
499
  }
235
500
 
501
+ const generation = sessionGeneration;
502
+ const root = scope.root;
236
503
  try {
237
504
  const result = await pi.exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
238
- cwd: scope.root,
505
+ cwd: root,
239
506
  timeout: 30_000,
240
507
  });
508
+ if (generation !== sessionGeneration) {
509
+ return undefined;
510
+ }
511
+
241
512
  if (result.code !== 0 || typeof result.stdout !== "string") {
242
513
  // A failed status leaves no trustworthy baseline, so the next tool must observe the worktree afresh
243
514
  // rather than diffing against a snapshot taken before the gap.
244
515
  latestSnapshot = undefined;
245
516
 
246
- return { root: scope.root, paths: [], fingerprints: {}, indeterminate: true };
517
+ return { root, paths: [], fingerprints: {}, indeterminate: true };
247
518
  }
248
519
 
249
- const snapshot = createWorktreeSnapshot(scope.root, result.stdout);
520
+ const snapshot = createWorktreeSnapshot(root, result.stdout);
250
521
  latestSnapshot = snapshot;
251
522
 
252
523
  return snapshot;
253
524
  } catch {
525
+ if (generation !== sessionGeneration) {
526
+ return undefined;
527
+ }
528
+
254
529
  latestSnapshot = undefined;
255
530
 
256
- return { root: scope.root, paths: [], fingerprints: {}, indeterminate: true };
531
+ return { root, paths: [], fingerprints: {}, indeterminate: true };
257
532
  }
258
533
  };
259
534
 
@@ -268,27 +543,45 @@ export default function workflowControls(pi: ExtensionAPI) {
268
543
  return takeSnapshot();
269
544
  };
270
545
 
271
- const setScopeEntries = (entries: ScopeItem[], ctx: ExtensionContext) => {
546
+ const setScopeEntries = (entries: ScopeItem[], ctx: ExtensionContext, options: { taskDigest?: string } = {}) => {
272
547
  if (entries.length === 0 || entries.length > 40) {
273
548
  throw new Error("Scope must contain between 1 and 40 paths");
274
549
  }
275
550
 
276
551
  scope.active = true;
277
552
  scope.entries = entries;
278
- scope.pending = scope.pending.filter((item) => !scopeMatches(entries, item));
553
+ if (options.taskDigest !== undefined) {
554
+ scope.taskDigest = options.taskDigest;
555
+ } else {
556
+ scope.taskDigest = undefined;
557
+ scope.pending = scope.pending.filter((item) => !scopeMatches(entries, item));
558
+ }
559
+
279
560
  scope.generation += 1;
280
561
  latestSnapshot = undefined;
281
562
  persistScope(ctx);
282
563
  };
283
564
 
284
- pi.on("session_start", async (_event, ctx) => {
285
- const root = await resolveRoot(ctx.cwd);
286
- scope = emptyScope(root);
565
+ const restoreSession = async (ctx: ExtensionContext) => {
566
+ sessionGeneration += 1;
567
+ const origin = captureSession(ctx);
568
+ // Retire armed prompts synchronously, before root lookup can yield to a tool call or another branch change.
569
+ scope = emptyScope(path.resolve(origin.cwd));
570
+ latestTaskContract = undefined;
287
571
  activeChallenge = undefined;
288
572
  latestChallenge = undefined;
573
+ taskContractError = undefined;
289
574
  observedToolFailures = 0;
290
575
  latestSnapshot = undefined;
291
576
  snapshots.clear();
577
+ emitScopeStatus(ctx);
578
+
579
+ const root = await resolveRoot(origin.cwd);
580
+ if (!sessionIsCurrent(origin, ctx)) {
581
+ return;
582
+ }
583
+
584
+ scope = emptyScope(root);
292
585
 
293
586
  for (const entry of ctx.sessionManager.getBranch?.() ?? []) {
294
587
  if (entry.type !== "custom") {
@@ -319,6 +612,7 @@ export default function workflowControls(pi: ExtensionAPI) {
319
612
  : [],
320
613
  indeterminate: Boolean(data.indeterminate),
321
614
  generation: Number.isInteger(data.generation) ? data.generation : 0,
615
+ taskDigest: typeof data.taskDigest === "string" ? data.taskDigest : undefined,
322
616
  };
323
617
  } else if (data?.active === false) {
324
618
  scope = emptyScope(root);
@@ -333,14 +627,36 @@ export default function workflowControls(pi: ExtensionAPI) {
333
627
  }
334
628
  }
335
629
 
630
+ try {
631
+ latestTaskContract = readCurrentTaskContract(ctx, root);
632
+ } catch (error) {
633
+ taskContractError = safeMessage(error);
634
+ ctx.ui.notify(`Task contract unavailable: ${taskContractError}`, "error");
635
+ }
636
+
637
+ if (
638
+ latestChallenge &&
639
+ latestChallenge.facts?.taskContractDigest !== latestTaskContract?.digest &&
640
+ (latestTaskContract || latestChallenge.facts?.taskContractDigest !== undefined)
641
+ ) {
642
+ latestChallenge = undefined;
643
+ }
644
+
336
645
  emitScopeStatus(ctx);
337
- });
646
+ };
647
+
648
+ pi.on("session_start", (_event, ctx) => restoreSession(ctx));
649
+ pi.on("session_tree", (_event, ctx) => restoreSession(ctx));
338
650
 
339
651
  pi.on("session_shutdown", (_event, ctx) => {
652
+ sessionGeneration += 1;
340
653
  snapshots.clear();
341
654
  latestSnapshot = undefined;
655
+ latestTaskContract = undefined;
342
656
  activeChallenge = undefined;
657
+ latestChallenge = undefined;
343
658
  experimentBusy = false;
659
+ taskContractError = undefined;
344
660
  ctx.ui.setStatus(SCOPE_STATUS, undefined);
345
661
  ctx.ui.setWidget(SCOPE_STATUS, undefined);
346
662
  });
@@ -374,7 +690,11 @@ export default function workflowControls(pi: ExtensionAPI) {
374
690
  return;
375
691
  }
376
692
 
377
- snapshots.set(event.toolCallId, await baselineSnapshot());
693
+ const generation = sessionGeneration;
694
+ const snapshot = await baselineSnapshot();
695
+ if (generation === sessionGeneration) {
696
+ snapshots.set(event.toolCallId, snapshot);
697
+ }
378
698
  });
379
699
 
380
700
  pi.on("tool_call", async (event: any, ctx) => {
@@ -399,6 +719,7 @@ export default function workflowControls(pi: ExtensionAPI) {
399
719
 
400
720
  const originalPath = event.input.path;
401
721
  const generation = scope.generation;
722
+ const origin = captureSession(ctx);
402
723
  if (!ctx.hasUI) {
403
724
  addPending([relativePath], ctx);
404
725
 
@@ -410,7 +731,7 @@ export default function workflowControls(pi: ExtensionAPI) {
410
731
  "Allow once without expanding scope",
411
732
  "Add this path to scope and allow",
412
733
  ]);
413
- if (generation !== scope.generation || event.input.path !== originalPath) {
734
+ if (!sessionIsCurrent(origin, ctx) || generation !== scope.generation || event.input.path !== originalPath) {
414
735
  return { block: true, reason: "Scope state or tool input changed during acknowledgement" };
415
736
  }
416
737
 
@@ -456,7 +777,12 @@ export default function workflowControls(pi: ExtensionAPI) {
456
777
  return;
457
778
  }
458
779
 
780
+ const origin = captureSession(ctx);
459
781
  const after = await takeSnapshot();
782
+ if (!sessionIsCurrent(origin, ctx)) {
783
+ return;
784
+ }
785
+
460
786
  const comparison = compareWorktreeSnapshots(before, after, scope.entries);
461
787
  if (comparison.indeterminate) {
462
788
  scope.indeterminate = true;
@@ -484,11 +810,35 @@ export default function workflowControls(pi: ExtensionAPI) {
484
810
  return { content: [...event.content, { type: "text", text: warning }] };
485
811
  });
486
812
 
487
- pi.on("before_agent_start", async (event) => {
813
+ pi.on("before_agent_start", async (event, ctx) => {
814
+ const origin = captureSession(ctx);
488
815
  const guidance = [];
816
+ let currentTask;
817
+ try {
818
+ const root = await resolveRoot(origin.cwd);
819
+ if (!sessionIsCurrent(origin, ctx)) {
820
+ return;
821
+ }
822
+
823
+ currentTask = refreshTaskContract(ctx, root);
824
+ } catch (error) {
825
+ if (!sessionIsCurrent(origin, ctx)) {
826
+ return;
827
+ }
828
+
829
+ taskContractError = safeMessage(error);
830
+ }
831
+
832
+ if (currentTask) {
833
+ guidance.push(`[SPECPI TASK CONTRACT]\n${renderTaskContract(currentTask)}`);
834
+ } else if (taskContractError) {
835
+ guidance.push(`[SPECPI TASK CONTRACT]\nUnavailable: ${taskContractError}`);
836
+ }
837
+
489
838
  if (scope.active) {
839
+ const taskStale = scope.taskDigest !== undefined && currentTask?.digest !== scope.taskDigest;
490
840
  guidance.push(
491
- `[SPECPI SCOPE]\nDeclared paths: ${scope.entries.map((item) => `${sanitizePathLabel(item.path)}${item.directory ? "/" : ""}`).join(", ")}\nPending outside-scope paths: ${scope.pending.map(sanitizePathLabel).join(", ") || "none"}. Do not describe pending paths as accepted scope.`,
841
+ `[SPECPI SCOPE]\nDeclared paths: ${scope.entries.map((item) => `${sanitizePathLabel(item.path)}${item.directory ? "/" : ""}`).join(", ")}\nPending outside-scope paths: ${scope.pending.map(sanitizePathLabel).join(", ") || "none"}. Do not describe pending paths as accepted scope.${taskStale ? "\nTask-bound scope is stale; do not widen it automatically." : ""}`,
492
842
  );
493
843
  }
494
844
 
@@ -504,30 +854,348 @@ export default function workflowControls(pi: ExtensionAPI) {
504
854
  return { systemPrompt: `${event.systemPrompt}\n\n${guidance.join("\n\n")}` };
505
855
  });
506
856
 
857
+ const currentSnapshot = async (root: string) => {
858
+ try {
859
+ const status = await pi.exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
860
+ cwd: root,
861
+ timeout: 30_000,
862
+ });
863
+ if (status.code !== 0 || typeof status.stdout !== "string") {
864
+ return { root, paths: [], fingerprints: {}, indeterminate: true, reason: "Git status failed" };
865
+ }
866
+
867
+ const snapshot = createWorktreeSnapshot(root, status.stdout);
868
+ const fingerprints: Record<string, string> = {};
869
+ for (const relativePath of snapshot.paths) {
870
+ const fingerprint = handoffFingerprint(snapshot.fingerprints[relativePath]);
871
+ const candidate = path.resolve(root, relativePath);
872
+ const candidateRelative = path.relative(root, candidate);
873
+ if (
874
+ candidateRelative === ".." ||
875
+ candidateRelative.startsWith(`..${path.sep}`) ||
876
+ path.isAbsolute(candidateRelative)
877
+ ) {
878
+ fingerprints[relativePath] = `type:unknown;mode:unknown;${fingerprint}`;
879
+
880
+ continue;
881
+ }
882
+
883
+ try {
884
+ const stat = fs.lstatSync(candidate);
885
+ const type = stat.isSymbolicLink()
886
+ ? "symlink"
887
+ : stat.isFile()
888
+ ? "file"
889
+ : stat.isDirectory()
890
+ ? "directory"
891
+ : "other";
892
+ fingerprints[relativePath] = `type:${type};mode:${(stat.mode & 0o7777).toString(8)};${fingerprint}`;
893
+ } catch {
894
+ fingerprints[relativePath] = `type:missing;mode:unknown;${fingerprint}`;
895
+ }
896
+ }
897
+
898
+ return { ...snapshot, fingerprints };
899
+ } catch {
900
+ return { root, paths: [], fingerprints: {}, indeterminate: true, reason: "Git status was unavailable" };
901
+ }
902
+ };
903
+
904
+ const handoffFingerprint = (value: unknown) => {
905
+ if (typeof value !== "string") {
906
+ return "unavailable";
907
+ }
908
+
909
+ if (/^[a-f0-9]{64}$/u.test(value)) {
910
+ return `sha256:${value}`;
911
+ }
912
+
913
+ if (value === "missing") {
914
+ return "missing";
915
+ }
916
+
917
+ if (value.startsWith("symlink:")) {
918
+ return "symlink";
919
+ }
920
+
921
+ if (/^[0-9]+:[0-9]+:/u.test(value)) {
922
+ return `metadata:${value}`;
923
+ }
924
+
925
+ return safeMessage(value);
926
+ };
927
+
928
+ const renderTaskHandoff = (contract: any, snapshot: any, root: string) => {
929
+ const indeterminate = Boolean(scope.indeterminate || snapshot?.indeterminate);
930
+ const lines = [
931
+ "## Task Handoff",
932
+ "",
933
+ `Generated for task \`${contract.id}\` at root \`${markdownPathLabel(root)}\`.`,
934
+ "",
935
+ renderTaskContract(contract),
936
+ "",
937
+ "### Current changes",
938
+ ];
939
+ if (indeterminate) {
940
+ lines.push(
941
+ `- Snapshot indeterminate: ${safeMessage(snapshot?.reason ?? "some changes may be unobserved")}.`,
942
+ "- Change paths and fingerprints below are incomplete.",
943
+ );
944
+ } else if (snapshot?.paths.length === 0) {
945
+ lines.push("- No changed paths observed.");
946
+ } else {
947
+ for (const relativePath of snapshot.paths.slice(0, 256)) {
948
+ lines.push(
949
+ `- \`${markdownPathLabel(relativePath)}\` — ${handoffFingerprint(snapshot.fingerprints[relativePath])}`,
950
+ );
951
+ }
952
+ }
953
+
954
+ const violations = indeterminate ? [] : taskContractScopeViolations(contract, snapshot.paths);
955
+ lines.push("", "### Latest review");
956
+ if (!latestChallenge?.result) {
957
+ lines.push("- No completion challenge review is recorded for this task contract.");
958
+ } else {
959
+ lines.push(`- Verdict: **${latestChallenge.result.verdict}**.`);
960
+ for (const item of latestChallenge.result.requirements ?? []) {
961
+ const id = item.id ? `${item.id}: ` : "";
962
+ lines.push(`- ${id}${item.status}: ${safeMessage(item.evidence || "No evidence recorded")}`);
963
+ }
964
+
965
+ if (latestChallenge.result.validationGaps?.length) {
966
+ lines.push(`- Validation gaps: ${latestChallenge.result.validationGaps.map(safeMessage).join("; ")}`);
967
+ }
968
+ }
969
+
970
+ lines.push("", "### Unresolved facts");
971
+ const unresolved = [];
972
+ if (scope.pending.length > 0) {
973
+ unresolved.push(`Pending scope findings: ${scope.pending.map(markdownPathLabel).join(", ")}`);
974
+ }
975
+
976
+ if (violations.length > 0) {
977
+ unresolved.push(`Task paths outside declared paths: ${violations.map(markdownPathLabel).join(", ")}`);
978
+ }
979
+
980
+ if (scope.taskDigest !== undefined && scope.taskDigest !== contract.digest) {
981
+ unresolved.push(
982
+ "The imported scope is bound to an older task contract digest; it was not widened automatically.",
983
+ );
984
+ }
985
+
986
+ if (indeterminate) {
987
+ unresolved.push("The current worktree snapshot may omit changed paths or fingerprints.");
988
+ }
989
+
990
+ if (latestChallenge?.result) {
991
+ for (const item of latestChallenge.result.requirements ?? []) {
992
+ if (item.status !== "proven") {
993
+ unresolved.push(`${item.id ? `${item.id} ` : ""}requirement remains ${item.status}.`);
994
+ }
995
+ }
996
+
997
+ unresolved.push(
998
+ ...(latestChallenge.result.contradictions ?? []).map((item: string) => `Contradiction: ${item}`),
999
+ );
1000
+ unresolved.push(
1001
+ ...(latestChallenge.result.residualRisks ?? []).map((item: string) => `Residual risk: ${item}`),
1002
+ );
1003
+ }
1004
+
1005
+ lines.push(
1006
+ ...(unresolved.length > 0
1007
+ ? unresolved.map((item) => `- ${safeMessage(item)}`)
1008
+ : ["- No unresolved facts recorded."]),
1009
+ );
1010
+ lines.push(
1011
+ "",
1012
+ "> This handoff is a bounded view of the current conversation state. It does not launch, export, or commit work.",
1013
+ );
1014
+
1015
+ return lines.join("\n");
1016
+ };
1017
+
1018
+ pi.registerCommand("task", {
1019
+ description: "Set, inspect, or hand off the current task contract",
1020
+ getArgumentCompletions: (prefix: string) =>
1021
+ ["set", "clear", "status", "handoff"]
1022
+ .filter((value) => value.startsWith(prefix.trim().toLowerCase()))
1023
+ .map((value) => ({ value, label: value })),
1024
+ handler: async (args, ctx) => {
1025
+ const origin = captureSession(ctx);
1026
+ const action = args.trim().toLowerCase() || "status";
1027
+ try {
1028
+ const root = await resolveRoot(origin.cwd);
1029
+ if (!sessionIsCurrent(origin, ctx)) {
1030
+ return;
1031
+ }
1032
+
1033
+ const current = refreshTaskContract(ctx, root);
1034
+ if (action === "status") {
1035
+ if (!current) {
1036
+ ctx.ui.notify("No task contract is active for this project.", "info");
1037
+
1038
+ return;
1039
+ }
1040
+
1041
+ ctx.ui.notify(
1042
+ `Task ${current.id.slice(0, 8)}: ${current.objective}; ${current.requirements.length} fixed requirement(s); digest ${current.digest.slice(0, 12)}.`,
1043
+ "info",
1044
+ );
1045
+
1046
+ return;
1047
+ }
1048
+
1049
+ if (action === "clear") {
1050
+ persistTaskContract(undefined, ctx, "task contract cleared by human");
1051
+ ctx.ui.notify(
1052
+ "Task contract cleared. Existing scope remains unchanged and is reported as stale if it was task-bound.",
1053
+ "warning",
1054
+ );
1055
+
1056
+ return;
1057
+ }
1058
+
1059
+ if (action === "set") {
1060
+ if (!ctx.hasUI || typeof ctx.ui.editor !== "function") {
1061
+ ctx.ui.notify("/task set requires interactive editor support.", "error");
1062
+
1063
+ return;
1064
+ }
1065
+
1066
+ const edited = await ctx.ui.editor("Task contract", taskContractEditorText(current));
1067
+ if (!sessionIsCurrent(origin, ctx) || edited === undefined) {
1068
+ return;
1069
+ }
1070
+
1071
+ const card = parseTaskContractCard(edited);
1072
+ const contractOrigin = current?.origin === "improvement" ? "improvement" : "human";
1073
+ const contract = createTaskContract(card, {
1074
+ root,
1075
+ origin: contractOrigin,
1076
+ ...(contractOrigin === "improvement"
1077
+ ? { gapId: current.gapId, selectionId: current.selectionId, id: current.id }
1078
+ : current
1079
+ ? { id: current.id }
1080
+ : {}),
1081
+ });
1082
+ persistTaskContract(contract, ctx, "task contract revised by human");
1083
+ ctx.ui.notify(
1084
+ `Task contract set: ${contract.objective} (${contract.requirements.length} requirement(s)).`,
1085
+ "info",
1086
+ );
1087
+
1088
+ return;
1089
+ }
1090
+
1091
+ if (action === "handoff") {
1092
+ if (!current) {
1093
+ ctx.ui.notify("No task contract is active for this project.", "error");
1094
+
1095
+ return;
1096
+ }
1097
+
1098
+ const snapshot = await currentSnapshot(root);
1099
+ if (!sessionIsCurrent(origin, ctx)) {
1100
+ return;
1101
+ }
1102
+
1103
+ const markdown = renderTaskHandoff(current, snapshot, root);
1104
+ const data: TaskHandoffEntryData = {
1105
+ markdown,
1106
+ contractId: current.id,
1107
+ contractDigest: current.digest,
1108
+ indeterminate: Boolean(scope.indeterminate || snapshot.indeterminate),
1109
+ createdAt: new Date().toISOString(),
1110
+ };
1111
+ pi.appendEntry(TASK_HANDOFF_ENTRY, data);
1112
+ if (!supportsEntryRenderer && typeof ctx.ui.editor === "function") {
1113
+ await ctx.ui.editor("Task handoff (view only)", markdown);
1114
+ }
1115
+
1116
+ ctx.ui.notify("Task handoff rendered in the current conversation.", "info");
1117
+
1118
+ return;
1119
+ }
1120
+
1121
+ ctx.ui.notify("Usage: /task [set|clear|status|handoff]", "error");
1122
+ } catch (error) {
1123
+ if (!sessionIsCurrent(origin, ctx)) {
1124
+ return;
1125
+ }
1126
+
1127
+ taskContractError = safeMessage(error);
1128
+ ctx.ui.notify(taskContractError, "error");
1129
+ }
1130
+ },
1131
+ });
1132
+
507
1133
  pi.registerCommand("scope", {
508
1134
  description: "Declare expected project paths and review scope drift",
509
1135
  getArgumentCompletions: (prefix: string) =>
510
- ["set", "add", "remove", "accept", "recheck", "status", "clear"]
1136
+ ["set", "task", "add", "remove", "accept", "recheck", "status", "clear"]
511
1137
  .filter((value) => value.startsWith(prefix.trim().toLowerCase()))
512
1138
  .map((value) => ({ value, label: value })),
513
1139
  handler: async (args, ctx) => {
1140
+ const origin = captureSession(ctx);
514
1141
  const [actionRaw, ...rest] = args.trim().split(/\s+/u).filter(Boolean);
515
1142
  const action = actionRaw?.toLowerCase() || (scope.active ? "status" : "set");
516
1143
  const requestedPath = rest.join(" ");
517
1144
  try {
1145
+ if (action === "task") {
1146
+ const root = await resolveRoot(origin.cwd);
1147
+ if (!sessionIsCurrent(origin, ctx)) {
1148
+ return;
1149
+ }
1150
+
1151
+ const contract = refreshTaskContract(ctx, root);
1152
+ if (!contract) {
1153
+ ctx.ui.notify("No task contract is active for this project.", "error");
1154
+
1155
+ return;
1156
+ }
1157
+
1158
+ if (contract.paths.length === 0) {
1159
+ ctx.ui.notify("The active task contract declares no importable paths.", "error");
1160
+
1161
+ return;
1162
+ }
1163
+
1164
+ const entries = normalizeScopeEntries(root, contract.paths);
1165
+ setScopeEntries(entries, ctx, { taskDigest: contract.digest });
1166
+ ctx.ui.notify(
1167
+ `Scope imported from task ${contract.id.slice(0, 8)}. Existing pending findings were preserved; run /scope status to review them.`,
1168
+ scope.pending.length > 0 ? "warning" : "info",
1169
+ );
1170
+
1171
+ return;
1172
+ }
1173
+
518
1174
  if (action === "status") {
1175
+ const root = await resolveRoot(origin.cwd);
1176
+ if (!sessionIsCurrent(origin, ctx)) {
1177
+ return;
1178
+ }
1179
+
1180
+ refreshTaskContract(ctx, root);
1181
+ const taskStale = scope.taskDigest !== undefined && latestTaskContract?.digest !== scope.taskDigest;
519
1182
  ctx.ui.notify(
520
1183
  scope.active
521
- ? `Scope: ${scope.entries.map((item) => `${sanitizePathLabel(item.path)}${item.directory ? "/" : ""}`).join(", ")}; pending: ${scope.pending.map(sanitizePathLabel).join(", ") || "none"}; snapshot: ${scope.indeterminate ? "indeterminate" : "observed"}.`
1184
+ ? `Scope: ${scope.entries.map((item) => `${sanitizePathLabel(item.path)}${item.directory ? "/" : ""}`).join(", ")}; pending: ${scope.pending.map(sanitizePathLabel).join(", ") || "none"}; snapshot: ${scope.indeterminate ? "indeterminate" : "observed"}; task binding: ${scope.taskDigest ? (taskStale ? "stale" : "current") : "manual"}.`
522
1185
  : "Scope monitoring is inactive.",
523
- scope.pending.length > 0 || scope.indeterminate ? "warning" : "info",
1186
+ scope.pending.length > 0 || scope.indeterminate || taskStale ? "warning" : "info",
524
1187
  );
525
1188
 
526
1189
  return;
527
1190
  }
528
1191
 
529
1192
  if (action === "clear") {
530
- scope = emptyScope(await resolveRoot(ctx.cwd));
1193
+ const root = await resolveRoot(origin.cwd);
1194
+ if (!sessionIsCurrent(origin, ctx)) {
1195
+ return;
1196
+ }
1197
+
1198
+ scope = emptyScope(root);
531
1199
  scope.generation += 1;
532
1200
  // Nothing is observed while scope is off, so the cached baseline is stale the moment it is cleared;
533
1201
  // reactivating later must start from a fresh snapshot rather than blame the unmonitored gap on the
@@ -550,7 +1218,7 @@ export default function workflowControls(pi: ExtensionAPI) {
550
1218
  .map((item) => `${sanitizePathLabel(item.path)}${item.directory ? "/" : ""}`)
551
1219
  .join("\n");
552
1220
  const edited = await ctx.ui.editor("Scope paths — one project-relative path per line", initial);
553
- if (edited === undefined) {
1221
+ if (!sessionIsCurrent(origin, ctx) || edited === undefined) {
554
1222
  return;
555
1223
  }
556
1224
 
@@ -582,6 +1250,10 @@ export default function workflowControls(pi: ExtensionAPI) {
582
1250
  // side effect of the next successful comparison. Re-baselining here is that act.
583
1251
  latestSnapshot = undefined;
584
1252
  const rebaselined = await takeSnapshot();
1253
+ if (!sessionIsCurrent(origin, ctx)) {
1254
+ return;
1255
+ }
1256
+
585
1257
  scope.indeterminate = Boolean(rebaselined?.indeterminate);
586
1258
  scope.generation += 1;
587
1259
  persistScope(ctx);
@@ -630,6 +1302,8 @@ export default function workflowControls(pi: ExtensionAPI) {
630
1302
  const deactivated = scope.entries.length === 0;
631
1303
  if (deactivated) {
632
1304
  scope = emptyScope(scope.root);
1305
+ } else {
1306
+ scope.taskDigest = undefined;
633
1307
  }
634
1308
 
635
1309
  scope.generation += 1;
@@ -685,6 +1359,7 @@ export default function workflowControls(pi: ExtensionAPI) {
685
1359
  }
686
1360
 
687
1361
  scope.active = true;
1362
+ scope.taskDigest = undefined;
688
1363
  scope.pending = scope.pending.filter((item) => !scopeMatches([normalized], item));
689
1364
  scope.generation += 1;
690
1365
  persistScope(ctx);
@@ -739,7 +1414,8 @@ export default function workflowControls(pi: ExtensionAPI) {
739
1414
  }
740
1415
  }
741
1416
 
742
- const template = `Name: ${query}\nHypothesis: \nAcceptance: \nNon-goals:\n- `;
1417
+ const currentTask = refreshTaskContract(ctx, repository.repoRoot);
1418
+ const template = experimentCardEditorText(currentTask, query);
743
1419
  const edited = await ctx.ui.editor("Experiment card", template);
744
1420
  if (edited === undefined) {
745
1421
  return;
@@ -951,7 +1627,8 @@ export default function workflowControls(pi: ExtensionAPI) {
951
1627
  requirements: Type.Array(
952
1628
  Type.Object(
953
1629
  {
954
- requirement: Type.String({ minLength: 1, maxLength: 360 }),
1630
+ id: Type.Optional(Type.String({ minLength: 1, maxLength: 120 })),
1631
+ requirement: Type.Optional(Type.String({ minLength: 1, maxLength: 360 })),
955
1632
  status: StringEnum(["proven", "partial", "unproven"] as const),
956
1633
  evidence: Type.String({ maxLength: 600 }),
957
1634
  },
@@ -965,6 +1642,7 @@ export default function workflowControls(pi: ExtensionAPI) {
965
1642
  validationGaps: Type.Array(Type.String({ minLength: 1, maxLength: 360 }), { maxItems: 12 }),
966
1643
  residualRisks: Type.Array(Type.String({ minLength: 1, maxLength: 360 }), { maxItems: 12 }),
967
1644
  nextAction: Type.String({ maxLength: 500 }),
1645
+ taskContractDigest: Type.Optional(Type.String({ minLength: 64, maxLength: 64 })),
968
1646
  },
969
1647
  { additionalProperties: false },
970
1648
  );
@@ -986,7 +1664,26 @@ export default function workflowControls(pi: ExtensionAPI) {
986
1664
  throw new Error("No matching completion challenge is active in this session");
987
1665
  }
988
1666
 
989
- const result = validateChallengeSubmission(params, activeChallenge.facts);
1667
+ let currentTask;
1668
+ try {
1669
+ currentTask = readCurrentTaskContract(ctx, activeChallenge.root);
1670
+ } catch {
1671
+ invalidateChallenge(ctx, "task contract could not be revalidated");
1672
+
1673
+ throw new Error("Task contract could not be revalidated for this challenge");
1674
+ }
1675
+
1676
+ if (currentTask?.digest !== activeChallenge.taskContractDigest) {
1677
+ invalidateChallenge(ctx, "task contract changed during challenge");
1678
+
1679
+ throw new Error("No matching completion challenge is active for the current task contract");
1680
+ }
1681
+
1682
+ const result = validateChallengeSubmission(params, {
1683
+ ...activeChallenge.facts,
1684
+ challengeGeneration: activeChallenge.generation,
1685
+ taskContractDigest: activeChallenge.taskContractDigest,
1686
+ });
990
1687
  const data: ChallengeEntryData = {
991
1688
  kind: "result",
992
1689
  generation: activeChallenge.generation,
@@ -998,6 +1695,7 @@ export default function workflowControls(pi: ExtensionAPI) {
998
1695
  pi.appendEntry(CHALLENGE_ENTRY, data);
999
1696
  latestChallenge = data;
1000
1697
  activeChallenge = undefined;
1698
+ emitScopeStatus(ctx, { taskReviewChanged: true });
1001
1699
 
1002
1700
  return {
1003
1701
  content: [{ type: "text", text: data.markdown }],
@@ -1014,8 +1712,26 @@ export default function workflowControls(pi: ExtensionAPI) {
1014
1712
  .filter((value) => value.startsWith(prefix.trim().toLowerCase()))
1015
1713
  .map((value) => ({ value, label: value })),
1016
1714
  handler: async (args, ctx) => {
1715
+ const origin = captureSession(ctx);
1017
1716
  const action = args.trim().toLowerCase();
1018
1717
  if (action === "status") {
1718
+ try {
1719
+ const root = await resolveRoot(origin.cwd);
1720
+ if (!sessionIsCurrent(origin, ctx)) {
1721
+ return;
1722
+ }
1723
+
1724
+ refreshTaskContract(ctx, root);
1725
+ } catch (error) {
1726
+ if (!sessionIsCurrent(origin, ctx)) {
1727
+ return;
1728
+ }
1729
+
1730
+ ctx.ui.notify(`Task contract unavailable: ${safeMessage(error)}`, "error");
1731
+
1732
+ return;
1733
+ }
1734
+
1019
1735
  if (!latestChallenge) {
1020
1736
  ctx.ui.notify("No completed challenge exists on this session branch.", "info");
1021
1737
  } else if (supportsEntryRenderer) {
@@ -1061,20 +1777,49 @@ export default function workflowControls(pi: ExtensionAPI) {
1061
1777
  }
1062
1778
 
1063
1779
  let snapshot;
1780
+ let challengeRoot: string | undefined;
1781
+ let challengeTask;
1782
+ let challengeTaskScopeStale = scope.taskDigest !== undefined;
1783
+ try {
1784
+ const root = await resolveRoot(origin.cwd);
1785
+ if (!sessionIsCurrent(origin, ctx)) {
1786
+ return;
1787
+ }
1788
+
1789
+ challengeRoot = root;
1790
+ challengeTask = refreshTaskContract(ctx, root);
1791
+ challengeTaskScopeStale = scope.taskDigest !== undefined && challengeTask?.digest !== scope.taskDigest;
1792
+ } catch (error) {
1793
+ if (!sessionIsCurrent(origin, ctx)) {
1794
+ return;
1795
+ }
1796
+
1797
+ ctx.ui.notify(`Task contract unavailable: ${safeMessage(error)}. Challenge was not started.`, "error");
1798
+
1799
+ return;
1800
+ }
1801
+
1064
1802
  try {
1065
- const root = await resolveRoot(ctx.cwd);
1066
1803
  const status = await pi.exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
1067
- cwd: root,
1804
+ cwd: challengeRoot,
1068
1805
  timeout: 30_000,
1069
1806
  });
1070
- snapshot = status.code === 0 ? createWorktreeSnapshot(root, status.stdout) : undefined;
1807
+ if (!sessionIsCurrent(origin, ctx)) {
1808
+ return;
1809
+ }
1810
+
1811
+ snapshot = status.code === 0 ? createWorktreeSnapshot(challengeRoot, status.stdout) : undefined;
1071
1812
  } catch {
1072
1813
  snapshot = undefined;
1073
1814
  }
1074
1815
 
1816
+ if (!sessionIsCurrent(origin, ctx)) {
1817
+ return;
1818
+ }
1819
+
1075
1820
  let experiment;
1076
1821
  try {
1077
- experiment = findExperiment(stateDir, "", ctx.cwd);
1822
+ experiment = findExperiment(stateDir, "", origin.cwd);
1078
1823
  } catch {
1079
1824
  experiment = undefined;
1080
1825
  }
@@ -1082,18 +1827,23 @@ export default function workflowControls(pi: ExtensionAPI) {
1082
1827
  const facts = boundedChallengeFacts({
1083
1828
  changedPaths: snapshot?.paths ?? [],
1084
1829
  scopeEntries: scope.active
1085
- ? scope.entries.map((item) => `${sanitizePathLabel(item.path)}${item.directory ? "/" : ""}`)
1830
+ ? scope.entries.map((item) => `${item.path}${item.directory ? "/" : ""}`)
1086
1831
  : [],
1087
- pendingScope: scope.active ? scope.pending.map(sanitizePathLabel) : [],
1832
+ pendingScope: scope.active ? scope.pending : [],
1088
1833
  experiment,
1089
1834
  observedToolFailures,
1090
- snapshotIndeterminate: !snapshot || snapshot.indeterminate,
1835
+ snapshotIndeterminate: scope.indeterminate || !snapshot || snapshot.indeterminate,
1836
+ taskContract: challengeTask,
1837
+ taskContractDigest: challengeTask?.digest,
1838
+ scopeTaskStale: challengeTaskScopeStale,
1091
1839
  });
1092
1840
  const generation = randomUUID();
1093
1841
  const prompt = challengePrompt(generation, facts);
1094
1842
  activeChallenge = {
1095
1843
  generation,
1096
- sessionId: ctx.sessionManager.getSessionId(),
1844
+ sessionId: origin.sessionId,
1845
+ root: challengeRoot ?? scope.root,
1846
+ taskContractDigest: facts.taskContractDigest,
1097
1847
  facts,
1098
1848
  prompt,
1099
1849
  delivered: false,
@@ -1117,6 +1867,32 @@ export default function workflowControls(pi: ExtensionAPI) {
1117
1867
  });
1118
1868
 
1119
1869
  if (supportsEntryRenderer) {
1870
+ pi.registerEntryRenderer<TaskContractEntryData>(TASK_CONTRACT_ENTRY, (entry, _options, theme) => {
1871
+ const data = entry.data;
1872
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
1873
+ if (data?.kind === "set" && data.contract) {
1874
+ try {
1875
+ box.addChild(new Markdown(renderTaskContract(data.contract), 0, 0, getMarkdownTheme()));
1876
+ } catch (error) {
1877
+ box.addChild(
1878
+ new Text(theme.fg("warning", `Task contract unavailable: ${safeMessage(error)}`), 0, 0),
1879
+ );
1880
+ }
1881
+ } else {
1882
+ box.addChild(new Text(theme.fg("dim", "Task contract cleared"), 0, 0));
1883
+ }
1884
+
1885
+ return box;
1886
+ });
1887
+
1888
+ pi.registerEntryRenderer<TaskHandoffEntryData>(TASK_HANDOFF_ENTRY, (entry, _options, theme) => {
1889
+ const data = entry.data;
1890
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
1891
+ box.addChild(new Markdown(data?.markdown ?? "Task handoff unavailable.", 0, 0, getMarkdownTheme()));
1892
+
1893
+ return box;
1894
+ });
1895
+
1120
1896
  pi.registerEntryRenderer<ChallengeEntryData>(CHALLENGE_ENTRY, (entry, _options, theme) => {
1121
1897
  const data = entry.data;
1122
1898
  const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));