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.
@@ -18,15 +18,39 @@ import {
18
18
  archiveWishlist,
19
19
  collectChangedFilePaths,
20
20
  createIssueDraft,
21
+ latestLocalRetirementDecision,
22
+ latestOutcomeDecision,
21
23
  latestRetirementDecision,
24
+ normalizeCapability,
22
25
  readCollectionMode,
23
26
  recordCapabilityGap,
24
27
  refreshWishlist,
25
28
  renderWishlistHistory,
26
29
  sanitizeWishlistText,
27
30
  setCollectionMode,
31
+ wishlistSourceRootIdentity,
32
+ wishlistSourceRootSalt,
28
33
  } from "./core.mjs";
29
34
  import { VALIDATOR_CATALOG } from "./validators.mjs";
35
+ import { isValidValidatorName, validateCapabilityRegistry } from "./registry.mjs";
36
+ import {
37
+ TASK_CONTRACT_ENTRY,
38
+ createTaskContract,
39
+ markdownPathLabel,
40
+ readTaskContract,
41
+ taskContractScopeViolations,
42
+ } from "../workflow-controls/task-contract.mjs";
43
+ import {
44
+ captureSourceSnapshot,
45
+ assertNoRootNpmrc,
46
+ assertSafeSourceInput,
47
+ canonicalSourceRoot,
48
+ compareSourceSnapshots,
49
+ createVerificationReceipt,
50
+ readSafeSourceText,
51
+ validateSourceSnapshot,
52
+ validateVerificationReceipt,
53
+ } from "./verification.mjs";
30
54
 
