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,1144 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { getMarkdownTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { StringEnum } from "@earendil-works/pi-ai";
7
+ import { Box, Markdown, Text, truncateToWidth } from "@earendil-works/pi-tui";
8
+ import { Type } from "typebox";
9
+ import {
10
+ canonicalRoot,
11
+ compareWorktreeSnapshots,
12
+ createWorktreeSnapshot,
13
+ normalizeScopeEntries,
14
+ relativeMutationPath,
15
+ sanitizePathLabel,
16
+ scopeMatches,
17
+ } from "./scope.mjs";
18
+ import {
19
+ createExperiment,
20
+ defaultPatchPath,
21
+ discardExperiment,
22
+ experimentStatus,
23
+ exportExperimentPatch,
24
+ findExperiment,
25
+ inspectRepository,
26
+ readExperimentRegistry,
27
+ recoverExperiments,
28
+ repairExperimentRecord,
29
+ } from "./experiments.mjs";
30
+ import {
31
+ boundedChallengeFacts,
32
+ challengePrompt,
33
+ renderChallengeMarkdown,
34
+ validateChallengeSubmission,
35
+ } from "./challenge.mjs";
36
+
37
+ const agentDir = path.resolve(process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent"));
38
+ const stateDir = path.join(agentDir, "specpi");
39
+ const SCOPE_ENTRY = "specpi-scope-state";
40
+ const CHALLENGE_ENTRY = "specpi-completion-challenge";
41
+ const SCOPE_STATUS = "specpi-scope";
42
+ const MAX_PENDING_SCOPE = 40;
43
+ // `read` is the one documented Pi seam that cannot mutate the worktree. Every other tool, including extension-provided
44
+ // ones, still gets snapshotted because an unrecognised tool is exactly the case post-hoc detection exists for.
45
+ const READ_ONLY_TOOLS = new Set(["read"]);
46
+
47
+ interface ScopeItem {
48
+ path: string;
49
+ directory: boolean;
50
+ }
51
+
52
+ interface ScopeState {
53
+ active: boolean;
54
+ root: string;
55
+ entries: ScopeItem[];
56
+ pending: string[];
57
+ observed: string[];
58
+ indeterminate: boolean;
59
+ generation: number;
60
+ }
61
+
62
+ interface ActiveChallenge {
63
+ generation: string;
64
+ sessionId: string;
65
+ facts: any;
66
+ prompt: string;
67
+ delivered: boolean;
68
+ }
69
+
70
+ interface ChallengeEntryData {
71
+ kind: "active" | "result" | "display" | "expired" | "cleared";
72
+ generation: string;
73
+ facts?: any;
74
+ result?: any;
75
+ markdown?: string;
76
+ createdAt?: string;
77
+ }
78
+
79
+ function validScopeEntry(value: any): value is ScopeItem {
80
+ return (
81
+ value &&
82
+ typeof value.path === "string" &&
83
+ value.path.length > 0 &&
84
+ typeof value.directory === "boolean" &&
85
+ !path.isAbsolute(value.path) &&
86
+ !value.path.split(/[\\/]/u).includes("..")
87
+ );
88
+ }
89
+
90
+ function emptyScope(root: string): ScopeState {
91
+ return {
92
+ active: false,
93
+ root,
94
+ entries: [],
95
+ pending: [],
96
+ observed: [],
97
+ indeterminate: false,
98
+ generation: 0,
99
+ };
100
+ }
101
+
102
+ function parseExperimentCard(source: string, fallbackName: string) {
103
+ const field = (name: string) => source.match(new RegExp(`^${name}:\\s*(.+)$`, "imu"))?.[1]?.trim() ?? "";
104
+ const nonGoalsBlock = source.match(/^Non-goals:\s*\n([\s\S]*)$/imu)?.[1] ?? "";
105
+
106
+ return {
107
+ name: field("Name") || fallbackName,
108
+ hypothesis: field("Hypothesis"),
109
+ acceptance: field("Acceptance"),
110
+ nonGoals: nonGoalsBlock
111
+ .split("\n")
112
+ .map((line) => line.replace(/^\s*-\s*/u, "").trim())
113
+ .filter(Boolean),
114
+ };
115
+ }
116
+
117
+ function safeMessage(error: unknown) {
118
+ return (error instanceof Error ? error.message : String(error))
119
+ .replace(/[\u0000-\u001f\u007f]+/gu, " ")
120
+ .slice(0, 500);
121
+ }
122
+
123
+ export default function workflowControls(pi: ExtensionAPI) {
124
+ let scope = emptyScope(canonicalRoot(process.cwd()));
125
+ let activeChallenge: ActiveChallenge | undefined;
126
+ let latestChallenge: ChallengeEntryData | undefined;
127
+ let observedToolFailures = 0;
128
+ let experimentBusy = false;
129
+ let latestSnapshot: any;
130
+ const snapshots = new Map<string, any>();
131
+ const supportsEntryRenderer = typeof pi.registerEntryRenderer === "function";
132
+
133
+ const exec = (command: string, args: string[], options: any = {}) => pi.exec(command, args, options);
134
+
135
+ const resolveRoot = async (cwd: string) => {
136
+ try {
137
+ const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd, timeout: 15_000 });
138
+ if (result.code === 0 && typeof result.stdout === "string" && result.stdout.trim()) {
139
+ return canonicalRoot(path.resolve(cwd, result.stdout.trim()));
140
+ }
141
+ } catch {
142
+ /* A non-Git session still supports direct write/edit scope checks. */
143
+ }
144
+
145
+ return canonicalRoot(cwd);
146
+ };
147
+
148
+ const emitScopeStatus = (ctx: ExtensionContext) => {
149
+ const summary = scope.active
150
+ ? {
151
+ active: true,
152
+ pending: scope.pending.length,
153
+ entries: scope.entries.length,
154
+ indeterminate: scope.indeterminate,
155
+ }
156
+ : { active: false, pending: 0, entries: 0, indeterminate: false };
157
+ pi.events.emit("specpi:workflow-status", summary);
158
+ if (!scope.active) {
159
+ ctx.ui.setStatus(SCOPE_STATUS, undefined);
160
+ ctx.ui.setWidget(SCOPE_STATUS, undefined);
161
+
162
+ return;
163
+ }
164
+
165
+ const label = scope.pending.length > 0 || scope.indeterminate ? "scope: review" : "scope: clean";
166
+ ctx.ui.setStatus(SCOPE_STATUS, label);
167
+ ctx.ui.setWidget(SCOPE_STATUS, (_tui, theme) => ({
168
+ invalidate() {},
169
+ render(width: number): string[] {
170
+ const pending = scope.pending.length > 0 ? ` · ${scope.pending.length} pending` : "";
171
+ const uncertain = scope.indeterminate ? " · snapshot uncertain" : "";
172
+
173
+ return [
174
+ truncateToWidth(
175
+ theme.fg(
176
+ scope.pending.length > 0 || scope.indeterminate ? "warning" : "dim",
177
+ `scope · ${scope.entries.length} paths · ${scope.observed.length} changed${pending}${uncertain}`,
178
+ ),
179
+ width,
180
+ "",
181
+ ),
182
+ ];
183
+ },
184
+ }));
185
+ };
186
+
187
+ // A branch entry is a record of what scope looked like at one moment. Handing `appendEntry` the live arrays would
188
+ // let a later `push` or in-place edit rewrite entries that were already appended, so every array is copied here.
189
+ const persistScope = (ctx: ExtensionContext) => {
190
+ pi.appendEntry(SCOPE_ENTRY, {
191
+ active: scope.active,
192
+ root: scope.root,
193
+ entries: scope.entries.map((item) => ({ ...item })),
194
+ pending: [...scope.pending],
195
+ observed: [...scope.observed],
196
+ indeterminate: scope.indeterminate,
197
+ generation: scope.generation,
198
+ });
199
+ emitScopeStatus(ctx);
200
+ };
201
+
202
+ const addPending = (rawPaths: string[], ctx: ExtensionContext) => {
203
+ let changed = false;
204
+ // Keep canonical Git paths internally so matching, acknowledgement, and scope expansion refer to the real file.
205
+ // Escape only at a display boundary; storing the escaped label would turn `100%.md` into a different path.
206
+ for (const relativePath of rawPaths) {
207
+ if (scopeMatches(scope.entries, relativePath) || scope.pending.includes(relativePath)) {
208
+ continue;
209
+ }
210
+
211
+ if (scope.pending.length < MAX_PENDING_SCOPE) {
212
+ scope.pending.push(relativePath);
213
+ changed = true;
214
+ } else if (!scope.indeterminate) {
215
+ // Dropping a finding on the floor is itself uncertainty, so it has to reach the branch record and not
216
+ // just the widget; otherwise a resumed session looks cleaner than the observation actually was.
217
+ scope.indeterminate = true;
218
+ changed = true;
219
+ }
220
+ }
221
+
222
+ if (changed) {
223
+ scope.pending.sort();
224
+ scope.generation += 1;
225
+ persistScope(ctx);
226
+ } else {
227
+ emitScopeStatus(ctx);
228
+ }
229
+ };
230
+
231
+ const takeSnapshot = async () => {
232
+ if (!scope.active) {
233
+ return undefined;
234
+ }
235
+
236
+ try {
237
+ const result = await pi.exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
238
+ cwd: scope.root,
239
+ timeout: 30_000,
240
+ });
241
+ if (result.code !== 0 || typeof result.stdout !== "string") {
242
+ // A failed status leaves no trustworthy baseline, so the next tool must observe the worktree afresh
243
+ // rather than diffing against a snapshot taken before the gap.
244
+ latestSnapshot = undefined;
245
+
246
+ return { root: scope.root, paths: [], fingerprints: {}, indeterminate: true };
247
+ }
248
+
249
+ const snapshot = createWorktreeSnapshot(scope.root, result.stdout);
250
+ latestSnapshot = snapshot;
251
+
252
+ return snapshot;
253
+ } catch {
254
+ latestSnapshot = undefined;
255
+
256
+ return { root: scope.root, paths: [], fingerprints: {}, indeterminate: true };
257
+ }
258
+ };
259
+
260
+ // Each snapshot hashes every changed file, so recomputing a "before" that is byte-for-byte the "after" of the tool
261
+ // that just finished doubles the cost of an already expensive check for nothing. Nothing but a tool runs in
262
+ // between, so the previous result is the current baseline until scope itself changes.
263
+ const baselineSnapshot = async () => {
264
+ if (latestSnapshot && latestSnapshot.root === scope.root) {
265
+ return latestSnapshot;
266
+ }
267
+
268
+ return takeSnapshot();
269
+ };
270
+
271
+ const setScopeEntries = (entries: ScopeItem[], ctx: ExtensionContext) => {
272
+ if (entries.length === 0 || entries.length > 40) {
273
+ throw new Error("Scope must contain between 1 and 40 paths");
274
+ }
275
+
276
+ scope.active = true;
277
+ scope.entries = entries;
278
+ scope.pending = scope.pending.filter((item) => !scopeMatches(entries, item));
279
+ scope.generation += 1;
280
+ latestSnapshot = undefined;
281
+ persistScope(ctx);
282
+ };
283
+
284
+ pi.on("session_start", async (_event, ctx) => {
285
+ const root = await resolveRoot(ctx.cwd);
286
+ scope = emptyScope(root);
287
+ activeChallenge = undefined;
288
+ latestChallenge = undefined;
289
+ observedToolFailures = 0;
290
+ latestSnapshot = undefined;
291
+ snapshots.clear();
292
+
293
+ for (const entry of ctx.sessionManager.getBranch?.() ?? []) {
294
+ if (entry.type !== "custom") {
295
+ continue;
296
+ }
297
+
298
+ if (entry.customType === SCOPE_ENTRY) {
299
+ const data = entry.data as any;
300
+ if (
301
+ data?.active === true &&
302
+ data.root === root &&
303
+ Array.isArray(data.entries) &&
304
+ data.entries.length > 0 &&
305
+ data.entries.length <= 40 &&
306
+ data.entries.every(validScopeEntry)
307
+ ) {
308
+ scope = {
309
+ active: true,
310
+ root,
311
+ // Copy on the way in as well as on the way out: a later `push` from /scope add would otherwise
312
+ // mutate the branch entry this state was restored from.
313
+ entries: data.entries.map((item: ScopeItem) => ({ ...item })),
314
+ pending: Array.isArray(data.pending)
315
+ ? data.pending.filter((item: any) => typeof item === "string").slice(0, MAX_PENDING_SCOPE)
316
+ : [],
317
+ observed: Array.isArray(data.observed)
318
+ ? data.observed.filter((item: any) => typeof item === "string").slice(0, 256)
319
+ : [],
320
+ indeterminate: Boolean(data.indeterminate),
321
+ generation: Number.isInteger(data.generation) ? data.generation : 0,
322
+ };
323
+ } else if (data?.active === false) {
324
+ scope = emptyScope(root);
325
+ }
326
+ } else if (entry.customType === CHALLENGE_ENTRY) {
327
+ const data = entry.data as ChallengeEntryData | undefined;
328
+ if (data?.kind === "result") {
329
+ latestChallenge = data;
330
+ } else if (data?.kind === "cleared") {
331
+ latestChallenge = undefined;
332
+ }
333
+ }
334
+ }
335
+
336
+ emitScopeStatus(ctx);
337
+ });
338
+
339
+ pi.on("session_shutdown", (_event, ctx) => {
340
+ snapshots.clear();
341
+ latestSnapshot = undefined;
342
+ activeChallenge = undefined;
343
+ experimentBusy = false;
344
+ ctx.ui.setStatus(SCOPE_STATUS, undefined);
345
+ ctx.ui.setWidget(SCOPE_STATUS, undefined);
346
+ });
347
+
348
+ pi.on("input", () => {
349
+ observedToolFailures = 0;
350
+ });
351
+
352
+ // The challenge prompt tells the model not to implement anything this turn. If the turn ends without the tool call
353
+ // that retires it, leaving it armed would silently apply that instruction to every later turn, so it expires here.
354
+ pi.on("agent_settled", (_event, ctx) => {
355
+ if (!activeChallenge?.delivered) {
356
+ return;
357
+ }
358
+
359
+ const abandoned = activeChallenge;
360
+ activeChallenge = undefined;
361
+ pi.appendEntry<ChallengeEntryData>(CHALLENGE_ENTRY, {
362
+ kind: "expired",
363
+ generation: abandoned.generation,
364
+ createdAt: new Date().toISOString(),
365
+ });
366
+ ctx.ui.notify(
367
+ `Completion challenge ${abandoned.generation.slice(0, 8)} ended without a structured result. Run /challenge again if you still want one.`,
368
+ "warning",
369
+ );
370
+ });
371
+
372
+ pi.on("tool_execution_start", async (event: any) => {
373
+ if (!scope.active || READ_ONLY_TOOLS.has(event.toolName)) {
374
+ return;
375
+ }
376
+
377
+ snapshots.set(event.toolCallId, await baselineSnapshot());
378
+ });
379
+
380
+ pi.on("tool_call", async (event: any, ctx) => {
381
+ if (!scope.active || (event.toolName !== "write" && event.toolName !== "edit")) {
382
+ return;
383
+ }
384
+
385
+ if (!event.input || typeof event.input.path !== "string") {
386
+ return;
387
+ }
388
+
389
+ let relativePath;
390
+ try {
391
+ relativePath = relativeMutationPath(scope.root, event.input.path, { cwd: ctx.cwd });
392
+ } catch (error) {
393
+ return { block: true, reason: `Scope path rejected: ${safeMessage(error)}` };
394
+ }
395
+
396
+ if (scopeMatches(scope.entries, relativePath)) {
397
+ return;
398
+ }
399
+
400
+ const originalPath = event.input.path;
401
+ const generation = scope.generation;
402
+ if (!ctx.hasUI) {
403
+ addPending([relativePath], ctx);
404
+
405
+ return;
406
+ }
407
+
408
+ const answer = await ctx.ui.select(`Outside declared scope: ${relativePath}`, [
409
+ "Deny this call (Recommended)",
410
+ "Allow once without expanding scope",
411
+ "Add this path to scope and allow",
412
+ ]);
413
+ if (generation !== scope.generation || event.input.path !== originalPath) {
414
+ return { block: true, reason: "Scope state or tool input changed during acknowledgement" };
415
+ }
416
+
417
+ if (answer === "Allow once without expanding scope") {
418
+ addPending([relativePath], ctx);
419
+
420
+ return;
421
+ }
422
+
423
+ if (answer === "Add this path to scope and allow") {
424
+ if (scope.entries.length >= 40) {
425
+ return { block: true, reason: "Scope already contains the maximum of 40 paths" };
426
+ }
427
+
428
+ const entry = normalizeScopeEntries(scope.root, [relativePath])[0];
429
+ setScopeEntries([...scope.entries, entry], ctx);
430
+
431
+ return;
432
+ }
433
+
434
+ return { block: true, reason: `Mutation outside declared scope denied: ${relativePath}` };
435
+ });
436
+
437
+ pi.on("tool_result", async (event: any, ctx) => {
438
+ if (event.isError) {
439
+ observedToolFailures = Math.min(99, observedToolFailures + 1);
440
+ }
441
+
442
+ if (!scope.active) {
443
+ return;
444
+ }
445
+
446
+ if (READ_ONLY_TOOLS.has(event.toolName)) {
447
+ return;
448
+ }
449
+
450
+ const before = snapshots.get(event.toolCallId);
451
+ snapshots.delete(event.toolCallId);
452
+ if (!before) {
453
+ scope.indeterminate = true;
454
+ persistScope(ctx);
455
+
456
+ return;
457
+ }
458
+
459
+ const after = await takeSnapshot();
460
+ const comparison = compareWorktreeSnapshots(before, after, scope.entries);
461
+ if (comparison.indeterminate) {
462
+ scope.indeterminate = true;
463
+ persistScope(ctx);
464
+
465
+ return;
466
+ }
467
+
468
+ const observed = [...new Set([...scope.observed, ...comparison.changed])].sort().slice(0, 256);
469
+ const observedGrew = observed.length !== scope.observed.length;
470
+ scope.observed = observed;
471
+ if (comparison.outside.length === 0) {
472
+ if (observedGrew) {
473
+ persistScope(ctx);
474
+ } else {
475
+ emitScopeStatus(ctx);
476
+ }
477
+
478
+ return;
479
+ }
480
+
481
+ addPending(comparison.outside, ctx);
482
+ const warning = `SpecPi scope warning: mutation outside declared scope is pending acknowledgement: ${comparison.outside.slice(0, 8).map(sanitizePathLabel).join(", ")}. The human can run /scope accept <path> to acknowledge it without widening scope, /scope add <path> to widen scope, or /scope clear.`;
483
+
484
+ return { content: [...event.content, { type: "text", text: warning }] };
485
+ });
486
+
487
+ pi.on("before_agent_start", async (event) => {
488
+ const guidance = [];
489
+ if (scope.active) {
490
+ 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.`,
492
+ );
493
+ }
494
+
495
+ if (activeChallenge) {
496
+ activeChallenge.delivered = true;
497
+ guidance.push(activeChallenge.prompt);
498
+ }
499
+
500
+ if (guidance.length === 0) {
501
+ return;
502
+ }
503
+
504
+ return { systemPrompt: `${event.systemPrompt}\n\n${guidance.join("\n\n")}` };
505
+ });
506
+
507
+ pi.registerCommand("scope", {
508
+ description: "Declare expected project paths and review scope drift",
509
+ getArgumentCompletions: (prefix: string) =>
510
+ ["set", "add", "remove", "accept", "recheck", "status", "clear"]
511
+ .filter((value) => value.startsWith(prefix.trim().toLowerCase()))
512
+ .map((value) => ({ value, label: value })),
513
+ handler: async (args, ctx) => {
514
+ const [actionRaw, ...rest] = args.trim().split(/\s+/u).filter(Boolean);
515
+ const action = actionRaw?.toLowerCase() || (scope.active ? "status" : "set");
516
+ const requestedPath = rest.join(" ");
517
+ try {
518
+ if (action === "status") {
519
+ ctx.ui.notify(
520
+ 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"}.`
522
+ : "Scope monitoring is inactive.",
523
+ scope.pending.length > 0 || scope.indeterminate ? "warning" : "info",
524
+ );
525
+
526
+ return;
527
+ }
528
+
529
+ if (action === "clear") {
530
+ scope = emptyScope(await resolveRoot(ctx.cwd));
531
+ scope.generation += 1;
532
+ // Nothing is observed while scope is off, so the cached baseline is stale the moment it is cleared;
533
+ // reactivating later must start from a fresh snapshot rather than blame the unmonitored gap on the
534
+ // first tool that runs afterwards.
535
+ latestSnapshot = undefined;
536
+ persistScope(ctx);
537
+ ctx.ui.notify("Scope monitoring cleared.", "info");
538
+
539
+ return;
540
+ }
541
+
542
+ if (action === "set") {
543
+ if (!ctx.hasUI || typeof ctx.ui.editor !== "function") {
544
+ ctx.ui.notify("/scope set requires interactive editor support.", "error");
545
+
546
+ return;
547
+ }
548
+
549
+ const initial = scope.entries
550
+ .map((item) => `${sanitizePathLabel(item.path)}${item.directory ? "/" : ""}`)
551
+ .join("\n");
552
+ const edited = await ctx.ui.editor("Scope paths — one project-relative path per line", initial);
553
+ if (edited === undefined) {
554
+ return;
555
+ }
556
+
557
+ const inputs = edited
558
+ .split("\n")
559
+ .map((line) => line.trim())
560
+ .filter(Boolean)
561
+ .map((input) => {
562
+ const existing = scope.entries.find(
563
+ (item) => `${sanitizePathLabel(item.path)}${item.directory ? "/" : ""}` === input,
564
+ );
565
+
566
+ return existing ? `${existing.path}${existing.directory ? "/" : ""}` : input;
567
+ });
568
+ setScopeEntries(normalizeScopeEntries(scope.root, inputs), ctx);
569
+ ctx.ui.notify("Scope contract updated.", "info");
570
+
571
+ return;
572
+ }
573
+
574
+ if (action === "recheck") {
575
+ if (!scope.active) {
576
+ ctx.ui.notify("Scope monitoring is inactive.", "error");
577
+
578
+ return;
579
+ }
580
+
581
+ // Uncertainty is sticky on purpose, so clearing it has to be a deliberate human act rather than a
582
+ // side effect of the next successful comparison. Re-baselining here is that act.
583
+ latestSnapshot = undefined;
584
+ const rebaselined = await takeSnapshot();
585
+ scope.indeterminate = Boolean(rebaselined?.indeterminate);
586
+ scope.generation += 1;
587
+ persistScope(ctx);
588
+ ctx.ui.notify(
589
+ scope.indeterminate
590
+ ? "Scope re-baselined but the worktree snapshot is still indeterminate."
591
+ : "Scope re-baselined; snapshot uncertainty cleared. Pending findings are unchanged.",
592
+ scope.indeterminate ? "warning" : "info",
593
+ );
594
+
595
+ return;
596
+ }
597
+
598
+ if (!["add", "accept", "remove"].includes(action) || !requestedPath) {
599
+ ctx.ui.notify(
600
+ "Usage: /scope [set|add <path>|remove <path>|accept <path>|recheck|status|clear]",
601
+ "error",
602
+ );
603
+
604
+ return;
605
+ }
606
+
607
+ const displayedPending = scope.pending.find((item) => sanitizePathLabel(item) === requestedPath);
608
+ const displayedEntry = scope.entries.find(
609
+ (item) => `${sanitizePathLabel(item.path)}${item.directory ? "/" : ""}` === requestedPath,
610
+ );
611
+ const sourcePath =
612
+ action === "remove" && displayedEntry
613
+ ? `${displayedEntry.path}${displayedEntry.directory ? "/" : ""}`
614
+ : (displayedPending ?? requestedPath);
615
+ const normalized =
616
+ action === "accept" && displayedPending !== undefined
617
+ ? { path: displayedPending, directory: false }
618
+ : normalizeScopeEntries(scope.root, [sourcePath])[0];
619
+ if (action === "remove") {
620
+ const before = scope.entries.length;
621
+ scope.entries = scope.entries.filter(
622
+ (item) => item.path !== normalized.path || item.directory !== normalized.directory,
623
+ );
624
+ if (before === scope.entries.length) {
625
+ ctx.ui.notify(`${sanitizePathLabel(normalized.path)} is not a declared scope path.`, "error");
626
+
627
+ return;
628
+ }
629
+
630
+ const deactivated = scope.entries.length === 0;
631
+ if (deactivated) {
632
+ scope = emptyScope(scope.root);
633
+ }
634
+
635
+ scope.generation += 1;
636
+ latestSnapshot = undefined;
637
+ persistScope(ctx);
638
+ ctx.ui.notify(
639
+ deactivated
640
+ ? `${sanitizePathLabel(normalized.path)} removed; it was the last declared path, so scope monitoring is now off and pending findings were discarded.`
641
+ : `${sanitizePathLabel(normalized.path)} removed from declared scope.`,
642
+ deactivated ? "warning" : "info",
643
+ );
644
+
645
+ return;
646
+ }
647
+
648
+ // `accept` acknowledges one observed finding and nothing more. Widening the contract is what `add` is
649
+ // for, and conflating them would expand scope on the very gesture meant to review a drift report.
650
+ if (action === "accept") {
651
+ if (!scope.active) {
652
+ ctx.ui.notify("Scope monitoring is inactive.", "error");
653
+
654
+ return;
655
+ }
656
+
657
+ const before = scope.pending.length;
658
+ scope.pending = scope.pending.filter((item) => !scopeMatches([normalized], item));
659
+ if (before === scope.pending.length) {
660
+ ctx.ui.notify(`${sanitizePathLabel(normalized.path)} has no pending scope finding.`, "error");
661
+
662
+ return;
663
+ }
664
+
665
+ scope.generation += 1;
666
+ persistScope(ctx);
667
+ ctx.ui.notify(
668
+ `${sanitizePathLabel(normalized.path)} acknowledged. The declared scope is unchanged, so a later change there is reported again.`,
669
+ "info",
670
+ );
671
+
672
+ return;
673
+ }
674
+
675
+ if (
676
+ !scope.entries.some(
677
+ (item) => item.path === normalized.path && item.directory === normalized.directory,
678
+ )
679
+ ) {
680
+ if (scope.entries.length >= 40) {
681
+ throw new Error("Scope already contains the maximum of 40 paths");
682
+ }
683
+
684
+ scope.entries.push(normalized);
685
+ }
686
+
687
+ scope.active = true;
688
+ scope.pending = scope.pending.filter((item) => !scopeMatches([normalized], item));
689
+ scope.generation += 1;
690
+ persistScope(ctx);
691
+ ctx.ui.notify(`${sanitizePathLabel(normalized.path)} added to declared scope.`, "info");
692
+ } catch (error) {
693
+ ctx.ui.notify(safeMessage(error), "error");
694
+ }
695
+ },
696
+ });
697
+
698
+ const withExperimentOperation = async (ctx: ExtensionContext, operation: () => Promise<void>) => {
699
+ if (experimentBusy) {
700
+ ctx.ui.notify("Another experiment operation is already active.", "warning");
701
+
702
+ return;
703
+ }
704
+
705
+ experimentBusy = true;
706
+ try {
707
+ await operation();
708
+ } catch (error) {
709
+ ctx.ui.notify(safeMessage(error), "error");
710
+ } finally {
711
+ experimentBusy = false;
712
+ }
713
+ };
714
+
715
+ pi.registerCommand("experiment", {
716
+ description: "Create and close bounded detached Git worktree experiments",
717
+ getArgumentCompletions: (prefix: string) =>
718
+ ["start", "status", "close", "recover"]
719
+ .filter((value) => value.startsWith(prefix.trim().toLowerCase()))
720
+ .map((value) => ({ value, label: value })),
721
+ handler: async (args, ctx) => {
722
+ const [actionRaw, ...rest] = args.trim().split(/\s+/u).filter(Boolean);
723
+ const action = actionRaw?.toLowerCase() || "status";
724
+ const query = rest.join(" ");
725
+ await withExperimentOperation(ctx, async () => {
726
+ if (action === "start") {
727
+ if (!ctx.hasUI || typeof ctx.ui.editor !== "function") {
728
+ throw new Error("Starting an experiment requires interactive editor support");
729
+ }
730
+
731
+ const repository = await inspectRepository(exec, ctx.cwd);
732
+ if (repository.changedPaths.length > 0) {
733
+ const proceed = await ctx.ui.confirm(
734
+ "Start from HEAD without current uncommitted changes?",
735
+ `${repository.changedPaths.length} dirty path(s) remain untouched in the base worktree and will not be copied.`,
736
+ );
737
+ if (!proceed) {
738
+ return;
739
+ }
740
+ }
741
+
742
+ const template = `Name: ${query}\nHypothesis: \nAcceptance: \nNon-goals:\n- `;
743
+ const edited = await ctx.ui.editor("Experiment card", template);
744
+ if (edited === undefined) {
745
+ return;
746
+ }
747
+
748
+ const card = parseExperimentCard(edited, query);
749
+ const preview = `Base: ${repository.baseCommit}\nRepository: ${repository.repoRoot}\nHypothesis: ${card.hypothesis}\nAcceptance: ${card.acceptance}`;
750
+ if (!(await ctx.ui.confirm("Create detached experiment worktree?", preview))) {
751
+ return;
752
+ }
753
+
754
+ const record = await createExperiment({ exec, stateDir, repository, card });
755
+ ctx.ui.notify(
756
+ `Experiment ${record.id.slice(0, 8)} created at ${record.worktreePath}. Open a separate human-controlled Pi session in that directory.`,
757
+ "info",
758
+ );
759
+
760
+ return;
761
+ }
762
+
763
+ if (action === "status") {
764
+ if (!query) {
765
+ try {
766
+ const current = findExperiment(stateDir, "", ctx.cwd);
767
+ const status = await experimentStatus(exec, current);
768
+ ctx.ui.notify(
769
+ `${current.name} (${current.id.slice(0, 8)}): ${current.status}; ${status.changedPaths.length} changed, ${status.committed} committed, ${status.untracked} untracked, ${status.ignored} ignored path(s) (not exportable); acceptance: ${current.acceptance}`,
770
+ "info",
771
+ );
772
+ } catch {
773
+ const records = readExperimentRegistry(stateDir).experiments;
774
+ ctx.ui.notify(
775
+ records.length > 0
776
+ ? records
777
+ .map(
778
+ (item) =>
779
+ `${item.id.slice(0, 8)} ${item.name} [${item.status}] ${item.worktreePath}`,
780
+ )
781
+ .join("\n")
782
+ : "No retained experiments.",
783
+ "info",
784
+ );
785
+ }
786
+
787
+ return;
788
+ }
789
+
790
+ const record = findExperiment(stateDir, query, ctx.cwd);
791
+ const status = await experimentStatus(exec, record);
792
+ ctx.ui.notify(
793
+ `${record.name} (${record.id.slice(0, 8)}): ${record.status}; ${status.changedPaths.length} changed, ${status.committed} committed, ${status.untracked} untracked, ${status.ignored} ignored path(s) (not exportable); acceptance: ${record.acceptance}`,
794
+ "info",
795
+ );
796
+
797
+ return;
798
+ }
799
+
800
+ if (action === "close") {
801
+ const record = findExperiment(stateDir, query, ctx.cwd);
802
+ const status = await experimentStatus(exec, record);
803
+ if (!ctx.hasUI) {
804
+ throw new Error("Closing an experiment requires interactive confirmation");
805
+ }
806
+
807
+ const extraNote = [
808
+ status.committed > 0 ? `${status.committed} committed path(s)` : "",
809
+ status.ignored > 0 ? `${status.ignored} ignored path(s) a patch cannot carry` : "",
810
+ status.committedUnknown ? "commit history could not be read" : "",
811
+ ]
812
+ .filter(Boolean)
813
+ .join(", ");
814
+ const ignoredNote = extraNote ? `; ${extraNote}` : "";
815
+ const choice = await ctx.ui.select(
816
+ `${record.name}: ${status.changedPaths.length} changed path(s)${ignoredNote}; acceptance: ${record.acceptance}`,
817
+ ["Keep worktree", "Export patch", "Discard worktree"],
818
+ );
819
+ if (choice === "Keep worktree" || !choice) {
820
+ ctx.ui.notify("Experiment kept; no files changed.", "info");
821
+
822
+ return;
823
+ }
824
+
825
+ if (choice === "Export patch") {
826
+ const suggested = defaultPatchPath(stateDir, record);
827
+ const edited =
828
+ typeof ctx.ui.editor === "function"
829
+ ? await ctx.ui.editor("Patch output path", suggested)
830
+ : suggested;
831
+ if (edited === undefined || !edited.trim()) {
832
+ return;
833
+ }
834
+
835
+ const destination = path.resolve(edited.trim());
836
+ const overwrite = fs.existsSync(destination)
837
+ ? await ctx.ui.confirm("Overwrite existing patch?", destination)
838
+ : false;
839
+ if (fs.existsSync(destination) && !overwrite) {
840
+ return;
841
+ }
842
+
843
+ const exported = await exportExperimentPatch({
844
+ exec,
845
+ stateDir,
846
+ record,
847
+ outputPath: destination,
848
+ overwrite,
849
+ });
850
+ ctx.ui.notify(
851
+ status.ignored > 0
852
+ ? `Patch exported to ${exported.outputPath}; worktree kept. ${status.ignored} ignored path(s) are NOT in the patch: ${status.ignoredPaths.slice(0, 5).map(sanitizePathLabel).join(", ")}`
853
+ : `Patch exported to ${exported.outputPath}; worktree kept.`,
854
+ status.ignored > 0 ? "warning" : "info",
855
+ );
856
+
857
+ return;
858
+ }
859
+
860
+ if (
861
+ !(await ctx.ui.confirm(
862
+ "Discard this registered experiment worktree?",
863
+ `${record.worktreePath}\nThis does not alter the base worktree.`,
864
+ ))
865
+ ) {
866
+ return;
867
+ }
868
+
869
+ // Ignored files never appear in `status --untracked-files=all` and never reach a patch, so without
870
+ // counting them here a worktree holding only ignored work would be deleted as if it were empty.
871
+ if (
872
+ status.hasWork &&
873
+ !(await ctx.ui.confirm(
874
+ "Discard dirty experiment permanently?",
875
+ status.ignored > 0
876
+ ? `${status.changedPaths.length} changed and ${status.committed} committed path(s) will be removed, plus ${status.ignored} ignored path(s) a patch cannot carry: ${status.ignoredPaths.slice(0, 5).map(sanitizePathLabel).join(", ")}`
877
+ : `${status.changedPaths.length} changed and ${status.committed} committed path(s) will be removed. Export a patch first if needed.`,
878
+ ))
879
+ ) {
880
+ return;
881
+ }
882
+
883
+ await discardExperiment({ exec, stateDir, record });
884
+ ctx.ui.notify(`Experiment ${record.id.slice(0, 8)} discarded.`, "warning");
885
+
886
+ return;
887
+ }
888
+
889
+ if (action === "recover") {
890
+ const repository = await inspectRepository(exec, ctx.cwd);
891
+ const findings = await recoverExperiments({ exec, stateDir, repoRoot: repository.repoRoot });
892
+ const pending = findings.filter((item) => item.needsRecovery);
893
+ if (pending.length === 0) {
894
+ ctx.ui.notify("No experiment recovery is needed for this repository.", "info");
895
+
896
+ return;
897
+ }
898
+
899
+ if (!ctx.hasUI) {
900
+ throw new Error(`${pending.length} experiment record(s) need interactive recovery`);
901
+ }
902
+
903
+ for (const finding of pending) {
904
+ // An orphan directory is neither present nor missing: Git has forgotten it but the files are
905
+ // still there, so it can only be released, never activated, and SpecPi never deletes it.
906
+ const state = finding.present
907
+ ? "worktree present"
908
+ : finding.orphanDirectory
909
+ ? "directory left behind by an interrupted creation"
910
+ : "worktree missing";
911
+ const options = finding.present
912
+ ? ["Leave unchanged", "Activate registry record"]
913
+ : finding.orphanDirectory
914
+ ? ["Leave unchanged", "Release record and keep the directory"]
915
+ : ["Leave unchanged", "Forget missing record"];
916
+ const choice = await ctx.ui.select(
917
+ `${finding.record.id.slice(0, 8)} ${finding.record.name}: ${state}`,
918
+ options,
919
+ );
920
+ const access = { exec, repoRoot: repository.repoRoot };
921
+ if (choice === "Activate registry record") {
922
+ await repairExperimentRecord(stateDir, finding.record.id, "activate", access);
923
+ } else if (choice === "Forget missing record") {
924
+ await repairExperimentRecord(stateDir, finding.record.id, "forget", access);
925
+ } else if (choice === "Release record and keep the directory") {
926
+ const released = await repairExperimentRecord(
927
+ stateDir,
928
+ finding.record.id,
929
+ "release",
930
+ access,
931
+ );
932
+ ctx.ui.notify(
933
+ `Record released. ${released?.released ?? finding.record.worktreePath} was left in place for you to inspect or delete.`,
934
+ "warning",
935
+ );
936
+ }
937
+ }
938
+
939
+ return;
940
+ }
941
+
942
+ throw new Error("Usage: /experiment [start [name]|status [id]|close [id]|recover]");
943
+ });
944
+ },
945
+ });
946
+
947
+ const challengeSchema = Type.Object(
948
+ {
949
+ generation: Type.String({ minLength: 36, maxLength: 36 }),
950
+ verdict: StringEnum(["ready-for-human-review", "incomplete", "blocked"] as const),
951
+ requirements: Type.Array(
952
+ Type.Object(
953
+ {
954
+ requirement: Type.String({ minLength: 1, maxLength: 360 }),
955
+ status: StringEnum(["proven", "partial", "unproven"] as const),
956
+ evidence: Type.String({ maxLength: 600 }),
957
+ },
958
+ { additionalProperties: false },
959
+ ),
960
+ { minItems: 1, maxItems: 16 },
961
+ ),
962
+ contradictions: Type.Array(Type.String({ minLength: 1, maxLength: 360 }), { maxItems: 12 }),
963
+ falsePositiveChecks: Type.Array(Type.String({ minLength: 1, maxLength: 360 }), { maxItems: 12 }),
964
+ scopeFindings: Type.Array(Type.String({ minLength: 1, maxLength: 360 }), { maxItems: 12 }),
965
+ validationGaps: Type.Array(Type.String({ minLength: 1, maxLength: 360 }), { maxItems: 12 }),
966
+ residualRisks: Type.Array(Type.String({ minLength: 1, maxLength: 360 }), { maxItems: 12 }),
967
+ nextAction: Type.String({ maxLength: 500 }),
968
+ },
969
+ { additionalProperties: false },
970
+ );
971
+
972
+ pi.registerTool({
973
+ name: "submit_completion_challenge",
974
+ label: "Submit Completion Challenge",
975
+ description:
976
+ "Submit the structured result for an active user-requested /challenge. Use only while the matching challenge generation is active, cite available evidence without inventing proof, and make this the final tool call of that turn.",
977
+ promptSnippet: "Finish an active completion challenge with a bounded structured readiness review",
978
+ parameters: challengeSchema,
979
+ async execute(_toolCallId, params: any, _signal, _onUpdate, ctx) {
980
+ const sessionId = ctx.sessionManager.getSessionId();
981
+ if (
982
+ !activeChallenge ||
983
+ activeChallenge.generation !== params.generation ||
984
+ activeChallenge.sessionId !== sessionId
985
+ ) {
986
+ throw new Error("No matching completion challenge is active in this session");
987
+ }
988
+
989
+ const result = validateChallengeSubmission(params, activeChallenge.facts);
990
+ const data: ChallengeEntryData = {
991
+ kind: "result",
992
+ generation: activeChallenge.generation,
993
+ facts: boundedChallengeFacts(activeChallenge.facts),
994
+ result,
995
+ markdown: renderChallengeMarkdown(result, { generation: activeChallenge.generation }),
996
+ createdAt: new Date().toISOString(),
997
+ };
998
+ pi.appendEntry(CHALLENGE_ENTRY, data);
999
+ latestChallenge = data;
1000
+ activeChallenge = undefined;
1001
+
1002
+ return {
1003
+ content: [{ type: "text", text: data.markdown }],
1004
+ details: { generation: data.generation, verdict: result.verdict },
1005
+ terminate: true,
1006
+ };
1007
+ },
1008
+ });
1009
+
1010
+ pi.registerCommand("challenge", {
1011
+ description: "Run or inspect an adversarial completion-readiness challenge",
1012
+ getArgumentCompletions: (prefix: string) =>
1013
+ ["status", "clear"]
1014
+ .filter((value) => value.startsWith(prefix.trim().toLowerCase()))
1015
+ .map((value) => ({ value, label: value })),
1016
+ handler: async (args, ctx) => {
1017
+ const action = args.trim().toLowerCase();
1018
+ if (action === "status") {
1019
+ if (!latestChallenge) {
1020
+ ctx.ui.notify("No completed challenge exists on this session branch.", "info");
1021
+ } else if (supportsEntryRenderer) {
1022
+ pi.appendEntry(CHALLENGE_ENTRY, { ...latestChallenge, kind: "display" });
1023
+ } else if (typeof ctx.ui.editor === "function") {
1024
+ await ctx.ui.editor("Completion challenge (view only)", latestChallenge.markdown ?? "Unavailable");
1025
+ } else {
1026
+ ctx.ui.notify(`${latestChallenge.result?.verdict ?? "unknown"}`, "info");
1027
+ }
1028
+
1029
+ return;
1030
+ }
1031
+
1032
+ if (action === "clear") {
1033
+ activeChallenge = undefined;
1034
+ latestChallenge = undefined;
1035
+ pi.appendEntry(CHALLENGE_ENTRY, {
1036
+ kind: "cleared",
1037
+ generation: randomUUID(),
1038
+ createdAt: new Date().toISOString(),
1039
+ });
1040
+ ctx.ui.notify("Completion challenge state cleared for this branch.", "info");
1041
+
1042
+ return;
1043
+ }
1044
+
1045
+ if (action) {
1046
+ ctx.ui.notify("Usage: /challenge [status|clear]", "error");
1047
+
1048
+ return;
1049
+ }
1050
+
1051
+ if (typeof ctx.isIdle === "function" && !ctx.isIdle()) {
1052
+ ctx.ui.notify("Wait for the active agent run to settle before starting a challenge.", "warning");
1053
+
1054
+ return;
1055
+ }
1056
+
1057
+ if (activeChallenge) {
1058
+ ctx.ui.notify("A completion challenge is already active.", "warning");
1059
+
1060
+ return;
1061
+ }
1062
+
1063
+ let snapshot;
1064
+ try {
1065
+ const root = await resolveRoot(ctx.cwd);
1066
+ const status = await pi.exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
1067
+ cwd: root,
1068
+ timeout: 30_000,
1069
+ });
1070
+ snapshot = status.code === 0 ? createWorktreeSnapshot(root, status.stdout) : undefined;
1071
+ } catch {
1072
+ snapshot = undefined;
1073
+ }
1074
+
1075
+ let experiment;
1076
+ try {
1077
+ experiment = findExperiment(stateDir, "", ctx.cwd);
1078
+ } catch {
1079
+ experiment = undefined;
1080
+ }
1081
+
1082
+ const facts = boundedChallengeFacts({
1083
+ changedPaths: snapshot?.paths ?? [],
1084
+ scopeEntries: scope.active
1085
+ ? scope.entries.map((item) => `${sanitizePathLabel(item.path)}${item.directory ? "/" : ""}`)
1086
+ : [],
1087
+ pendingScope: scope.active ? scope.pending.map(sanitizePathLabel) : [],
1088
+ experiment,
1089
+ observedToolFailures,
1090
+ snapshotIndeterminate: !snapshot || snapshot.indeterminate,
1091
+ });
1092
+ const generation = randomUUID();
1093
+ const prompt = challengePrompt(generation, facts);
1094
+ activeChallenge = {
1095
+ generation,
1096
+ sessionId: ctx.sessionManager.getSessionId(),
1097
+ facts,
1098
+ prompt,
1099
+ delivered: false,
1100
+ };
1101
+ pi.appendEntry<ChallengeEntryData>(CHALLENGE_ENTRY, {
1102
+ kind: "active",
1103
+ generation,
1104
+ facts: boundedChallengeFacts(facts),
1105
+ createdAt: new Date().toISOString(),
1106
+ });
1107
+ pi.sendMessage(
1108
+ {
1109
+ customType: CHALLENGE_ENTRY,
1110
+ content: prompt,
1111
+ display: true,
1112
+ details: { generation },
1113
+ },
1114
+ { deliverAs: "followUp", triggerTurn: true },
1115
+ );
1116
+ },
1117
+ });
1118
+
1119
+ if (supportsEntryRenderer) {
1120
+ pi.registerEntryRenderer<ChallengeEntryData>(CHALLENGE_ENTRY, (entry, _options, theme) => {
1121
+ const data = entry.data;
1122
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
1123
+ if ((data?.kind === "result" || data?.kind === "display") && data.markdown) {
1124
+ box.addChild(new Markdown(data.markdown, 0, 0, getMarkdownTheme()));
1125
+ } else if (data?.kind === "active") {
1126
+ box.addChild(
1127
+ new Text(theme.fg("accent", `Completion challenge started · ${data.generation.slice(0, 8)}`), 0, 0),
1128
+ );
1129
+ } else if (data?.kind === "expired") {
1130
+ box.addChild(
1131
+ new Text(
1132
+ theme.fg("dim", `Completion challenge expired unanswered · ${data.generation.slice(0, 8)}`),
1133
+ 0,
1134
+ 0,
1135
+ ),
1136
+ );
1137
+ } else {
1138
+ box.addChild(new Text(theme.fg("dim", "Completion challenge state cleared"), 0, 0));
1139
+ }
1140
+
1141
+ return box;
1142
+ });
1143
+ }
1144
+ }