@devspecai/pi-plugin 0.0.0-stage → 0.10.3

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 (106) hide show
  1. package/CHANGELOG.md +899 -0
  2. package/LICENSE +21 -0
  3. package/README.md +103 -2
  4. package/extensions/.gitkeep +0 -0
  5. package/extensions/core/activation-recovery.ts +135 -0
  6. package/extensions/core/active-deliveries.ts +171 -0
  7. package/extensions/core/active-session-plans.ts +374 -0
  8. package/extensions/core/adapter-overlap.ts +247 -0
  9. package/extensions/core/assistant-speech.ts +44 -0
  10. package/extensions/core/attach-context.ts +25 -0
  11. package/extensions/core/attachment-limits.ts +20 -0
  12. package/extensions/core/attachments.ts +487 -0
  13. package/extensions/core/auth-store.ts +91 -0
  14. package/extensions/core/automation-run-retry.ts +31 -0
  15. package/extensions/core/background-journal.ts +85 -0
  16. package/extensions/core/background-observer.ts +279 -0
  17. package/extensions/core/bond-recovery.ts +65 -0
  18. package/extensions/core/bond-state.ts +80 -0
  19. package/extensions/core/clear-conversation.ts +53 -0
  20. package/extensions/core/client-versions.ts +56 -0
  21. package/extensions/core/client.ts +704 -0
  22. package/extensions/core/command-offer.ts +88 -0
  23. package/extensions/core/command-runner.ts +62 -0
  24. package/extensions/core/command-turn.ts +88 -0
  25. package/extensions/core/commit-ledger.ts +195 -0
  26. package/extensions/core/commit-stamp.ts +98 -0
  27. package/extensions/core/config.ts +63 -0
  28. package/extensions/core/connection-context.ts +28 -0
  29. package/extensions/core/context-budget.ts +175 -0
  30. package/extensions/core/control-result-outbox.ts +80 -0
  31. package/extensions/core/control.ts +156 -0
  32. package/extensions/core/ctx-guard.ts +33 -0
  33. package/extensions/core/debug.ts +136 -0
  34. package/extensions/core/delivery-queue.ts +92 -0
  35. package/extensions/core/dispatch.ts +108 -0
  36. package/extensions/core/duck.ts +128 -0
  37. package/extensions/core/git-observer.ts +382 -0
  38. package/extensions/core/github-identity.ts +26 -0
  39. package/extensions/core/implementation-contract.ts +773 -0
  40. package/extensions/core/instructions.ts +40 -0
  41. package/extensions/core/interaction-events.ts +834 -0
  42. package/extensions/core/json-schema.ts +16 -0
  43. package/extensions/core/local-activity.ts +34 -0
  44. package/extensions/core/mcp-config-guard.ts +117 -0
  45. package/extensions/core/model-catalog.ts +76 -0
  46. package/extensions/core/model-surface.ts +125 -0
  47. package/extensions/core/notice-reply.ts +215 -0
  48. package/extensions/core/owner-inject.ts +148 -0
  49. package/extensions/core/post-session-receipt.ts +70 -0
  50. package/extensions/core/preempt.ts +100 -0
  51. package/extensions/core/project-choice.ts +145 -0
  52. package/extensions/core/project-scope.ts +335 -0
  53. package/extensions/core/project-selection-ui.ts +202 -0
  54. package/extensions/core/provenance-refs.ts +158 -0
  55. package/extensions/core/push-channel.ts +281 -0
  56. package/extensions/core/question-receipt-lifecycle.ts +158 -0
  57. package/extensions/core/recall.ts +121 -0
  58. package/extensions/core/remote-payload.ts +1329 -0
  59. package/extensions/core/remote-session-arg.ts +24 -0
  60. package/extensions/core/repository-context.ts +35 -0
  61. package/extensions/core/request-chain.ts +1244 -0
  62. package/extensions/core/result-projection.ts +939 -0
  63. package/extensions/core/result.ts +100 -0
  64. package/extensions/core/review-context.ts +70 -0
  65. package/extensions/core/review-watch.ts +94 -0
  66. package/extensions/core/room-access.ts +12 -0
  67. package/extensions/core/room-context.ts +178 -0
  68. package/extensions/core/room-reader.ts +308 -0
  69. package/extensions/core/room-store.ts +381 -0
  70. package/extensions/core/room-sync.ts +79 -0
  71. package/extensions/core/room-tools.ts +86 -0
  72. package/extensions/core/room-unread.ts +173 -0
  73. package/extensions/core/session-events.ts +62 -0
  74. package/extensions/core/session-polls.ts +292 -0
  75. package/extensions/core/settings.ts +39 -0
  76. package/extensions/core/status-line.ts +432 -0
  77. package/extensions/core/still-to-discuss.ts +124 -0
  78. package/extensions/core/subagent-fleet.ts +238 -0
  79. package/extensions/core/system-notice.ts +227 -0
  80. package/extensions/core/telemetry.ts +177 -0
  81. package/extensions/core/tool-selection.ts +72 -0
  82. package/extensions/core/trail-events.ts +247 -0
  83. package/extensions/core/trail-projection.ts +373 -0
  84. package/extensions/core/trail.ts +465 -0
  85. package/extensions/core/turn-lifecycle.ts +472 -0
  86. package/extensions/core/version-mismatch.ts +165 -0
  87. package/extensions/core/work-prompt.ts +44 -0
  88. package/extensions/core/work-state.ts +179 -0
  89. package/extensions/devspec-adapter-guard.ts +106 -0
  90. package/extensions/devspec-auth.ts +191 -0
  91. package/extensions/devspec-brand.ts +140 -0
  92. package/extensions/devspec-commit-ledger.ts +477 -0
  93. package/extensions/devspec-context.ts +146 -0
  94. package/extensions/devspec-memory.ts +136 -0
  95. package/extensions/devspec-provenance-assistance.ts +233 -0
  96. package/extensions/devspec-remote.ts +5152 -0
  97. package/extensions/devspec-tools.ts +552 -0
  98. package/extensions/devspec-work.ts +244 -0
  99. package/extensions/generated/README.md +18 -0
  100. package/extensions/generated/mcp-catalog.ts +15811 -0
  101. package/package.json +71 -4
  102. package/prompts/.gitkeep +0 -0
  103. package/runtime/commit-stamp.mjs +279 -0
  104. package/runtime/git-observer-shim.mjs +390 -0
  105. package/skills/.gitkeep +0 -0
  106. package/themes/.gitkeep +0 -0