31
55
  const agentDir = path.resolve(process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent"));
32
56
  const stateDir = path.join(agentDir, "specpi");
@@ -51,6 +75,16 @@ interface WishlistReportEntry {
51
75
  interface ActiveImprovement {
52
76
  gapId: string;
53
77
  sessionId: string;
78
+ root: string;
79
+ selectionId: string;
80
+ baseline: any;
81
+ checkScript: string;
82
+ scripts: Record<string, string>;
83
+ validatorNames: string[];
84
+ validatorIds: string[];
85
+ registryDefinitions: any[];
86
+ registryHadGap: boolean;
87
+ moduleDigests: Record<string, string>;
54
88
  }
55
89
 
56
90
  function sourceCheckout(cwd: string) {
@@ -61,17 +95,33 @@ function sourceCheckout(cwd: string) {
61
95
  throw new Error("Run /harness-improvement from a complete SpecPi source checkout");
62
96
  }
63
97
 
64
- const manifest = JSON.parse(fs.readFileSync(packageFile, "utf8"));
98
+ assertNoRootNpmrc(cwd);
99
+ assertSafeSourceInput(cwd, "package.json");
100
+ assertSafeSourceInput(cwd, "extensions/tool-wishlist/capabilities.json");
101
+ assertSafeSourceInput(cwd, "extensions/tool-wishlist/validators.mjs");
102
+
103
+ const manifest = JSON.parse(readSafeSourceText(cwd, "package.json"));
65
104
  if (manifest?.name !== "specpi" || typeof manifest?.scripts?.check !== "string") {
66
105
  throw new Error("The current directory is not a verifiable SpecPi source checkout");
67
106
  }
68
107
 
69
- return { registryFile, validatorsFile, version: String(manifest.version ?? "unknown") };
108
+ const registry = validateCapabilityRegistry(
109
+ JSON.parse(readSafeSourceText(cwd, "extensions/tool-wishlist/capabilities.json")),
110
+ );
111
+
112
+ return {
113
+ registryFile,
114
+ validatorsFile,
115
+ version: String(manifest.version ?? "unknown"),
116
+ checkScript: manifest.scripts.check,
117
+ scripts: cloneJson(manifest.scripts),
118
+ registry,
119
+ validatorNames: Object.keys(VALIDATOR_CATALOG).sort(),
120
+ };
70
121
  }
71
122
 
72
123
  function sourceCapability(cwd: string, gapId: string) {
73
- const { registryFile } = sourceCheckout(cwd);
74
- const registry = JSON.parse(fs.readFileSync(registryFile, "utf8"));
124
+ const { registry } = sourceCheckout(cwd);
75
125
  const capability = registry?.capabilities?.find((item: any) => item?.id === gapId);
76
126
  if (!capability || !Array.isArray(capability.validations) || capability.validations.length === 0) {
77
127
  throw new Error(
@@ -82,6 +132,262 @@ function sourceCapability(cwd: string, gapId: string) {
82
132
  return capability;
83
133
  }
84
134
 
135
+ function cloneJson<T>(value: T): T {
136
+ return JSON.parse(JSON.stringify(value)) as T;
137
+ }
138
+
139
+ function sourceModulePaths() {
140
+ return {
141
+ editorConfig: ".editorconfig",
142
+ prettierIgnore: ".prettierignore",
143
+ eslintConfig: "eslint.config.js",
144
+ prettierConfig: "prettier.config.mjs",
145
+ packageCheck: "scripts/check-package.mjs",
146
+ core: "extensions/tool-wishlist/core.mjs",
147
+ dispatcher: "extensions/tool-wishlist/index.ts",
148
+ registry: "extensions/tool-wishlist/registry.mjs",
149
+ validators: "extensions/tool-wishlist/validators.mjs",
150
+ verification: "extensions/tool-wishlist/verification.mjs",
151
+ taskContract: "extensions/workflow-controls/task-contract.mjs",
152
+ scope: "extensions/workflow-controls/scope.mjs",
153
+ };
154
+ }
155
+
156
+ function sourceModuleDigests(snapshot: any) {
157
+ const digests: Record<string, string> = {};
158
+ for (const relativePath of Object.values(sourceModulePaths())) {
159
+ const digest = snapshot.files?.[relativePath]?.sha256;
160
+ if (typeof digest !== "string") {
161
+ throw new Error(`Verification baseline is missing ${markdownPathLabel(relativePath)}`);
162
+ }
163
+
164
+ digests[relativePath] = digest;
165
+ }
166
+
167
+ return digests;
168
+ }
169
+
170
+ function branchEntries(ctx: any) {
171
+ return (ctx.sessionManager.getBranch?.() ?? []).filter((entry: any) => entry?.type === "custom");
172
+ }
173
+
174
+ function branchTaskContract(ctx: any, root: string, { tolerateMalformed = false } = {}) {
175
+ try {
176
+ return readTaskContract(branchEntries(ctx), root);
177
+ } catch (error) {
178
+ if (tolerateMalformed) {
179
+ return undefined;
180
+ }
181
+
182
+ throw error;
183
+ }
184
+ }
185
+
186
+ function parseGitInventory(stdout: string) {
187
+ const ignored = new Set<string>();
188
+ const fields = stdout.includes("\0") ? stdout.split("\0") : stdout.split("\n");
189
+ for (const field of fields) {
190
+ if (!field.startsWith("!! ")) {
191
+ continue;
192
+ }
193
+
194
+ const value = field.slice(3).replace(/[\\/]+$/u, "");
195
+ if (value) {
196
+ ignored.add(value);
197
+ }
198
+ }
199
+
200
+ return [...ignored];
201
+ }
202
+
203
+ async function gitIgnoredPaths(pi: any, cwd: string, signal: any) {
204
+ try {
205
+ const result = await pi.exec(
206
+ "git",
207
+ ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=matching"],
208
+ { cwd, signal, timeout: 30_000 },
209
+ );
210
+ if (result.code !== 0 || typeof result.stdout !== "string") {
211
+ if (fs.existsSync(path.join(cwd, ".git")) || fs.existsSync(path.join(cwd, ".gitignore"))) {
212
+ throw new Error("Could not enumerate Git ignored inputs for the SpecPi checkout");
213
+ }
214
+
215
+ return [];
216
+ }
217
+
218
+ return parseGitInventory(result.stdout);
219
+ } catch (error) {
220
+ if (signal?.aborted) {
221
+ throw signal.reason ?? error;
222
+ }
223
+
224
+ throw error;
225
+ }
226
+ }
227
+
228
+ async function gitHead(pi: any, cwd: string, signal: any) {
229
+ try {
230
+ const result = await pi.exec("git", ["rev-parse", "HEAD"], { cwd, signal, timeout: 30_000 });
231
+ const value = typeof result.stdout === "string" ? result.stdout.trim() : "";
232
+ if (result.code !== 0 || !/^[a-f0-9]{7,64}$/iu.test(value)) {
233
+ return undefined;
234
+ }
235
+
236
+ return value;
237
+ } catch (error) {
238
+ if (signal?.aborted) {
239
+ throw signal.reason ?? error;
240
+ }
241
+
242
+ return undefined;
243
+ }
244
+ }
245
+
246
+ async function resolveTaskContractRoot(pi: any, cwd: string, signal: any) {
247
+ let result;
248
+ try {
249
+ result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd, signal, timeout: 15_000 });
250
+ } catch (error) {
251
+ if (signal?.aborted) {
252
+ throw signal.reason ?? error;
253
+ }
254
+
255
+ return canonicalSourceRoot(cwd);
256
+ }
257
+
258
+ if (signal?.aborted) {
259
+ throw signal.reason ?? new Error("Wishlist report cancelled");
260
+ }
261
+
262
+ if (result?.code === 0 && typeof result.stdout === "string" && result.stdout.trim()) {
263
+ const candidate = path.resolve(cwd, result.stdout.trim());
264
+ const relative = path.relative(candidate, cwd);
265
+ if (relative !== "" && relative.startsWith("..")) {
266
+ throw new Error("Git reported a task contract root outside the current project");
267
+ }
268
+
269
+ return canonicalSourceRoot(candidate);
270
+ }
271
+
272
+ return canonicalSourceRoot(cwd);
273
+ }
274
+
275
+ async function captureCheckoutSnapshot(pi: any, cwd: string, signal: any) {
276
+ const ignoredPaths = await gitIgnoredPaths(pi, cwd, signal);
277
+ const headResult = await gitHead(pi, cwd, signal);
278
+
279
+ return captureSourceSnapshot(cwd, {
280
+ ...(ignoredPaths === undefined ? {} : { ignoredPaths }),
281
+ ...(headResult === undefined ? {} : { head: headResult }),
282
+ });
283
+ }
284
+
285
+ function policyFromCheckout(checkout: ReturnType<typeof sourceCheckout>, baseline: any, gapId: string) {
286
+ const currentEntry = checkout.registry.capabilities.find((item: any) => item.id === gapId);
287
+
288
+ return {
289
+ checkScript: checkout.checkScript,
290
+ scripts: cloneJson(checkout.scripts),
291
+ validatorNames: [...checkout.validatorNames],
292
+ registryDefinitions: cloneJson(checkout.registry.capabilities),
293
+ registryHadGap: Boolean(currentEntry),
294
+ validatorIds: currentEntry ? [...currentEntry.validations] : [],
295
+ moduleDigests: sourceModuleDigests(baseline),
296
+ };
297
+ }
298
+
299
+ function sameJson(left: unknown, right: unknown) {
300
+ return JSON.stringify(left) === JSON.stringify(right);
301
+ }
302
+
303
+ function assertFrozenSourcePolicy(cwd: string, gapId: string, active: ActiveImprovement) {
304
+ const checkout = sourceCheckout(cwd);
305
+ if (checkout.checkScript !== active.checkScript) {
306
+ throw new Error("The SpecPi check script changed after this improvement was selected; reselect the gap");
307
+ }
308
+
309
+ if (!sameJson(checkout.scripts, active.scripts)) {
310
+ throw new Error("The SpecPi npm scripts changed after this improvement was selected; reselect the gap");
311
+ }
312
+
313
+ if (!sameJson(checkout.validatorNames, active.validatorNames)) {
314
+ throw new Error("The closed validator catalog changed after this improvement was selected; reselect the gap");
315
+ }
316
+
317
+ const frozenById = new Map(active.registryDefinitions.map((item: any) => [item.id, item]));
318
+ const currentById = new Map(checkout.registry.capabilities.map((item: any) => [item.id, item]));
319
+ for (const [id, frozen] of frozenById) {
320
+ if (!sameJson(currentById.get(id), frozen)) {
321
+ throw new Error(
322
+ `The reviewed registry entry ${id} changed after this improvement was selected; reselect the gap`,
323
+ );
324
+ }
325
+ }
326
+
327
+ const extra = checkout.registry.capabilities.filter((item: any) => !frozenById.has(item.id));
328
+ if (active.registryHadGap) {
329
+ if (extra.length > 0) {
330
+ throw new Error("The capability registry gained entries after selection; reselect the gap");
331
+ }
332
+ } else if (extra.length !== 1 || extra[0]?.id !== gapId) {
333
+ throw new Error("A new improvement may add only its own capability registry entry");
334
+ } else if (
335
+ !Array.isArray(extra[0].validations) ||
336
+ extra[0].validations.length === 0 ||
337
+ extra[0].validations.some((validator: string) => !active.validatorNames.includes(validator))
338
+ ) {
339
+ throw new Error("A new capability registry entry must use only the frozen validator catalog");
340
+ }
341
+
342
+ if (active.registryHadGap && !sameJson(currentById.get(gapId)?.validations, active.validatorIds)) {
343
+ throw new Error("The selected capability validator list changed after selection; reselect the gap");
344
+ }
345
+
346
+ return { checkout, capability: currentById.get(gapId) };
347
+ }
348
+
349
+ function assertFrozenModuleDigests(snapshot: any, active: ActiveImprovement) {
350
+ for (const [relativePath, expected] of Object.entries(active.moduleDigests)) {
351
+ if (snapshot.files?.[relativePath]?.sha256 !== expected) {
352
+ throw new Error(`Protected verification input changed after selection: ${markdownPathLabel(relativePath)}`);
353
+ }
354
+ }
355
+ }
356
+
357
+ function assertFinishSelectionStillActive(
358
+ current: ActiveImprovement | undefined,
359
+ expected: ActiveImprovement,
360
+ ctx: any,
361
+ signal?: any,
362
+ ) {
363
+ if (signal?.aborted) {
364
+ throw signal.reason ?? new Error("Verification cancelled");
365
+ }
366
+
367
+ if (
368
+ !current ||
369
+ current.gapId !== expected.gapId ||
370
+ current.sessionId !== expected.sessionId ||
371
+ current.root !== expected.root ||
372
+ current.selectionId !== expected.selectionId ||
373
+ current.baseline?.digest !== expected.baseline?.digest ||
374
+ current.checkScript !== expected.checkScript ||
375
+ !sameJson(current.scripts, expected.scripts) ||
376
+ !sameJson(current.validatorNames, expected.validatorNames) ||
377
+ !sameJson(current.validatorIds, expected.validatorIds) ||
378
+ !sameJson(current.registryDefinitions, expected.registryDefinitions) ||
379
+ current.registryHadGap !== expected.registryHadGap ||
380
+ !sameJson(current.moduleDigests, expected.moduleDigests) ||
381
+ ctx.sessionManager.getSessionId() !== expected.sessionId
382
+ ) {
383
+ throw new Error("The active improvement selection changed while verification was running");
384
+ }
385
+
386
+ if (canonicalSourceRoot(ctx.cwd) !== expected.root) {
387
+ throw new Error("The SpecPi source root changed while verification was running");
388
+ }
389
+ }
390
+
85
391
  function reportField(body: string, label: string) {
86
392
  const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
87
393
 
@@ -229,7 +535,7 @@ function improvementPrompt(group: any, context: ImprovementContext = {}) {
229
535
  lines.push(
230
536
  "",
231
537
  "This exact menu selection authorizes implementation of the smallest sufficient intervention in the current SpecPi source checkout. Load and follow the specpi-improve skill. Treat the wishlist evidence as a lead, inspect current behavior, keep scope minimal, and do not ask for another approval unless scope expands or external/remote state would change.",
232
- "Run direct acceptance checks and focused tests. At the end, call finish_harness_improvement with the gap ID, concise acceptance evidence, and a validation note. That tool must run the repository gate, verify registry integration, run supported capability validators, and retire the item. If any check fails, do not retire it; leave it selected and report the blocker.",
538
+ "First call record_harness_contract with the exact gap, selection ID, source root, proposed requirements, acceptance checks, paths, rollback, and non-goals. Run direct acceptance checks and focused tests. At the end, call finish_harness_improvement with the gap ID, concise acceptance evidence, and a validation note. That tool independently runs the repository gate, verifies registry integration and frozen policy inputs, runs supported capability validators, records a bounded local receipt, and retires the item. If any check fails, do not retire it; leave it selected and report the blocker.",
233
539
  );
234
540
 
235
541
  return lines.join("\n");
@@ -253,29 +559,116 @@ function truncateReportDisplay(markdown: string) {
253
559
  }
254
560
 
255
561
  function cleanDisplayPath(value: string) {
256
- return value.replace(/[\u0000-\u001f\u007f]/g, "?");
562
+ return markdownPathLabel(value);
257
563
  }
258
564
 
259
565
  export default function toolWishlist(pi: ExtensionAPI) {
260
566
  let activeRunId = randomUUID();
261
567
  let activeImprovement: ActiveImprovement | undefined;
568
+ let improvementLifecycleGeneration = 0;
569
+ let improvementMenuGeneration = 0;
570
+ let finishBusy = false;
571
+ let contractBusy = false;
262
572
  const supportsReportEntries = typeof pi.registerEntryRenderer === "function";
263
573
 
264
- pi.on("session_start", async (_event, ctx) => {
574
+ const restoreActiveImprovement = (ctx: any) => {
575
+ improvementLifecycleGeneration += 1;
265
576
  activeImprovement = undefined;
266
577
  for (const entry of ctx.sessionManager.getBranch?.() ?? []) {
267
578
  if (entry.type !== "custom" || entry.customType !== HARNESS_IMPROVEMENT_ENTRY) {
268
579
  continue;
269
580
  }
270
581
 
271
- const data = entry.data as { gapId?: string; status?: string } | undefined;
272
- activeImprovement =
273
- data?.status === "active" && data.gapId
274
- ? { gapId: data.gapId, sessionId: ctx.sessionManager.getSessionId() }
275
- : undefined;
582
+ const data = entry.data as
583
+ | {
584
+ gapId?: string;
585
+ status?: string;
586
+ root?: string;
587
+ selectionId?: string;
588
+ baseline?: any;
589
+ checkScript?: string;
590
+ scripts?: Record<string, string>;
591
+ validatorNames?: string[];
592
+ validatorIds?: string[];
593
+ registryDefinitions?: any[];
594
+ registryHadGap?: boolean;
595
+ moduleDigests?: Record<string, string>;
596
+ }
597
+ | undefined;
598
+ if (data?.status !== "active") {
599
+ activeImprovement = undefined;
600
+ continue;
601
+ }
602
+
603
+ try {
604
+ if (
605
+ !data.gapId ||
606
+ !data.root ||
607
+ !data.selectionId ||
608
+ !data.baseline ||
609
+ typeof data.checkScript !== "string" ||
610
+ !data.scripts ||
611
+ typeof data.scripts !== "object" ||
612
+ !Array.isArray(data.validatorNames) ||
613
+ !data.validatorNames.every(isValidValidatorName) ||
614
+ !Array.isArray(data.validatorIds) ||
615
+ !data.validatorIds.every(isValidValidatorName) ||
616
+ !Array.isArray(data.registryDefinitions) ||
617
+ typeof data.registryHadGap !== "boolean" ||
618
+ !data.moduleDigests ||
619
+ typeof data.moduleDigests !== "object"
620
+ ) {
621
+ throw new Error("legacy improvement selection metadata");
622
+ }
623
+
624
+ const baseline = validateSourceSnapshot(data.baseline, data.root);
625
+ const expectedModuleDigests = sourceModuleDigests(baseline);
626
+ if (!sameJson(data.moduleDigests, expectedModuleDigests)) {
627
+ throw new Error("improvement module baseline mismatch");
628
+ }
629
+
630
+ validateCapabilityRegistry({ schema: 1, capabilities: data.registryDefinitions });
631
+ activeImprovement = {
632
+ gapId: data.gapId,
633
+ sessionId: ctx.sessionManager.getSessionId(),
634
+ root: baseline.root,
635
+ selectionId: data.selectionId,
636
+ baseline,
637
+ checkScript: data.checkScript,
638
+ scripts: cloneJson(data.scripts),
639
+ validatorNames: [...data.validatorNames],
640
+ validatorIds: [...data.validatorIds],
641
+ registryDefinitions: cloneJson(data.registryDefinitions),
642
+ registryHadGap: data.registryHadGap,
643
+ moduleDigests: { ...data.moduleDigests },
644
+ };
645
+ } catch {
646
+ // Older active entries intentionally require a fresh human selection.
647
+ activeImprovement = undefined;
648
+ }
276
649
  }
650
+ };
651
+
652
+ pi.on("session_start", (_event, ctx) => {
653
+ restoreActiveImprovement(ctx);
654
+ });
655
+
656
+ pi.on("session_tree", (_event, ctx) => {
657
+ restoreActiveImprovement(ctx);
277
658
  });
278
659
 
660
+ pi.on("session_shutdown", () => {
661
+ improvementLifecycleGeneration += 1;
662
+ activeImprovement = undefined;
663
+ });
664
+
665
+ const assertImprovementStillCurrent = (expected: ActiveImprovement, generation: number, ctx: any, signal?: any) => {
666
+ assertFinishSelectionStillActive(activeImprovement, expected, ctx, signal);
667
+ if (improvementLifecycleGeneration !== generation) {
668
+ throw new Error("The active improvement selection changed with the session branch");
669
+ }
670
+ };
671
+
279
672
  pi.on("before_agent_start", async () => {
280
673
  activeRunId = randomUUID();
281
674
  });
@@ -289,7 +682,7 @@ export default function toolWishlist(pi: ExtensionAPI) {
289
682
  if (supportsReportEntries) {
290
683
  pi.appendEntry<WishlistReportEntry>(WISHLIST_REPORT_ENTRY, {
291
684
  markdown: content,
292
- reportPath: displayPath,
685
+ reportPath,
293
686
  truncated: display.truncated,
294
687
  });
295
688
  } else if ((ctx.mode === "tui" || ctx.mode === undefined) && typeof ctx.ui.editor === "function") {
@@ -381,10 +774,12 @@ export default function toolWishlist(pi: ExtensionAPI) {
381
774
  };
382
775
  }
383
776
 
777
+ const contractRoot = await resolveTaskContractRoot(pi, ctx.cwd, signal);
778
+ const contract = branchTaskContract(ctx, contractRoot, { tolerateMalformed: true });
384
779
  const result = await recordCapabilityGap({
385
780
  stateDir,
386
781
  sessionId: ctx.sessionManager.getSessionId(),
387
- runId: activeRunId,
782
+ runId: contract?.id ?? activeRunId,
388
783
  cwd: ctx.cwd,
389
784
  gap: params,
390
785
  signal,
@@ -407,6 +802,138 @@ export default function toolWishlist(pi: ExtensionAPI) {
407
802
  },
408
803
  });
409
804
 
805
+ pi.registerTool({
806
+ name: "record_harness_contract",
807
+ label: "Record Harness Improvement Contract",
808
+ description:
809
+ "Record one bounded task contract for the exact human-selected wishlist improvement. The model may propose objective, hypothesis, requirements, acceptance checks, paths, rollback, and non-goals; this tool never creates human approval or broadens the selected gap.",
810
+ parameters: Type.Object(
811
+ {
812
+ gapId: Type.String({ minLength: 3, maxLength: 120 }),
813
+ selectionId: Type.String({ minLength: 1, maxLength: 120 }),
814
+ sourceRoot: Type.String({ minLength: 1, maxLength: 500 }),
815
+ objective: Type.String({ minLength: 3, maxLength: 600 }),
816
+ hypothesis: Type.String({ minLength: 3, maxLength: 600 }),
817
+ requirements: Type.Array(
818
+ Type.Object(
819
+ {
820
+ id: Type.Optional(Type.String({ minLength: 1, maxLength: 120 })),
821
+ description: Type.String({ minLength: 3, maxLength: 360 }),
822
+ acceptance: Type.String({ minLength: 3, maxLength: 600 }),
823
+ },
824
+ { additionalProperties: false },
825
+ ),
826
+ { minItems: 1, maxItems: 16 },
827
+ ),
828
+ paths: Type.Array(Type.String({ minLength: 1, maxLength: 240 }), { minItems: 1, maxItems: 40 }),
829
+ rollback: Type.String({ minLength: 3, maxLength: 600 }),
830
+ nonGoals: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 360 }), { maxItems: 16 })),
831
+ },
832
+ { additionalProperties: false },
833
+ ),
834
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
835
+ if (contractBusy) {
836
+ throw new Error("A task contract is already being recorded for this improvement");
837
+ }
838
+
839
+ contractBusy = true;
840
+ try {
841
+ const generation = improvementLifecycleGeneration;
842
+ const sessionId = ctx.sessionManager.getSessionId();
843
+ if (
844
+ !activeImprovement ||
845
+ activeImprovement.gapId !== params.gapId ||
846
+ activeImprovement.sessionId !== sessionId
847
+ ) {
848
+ throw new Error("This item was not authorized by /harness-improvement in the current session");
849
+ }
850
+
851
+ const expectedSelection = structuredClone(activeImprovement);
852
+ const sourceRoot = canonicalSourceRoot(params.sourceRoot);
853
+ if (sourceRoot !== expectedSelection.root || sourceRoot !== canonicalSourceRoot(ctx.cwd)) {
854
+ throw new Error("The task contract source root must exactly match the selected SpecPi checkout");
855
+ }
856
+
857
+ if (params.selectionId !== expectedSelection.selectionId) {
858
+ throw new Error("The task contract selection ID does not match the active human selection");
859
+ }
860
+
861
+ assertImprovementStillCurrent(expectedSelection, generation, ctx, signal);
862
+ const refreshed = await refreshWishlist({ stateDir, signal });
863
+ assertImprovementStillCurrent(expectedSelection, generation, ctx, signal);
864
+ const selected = improvementCandidatesFromRefresh(refreshed).find(
865
+ (item: any) => item.canonicalKey === expectedSelection.gapId && item.state === "selected",
866
+ );
867
+ if (!selected) {
868
+ throw new Error(
869
+ "The selected wishlist gap is no longer selected; reselect it before recording a contract",
870
+ );
871
+ }
872
+
873
+ const input = {
874
+ objective: params.objective,
875
+ hypothesis: params.hypothesis,
876
+ requirements: params.requirements,
877
+ paths: params.paths,
878
+ rollback: params.rollback,
879
+ nonGoals: params.nonGoals,
880
+ };
881
+ const existing = branchTaskContract(ctx, expectedSelection.root);
882
+ if (
883
+ existing &&
884
+ (existing.origin !== "improvement" ||
885
+ existing.gapId !== expectedSelection.gapId ||
886
+ existing.selectionId !== expectedSelection.selectionId)
887
+ ) {
888
+ throw new Error("A different task contract is active; revise it through /task or reselect the gap");
889
+ }
890
+
891
+ const contract = createTaskContract(
892
+ {
893
+ ...input,
894
+ },
895
+ {
896
+ root: expectedSelection.root,
897
+ origin: "improvement",
898
+ gapId: expectedSelection.gapId,
899
+ selectionId: expectedSelection.selectionId,
900
+ ...(existing ? { id: existing.id } : {}),
901
+ },
902
+ );
903
+ if (existing) {
904
+ if (existing.digest !== contract.digest) {
905
+ throw new Error("The improvement task contract is immutable; revise it through /task");
906
+ }
907
+
908
+ return {
909
+ content: [
910
+ {
911
+ type: "text",
912
+ text: `Harness improvement contract already recorded (${existing.id}); digest ${existing.digest}.`,
913
+ },
914
+ ],
915
+ details: { contract: existing, idempotent: true },
916
+ };
917
+ }
918
+
919
+ assertImprovementStillCurrent(expectedSelection, generation, ctx, signal);
920
+ pi.appendEntry(TASK_CONTRACT_ENTRY, { kind: "set", contract });
921
+
922
+ return {
923
+ content: [
924
+ {
925
+ type: "text",
926
+ text: `Harness improvement contract recorded (${contract.id}); digest ${contract.digest}.`,
927
+ },
928
+ ],
929
+ details: { contract, idempotent: false },
930
+ };
931
+ } finally {
932
+ contractBusy = false;
933
+ }
934
+ },
935
+ });
936
+
410
937
  pi.registerTool({
411
938
  name: "finish_harness_improvement",
412
939
  label: "Finish Harness Improvement",
@@ -433,88 +960,261 @@ export default function toolWishlist(pi: ExtensionAPI) {
433
960
  { additionalProperties: false },
434
961
  ),
435
962
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
436
- const sessionId = ctx.sessionManager.getSessionId();
437
- if (
438
- !activeImprovement ||
439
- activeImprovement.gapId !== params.gapId ||
440
- activeImprovement.sessionId !== sessionId
441
- ) {
442
- throw new Error("This item was not authorized by /harness-improvement in the current session");
963
+ if (finishBusy) {
964
+ throw new Error("A finish verification is already running for this improvement");
443
965
  }
444
966
 
445
- const refreshed = await refreshWishlist({ stateDir, signal });
446
- const selected = improvementCandidatesFromRefresh(refreshed).find(
447
- (item: any) => item.canonicalKey === params.gapId,
448
- );
449
- if (!selected || selected.state !== "selected") {
450
- throw new Error(`${params.gapId} is not selected`);
451
- }
967
+ finishBusy = true;
968
+ try {
969
+ const generation = improvementLifecycleGeneration;
970
+ const sessionId = ctx.sessionManager.getSessionId();
971
+ const selectedImprovement = activeImprovement;
972
+ if (
973
+ !selectedImprovement ||
974
+ selectedImprovement.gapId !== params.gapId ||
975
+ selectedImprovement.sessionId !== sessionId
976
+ ) {
977
+ throw new Error("This item was not authorized by /harness-improvement in the current session");
978
+ }
452
979
 
453
- const capability = sourceCapability(ctx.cwd, params.gapId);
454
- const npm = process.platform === "win32" ? "npm.cmd" : "npm";
455
- const check = await pi.exec(npm, ["run", "check"], { cwd: ctx.cwd, signal, timeout: 15 * 60 * 1000 });
456
- if (check.code !== 0) {
457
- throw new Error(`SpecPi repository verification failed with exit code ${check.code}`);
458
- }
980
+ const expectedSelection = structuredClone(selectedImprovement);
981
+ assertImprovementStillCurrent(expectedSelection, generation, ctx, signal);
982
+
983
+ const root = canonicalSourceRoot(ctx.cwd);
984
+ if (root !== selectedImprovement.root) {
985
+ throw new Error("The SpecPi source root changed after this improvement was selected");
986
+ }
987
+
988
+ const contract = branchTaskContract(ctx, root);
989
+ if (
990
+ !contract ||
991
+ contract.origin !== "improvement" ||
992
+ contract.gapId !== params.gapId ||
993
+ contract.selectionId !== selectedImprovement.selectionId ||
994
+ contract.root !== root
995
+ ) {
996
+ throw new Error("Record a task contract for this exact improvement selection before finishing");
997
+ }
998
+
999
+ const assertFinishStillCurrent = () => {
1000
+ assertImprovementStillCurrent(expectedSelection, generation, ctx, signal);
1001
+ const currentContract = branchTaskContract(ctx, root);
1002
+ if (!currentContract || currentContract.digest !== contract.digest) {
1003
+ throw new Error("The improvement task contract changed while verification was running");
1004
+ }
1005
+ };
1006
+
1007
+ const refreshed = await refreshWishlist({ stateDir, signal });
1008
+ assertFinishStillCurrent();
1009
+ const selected = improvementCandidatesFromRefresh(refreshed).find(
1010
+ (item: any) => item.canonicalKey === params.gapId,
1011
+ );
1012
+ if (!selected || selected.state !== "selected") {
1013
+ throw new Error(`${params.gapId} is not selected`);
1014
+ }
459
1015
 
460
- const verifiedBy = ["npm run check"];
461
- for (const validator of capability.validations) {
462
- const timeout = VALIDATOR_CATALOG[validator]?.timeoutMs ?? 3 * 60 * 1000;
463
- const result = await pi.exec(process.execPath, validatorArgs(validator, ctx.cwd), {
464
- cwd: ctx.cwd,
1016
+ const baseline = validateSourceSnapshot(selectedImprovement.baseline, root);
1017
+ const before = await captureCheckoutSnapshot(pi, root, signal);
1018
+ assertFinishStillCurrent();
1019
+ const baselineComparison = compareSourceSnapshots(baseline, before);
1020
+ if (baselineComparison.headChanged) {
1021
+ throw new Error("The source checkout HEAD changed after selection; reselect the gap");
1022
+ }
1023
+
1024
+ const baselineViolations = taskContractScopeViolations(contract, baselineComparison.changed);
1025
+ if (baselineViolations.length > 0) {
1026
+ throw new Error(
1027
+ `Changes outside the task contract: ${baselineViolations.map(markdownPathLabel).join(", ")}`,
1028
+ );
1029
+ }
1030
+
1031
+ assertFrozenModuleDigests(before, selectedImprovement);
1032
+ const policy = assertFrozenSourcePolicy(root, params.gapId, selectedImprovement);
1033
+ const capability = policy.capability;
1034
+ if (!capability || !Array.isArray(capability.validations) || capability.validations.length === 0) {
1035
+ throw new Error(
1036
+ `Implementation is not integrated: the reviewed capability registry has no validated ${params.gapId} entry`,
1037
+ );
1038
+ }
1039
+
1040
+ const validators = selectedImprovement.registryHadGap
1041
+ ? selectedImprovement.validatorIds
1042
+ : [...capability.validations];
1043
+ if (
1044
+ validators.length === 0 ||
1045
+ validators.some(
1046
+ (validator: string) =>
1047
+ !isValidValidatorName(validator) || !selectedImprovement.validatorNames.includes(validator),
1048
+ )
1049
+ ) {
1050
+ throw new Error("The selected capability references a validator outside the frozen closed catalog");
1051
+ }
1052
+
1053
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
1054
+ const check = await pi.exec(npm, ["run", "check"], {
1055
+ cwd: root,
465
1056
  signal,
466
- timeout,
1057
+ timeout: 15 * 60 * 1000,
467
1058
  });
468
- if (result.code !== 0) {
469
- const detail = `${result.stderr || result.stdout || ""}`.trim().slice(0, 300);
1059
+ assertFinishStillCurrent();
1060
+ if (check.code !== 0) {
1061
+ throw new Error(`SpecPi repository verification failed with exit code ${check.code}`);
1062
+ }
1063
+
1064
+ const gates = [{ id: "npm run check", exitCode: 0 }];
1065
+ let after = await captureCheckoutSnapshot(pi, root, signal);
1066
+ assertFinishStillCurrent();
1067
+ assertFrozenModuleDigests(after, selectedImprovement);
1068
+ const afterCheck = compareSourceSnapshots(before, after);
1069
+ if (afterCheck.changed.length > 0 || afterCheck.headChanged) {
1070
+ throw new Error("SpecPi check changed the source checkout; verification proof is stale");
1071
+ }
1072
+
1073
+ for (const validator of validators) {
1074
+ const validatorBefore = after;
1075
+ const timeout = VALIDATOR_CATALOG[validator]?.timeoutMs ?? 3 * 60 * 1000;
1076
+ const result = await pi.exec(process.execPath, validatorArgs(validator, root), {
1077
+ cwd: root,
1078
+ signal,
1079
+ timeout,
1080
+ });
1081
+ assertFinishStillCurrent();
1082
+ if (result.code !== 0) {
1083
+ const detail = sanitizeWishlistText(`${result.stderr || result.stdout || ""}`, 300);
1084
+ throw new Error(
1085
+ `Capability validator ${validator} failed with exit code ${result.code}${detail ? `: ${detail}` : ""}`,
1086
+ );
1087
+ }
1088
+
1089
+ gates.push({ id: validator, exitCode: result.code });
1090
+ after = await captureCheckoutSnapshot(pi, root, signal);
1091
+ assertFinishStillCurrent();
1092
+ assertFrozenModuleDigests(after, selectedImprovement);
1093
+ const validatorComparison = compareSourceSnapshots(validatorBefore, after);
1094
+ if (validatorComparison.changed.length > 0 || validatorComparison.headChanged) {
1095
+ throw new Error(
1096
+ `Validator ${validator} changed the source checkout; verification proof is stale`,
1097
+ );
1098
+ }
1099
+ }
1100
+
1101
+ const finalComparison = compareSourceSnapshots(baseline, after);
1102
+ if (finalComparison.headChanged) {
1103
+ throw new Error("The source checkout HEAD changed during verification; proof is stale");
1104
+ }
1105
+
1106
+ const finalViolations = taskContractScopeViolations(contract, finalComparison.changed);
1107
+ if (finalViolations.length > 0) {
470
1108
  throw new Error(
471
- `Capability validator ${validator} failed with exit code ${result.code}${detail ? `: ${detail}` : ""}`,
1109
+ `Changes outside the task contract: ${finalViolations.map(markdownPathLabel).join(", ")}`,
472
1110
  );
473
1111
  }
474
1112
 
475
- verifiedBy.push(validator);
476
- }
477
-
478
- const changed = await gitChangedFiles(pi, ctx.cwd, signal);
479
- const journal: Record<string, unknown> = {
480
- schema: 1,
481
- evidence: params.acceptanceEvidence,
482
- gates: verifiedBy,
483
- changedFilesTruncated: false,
484
- version: sourceCheckout(ctx.cwd).version,
485
- };
486
- if (changed) {
487
- journal.changedFiles = changed.files;
488
- journal.changedFilesTruncated = changed.truncated;
489
- }
1113
+ assertFrozenModuleDigests(after, selectedImprovement);
1114
+ const receipt = createVerificationReceipt({
1115
+ before,
1116
+ after,
1117
+ registryDigest: after.files["extensions/tool-wishlist/capabilities.json"]?.sha256,
1118
+ validatorDigest: selectedImprovement.moduleDigests["extensions/tool-wishlist/validators.mjs"],
1119
+ gates,
1120
+ contractDigest: contract.digest,
1121
+ gapId: params.gapId,
1122
+ selectionId: selectedImprovement.selectionId,
1123
+ sourceRootSalt: wishlistSourceRootSalt(stateDir),
1124
+ });
490
1125
 
491
- const note = `${params.validationNote}; ${verifiedBy.join(", ")} passed`;
492
- await appendWishlistDecision({
493
- stateDir,
494
- action: "retire",
495
- canonicalKey: params.gapId,
496
- note,
497
- signal,
498
- journal,
499
- });
500
- pi.appendEntry(HARNESS_IMPROVEMENT_ENTRY, { gapId: params.gapId, status: "finished" });
501
- activeImprovement = undefined;
1126
+ const changed = await gitChangedFiles(pi, root, signal);
1127
+ assertFinishStillCurrent();
1128
+ const journal: Record<string, unknown> = {
1129
+ schema: 1,
1130
+ evidence: params.acceptanceEvidence,
1131
+ gates: gates.map((gate) => gate.id),
1132
+ changedFilesTruncated: false,
1133
+ version: policy.checkout.version,
1134
+ receipt,
1135
+ };
1136
+ if (changed) {
1137
+ journal.changedFiles = changed.files;
1138
+ journal.changedFilesTruncated = changed.truncated;
1139
+ }
502
1140
 
503
- return {
504
- content: [
505
- {
506
- type: "text",
507
- text: `Harness improvement verified and retired: ${params.gapId}. Gates: ${verifiedBy.join(", ")}. Proof recorded in the improvement journal.`,
1141
+ const note = `${params.validationNote}; ${gates.map((gate) => gate.id).join(", ")} passed`;
1142
+ await appendWishlistDecision({
1143
+ stateDir,
1144
+ action: "retire",
1145
+ canonicalKey: params.gapId,
1146
+ note,
1147
+ signal,
1148
+ journal,
1149
+ precondition: async () => {
1150
+ assertFinishStillCurrent();
1151
+ const currentContract = branchTaskContract(ctx, root);
1152
+
1153
+ const currentRoot = canonicalSourceRoot(ctx.cwd);
1154
+ if (currentRoot !== root) {
1155
+ throw new Error("The SpecPi source root changed while verification was running");
1156
+ }
1157
+
1158
+ const currentSnapshot = await captureCheckoutSnapshot(pi, root, signal);
1159
+ assertFrozenModuleDigests(currentSnapshot, selectedImprovement);
1160
+ const currentComparison = compareSourceSnapshots(after, currentSnapshot);
1161
+ if (currentComparison.changed.length > 0 || currentComparison.headChanged) {
1162
+ throw new Error("Source changed after verification; proof was not appended");
1163
+ }
1164
+
1165
+ assertFinishStillCurrent();
1166
+ const currentPolicy = assertFrozenSourcePolicy(root, params.gapId, selectedImprovement);
1167
+ if (!currentPolicy.capability) {
1168
+ throw new Error("The reviewed capability registry entry disappeared before retirement");
1169
+ }
1170
+
1171
+ const validatedReceipt = validateVerificationReceipt(receipt);
1172
+ if (
1173
+ validatedReceipt.sourceDigest !== currentSnapshot.digest ||
1174
+ validatedReceipt.registryDigest !==
1175
+ currentSnapshot.files["extensions/tool-wishlist/capabilities.json"]?.sha256 ||
1176
+ validatedReceipt.contractDigest !== currentContract.digest ||
1177
+ validatedReceipt.gapId !== params.gapId ||
1178
+ validatedReceipt.selectionId !== selectedImprovement.selectionId ||
1179
+ validatedReceipt.sourceRootIdentity !== wishlistSourceRootIdentity(stateDir, root)
1180
+ ) {
1181
+ throw new Error(
1182
+ "Verification receipt no longer matches the active source, card, or selection",
1183
+ );
1184
+ }
508
1185
  },
509
- ],
510
- details: {
1186
+ });
1187
+ assertFinishStillCurrent();
1188
+ pi.appendEntry(HARNESS_IMPROVEMENT_ENTRY, {
511
1189
  gapId: params.gapId,
512
- state: "retired",
513
- verifiedBy,
514
- acceptanceEvidence: params.acceptanceEvidence,
515
- journal,
516
- },
517
- };
1190
+ status: "finished",
1191
+ root,
1192
+ selectionId: selectedImprovement.selectionId,
1193
+ contractDigest: contract.digest,
1194
+ receiptDigest: receipt.sourceDigest,
1195
+ });
1196
+ if (activeImprovement?.selectionId === selectedImprovement.selectionId) {
1197
+ activeImprovement = undefined;
1198
+ }
1199
+
1200
+ return {
1201
+ content: [
1202
+ {
1203
+ type: "text",
1204
+ text: `Harness improvement verified and retired: ${params.gapId}. Gates: ${gates.map((gate) => gate.id).join(", ")}. Proof recorded in the improvement journal.`,
1205
+ },
1206
+ ],
1207
+ details: {
1208
+ gapId: params.gapId,
1209
+ state: "retired",
1210
+ verifiedBy: gates.map((gate) => gate.id),
1211
+ acceptanceEvidence: params.acceptanceEvidence,
1212
+ journal,
1213
+ },
1214
+ };
1215
+ } finally {
1216
+ finishBusy = false;
1217
+ }
518
1218
  },
519
1219
  });
520
1220
 
@@ -528,7 +1228,7 @@ export default function toolWishlist(pi: ExtensionAPI) {
528
1228
  const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
529
1229
  box.addChild(new Markdown(data.markdown, 0, 0, getMarkdownTheme()));
530
1230
  const suffix = data.truncated ? " (display truncated; file contains the complete report)" : "";
531
- box.addChild(new Text(theme.fg("dim", `Report: ${data.reportPath}${suffix}`), 0, 0));
1231
+ box.addChild(new Text(theme.fg("dim", `Report: ${cleanDisplayPath(data.reportPath)}${suffix}`), 0, 0));
532
1232
 
533
1233
  return box;
534
1234
  });
@@ -549,15 +1249,38 @@ export default function toolWishlist(pi: ExtensionAPI) {
549
1249
  return;
550
1250
  }
551
1251
 
1252
+ const generation = improvementLifecycleGeneration;
1253
+ const menuGeneration = ++improvementMenuGeneration;
1254
+ const sessionId = ctx.sessionManager.getSessionId();
1255
+ let leafId = ctx.sessionManager.getLeafId?.();
1256
+ let root: string;
1257
+ let checkout: ReturnType<typeof sourceCheckout>;
552
1258
  try {
553
- sourceCheckout(ctx.cwd);
1259
+ root = canonicalSourceRoot(ctx.cwd);
1260
+ checkout = sourceCheckout(root);
554
1261
  } catch (error) {
555
1262
  ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
556
1263
 
557
1264
  return;
558
1265
  }
559
1266
 
1267
+ const assertSelectionContextCurrent = () => {
1268
+ if (
1269
+ improvementLifecycleGeneration !== generation ||
1270
+ improvementMenuGeneration !== menuGeneration ||
1271
+ ctx.sessionManager.getSessionId() !== sessionId ||
1272
+ ctx.sessionManager.getLeafId?.() !== leafId
1273
+ ) {
1274
+ throw new Error("The improvement selection changed with the session branch; choose it again");
1275
+ }
1276
+
1277
+ if (canonicalSourceRoot(ctx.cwd) !== root) {
1278
+ throw new Error("The SpecPi source root changed while choosing the improvement; choose it again");
1279
+ }
1280
+ };
1281
+
560
1282
  const refreshed = await refreshWishlist({ stateDir });
1283
+ assertSelectionContextCurrent();
561
1284
  const improvements = improvementCandidatesFromRefresh(refreshed);
562
1285
  if (improvements.length === 0) {
563
1286
  ctx.ui.notify("No qualified or review-needed harness improvements are available.", "info");
@@ -571,6 +1294,7 @@ export default function toolWishlist(pi: ExtensionAPI) {
571
1294
  return `${status.toUpperCase()} · ${item.title} · ${item.canonicalKey}`;
572
1295
  });
573
1296
  const chosen = await ctx.ui.select("Choose one harness improvement", labels);
1297
+ assertSelectionContextCurrent();
574
1298
  if (!chosen) {
575
1299
  return;
576
1300
  }
@@ -580,6 +1304,19 @@ export default function toolWishlist(pi: ExtensionAPI) {
580
1304
  return;
581
1305
  }
582
1306
 
1307
+ let baseline: any;
1308
+ let policy: ReturnType<typeof policyFromCheckout>;
1309
+ try {
1310
+ baseline = await captureCheckoutSnapshot(pi, root, undefined);
1311
+ assertSelectionContextCurrent();
1312
+ checkout = sourceCheckout(root);
1313
+ policy = policyFromCheckout(checkout, baseline, group.canonicalKey);
1314
+ } catch (error) {
1315
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1316
+
1317
+ return;
1318
+ }
1319
+
583
1320
  let promptContext: ImprovementContext = {};
584
1321
  if (group.reviewNeeded && group.state === "retired") {
585
1322
  const signals = typeof group.reviewSignalCount === "number" ? group.reviewSignalCount : 0;
@@ -599,18 +1336,23 @@ export default function toolWishlist(pi: ExtensionAPI) {
599
1336
  canonicalKey: group.canonicalKey,
600
1337
  note: `Reopened for review: ${signals} post-retirement signal(s)`,
601
1338
  evidence,
1339
+ precondition: assertSelectionContextCurrent,
602
1340
  });
1341
+ assertSelectionContextCurrent();
603
1342
  await appendWishlistDecision({
604
1343
  stateDir,
605
1344
  action: "select",
606
1345
  canonicalKey: group.canonicalKey,
607
1346
  note: "Chosen through harness improvement menu",
1347
+ precondition: assertSelectionContextCurrent,
608
1348
  });
1349
+ assertSelectionContextCurrent();
609
1350
  const retirement = latestRetirementDecision(refreshed.decisions ?? [], group.canonicalKey);
610
1351
  if (retirement?.journal) {
611
1352
  promptContext.originalEvidence = retirement.journal.evidence ?? [];
612
1353
  promptContext.changedFiles = retirement.journal.changedFiles ?? [];
613
- promptContext.changedSince = (await gitLogSince(pi, ctx.cwd, retirement.timestamp)) ?? undefined;
1354
+ promptContext.changedSince = (await gitLogSince(pi, root, retirement.timestamp)) ?? undefined;
1355
+ assertSelectionContextCurrent();
614
1356
  }
615
1357
  } else if (group.state === "open") {
616
1358
  await appendWishlistDecision({
@@ -618,18 +1360,41 @@ export default function toolWishlist(pi: ExtensionAPI) {
618
1360
  action: "select",
619
1361
  canonicalKey: group.canonicalKey,
620
1362
  note: "Chosen through harness improvement menu",
1363
+ precondition: assertSelectionContextCurrent,
621
1364
  });
1365
+ assertSelectionContextCurrent();
622
1366
  }
623
1367
 
624
- const sessionId = ctx.sessionManager.getSessionId();
625
- activeImprovement = { gapId: group.canonicalKey, sessionId };
626
- pi.appendEntry(HARNESS_IMPROVEMENT_ENTRY, { gapId: group.canonicalKey, status: "active" });
1368
+ assertSelectionContextCurrent();
1369
+ const selectionId = randomUUID();
1370
+ activeImprovement = {
1371
+ gapId: group.canonicalKey,
1372
+ sessionId,
1373
+ root,
1374
+ selectionId,
1375
+ baseline,
1376
+ ...policy,
1377
+ };
1378
+ assertSelectionContextCurrent();
1379
+ pi.appendEntry(TASK_CONTRACT_ENTRY, { kind: "cleared" });
1380
+ leafId = ctx.sessionManager.getLeafId?.();
1381
+ assertSelectionContextCurrent();
1382
+ pi.appendEntry(HARNESS_IMPROVEMENT_ENTRY, {
1383
+ gapId: group.canonicalKey,
1384
+ status: "active",
1385
+ root,
1386
+ selectionId,
1387
+ baseline,
1388
+ ...policy,
1389
+ });
1390
+ leafId = ctx.sessionManager.getLeafId?.();
1391
+ assertSelectionContextCurrent();
627
1392
  pi.sendUserMessage(improvementPrompt(group, promptContext));
628
1393
  },
629
1394
  });
630
1395
 
631
1396
  const usage =
632
- "Usage: /wishlist [status|on|off|history [id]|decline <id>|merge <from> <to>|unmerge <merge-decision-id>|draft <id>|archive|reset]";
1397
+ "Usage: /wishlist [status|on|off|history [id]|outcome <id>|decline <id>|merge <from> <to>|unmerge <merge-decision-id>|draft <id>|archive|reset]";
633
1398
  pi.registerCommand("wishlist", {
634
1399
  description: "View and curate SpecPi's local capability evidence",
635
1400
  getArgumentCompletions: (prefix: string) => {
@@ -638,6 +1403,7 @@ export default function toolWishlist(pi: ExtensionAPI) {
638
1403
  "on",
639
1404
  "off",
640
1405
  "history",
1406
+ "outcome",
641
1407
  "decline",
642
1408
  "merge",
643
1409
  "unmerge",
@@ -658,7 +1424,7 @@ export default function toolWishlist(pi: ExtensionAPI) {
658
1424
  const result = await refreshWishlist({ stateDir });
659
1425
  const metrics = result.metrics;
660
1426
  ctx.ui.notify(
661
- `Wishlist collection: ${mode}; ${result.uniqueGaps} queued gap${result.uniqueGaps === 1 ? "" : "s"}; retirements ${metrics.retirements}, reopen rate ${metrics.reopenRate}%, open reviews ${metrics.openReviews}. ${cleanDisplayPath(result.reportPath)}`,
1427
+ `Wishlist collection: ${mode}; ${result.uniqueGaps} queued gap${result.uniqueGaps === 1 ? "" : "s"}; retirements ${metrics.retirements}, reopen rate ${metrics.reopenRateKnown ? `${metrics.reopenRate}%` : "unknown"}, open reviews ${metrics.openReviews}; local cohort rate ${metrics.reopenRateKnown ? `${metrics.reopenRate}%` : "unknown"}, baseline reviews ${metrics.baselineReviews}. ${cleanDisplayPath(result.reportPath)}`,
662
1428
  "info",
663
1429
  );
664
1430
 
@@ -672,6 +1438,89 @@ export default function toolWishlist(pi: ExtensionAPI) {
672
1438
  return;
673
1439
  }
674
1440
 
1441
+ if (action === "outcome") {
1442
+ if (parts.length !== 1) {
1443
+ ctx.ui.notify(usage, "error");
1444
+
1445
+ return;
1446
+ }
1447
+
1448
+ if (readCollectionMode(stateDir) !== "on") {
1449
+ ctx.ui.notify("Record an outcome only while local wishlist collection is on.", "error");
1450
+
1451
+ return;
1452
+ }
1453
+
1454
+ if (!ctx.hasUI || typeof ctx.ui.select !== "function") {
1455
+ ctx.ui.notify("/wishlist outcome requires an interactive menu.", "error");
1456
+
1457
+ return;
1458
+ }
1459
+
1460
+ const gapId = normalizeCapability(parts[0]);
1461
+ const refreshed = await refreshWishlist({ stateDir });
1462
+ const retirement = latestLocalRetirementDecision(refreshed.decisions, gapId);
1463
+ if (!retirement?.journal?.receipt) {
1464
+ ctx.ui.notify("This gap has no locally recorded retirement receipt to assess.", "error");
1465
+
1466
+ return;
1467
+ }
1468
+
1469
+ const latestOutcome = latestOutcomeDecision(refreshed.decisions, gapId, retirement.id);
1470
+ const choices = ["helped", "failed", "not-exercised", "reverted"];
1471
+ const chosenOutcome = await ctx.ui.select(
1472
+ `How did the retired improvement work for ${gapId}?`,
1473
+ choices,
1474
+ );
1475
+ if (!chosenOutcome || !choices.includes(chosenOutcome)) {
1476
+ return;
1477
+ }
1478
+
1479
+ const requestId = randomUUID();
1480
+ const retirementId = retirement.id;
1481
+ const retirementReceipt = cloneJson(retirement.journal.receipt);
1482
+ const previousOutcomeId = latestOutcome?.id ?? "";
1483
+ const result = await appendWishlistDecision({
1484
+ stateDir,
1485
+ action: "outcome",
1486
+ canonicalKey: gapId,
1487
+ targetKey: retirementId,
1488
+ outcome: chosenOutcome,
1489
+ requestId,
1490
+ previousOutcomeId,
1491
+ note: "",
1492
+ precondition: async ({ decisions }: any) => {
1493
+ if (readCollectionMode(stateDir) !== "on") {
1494
+ throw new Error("Local wishlist collection was turned off before the outcome was recorded");
1495
+ }
1496
+
1497
+ const currentRetirement = latestLocalRetirementDecision(decisions, gapId);
1498
+ if (
1499
+ !currentRetirement ||
1500
+ currentRetirement.id !== retirementId ||
1501
+ !sameJson(currentRetirement.journal?.receipt, retirementReceipt)
1502
+ ) {
1503
+ throw new Error("The retirement changed while the outcome menu was open; choose again");
1504
+ }
1505
+
1506
+ const currentOutcome = latestOutcomeDecision(decisions, gapId, retirementId);
1507
+ if ((currentOutcome?.id ?? "") !== previousOutcomeId) {
1508
+ throw new Error(
1509
+ "The retirement outcome changed while the outcome menu was open; choose again",
1510
+ );
1511
+ }
1512
+
1513
+ validateVerificationReceipt(retirementReceipt);
1514
+ if (retirementReceipt.gapId !== gapId) {
1515
+ throw new Error("The retirement receipt is bound to a different wishlist gap");
1516
+ }
1517
+ },
1518
+ });
1519
+ ctx.ui.notify(`Wishlist outcome recorded: ${gapId} → ${chosenOutcome}.`, "info");
1520
+
1521
+ return result;
1522
+ }
1523
+
675
1524
  if (action === "archive" || action === "reset") {
676
1525
  if (!ctx.hasUI) {
677
1526
  ctx.ui.notify(`${action} requires interactive confirmation.`, "error");