specpi 0.10.0

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/LICENSE +21 -0
  3. package/NPM_RELEASE.md +110 -0
  4. package/README.md +155 -0
  5. package/SECURITY.md +85 -0
  6. package/SECURITY_MODEL.md +107 -0
  7. package/THIRD_PARTY.md +61 -0
  8. package/browser-runtime/package-lock.json +86 -0
  9. package/browser-runtime/package.json +15 -0
  10. package/extensions/browser/core.mjs +306 -0
  11. package/extensions/browser/index.ts +723 -0
  12. package/extensions/browser/smoke.mjs +47 -0
  13. package/extensions/command-guard/bash.mjs +1426 -0
  14. package/extensions/command-guard/cmd.mjs +369 -0
  15. package/extensions/command-guard/core.mjs +506 -0
  16. package/extensions/command-guard/index.ts +634 -0
  17. package/extensions/command-guard/managed-files.mjs +22 -0
  18. package/extensions/command-guard/paths.mjs +398 -0
  19. package/extensions/command-guard/powershell-parser.ps1 +47 -0
  20. package/extensions/command-guard/powershell.mjs +655 -0
  21. package/extensions/command-guard/redact.mjs +65 -0
  22. package/extensions/command-guard/rules.mjs +2557 -0
  23. package/extensions/command-guard/smoke.mjs +422 -0
  24. package/extensions/files/core.mjs +422 -0
  25. package/extensions/files/index.ts +678 -0
  26. package/extensions/spec/core.mjs +47 -0
  27. package/extensions/spec.ts +457 -0
  28. package/extensions/tool-wishlist/capabilities.json +114 -0
  29. package/extensions/tool-wishlist/core.mjs +1525 -0
  30. package/extensions/tool-wishlist/index.ts +804 -0
  31. package/extensions/tool-wishlist/registry.mjs +99 -0
  32. package/extensions/tool-wishlist/validators.mjs +345 -0
  33. package/extensions/ui-refresh/index.ts +54 -0
  34. package/extensions/workflow-controls/challenge.mjs +196 -0
  35. package/extensions/workflow-controls/experiments.mjs +628 -0
  36. package/extensions/workflow-controls/index.ts +1144 -0
  37. package/extensions/workflow-controls/scope.mjs +272 -0
  38. package/extensions/workflow-controls/smoke.mjs +201 -0
  39. package/package.json +98 -0
  40. package/scripts/check-package.mjs +483 -0
  41. package/scripts/check-pi-package.mjs +223 -0
  42. package/scripts/check-release-order.mjs +97 -0
  43. package/scripts/lib.mjs +182 -0
  44. package/scripts/lock.mjs +122 -0
  45. package/scripts/specpi.mjs +2037 -0
  46. package/scripts/verify-artifact.mjs +21 -0
  47. package/shell/pi-profiles.sh +14 -0
  48. package/site/logo.svg +9 -0
  49. package/site/self-improvement-loop-v2.svg +108 -0
  50. package/skills/donsetch/SKILL.md +76 -0
  51. package/skills/specpi-improve/SKILL.md +54 -0
  52. package/specpi +4 -0
  53. package/specpi.cmd +4 -0
  54. package/templates/AGENTS.md +23 -0
  55. package/templates/settings.json +10 -0
  56. package/themes/specpi-spec.json +96 -0
  57. package/themes/tea-house.json +89 -0