@@ -0,0 +1,472 @@
1
+ /** Turn/activity bookkeeping. Assistant speech admission is structural only;
2
+ * source capture and delivery live in assistant-speech.ts and the controller. */
3
+
4
+ import { hasAssistantSpeech } from "./assistant-speech.ts";
5
+ import { INTERACTION_EVENT_CUSTOM_TYPE } from "./interaction-events.ts";
6
+
7
+ export type UserKind = "remote" | "automation" | "interaction" | "local" | "harness";
8
+
9
+ export const REMOTE_PREFIX = "[DevSpec remote — message from";
10
+ export const AUTOMATION_RUN_PREFIX = "[DevSpec remote — automation run";
11
+ export const INTERACTION_CONTINUATION_PREFIX = "[DevSpec directed-question workflow continuation]";
12
+ const CONTEXT_TEXT_PREFIXES = [
13
+ "### DevSpec memory — auto-recall",
14
+ "[DevSpec remote — bond context",
15
+ "[DevSpec remote — room orientation",
16
+ ];
17
+ const SUBAGENT_NOTIFICATION_PREFIX = /^(?:Background task (?:completed|failed|paused|stopped)|Detached foreground task (?:completed|failed|paused|stopped)): \*\*[^\n]+\*\*(?: \([^\n]*\))?\n\n/;
18
+ const ASYNC_SUBAGENT_NOTIFICATION_PREFIX = /^Background task (?:completed|failed|paused|stopped): \*\*[^\n]+\*\*(?: \([^\n]*\))?\n\n/;
19
+ const GROUPED_SUBAGENT_NOTIFICATION_PREFIX = /^Background tasks completed \(\d+\):[^\n]*\n\n1\. /;
20
+
21
+ /**
22
+ * Leftover / injected origin (items 56b9a3b4, 4f801a67, 71dc7d84).
23
+ * Explicit DevSpec conversation envelopes win. Every other Pi customType is
24
+ * extension-authored, never proof that the local human typed the content.
25
+ * Text checks are compatibility backstops for hosts that drop customType.
26
+ * A typed `/bg-update` is NOT harness — slash commands stay first-class local.
27
+ */
28
+ export function isSubagentAsyncNotification(text: string, _customType?: string | null): boolean {
29
+ const trimmed = text.trim();
30
+ return ASYNC_SUBAGENT_NOTIFICATION_PREFIX.test(trimmed)
31
+ || GROUPED_SUBAGENT_NOTIFICATION_PREFIX.test(trimmed);
32
+ }
33
+
34
+ export function isHarnessOrigin(text: string, customType?: string | null): boolean {
35
+ if (text.startsWith(REMOTE_PREFIX) || text.startsWith(AUTOMATION_RUN_PREFIX) ||
36
+ text.startsWith(INTERACTION_CONTINUATION_PREFIX) || customType === INTERACTION_EVENT_CUSTOM_TYPE) return false;
37
+ if (typeof customType === "string" && customType.trim()) return true;
38
+ const t = text.trim();
39
+ if (!t) return false;
40
+ if (CONTEXT_TEXT_PREFIXES.some((prefix) => t.startsWith(prefix))) return true;
41
+ if (t.includes("<background-task-notification")) return true;
42
+ if (SUBAGENT_NOTIFICATION_PREFIX.test(t)) return true;
43
+ if (GROUPED_SUBAGENT_NOTIFICATION_PREFIX.test(t)) return true;
44
+ // CONTROL reload queues this command; it is not a typed local prompt.
45
+ if (t === "/devspec.reload") return true;
46
+ return /^\[bg (completed|failed|killed)\]/m.test(t);
47
+ }
48
+
49
+ export function classifyUserKind(text: string, customType?: string | null): UserKind {
50
+ if (text.startsWith(REMOTE_PREFIX)) return "remote";
51
+ if (text.startsWith(AUTOMATION_RUN_PREFIX)) return "automation";
52
+ if (text.startsWith(INTERACTION_CONTINUATION_PREFIX) || customType === INTERACTION_EVENT_CUSTOM_TYPE) return "interaction";
53
+ if (isHarnessOrigin(text, customType)) return "harness";
54
+ return "local";
55
+ }
56
+
57
+ export class TurnTracker {
58
+ userKind: UserKind | null = null;
59
+ userText = "";
60
+ lastAssistantText = "";
61
+ /** True when the last assistant close was a finished text-only answer. */
62
+ lastCloseWasAnswer = false;
63
+ /** True until this segment posts its first user-facing answer. */
64
+ awaitingFirstAnswer = false;
65
+ /** True only for a freshly typed local prompt — never after carryUser. */
66
+ shouldMirrorPrompt = false;
67
+ /** Historical harness trail eligibility. Never gates actual assistant speech. */
68
+ harnessSpeechAllowed = false;
69
+ /**
70
+ * Owner-started automatic-wake tasks that have not reached terminal state.
71
+ * Never gates speech or settles (759f2818). The WIRING passes one shared
72
+ * connection-scoped set into every segment's tracker (TurnTracker ctor):
73
+ * presence (Working) follows outstanding background work across settles
74
+ * and completion wakes (owner direction 2026-09-17 PM — bubbles stay
75
+ * decoupled, but "bg 2 running" means still working). Standalone trackers
76
+ * (tests) get an independent set.
77
+ */
78
+ private pendingBackgroundTaskIds: Set<string>;
79
+
80
+ constructor(sharedPendingBackgroundTaskIds?: Set<string>) {
81
+ this.pendingBackgroundTaskIds = sharedPendingBackgroundTaskIds ?? new Set();
82
+ }
83
+ /** Compatibility/readability projection used by lifecycle predicates. */
84
+ get awaitingBackground(): boolean {
85
+ return this.pendingBackgroundTaskIds.size > 0;
86
+ }
87
+ /** True when the last assistant close was wait/receipt process, not an answer. */
88
+ lastCloseWasProcess = false;
89
+
90
+ userMessage(text: string, origin?: { customType?: string | null; backgroundTaskId?: string | null }): void {
91
+ const kind = classifyUserKind(text, origin?.customType);
92
+ // A leftover wake may replay the last typed prompt as a user message.
93
+ // Once this segment is harness, stay harness — do not remirror that prompt.
94
+ if (this.userKind === "harness" && kind !== "remote" && kind !== "automation") {
95
+ // A second completion arriving ON a harness wake segment must still
96
+ // resolve its task — the early return leaked B2's id live on
97
+ // 2026-09-17 (session ea7704f4), parking Working at a stuck timer long
98
+ // after every task had finished (759f2818).
99
+ if (origin?.backgroundTaskId) this.resolveBackgroundTask(origin.backgroundTaskId);
100
+ this.awaitingFirstAnswer = false;
101
+ this.shouldMirrorPrompt = false;
102
+ return;
103
+ }
104
+ // Mid-turn context/harness (auto-recall, leftover receipts) must not
105
+ // steal an already-started remote/local/automation turn (item 60cf5f00).
106
+ if (kind === "harness" && this.userKind && this.userKind !== "harness") {
107
+ // A terminal notification resolves only the task it names. This is
108
+ // deliberately idempotent because before_agent_start + message_start
109
+ // may observe the same custom message. One sibling completion must
110
+ // never consume every pending task (item 15cb0983).
111
+ if (origin?.backgroundTaskId) this.resolveBackgroundTask(origin.backgroundTaskId);
112
+ return;
113
+ }
114
+ this.userText = text;
115
+ this.awaitingFirstAnswer = true;
116
+ this.userKind = kind;
117
+ this.shouldMirrorPrompt = kind === "local";
118
+ if (kind === "harness") {
119
+ this.harnessSpeechAllowed = isBackgroundCompletionOrigin(text, origin?.customType);
120
+ // A fresh completion-wake segment resolves its task too — with the
121
+ // connection-scoped shared set this is what releases Working when the
122
+ // last task completes (the old parked-tracker model resolved on the
123
+ // surviving owner tracker; there is no surviving tracker any more).
124
+ if (origin?.backgroundTaskId) this.resolveBackgroundTask(origin.backgroundTaskId);
125
+ }
126
+ }
127
+
128
+ /** Copy the user kind onto a continuation segment without re-arming first-answer or remirroring. */
129
+ carryUser(text: string, from?: TurnTracker): void {
130
+ this.userText = text;
131
+ this.awaitingFirstAnswer = false;
132
+ this.userKind = classifyUserKind(text);
133
+ this.shouldMirrorPrompt = false;
134
+ if (from) {
135
+ // Merge, never replace: the wiring hands every segment the SAME
136
+ // connection-scoped set, so a resolve on any segment is visible to
137
+ // all (15cb0983's copy-not-alias is superseded by sharing).
138
+ if (this.pendingBackgroundTaskIds !== from.pendingBackgroundTaskIds) {
139
+ for (const id of from.pendingBackgroundTasks()) this.pendingBackgroundTaskIds.add(id);
140
+ }
141
+ this.lastCloseWasProcess = from.lastCloseWasProcess;
142
+ this.harnessSpeechAllowed = from.harnessSpeechAllowed;
143
+ }
144
+ }
145
+
146
+ /** A successful automatic-wake task started in this owner turn. */
147
+ noteBackgroundTask(taskId: string): void {
148
+ const id = taskId.trim();
149
+ if (id) this.pendingBackgroundTaskIds.add(id);
150
+ }
151
+
152
+ /** Resolve one matching terminal notification; unknown/duplicate ids are no-ops. */
153
+ resolveBackgroundTask(taskId: string): boolean {
154
+ const id = taskId.trim();
155
+ return id ? this.pendingBackgroundTaskIds.delete(id) : false;
156
+ }
157
+
158
+ pendingBackgroundTasks(): string[] {
159
+ return [...this.pendingBackgroundTaskIds];
160
+ }
161
+
162
+ /** Retain raw finalized assistant text. "process" remains in the legacy debug
163
+ * type only; wording and input provenance no longer suppress actual speech. */
164
+ assistantFinal(text: string, closedAnswer = false): "empty" | "process" | "stored" {
165
+ if (!hasAssistantSpeech(text)) return "empty";
166
+ this.lastAssistantText = text;
167
+ this.awaitingFirstAnswer = false;
168
+ this.lastCloseWasAnswer = closedAnswer;
169
+ this.lastCloseWasProcess = false;
170
+ return "stored";
171
+ }
172
+
173
+ }
174
+
175
+ export type SettlePlan =
176
+ | { kind: "none" }
177
+ | { kind: "final"; message: string }
178
+ | { kind: "local_mirror"; prompt: string; answer: string };
179
+
180
+ /**
181
+ * Extract a successful provider launch that will both notify and wake Pi.
182
+ * The background-task providers share details.task; failed launches or
183
+ * notification-only tasks must not park an owner turn forever.
184
+ */
185
+ const AUTOMATIC_BACKGROUND_LAUNCH_TOOLS = new Set([
186
+ "bg_run",
187
+ "bg_run_pi_attested",
188
+ "bg_delegate",
189
+ "fusion_reason",
190
+ "fusion_investigate",
191
+ "fusion_research",
192
+ "fusion_validate",
193
+ ]);
194
+
195
+ export function automaticBackgroundTaskId(
196
+ toolName: string,
197
+ result: unknown,
198
+ isError = false,
199
+ ): string | null {
200
+ // Inspection tools such as bg_logs return the original task receipt too.
201
+ // Receipt shape alone is therefore not proof of launch (item 15cb0983).
202
+ if (!AUTOMATIC_BACKGROUND_LAUNCH_TOOLS.has(toolName)) return null;
203
+ if (isError || !result || typeof result !== "object") return null;
204
+ const outer = result as { details?: unknown; task?: unknown };
205
+ const details = outer.details && typeof outer.details === "object"
206
+ ? outer.details as { task?: unknown }
207
+ : outer;
208
+ const task = details.task;
209
+ if (!task || typeof task !== "object") return null;
210
+ const receipt = task as {
211
+ id?: unknown;
212
+ notifyOnCompletion?: unknown;
213
+ triggerOnCompletion?: unknown;
214
+ };
215
+ if (receipt.notifyOnCompletion !== true || receipt.triggerOnCompletion !== true) return null;
216
+ return typeof receipt.id === "string" && receipt.id.trim() ? receipt.id.trim() : null;
217
+ }
218
+
219
+ /**
220
+ * Terminally-finished background task ids from background-management tool
221
+ * results (item f453c61f).
222
+ *
223
+ * Completion custom-messages are the primary resolution path, but a task
224
+ * killed from outside (bg_kill) never produces one — and a kill result only
225
+ * lands here, in tool_execution_end. Without this the id leaks and every
226
+ * later settle posts complete_turn:false forever (live 2026-09-20: 5h stuck
227
+ * Working on a killed dev server). bg_status results reconcile the same way
228
+ * for tasks that died without any notice at all.
229
+ *
230
+ * Resolve ONLY on explicit terminal states. Failed-tool results, running or
231
+ * unknown states, malformed shapes and missing lists resolve nothing: a
232
+ * wrong resolve shows Idle while work runs, which is worse than the leak.
233
+ * Ids absent from the pending set are naturally no-ops at resolve time, so
234
+ * a status listing that spans sessions cannot disturb this turn's bookkeeping.
235
+ */
236
+ const TERMINAL_BG_TASK_STATUSES = new Set(["completed", "failed", "killed"]);
237
+
238
+ function terminalTaskId(task: unknown): string | null {
239
+ if (!task || typeof task !== "object") return null;
240
+ const receipt = task as { id?: unknown; status?: unknown };
241
+ if (typeof receipt.id !== "string" || !receipt.id.trim()) return null;
242
+ if (typeof receipt.status !== "string") return null;
243
+ if (!TERMINAL_BG_TASK_STATUSES.has(receipt.status.trim().toLowerCase())) return null;
244
+ return receipt.id.trim();
245
+ }
246
+
247
+ export function terminalBackgroundTaskIds(
248
+ toolName: string,
249
+ result: unknown,
250
+ isError = false,
251
+ ): string[] {
252
+ if (isError || !result || typeof result !== "object") return [];
253
+ const outer = result as { details?: unknown; task?: unknown; tasks?: unknown };
254
+ if (toolName === "bg_kill") {
255
+ const details = outer.details && typeof outer.details === "object"
256
+ ? (outer.details as { task?: unknown })
257
+ : outer;
258
+ const id = terminalTaskId(details.task);
259
+ return id ? [id] : [];
260
+ }
261
+ if (toolName === "bg_status") {
262
+ const details = outer.details && typeof outer.details === "object"
263
+ ? (outer.details as { tasks?: unknown })
264
+ : outer;
265
+ const tasks = (details as { tasks?: unknown }).tasks;
266
+ if (!Array.isArray(tasks)) return [];
267
+ const ids: string[] = [];
268
+ for (const task of tasks) {
269
+ const id = terminalTaskId(task);
270
+ if (id) ids.push(id);
271
+ }
272
+ return ids;
273
+ }
274
+ return [];
275
+ }
276
+
277
+ /**
278
+ * Working-lease progress evidence (item 0b8035ac; server migration 952).
279
+ *
280
+ * The server renews a working attempt's lease on a keepalive only while the
281
+ * caller's progress marker keeps CHANGING; a marker frozen past the staleness
282
+ * bound is declined ('stale') and the attempt expires on its lease. This is
283
+ * what stops a wedged tracker (pendingBg stuck at 1, session ea7704f4,
284
+ * 2026-09-20) from holding Working for hours: the wedge froze every event
285
+ * stream, so a counter fed BY those streams provably stops too.
286
+ *
287
+ * One process-global monotonic counter, bumped by the extension's event
288
+ * handlers (turns, messages, tool executions, updates). Opaque and cheap —
289
+ * the server compares for change, never meaning. Monotonicity within the
290
+ * process is all the server needs; a restart re-seeds from zero and the first
291
+ * beat reads as progress (the marker differs from whatever the attempt held).
292
+ */
293
+ let progressEventCounter = 0;
294
+
295
+ /** Record one unit of observed work. Called from the extension's event handlers. */
296
+ export function noteProgressEvent(): void {
297
+ progressEventCounter += 1;
298
+ }
299
+
300
+ /** The current opaque marker value to ride the next keepalive/heartbeat. */
301
+ export function currentProgressMarker(): string {
302
+ return String(progressEventCounter);
303
+ }
304
+
305
+ /** Terminal task id from Pi's custom notification envelope/details. */
306
+ export function backgroundTaskNotificationId(text: string, details?: unknown): string | null {
307
+ let structuredId: string | null = null;
308
+ let structuredStatus: string | null = null;
309
+ if (details && typeof details === "object") {
310
+ const record = details as {
311
+ id?: unknown;
312
+ taskId?: unknown;
313
+ status?: unknown;
314
+ task?: { id?: unknown; status?: unknown };
315
+ };
316
+ for (const value of [record.id, record.taskId, record.task?.id]) {
317
+ if (typeof value === "string" && value.trim()) {
318
+ structuredId = value.trim();
319
+ break;
320
+ }
321
+ }
322
+ const status = record.status ?? record.task?.status;
323
+ if (typeof status === "string") structuredStatus = status.trim().toLowerCase();
324
+ }
325
+ const xmlId = text.match(/<task-id>\s*([^<]+?)\s*<\/task-id>/i)?.[1]?.trim() ?? null;
326
+ const xmlStatus = text.match(/<status>\s*([^<]+?)\s*<\/status>/i)?.[1]?.trim().toLowerCase() ?? null;
327
+ const status = structuredStatus || xmlStatus;
328
+ if (!status || !["completed", "failed", "killed"].includes(status)) return null;
329
+ return structuredId || xmlId;
330
+ }
331
+
332
+ /**
333
+ * Whether a user message arriving mid-run forces a delivery boundary
334
+ * (item b1fb756a). Only a segment that has PRODUCED an answer text has
335
+ * something to close — an injection before the first assistant text simply
336
+ * joins the current segment.
337
+ */
338
+ export function isDeliveryBoundary(tracker: TurnTracker): boolean {
339
+ return tracker.lastAssistantText !== "";
340
+ }
341
+
342
+ /** Next assistant round after a text-only close — the ack is its own bubble. */
343
+ export function isClosedAnswerBoundary(tracker: TurnTracker): boolean {
344
+ return tracker.lastCloseWasAnswer && tracker.lastAssistantText !== "";
345
+ }
346
+
347
+ /** Legacy split predicate; the source-aware controller never splits execution
348
+ * for speech. Kept for compatibility with bookkeeping callers/tests. */
349
+ export function shouldSplitSpeech(
350
+ tracker: TurnTracker,
351
+ input: { text: string; hasToolCalls: boolean },
352
+ ): boolean {
353
+ return input.hasToolCalls && hasAssistantSpeech(input.text);
354
+ }
355
+
356
+ const CLOSED_ANSWER_STOPS = new Set(["stop"]);
357
+ const OPEN_ROUND_STOPS = new Set(["toolUse", "aborted", "error", "length", "pending"]);
358
+
359
+ /** Grok (and others) emit curly I’ll; the wait regex is ASCII. */
360
+ export function foldApostrophes(text: string): string {
361
+ return text.replace(/[\u2018\u2019\u02BC\u2032]/g, "'");
362
+ }
363
+
364
+ function cleanedProcessText(text: string): string {
365
+ return foldApostrophes(text.replace(/[\u200B-\u200D\uFEFF]/g, "")).trim();
366
+ }
367
+
368
+ /**
369
+ * Mid-run process close while a background task is in flight (item 15d7aa56).
370
+ * The model is told to acknowledge and end the turn rather than poll; that
371
+ * sentence is trail, not a spoken answer. Matches the now-line contract
372
+ * (brief 7e99501c / decision af24bdb5): the room is things said, not work.
373
+ */
374
+ export function isWaitNarration(text: string): boolean {
375
+ const cleaned = cleanedProcessText(text);
376
+ if (!cleaned || cleaned.length > 500) return false;
377
+ if (/\bindependent work is done\b/i.test(cleaned)) return true;
378
+ // Owner-directed waits are real answers ("I'll wait for your go-ahead").
379
+ if (/\?/.test(cleaned)) return false;
380
+ const waitingOnWork =
381
+ /\bI(?:'ll| will) wait (?:for|until)\b/i.test(cleaned) &&
382
+ /\b(test|tests|typecheck|suite|task|results?|notification|build|lint|ci)\b/i.test(cleaned);
383
+ if (waitingOnWork) return true;
384
+ // "Typecheck is still running. I'll push after." — process even without "wait".
385
+ const runningWork = /\b(typecheck|lint|test|tests|suite|build|ci)\b/i.test(cleaned) &&
386
+ /\b(still running|is running)\b/i.test(cleaned);
387
+ if (runningWork) return true;
388
+ // A person's name in a work-status sentence does not turn process narration
389
+ // into an answer ("CI is still running, Ali"). Non-work waits addressed to
390
+ // the owner remain conversational.
391
+ if (/\b(you|your|Ali)\b/i.test(cleaned)) return false;
392
+ return false;
393
+ }
394
+
395
+ /**
396
+ * Trail process, not a spoken answer: wait-narration only. Since 2026-09-17
397
+ * (759f2818) this is purely textual — the old "text-only close while a bg_run
398
+ * is pending = process" blanket rule (6ea9aa47) is deleted: it could not tell
399
+ * "typecheck still running" from a 2.6k final report, and swallowed the
400
+ * latter whole. Wait-class shapes are all covered by isWaitNarration.
401
+ */
402
+ export function isProcessClose(input: { text: string }): boolean {
403
+ const cleaned = cleanedProcessText(input.text);
404
+ if (!cleaned) return false;
405
+ return isWaitNarration(cleaned);
406
+ }
407
+
408
+ /**
409
+ * Pure background-task receipt acknowledgements on a harness wake —
410
+ * "expected teardown", "completion receipt … no action needed". These are
411
+ * the harness mumbling to itself, not room content (56b9a3b4 / 15d7aa56).
412
+ * Length-capped so substantive wake news (a failed gate, a verified deploy)
413
+ * always posts; the 2026-09-17 incident was caused by the reverse bias.
414
+ */
415
+ export function isReceiptAcknowledgement(text: string): boolean {
416
+ const cleaned = cleanedProcessText(text);
417
+ if (!cleaned || cleaned.length > 500) return false;
418
+ return /(?:expected teardown|receipt|no action (?:needed|required)|no further action|nothing (?:further|outstanding|left)|already (?:consumed|acted on|passed|accounted)|minimal acknowledg)/i.test(cleaned);
419
+ }
420
+
421
+ /**
422
+ * Whether a harness-origin wake was a background task completion — the only
423
+ * harness wake whose substantive speech may reach the room. Context
424
+ * injections (auto-recall, attach context, orientation) and other leftovers
425
+ * stay silent.
426
+ */
427
+ export function isBackgroundCompletionOrigin(text: string, customType?: string | null): boolean {
428
+ if (customType === "background-task-notification" || customType === "subagent-notify") return true;
429
+ const t = text.trim();
430
+ if (!t) return false;
431
+ if (t.includes("<background-task-notification")) return true;
432
+ if (/^\[bg (?:completed|failed|killed)\]/m.test(t)) return true;
433
+ if (SUBAGENT_NOTIFICATION_PREFIX.test(t)) return true;
434
+ if (GROUPED_SUBAGENT_NOTIFICATION_PREFIX.test(t)) return true;
435
+ return false;
436
+ }
437
+
438
+ /**
439
+ * A text-only assistant close is a finished answer (item f0df6c3e).
440
+ * Tool-loop rounds (toolCall blocks or stopReason=toolUse) stay in the trail.
441
+ * Missing stopReason with no tool calls still counts as closed — some providers
442
+ * omit it; the content shape is the authority.
443
+ * Wait-narration / unresolved bg_run closes are the exception (15d7aa56 /
444
+ * 6ea9aa47): process, not a closed answer.
445
+ */
446
+ export function isClosedAnswer(input: {
447
+ text: string;
448
+ hasToolCalls: boolean;
449
+ stopReason?: string | null;
450
+ }): boolean {
451
+ const cleaned = input.text.replace(/[\u200B-\u200D\uFEFF]/g, "").trim();
452
+ if (!cleaned) return false;
453
+ if (isProcessClose({ text: cleaned })) {
454
+ return false;
455
+ }
456
+ if (input.hasToolCalls) return false;
457
+ const reason = input.stopReason ?? "stop";
458
+ if (OPEN_ROUND_STOPS.has(reason)) return false;
459
+ return CLOSED_ANSWER_STOPS.has(reason) || input.stopReason == null;
460
+ }
461
+
462
+ export function planSettle(tracker: TurnTracker, attached: boolean): SettlePlan {
463
+ if (!attached) return { kind: "none" };
464
+ // Legacy planner only. Source-aware controller speech bypasses settlement;
465
+ // this helper cannot grant source authority or decide execution completion.
466
+ if (!tracker.lastAssistantText) return { kind: "none" };
467
+ if (tracker.userKind === "local" && tracker.shouldMirrorPrompt) {
468
+ return { kind: "local_mirror", prompt: tracker.userText, answer: tracker.lastAssistantText };
469
+ }
470
+ // Remote/automation, local continuations, and unknowns with an answer: post the final.
471
+ return { kind: "final", message: tracker.lastAssistantText };
472
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * What Pi says when DevSpec and this extension no longer agree about something
3
+ * (DevSpec item 39c05c1f). Customers update the extension when they choose, so a
4
+ * newer DevSpec will regularly meet an older Pi. Whatever still matches keeps
5
+ * working; whatever does not degrades on its own and says, in words the person can
6
+ * act on, that updating the extension fixes it.
7
+ */
8
+ export const UPDATE_EXTENSION_INSTRUCTION =
9
+ "To fix this, update the DevSpec Pi extension: run `pi update --extensions`, then restart Pi.";
10
+
11
+ type SchemaNode = Record<string, unknown>;
12
+
13
+ function asNode(value: unknown): SchemaNode | null {
14
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as SchemaNode : null;
15
+ }
16
+
17
+ /** The branches a node may take: itself, or each of its anyOf/oneOf alternatives. */
18
+ function branches(node: SchemaNode): SchemaNode[] {
19
+ const alternatives = [node.anyOf, node.oneOf].flatMap((list) => Array.isArray(list) ? list.map(asNode).filter((entry): entry is SchemaNode => entry !== null) : []);
20
+ return [node, ...alternatives];
21
+ }
22
+
23
+ /**
24
+ * Find the field a refusal names (a dotted path such as `criteria.0.text`) in Pi's
25
+ * own schema for the tool, and whether Pi lists it as required.
26
+ */
27
+ function locateField(schema: SchemaNode | undefined, path: string): { node: SchemaNode; required: boolean } | null {
28
+ if (!schema) return null;
29
+ let current: SchemaNode[] = [schema];
30
+ let required = false;
31
+ for (const segment of path.split(".").filter(Boolean)) {
32
+ const next: SchemaNode[] = [];
33
+ required = false;
34
+ for (const node of current.flatMap(branches)) {
35
+ const properties = asNode(node.properties);
36
+ const property = properties ? asNode(properties[segment]) : null;
37
+ if (property) {
38
+ next.push(property);
39
+ if (Array.isArray(node.required) && node.required.includes(segment)) required = true;
40
+ continue;
41
+ }
42
+ const items = asNode(node.items);
43
+ if (items && /^\d+$/.test(segment)) next.push(items);
44
+ }
45
+ if (next.length === 0) return null;
46
+ current = next;
47
+ }
48
+ return { node: current[0]!, required };
49
+ }
50
+
51
+ function allowedValues(node: SchemaNode): unknown[] | null {
52
+ for (const branch of branches(node)) {
53
+ if (Array.isArray(branch.enum)) return branch.enum;
54
+ if (Object.hasOwn(branch, "const")) return [branch.const];
55
+ const items = asNode(branch.items);
56
+ if (items && Array.isArray(items.enum)) return items.enum;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function sameValues(left: readonly unknown[], right: readonly unknown[]): boolean {
62
+ const key = (value: unknown) => JSON.stringify(value);
63
+ const a = new Set(left.map(key));
64
+ const b = new Set(right.map(key));
65
+ return a.size === b.size && [...a].every((value) => b.has(value));
66
+ }
67
+
68
+ /**
69
+ * When DevSpec refuses a call, say whether that is because DevSpec's definition of
70
+ * the tool and this extension's no longer agree — and only then. The refusal names
71
+ * the field and the rule it broke; Pi compares that with its own schema for the
72
+ * tool. A call Pi's schema already allowed that the server refuses on the same
73
+ * terms is a version difference; anything else (a value that is simply invalid, a
74
+ * rule only the server can judge) is an ordinary mistake and gets no update hint.
75
+ */
76
+ export function toolVersionMismatchNote(toolName: string, refusal: unknown, wireSchema: Record<string, unknown> | undefined): string | null {
77
+ const row = asNode(refusal);
78
+ if (!row || typeof row.code !== "string") return null;
79
+ if (typeof row.tool === "string" && row.tool !== toolName) return null;
80
+ const close = `This is a version difference between DevSpec and this extension, not a mistake in the call. Tell the person: ${UPDATE_EXTENSION_INSTRUCTION}`;
81
+ if (row.code === "unknown_tool") {
82
+ return `DevSpec no longer has the \`${toolName}\` tool, which this version of the DevSpec Pi extension still offers. ${close}`;
83
+ }
84
+ const field = typeof row.field === "string" && row.field ? row.field : null;
85
+ if (!field) return null;
86
+ const mine = locateField(asNode(wireSchema) ?? undefined, field);
87
+ switch (row.code) {
88
+ case "argument_missing":
89
+ // Pi validates its own required fields before sending, so a field DevSpec
90
+ // requires and Pi does not is the only way to arrive here.
91
+ if (mine?.required) return null;
92
+ return `DevSpec now requires \`${field}\` for \`${toolName}\`, which this version of the DevSpec Pi extension does not ask for. ${close}`;
93
+ case "argument_not_allowed": {
94
+ if (!mine || !Array.isArray(row.allowed)) return null;
95
+ const offered = allowedValues(mine.node);
96
+ if (offered && sameValues(offered, row.allowed)) return null;
97
+ return `DevSpec accepts different values for \`${field}\` in \`${toolName}\` than this version of the DevSpec Pi extension offers. ${close}`;
98
+ }
99
+ case "argument_limit_exceeded": {
100
+ if (!mine || typeof row.keyword !== "string" || typeof row.limit !== "number") return null;
101
+ if (mine.node[row.keyword] === row.limit) return null;
102
+ return `DevSpec limits \`${field}\` in \`${toolName}\` differently from this version of the DevSpec Pi extension. ${close}`;
103
+ }
104
+ case "argument_malformed": {
105
+ if (!mine || typeof row.pattern !== "string") return null;
106
+ if (mine.node.pattern === row.pattern) return null;
107
+ return `DevSpec expects \`${field}\` in \`${toolName}\` in a different form from this version of the DevSpec Pi extension. ${close}`;
108
+ }
109
+ default:
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * What to say when DevSpec sends this Pi a remote message it cannot read — once per
116
+ * distinct reason, not on every retry. Before, Pi only wrote a debug line and
117
+ * retried, so a command sent from a room never arrived and nobody was told why.
118
+ * Pi keeps retrying with the cursor where it was, so a corrected message is still
119
+ * picked up; this only makes the wait visible. The room text is for whoever sent
120
+ * the command, who cannot see Pi's terminal; the reason stays local, because it is
121
+ * Pi's parser talking.
122
+ */
123
+ export function unreadableRemoteMessageNotice(
124
+ reason: string | undefined,
125
+ alreadyReported: string | undefined,
126
+ ): { key: string; local: string; room: string } | null {
127
+ const key = reason?.trim() || "the message did not match any format this extension reads";
128
+ if (key === alreadyReported) return null;
129
+ return {
130
+ key,
131
+ local: `DevSpec sent this Pi a message it cannot read (${key}), so Pi has not acted on it. Pi keeps checking and will act on the message once it can read it. ${UPDATE_EXTENSION_INSTRUCTION}`,
132
+ room: "I couldn't read a message DevSpec sent me, so I haven't acted on it. My DevSpec Pi extension is probably out of date: on the computer I'm running on, run `pi update --extensions`, then restart Pi.",
133
+ };
134
+ }
135
+
136
+ const ROOM_STATE_LABELS: Readonly<Record<string, string>> = {
137
+ session_polls: "polls",
138
+ still_to_discuss: "Still to Discuss",
139
+ session_activity: "what the session produced",
140
+ session_events: "recent changes to the session's records",
141
+ notice_reply: "details of replies to DevSpec notices",
142
+ };
143
+
144
+ /**
145
+ * Room state Pi could not read is already left out on its own, without holding
146
+ * up commands (item 01b7c2e2), but only a debug line said so — the agent simply
147
+ * worked without the room's polls or Still to Discuss. Say it once per distinct
148
+ * set, to the person at Pi only: this is context, not a command anyone is
149
+ * waiting on.
150
+ */
151
+ export function unreadableRoomStateNotice(
152
+ rejected: readonly string[] | undefined,
153
+ alreadyReported: string | undefined,
154
+ ): { key: string; local: string } | null {
155
+ if (!rejected || rejected.length === 0) return null;
156
+ const names = [...new Set(rejected)].sort();
157
+ const key = names.join(",");
158
+ if (key === alreadyReported) return null;
159
+ const labels = names.map((name) => ROOM_STATE_LABELS[name] ?? name);
160
+ const list = labels.length <= 1 ? labels.join("") : `${labels.slice(0, -1).join(", ")} and ${labels.at(-1)}`;
161
+ return {
162
+ key,
163
+ local: `This version of the DevSpec Pi extension could not read the room's ${list} from DevSpec, so Pi is working without ${labels.length === 1 ? "it" : "them"}; everything else is in force. ${UPDATE_EXTENSION_INSTRUCTION}`,
164
+ };
165
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * DevSpec Pi — explicitly selected work delivery (pure, unit-tested).
3
+ *
4
+ * The command resolves the selected item and mechanically loads the product
5
+ * contract. This formatter carries only work and contract identity, the contract
6
+ * itself, and the selected item payload. It does not maintain a second
7
+ * implementation workflow in prose, and there is no execution mode to carry.
8
+ */
9
+
10
+ import {
11
+ buildImplementationKickoff,
12
+ formatImplementationContext,
13
+ formatImplementationKickoffHeader,
14
+ type ImplementationContractContext,
15
+ } from "./implementation-contract.ts";
16
+
17
+ export interface WorkTarget {
18
+ id: string;
19
+ title?: string;
20
+ /** Full get_task payload. */
21
+ payload: unknown;
22
+ }
23
+
24
+ export function buildWorkPrompt(
25
+ item: WorkTarget,
26
+ context: ImplementationContractContext,
27
+ ): string {
28
+ const kickoff = buildImplementationKickoff({
29
+ taskIds: [item.id],
30
+ context,
31
+ });
32
+ return [
33
+ formatImplementationKickoffHeader(kickoff),
34
+ "",
35
+ `Work DevSpec task ${item.id}${item.title ? ` — “${item.title}”` : ""}. This item was explicitly selected here.`,
36
+ "",
37
+ formatImplementationContext(context),
38
+ "",
39
+ "Selected item context:",
40
+ "```json",
41
+ JSON.stringify(item.payload, null, 2),
42
+ "```",
43
+ ].join("\n");
44
+ }