@@ -0,0 +1,804 @@
1
+ /**
2
+ * SpecPi capability-gap collector and explicit improvement lifecycle.
3
+ *
4
+ * Observations are privacy-minimized and task-deduplicated. One explicit
5
+ * menu choice starts an improvement; proof-gated completion retires it.
6
+ */
7
+
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { randomUUID } from "node:crypto";
12
+ import { getMarkdownTheme, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import { StringEnum } from "@earendil-works/pi-ai";
14
+ import { Box, Markdown, Text } from "@earendil-works/pi-tui";
15
+ import { Type } from "typebox";
16
+ import {
17
+ appendWishlistDecision,
18
+ archiveWishlist,
19
+ collectChangedFilePaths,
20
+ createIssueDraft,
21
+ latestRetirementDecision,
22
+ readCollectionMode,
23
+ recordCapabilityGap,
24
+ refreshWishlist,
25
+ renderWishlistHistory,
26
+ sanitizeWishlistText,
27
+ setCollectionMode,
28
+ } from "./core.mjs";
29
+ import { VALIDATOR_CATALOG } from "./validators.mjs";
30
+
31
+ const agentDir = path.resolve(process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent"));
32
+ const stateDir = path.join(agentDir, "specpi");
33
+ const WISHLIST_REPORT_ENTRY = "specpi-wishlist-report";
34
+ const HARNESS_IMPROVEMENT_ENTRY = "specpi-harness-improvement";
35
+ const MAX_REPORT_DISPLAY_BYTES = 50 * 1024;
36
+ const MAX_REPORT_DISPLAY_LINES = 2000;
37
+ const MAX_JOURNAL_CHANGED_FILES = 40;
38
+
39
+ interface ImprovementContext {
40
+ originalEvidence?: string[];
41
+ changedFiles?: string[];
42
+ changedSince?: string[];
43
+ }
44
+
45
+ interface WishlistReportEntry {
46
+ markdown: string;
47
+ reportPath: string;
48
+ truncated: boolean;
49
+ }
50
+
51
+ interface ActiveImprovement {
52
+ gapId: string;
53
+ sessionId: string;
54
+ }
55
+
56
+ function sourceCheckout(cwd: string) {
57
+ const packageFile = path.join(cwd, "package.json");
58
+ const registryFile = path.join(cwd, "extensions", "tool-wishlist", "capabilities.json");
59
+ const validatorsFile = path.join(cwd, "extensions", "tool-wishlist", "validators.mjs");
60
+ if (!fs.existsSync(packageFile) || !fs.existsSync(registryFile) || !fs.existsSync(validatorsFile)) {
61
+ throw new Error("Run /harness-improvement from a complete SpecPi source checkout");
62
+ }
63
+
64
+ const manifest = JSON.parse(fs.readFileSync(packageFile, "utf8"));
65
+ if (manifest?.name !== "specpi" || typeof manifest?.scripts?.check !== "string") {
66
+ throw new Error("The current directory is not a verifiable SpecPi source checkout");
67
+ }
68
+
69
+ return { registryFile, validatorsFile, version: String(manifest.version ?? "unknown") };
70
+ }
71
+
72
+ function sourceCapability(cwd: string, gapId: string) {
73
+ const { registryFile } = sourceCheckout(cwd);
74
+ const registry = JSON.parse(fs.readFileSync(registryFile, "utf8"));
75
+ const capability = registry?.capabilities?.find((item: any) => item?.id === gapId);
76
+ if (!capability || !Array.isArray(capability.validations) || capability.validations.length === 0) {
77
+ throw new Error(
78
+ `Implementation is not integrated: the reviewed capability registry has no validated ${gapId} entry`,
79
+ );
80
+ }
81
+
82
+ return capability;
83
+ }
84
+
85
+ function reportField(body: string, label: string) {
86
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
87
+
88
+ return body
89
+ .match(new RegExp(`^- ${escaped}: (.+)$`, "m"))?.[1]
90
+ ?.replaceAll("`", "")
91
+ .trim();
92
+ }
93
+
94
+ function reportBullets(body: string, heading: string) {
95
+ const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
96
+ const block = body.match(new RegExp(`\\*\\*${escaped}\\*\\*\\n((?:- .*(?:\\n|$))*)`))?.[1] ?? "";
97
+
98
+ return block
99
+ .split("\n")
100
+ .filter((line) => line.startsWith("- "))
101
+ .map((line) => line.slice(2).trim());
102
+ }
103
+
104
+ function validatorArgs(validator: string, cwd: string) {
105
+ return [
106
+ sourceCheckout(cwd).validatorsFile,
107
+ validator,
108
+ "--state-dir",
109
+ stateDir,
110
+ "--cwd",
111
+ cwd,
112
+ "--browser-runtime",
113
+ path.join(stateDir, "browser-runtime"),
114
+ ];
115
+ }
116
+
117
+ async function gitChangedFiles(pi: any, cwd: string, signal: any) {
118
+ try {
119
+ const result = await pi.exec("git", ["status", "--porcelain"], { cwd, signal, timeout: 30_000 });
120
+ if (result.code !== 0 || typeof result.stdout !== "string") {
121
+ return undefined;
122
+ }
123
+
124
+ const files = collectChangedFilePaths(result.stdout);
125
+
126
+ return {
127
+ files: files.slice(0, MAX_JOURNAL_CHANGED_FILES),
128
+ truncated: files.length > MAX_JOURNAL_CHANGED_FILES,
129
+ };
130
+ } catch {
131
+ return undefined;
132
+ }
133
+ }
134
+
135
+ async function gitLogSince(pi: any, cwd: string, sinceIso: string) {
136
+ try {
137
+ const result = await pi.exec("git", ["log", `--since=${sinceIso}`, "--format=%h %s", "-8"], {
138
+ cwd,
139
+ timeout: 30_000,
140
+ });
141
+ if (result.code !== 0 || typeof result.stdout !== "string") {
142
+ return undefined;
143
+ }
144
+
145
+ return result.stdout
146
+ .split("\n")
147
+ .map((line: string) => sanitizeWishlistText(line, 240))
148
+ .filter(Boolean)
149
+ .slice(0, 8);
150
+ } catch {
151
+ return undefined;
152
+ }
153
+ }
154
+
155
+ export function improvementCandidatesFromRefresh(refreshed: any) {
156
+ if (Array.isArray(refreshed?.improvements)) {
157
+ return refreshed.improvements;
158
+ }
159
+
160
+ const candidates: any[] = [];
161
+ const report = `${String(refreshed?.report ?? "")}\n# END\n`;
162
+ for (const section of report.matchAll(/^# (Needs review|Selected|Open)\n([\s\S]*?)(?=^# )/gm)) {
163
+ const sectionName = section[1];
164
+ const body = `${section[2]}\n## END\n`;
165
+ for (const match of body.matchAll(/^## (.+)\n([\s\S]*?)(?=^## )/gm)) {
166
+ const itemBody = match[2];
167
+ const canonicalKey = reportField(itemBody, "ID");
168
+ if (!canonicalKey) {
169
+ continue;
170
+ }
171
+
172
+ candidates.push({
173
+ canonicalKey,
174
+ title: match[1].trim(),
175
+ state: reportField(itemBody, "Status") ?? "open",
176
+ qualified: reportField(itemBody, "Qualified") === "yes",
177
+ reviewNeeded: sectionName === "Needs review",
178
+ occurrences: Number(reportField(itemBody, "Occurrences") ?? 0),
179
+ sessions: Number(reportField(itemBody, "Distinct sessions") ?? 0),
180
+ projects: Number(reportField(itemBody, "Distinct projects") ?? 0),
181
+ impact: reportField(itemBody, "Impact") ?? "minor",
182
+ scenarios: reportBullets(itemBody, "Observed needs"),
183
+ limitations: reportBullets(itemBody, "Why current capabilities fell short"),
184
+ });
185
+ }
186
+ }
187
+
188
+ const rank = (item: any) => (item.state === "selected" ? 0 : item.reviewNeeded ? 1 : 2);
189
+
190
+ return candidates
191
+ .filter((item) => item.state === "selected" || item.reviewNeeded || (item.state === "open" && item.qualified))
192
+ .sort((a, b) => rank(a) - rank(b));
193
+ }
194
+
195
+ function improvementPrompt(group: any, context: ImprovementContext = {}) {
196
+ const observed = group.scenarios?.[0] ?? "No representative need was recorded.";
197
+ const limitation = group.limitations?.[0] ?? "No representative limitation was recorded.";
198
+ const lines = [
199
+ `Begin the selected SpecPi harness improvement: ${group.canonicalKey}.`,
200
+ "",
201
+ `Observed need: ${observed}`,
202
+ `Current limitation: ${limitation}`,
203
+ `Evidence: ${group.occurrences} unique task(s), ${group.projects} project(s), ${group.sessions} session(s); impact ${group.impact}.`,
204
+ ];
205
+ if (context.originalEvidence?.length) {
206
+ lines.push(
207
+ "",
208
+ "Original proof from the improvement journal:",
209
+ ...context.originalEvidence.slice(0, 5).map((item) => `- ${item}`),
210
+ );
211
+ }
212
+
213
+ if (context.changedFiles?.length) {
214
+ lines.push(
215
+ "",
216
+ "Files touched by the original change:",
217
+ ...context.changedFiles.slice(0, 10).map((file) => `- ${file}`),
218
+ );
219
+ }
220
+
221
+ if (context.changedSince?.length) {
222
+ lines.push(
223
+ "",
224
+ "Changed since the retirement (untrusted, sanitized Git metadata):",
225
+ ...context.changedSince.map((item) => `- ${item}`),
226
+ );
227
+ }
228
+
229
+ lines.push(
230
+ "",
231
+ "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.",
233
+ );
234
+
235
+ return lines.join("\n");
236
+ }
237
+
238
+ function truncateReportDisplay(markdown: string) {
239
+ const lines = markdown.split("\n");
240
+ const lineLimited =
241
+ lines.length > MAX_REPORT_DISPLAY_LINES ? lines.slice(0, MAX_REPORT_DISPLAY_LINES).join("\n") : markdown;
242
+ const encoded = Buffer.from(lineLimited, "utf8");
243
+ if (encoded.length <= MAX_REPORT_DISPLAY_BYTES) {
244
+ return { content: lineLimited, truncated: lines.length > MAX_REPORT_DISPLAY_LINES };
245
+ }
246
+
247
+ let end = MAX_REPORT_DISPLAY_BYTES;
248
+ while (end > 0 && (encoded[end] & 0xc0) === 0x80) {
249
+ end -= 1;
250
+ }
251
+
252
+ return { content: encoded.subarray(0, end).toString("utf8"), truncated: true };
253
+ }
254
+
255
+ function cleanDisplayPath(value: string) {
256
+ return value.replace(/[\u0000-\u001f\u007f]/g, "?");
257
+ }
258
+
259
+ export default function toolWishlist(pi: ExtensionAPI) {
260
+ let activeRunId = randomUUID();
261
+ let activeImprovement: ActiveImprovement | undefined;
262
+ const supportsReportEntries = typeof pi.registerEntryRenderer === "function";
263
+
264
+ pi.on("session_start", async (_event, ctx) => {
265
+ activeImprovement = undefined;
266
+ for (const entry of ctx.sessionManager.getBranch?.() ?? []) {
267
+ if (entry.type !== "custom" || entry.customType !== HARNESS_IMPROVEMENT_ENTRY) {
268
+ continue;
269
+ }
270
+
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;
276
+ }
277
+ });
278
+
279
+ pi.on("before_agent_start", async () => {
280
+ activeRunId = randomUUID();
281
+ });
282
+
283
+ const displayMarkdown = async (markdown: string, reportPath: string, ctx: any) => {
284
+ const display = truncateReportDisplay(markdown);
285
+ const displayPath = cleanDisplayPath(reportPath);
286
+ const content = display.truncated
287
+ ? `${display.content}\n\n> Display truncated. Open the report path below to view the complete file.`
288
+ : display.content;
289
+ if (supportsReportEntries) {
290
+ pi.appendEntry<WishlistReportEntry>(WISHLIST_REPORT_ENTRY, {
291
+ markdown: content,
292
+ reportPath: displayPath,
293
+ truncated: display.truncated,
294
+ });
295
+ } else if ((ctx.mode === "tui" || ctx.mode === undefined) && typeof ctx.ui.editor === "function") {
296
+ await ctx.ui.editor(
297
+ "SpecPi Wishlist (view only; changes are ignored)",
298
+ `${content}\n\n---\nReport: ${displayPath}`,
299
+ );
300
+ }
301
+ };
302
+
303
+ pi.registerTool({
304
+ name: "report_capability_gap",
305
+ label: "Report Capability Gap",
306
+ description:
307
+ "Privately record a material, reusable capability gap in SpecPi's local wishlist. Report only after reasonable existing tools or workarounds proved insufficient. Do not use for transient failures, command mistakes, credentials or permissions the user must supply, ordinary project-specific work, or speculative nice-to-haves. Never include secrets, source code, full commands, file paths, URLs with private data, or user prompt text. Report a gap at most once per user task. Collection requires an explicit local on/off decision and never uploads data.",
308
+ promptSnippet: "Record recurring, generalizable capability friction without interrupting the user task",
309
+ promptGuidelines: [
310
+ "Use report_capability_gap only for a material and generalizable missing capability after reasonable existing tools or workarounds have proved insufficient.",
311
+ "Do not use report_capability_gap for transient errors, model mistakes, missing credentials or permissions, ordinary project-specific work, or speculative nice-to-haves.",
312
+ "Call report_capability_gap at most once per distinct gap per user task; use a short durable capability phrase without project names, and never include secrets, source code, full commands, private paths, or user prompt text.",
313
+ "After report_capability_gap, continue the requested task; never treat a report as permission to modify SpecPi or external state.",
314
+ ],
315
+ parameters: Type.Object(
316
+ {
317
+ capability: Type.String({
318
+ minLength: 3,
319
+ maxLength: 120,
320
+ description: "Short, reusable noun phrase for the missing capability; omit project-specific names",
321
+ }),
322
+ scenario: Type.String({
323
+ minLength: 5,
324
+ maxLength: 300,
325
+ description: "Sanitized description of the general task that needed the capability",
326
+ }),
327
+ limitation: Type.String({
328
+ minLength: 5,
329
+ maxLength: 300,
330
+ description: "Why currently available capabilities were materially insufficient",
331
+ }),
332
+ impact: StringEnum(["minor", "degraded", "blocked"] as const, {
333
+ description:
334
+ "minor=extra friction, degraded=costly workaround, blocked=no reasonable completion path",
335
+ }),
336
+ workaround: Type.Optional(
337
+ Type.String({
338
+ maxLength: 240,
339
+ description:
340
+ "Sanitized workaround used, if any; never include commands, paths, source, or secrets",
341
+ }),
342
+ ),
343
+ suggestedFix: StringEnum(["tool", "skill", "prompt", "config", "bug", "unknown"] as const, {
344
+ description: "Smallest likely intervention; not every gap needs a new tool",
345
+ }),
346
+ },
347
+ { additionalProperties: false },
348
+ ),
349
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
350
+ let mode = readCollectionMode(stateDir);
351
+ if (mode === "undecided") {
352
+ if (!ctx.hasUI) {
353
+ return {
354
+ content: [
355
+ {
356
+ type: "text",
357
+ text: "Not recorded: local wishlist collection is undecided. The user can run /wishlist on or /wishlist off in an interactive session.",
358
+ },
359
+ ],
360
+ details: { recorded: false, mode },
361
+ };
362
+ }
363
+
364
+ const enabled = await ctx.ui.confirm(
365
+ "Enable local capability-gap collection?",
366
+ "SpecPi stores sanitized summaries and salted task, session, and project hashes locally. It never uploads them. You can change this later with /wishlist on or /wishlist off.",
367
+ );
368
+ mode = enabled ? "on" : "off";
369
+ await setCollectionMode({ stateDir, mode, signal });
370
+ }
371
+
372
+ if (mode === "off") {
373
+ return {
374
+ content: [
375
+ {
376
+ type: "text",
377
+ text: "Not recorded: local wishlist collection is off. Continue the user task.",
378
+ },
379
+ ],
380
+ details: { recorded: false, mode },
381
+ };
382
+ }
383
+
384
+ const result = await recordCapabilityGap({
385
+ stateDir,
386
+ sessionId: ctx.sessionManager.getSessionId(),
387
+ runId: activeRunId,
388
+ cwd: ctx.cwd,
389
+ gap: params,
390
+ signal,
391
+ });
392
+ const disposition = result.duplicate
393
+ ? "Already recorded for this task"
394
+ : result.regression
395
+ ? "Recorded post-retirement signal for explicit review"
396
+ : "Recorded";
397
+
398
+ return {
399
+ content: [
400
+ {
401
+ type: "text",
402
+ text: `${disposition}: ${result.canonicalKey} (${result.occurrences} occurrence${result.occurrences === 1 ? "" : "s"} across ${result.sessions} session${result.sessions === 1 ? "" : "s"}). Continue the user task; improvements begin only through /harness-improvement.`,
403
+ },
404
+ ],
405
+ details: { ...result, recorded: !result.duplicate, mode },
406
+ };
407
+ },
408
+ });
409
+
410
+ pi.registerTool({
411
+ name: "finish_harness_improvement",
412
+ label: "Finish Harness Improvement",
413
+ description:
414
+ "Complete the exact wishlist item selected through /harness-improvement. Use only after implementing the smallest sufficient change and running direct acceptance checks. This tool independently runs SpecPi's repository check, requires reviewed capability-registry integration, runs supported closed validators, and retires the item only when every gate passes.",
415
+ parameters: Type.Object(
416
+ {
417
+ gapId: Type.String({
418
+ minLength: 3,
419
+ maxLength: 120,
420
+ description: "Exact gap ID selected by /harness-improvement",
421
+ }),
422
+ acceptanceEvidence: Type.Array(Type.String({ minLength: 3, maxLength: 240 }), {
423
+ minItems: 1,
424
+ maxItems: 8,
425
+ description: "Concise direct checks already run and observed to pass",
426
+ }),
427
+ validationNote: Type.String({
428
+ minLength: 5,
429
+ maxLength: 240,
430
+ description: "Concise sanitized statement of the verified outcome",
431
+ }),
432
+ },
433
+ { additionalProperties: false },
434
+ ),
435
+ 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");
443
+ }
444
+
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
+ }
452
+
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
+ }
459
+
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,
465
+ signal,
466
+ timeout,
467
+ });
468
+ if (result.code !== 0) {
469
+ const detail = `${result.stderr || result.stdout || ""}`.trim().slice(0, 300);
470
+ throw new Error(
471
+ `Capability validator ${validator} failed with exit code ${result.code}${detail ? `: ${detail}` : ""}`,
472
+ );
473
+ }
474
+
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
+ }
490
+
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;
502
+
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.`,
508
+ },
509
+ ],
510
+ details: {
511
+ gapId: params.gapId,
512
+ state: "retired",
513
+ verifiedBy,
514
+ acceptanceEvidence: params.acceptanceEvidence,
515
+ journal,
516
+ },
517
+ };
518
+ },
519
+ });
520
+
521
+ if (supportsReportEntries) {
522
+ pi.registerEntryRenderer<WishlistReportEntry>(WISHLIST_REPORT_ENTRY, (entry, _options, theme) => {
523
+ const data = entry.data ?? {
524
+ markdown: "# Tool Wishlist\n\nReport unavailable.",
525
+ reportPath: "",
526
+ truncated: false,
527
+ };
528
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
529
+ box.addChild(new Markdown(data.markdown, 0, 0, getMarkdownTheme()));
530
+ 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));
532
+
533
+ return box;
534
+ });
535
+ }
536
+
537
+ pi.registerCommand("harness-improvement", {
538
+ description: "Choose one wishlist item and run its verified improvement loop",
539
+ handler: async (_args, ctx) => {
540
+ if (!ctx.hasUI || typeof ctx.ui.select !== "function") {
541
+ ctx.ui.notify("/harness-improvement requires an interactive menu.", "error");
542
+
543
+ return;
544
+ }
545
+
546
+ if (typeof ctx.isIdle === "function" && !ctx.isIdle()) {
547
+ ctx.ui.notify("Wait for the current agent turn to finish, then run /harness-improvement.", "warning");
548
+
549
+ return;
550
+ }
551
+
552
+ try {
553
+ sourceCheckout(ctx.cwd);
554
+ } catch (error) {
555
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
556
+
557
+ return;
558
+ }
559
+
560
+ const refreshed = await refreshWishlist({ stateDir });
561
+ const improvements = improvementCandidatesFromRefresh(refreshed);
562
+ if (improvements.length === 0) {
563
+ ctx.ui.notify("No qualified or review-needed harness improvements are available.", "info");
564
+
565
+ return;
566
+ }
567
+
568
+ const labels = improvements.map((item: any) => {
569
+ const status = item.state === "selected" ? "selected" : item.reviewNeeded ? "review" : "ready";
570
+
571
+ return `${status.toUpperCase()} · ${item.title} · ${item.canonicalKey}`;
572
+ });
573
+ const chosen = await ctx.ui.select("Choose one harness improvement", labels);
574
+ if (!chosen) {
575
+ return;
576
+ }
577
+
578
+ const group = improvements[labels.indexOf(chosen)];
579
+ if (!group) {
580
+ return;
581
+ }
582
+
583
+ let promptContext: ImprovementContext = {};
584
+ if (group.reviewNeeded && group.state === "retired") {
585
+ const signals = typeof group.reviewSignalCount === "number" ? group.reviewSignalCount : 0;
586
+ const evidence = [
587
+ `${signals} post-retirement signal(s) recorded after the retirement`,
588
+ ...(group.reviewFirstSeen && group.reviewLastSeen
589
+ ? [
590
+ `Signal window: ${String(group.reviewFirstSeen).slice(0, 10)} to ${String(group.reviewLastSeen).slice(0, 10)}`,
591
+ ]
592
+ : []),
593
+ ...(group.limitations?.[0] ? [`Latest limitation: ${group.limitations[0]}`] : []),
594
+ ...(group.scenarios?.[0] ? [`Latest need: ${group.scenarios[0]}`] : []),
595
+ ].slice(0, 5);
596
+ await appendWishlistDecision({
597
+ stateDir,
598
+ action: "reopen",
599
+ canonicalKey: group.canonicalKey,
600
+ note: `Reopened for review: ${signals} post-retirement signal(s)`,
601
+ evidence,
602
+ });
603
+ await appendWishlistDecision({
604
+ stateDir,
605
+ action: "select",
606
+ canonicalKey: group.canonicalKey,
607
+ note: "Chosen through harness improvement menu",
608
+ });
609
+ const retirement = latestRetirementDecision(refreshed.decisions ?? [], group.canonicalKey);
610
+ if (retirement?.journal) {
611
+ promptContext.originalEvidence = retirement.journal.evidence ?? [];
612
+ promptContext.changedFiles = retirement.journal.changedFiles ?? [];
613
+ promptContext.changedSince = (await gitLogSince(pi, ctx.cwd, retirement.timestamp)) ?? undefined;
614
+ }
615
+ } else if (group.state === "open") {
616
+ await appendWishlistDecision({
617
+ stateDir,
618
+ action: "select",
619
+ canonicalKey: group.canonicalKey,
620
+ note: "Chosen through harness improvement menu",
621
+ });
622
+ }
623
+
624
+ const sessionId = ctx.sessionManager.getSessionId();
625
+ activeImprovement = { gapId: group.canonicalKey, sessionId };
626
+ pi.appendEntry(HARNESS_IMPROVEMENT_ENTRY, { gapId: group.canonicalKey, status: "active" });
627
+ pi.sendUserMessage(improvementPrompt(group, promptContext));
628
+ },
629
+ });
630
+
631
+ const usage =
632
+ "Usage: /wishlist [status|on|off|history [id]|decline <id>|merge <from> <to>|unmerge <merge-decision-id>|draft <id>|archive|reset]";
633
+ pi.registerCommand("wishlist", {
634
+ description: "View and curate SpecPi's local capability evidence",
635
+ getArgumentCompletions: (prefix: string) => {
636
+ const options = [
637
+ "status",
638
+ "on",
639
+ "off",
640
+ "history",
641
+ "decline",
642
+ "merge",
643
+ "unmerge",
644
+ "draft",
645
+ "archive",
646
+ "reset",
647
+ ];
648
+ const first = prefix.trim().split(/\s+/, 1)[0] ?? "";
649
+ const matches = options.filter((option) => option.startsWith(first));
650
+
651
+ return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
652
+ },
653
+ handler: async (args, ctx) => {
654
+ const parts = args.trim().split(/\s+/).filter(Boolean);
655
+ const action = parts.shift()?.toLowerCase() ?? "list";
656
+ if (action === "status") {
657
+ const mode = readCollectionMode(stateDir);
658
+ const result = await refreshWishlist({ stateDir });
659
+ const metrics = result.metrics;
660
+ 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)}`,
662
+ "info",
663
+ );
664
+
665
+ return;
666
+ }
667
+
668
+ if (action === "on" || action === "off") {
669
+ await setCollectionMode({ stateDir, mode: action });
670
+ ctx.ui.notify(`Local wishlist collection is ${action}. No data is uploaded.`, "info");
671
+
672
+ return;
673
+ }
674
+
675
+ if (action === "archive" || action === "reset") {
676
+ if (!ctx.hasUI) {
677
+ ctx.ui.notify(`${action} requires interactive confirmation.`, "error");
678
+
679
+ return;
680
+ }
681
+
682
+ const confirmed = await ctx.ui.confirm(
683
+ `${action === "archive" ? "Archive" : "Reset"} active wishlist?`,
684
+ "Observations, decisions, and the report will move to a private timestamped archive. Collection mode and the private salt are preserved.",
685
+ );
686
+ if (!confirmed) {
687
+ return;
688
+ }
689
+
690
+ const result = await archiveWishlist({ stateDir, reason: action });
691
+ ctx.ui.notify(`Wishlist ${action} complete. Archive: ${cleanDisplayPath(result.archiveDir)}`, "info");
692
+
693
+ return;
694
+ }
695
+
696
+ if (["next", "select", "retire", "reopen"].includes(action)) {
697
+ ctx.ui.notify(
698
+ "Start or resume implementation with /harness-improvement; verification retires the selected item automatically.",
699
+ "info",
700
+ );
701
+
702
+ return;
703
+ }
704
+
705
+ if (action === "history") {
706
+ if (parts.length > 1) {
707
+ ctx.ui.notify(usage, "error");
708
+
709
+ return;
710
+ }
711
+
712
+ const refreshed = await refreshWishlist({ stateDir });
713
+ let markdown: string;
714
+ try {
715
+ markdown = renderWishlistHistory(refreshed.events, refreshed.decisions, parts[0]);
716
+ } catch (error) {
717
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
718
+
719
+ return;
720
+ }
721
+
722
+ await displayMarkdown(markdown, refreshed.reportPath, ctx);
723
+
724
+ return;
725
+ }
726
+
727
+ if (action === "draft") {
728
+ if (parts.length !== 1) {
729
+ ctx.ui.notify(usage, "error");
730
+
731
+ return;
732
+ }
733
+
734
+ const result = await createIssueDraft({ stateDir, canonicalKey: parts[0] });
735
+ const report = await refreshWishlist({ stateDir });
736
+ await displayMarkdown(result.markdown, report.reportPath, ctx);
737
+
738
+ return;
739
+ }
740
+
741
+ if (action === "decline") {
742
+ if (parts.length < 1) {
743
+ ctx.ui.notify(usage, "error");
744
+
745
+ return;
746
+ }
747
+
748
+ const [canonicalKey, ...note] = parts;
749
+ const result = await appendWishlistDecision({ stateDir, action, canonicalKey, note: note.join(" ") });
750
+ ctx.ui.notify(`Wishlist ${action}: ${result.canonicalKey}`, "info");
751
+
752
+ return;
753
+ }
754
+
755
+ if (action === "merge") {
756
+ if (parts.length !== 2) {
757
+ ctx.ui.notify(usage, "error");
758
+
759
+ return;
760
+ }
761
+
762
+ const result = await appendWishlistDecision({
763
+ stateDir,
764
+ action,
765
+ canonicalKey: parts[0],
766
+ targetKey: parts[1],
767
+ });
768
+ ctx.ui.notify(
769
+ `Wishlist merged: ${result.canonicalKey} → ${result.targetKey} (decision ${result.decisionId})`,
770
+ "info",
771
+ );
772
+
773
+ return;
774
+ }
775
+
776
+ if (action === "unmerge") {
777
+ if (parts.length !== 1) {
778
+ ctx.ui.notify(usage, "error");
779
+
780
+ return;
781
+ }
782
+
783
+ const result = await appendWishlistDecision({ stateDir, action, canonicalKey: parts[0] });
784
+ ctx.ui.notify(`Wishlist merge removed: ${result.canonicalKey}`, "info");
785
+
786
+ return;
787
+ }
788
+
789
+ if (action !== "list") {
790
+ ctx.ui.notify(usage, "error");
791
+
792
+ return;
793
+ }
794
+
795
+ const result = await refreshWishlist({ stateDir });
796
+ await displayMarkdown(result.report, result.reportPath, ctx);
797
+ const warning = result.invalidLines > 0 ? `; ${result.invalidLines} malformed state line(s) ignored` : "";
798
+ ctx.ui.notify(
799
+ `Tool wishlist refreshed: ${result.uniqueGaps} queued gap${result.uniqueGaps === 1 ? "" : "s"}, ${result.occurrences} occurrence${result.occurrences === 1 ? "" : "s"}${warning}. ${cleanDisplayPath(result.reportPath)}`,
800
+ result.invalidLines > 0 ? "warning" : "info",
801
+ );
802
+ },
803
+ });
804
+ }