@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,374 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /**
4
+ * Strict negotiated active-session-plan projection for canonical ingress 1.3.
5
+ *
6
+ * The server owns this schema. Pi validates the complete projection before it
7
+ * can affect tool activation or model context, then serializes it only at the
8
+ * newest delivery tail. Presence is read-awareness, never mutation authority.
9
+ */
10
+
11
+ export const ACTIVE_PLAN_PROJECTION_VERSION = 1 as const;
12
+ export const ACTIVE_PLAN_MAX_PLANS = 64;
13
+ export const ACTIVE_PLAN_MAX_STEPS = 64;
14
+ export const ACTIVE_PLAN_MAX_TITLE_CHARS = 300;
15
+ export const ACTIVE_PLAN_MAX_FAILURE_REASON_CHARS = 4_096;
16
+ export const ACTIVE_PLAN_MAX_IDENTITY_CHARS = 300;
17
+ export const ACTIVE_PLAN_MAX_TOTAL_TEXT_CHARS = 131_072;
18
+ export const ACTIVE_PLAN_AUTHORITY_NOTE =
19
+ "Advisory read-awareness only. Presence does not authorize execution or mutation; manage_plan still requires a capability-authenticated caller identity, explicit plan_id for cross-plan work, and expected_revision." as const;
20
+ export const ACTIVE_PLAN_CLEAR_TOMBSTONE = [
21
+ "== ACTIVE SESSION PLANS ==",
22
+ "Authoritative current inventory: none.",
23
+ "Any earlier active-session-plan projection in this conversation is stale. Do not continue, complete, abandon, or adopt a plan from that stale projection.",
24
+ ].join("\n");
25
+
26
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
27
+ const DATETIME_WITH_OFFSET_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
28
+
29
+ export type ActivePlanStepStatus = "pending" | "in_progress" | "completed" | "failed" | "skipped";
30
+
31
+ export interface ActivePlanAgentIdentity {
32
+ kind: "dev" | "connection";
33
+ connection_id: string | null;
34
+ agent_name: string;
35
+ codename: string | null;
36
+ }
37
+
38
+ export interface ActiveSessionPlanStep {
39
+ id: string;
40
+ position: number;
41
+ title: string;
42
+ status: ActivePlanStepStatus;
43
+ failure_reason?: string;
44
+ retryable?: boolean;
45
+ }
46
+
47
+ export interface ActiveSessionPlan {
48
+ id: string;
49
+ title: string;
50
+ revision: number;
51
+ status: "active";
52
+ created_at: string;
53
+ origin: ActivePlanAgentIdentity;
54
+ steward: ActivePlanAgentIdentity;
55
+ owner: { user_id: string; display_name: string };
56
+ orphaned: boolean;
57
+ progress: { terminal: number; total: number; completed: number; skipped: number };
58
+ steps: ActiveSessionPlanStep[];
59
+ }
60
+
61
+ export interface ActiveSessionPlansProjection {
62
+ version: typeof ACTIVE_PLAN_PROJECTION_VERSION;
63
+ advisory: true;
64
+ authority_note: typeof ACTIVE_PLAN_AUTHORITY_NOTE;
65
+ inventory: { returned: number; total_known: number; truncated: false };
66
+ plans: ActiveSessionPlan[];
67
+ }
68
+
69
+ interface ActivePlanAwarenessStore {
70
+ /** Pi local session id → SHA-256 of the last actually injected active projection. */
71
+ injectedByLocalSession: Map<string, string>;
72
+ }
73
+
74
+ const AWARENESS_STORE_KEY = Symbol.for("devspec.pi.active-plan-awareness.v1");
75
+ const MAX_AWARENESS_SESSIONS = 100;
76
+
77
+ function awarenessStore(): ActivePlanAwarenessStore {
78
+ const root = globalThis as typeof globalThis & { [AWARENESS_STORE_KEY]?: ActivePlanAwarenessStore };
79
+ const current = root[AWARENESS_STORE_KEY];
80
+ if (!current || !(current.injectedByLocalSession instanceof Map)) {
81
+ root[AWARENESS_STORE_KEY] = { injectedByLocalSession: new Map() };
82
+ }
83
+ return root[AWARENESS_STORE_KEY]!;
84
+ }
85
+
86
+ function exactAwarenessKey(value: string): string | null {
87
+ return value && value === value.trim() ? value : null;
88
+ }
89
+
90
+ /** Persist only a digest proving active-plan awareness reached this transcript. */
91
+ export function markActivePlanAwareness(localSessionId: string, activeTail: string): string | null {
92
+ const key = exactAwarenessKey(localSessionId);
93
+ if (!key || !activeTail || activeTail === ACTIVE_PLAN_CLEAR_TOMBSTONE) return null;
94
+ const digest = createHash("sha256").update(activeTail, "utf8").digest("hex");
95
+ const store = awarenessStore().injectedByLocalSession;
96
+ store.delete(key);
97
+ store.set(key, digest);
98
+ while (store.size > MAX_AWARENESS_SESSIONS) {
99
+ const oldest = store.keys().next().value;
100
+ if (typeof oldest !== "string") break;
101
+ store.delete(oldest);
102
+ }
103
+ return digest;
104
+ }
105
+
106
+ export function activePlanAwarenessHash(localSessionId: string): string | null {
107
+ const key = exactAwarenessKey(localSessionId);
108
+ return key ? awarenessStore().injectedByLocalSession.get(key) ?? null : null;
109
+ }
110
+
111
+ /** Clear only after the authoritative no-plan tombstone was actually injected. */
112
+ export function clearActivePlanAwareness(localSessionId: string): boolean {
113
+ const key = exactAwarenessKey(localSessionId);
114
+ return key ? awarenessStore().injectedByLocalSession.delete(key) : false;
115
+ }
116
+
117
+ export function resetActivePlanAwarenessForTests(): void {
118
+ awarenessStore().injectedByLocalSession.clear();
119
+ }
120
+
121
+ type Row = Record<string, unknown>;
122
+
123
+ function fail(path: string, message: string): never {
124
+ throw new Error(`${path}: ${message}`);
125
+ }
126
+
127
+ function record(value: unknown, path: string): Row {
128
+ if (!value || typeof value !== "object" || Array.isArray(value)) fail(path, "expected object");
129
+ return value as Row;
130
+ }
131
+
132
+ function exactKeys(value: Row, keys: readonly string[], path: string): void {
133
+ const actual = Object.keys(value).sort();
134
+ const expected = [...keys].sort();
135
+ if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
136
+ fail(path, `expected exactly ${expected.join(", ")}`);
137
+ }
138
+ }
139
+
140
+ function boundedString(value: unknown, path: string, max: number): string {
141
+ if (typeof value !== "string" || value.length === 0 || value.length > max) {
142
+ fail(path, `expected non-empty string <= ${max} characters`);
143
+ }
144
+ return value;
145
+ }
146
+
147
+ function uuid(value: unknown, path: string): string {
148
+ const parsed = boundedString(value, path, 64);
149
+ if (!UUID_RE.test(parsed)) fail(path, "expected UUID");
150
+ return parsed;
151
+ }
152
+
153
+ function datetime(value: unknown, path: string): string {
154
+ const parsed = boundedString(value, path, 64);
155
+ if (!DATETIME_WITH_OFFSET_RE.test(parsed) || Number.isNaN(Date.parse(parsed))) fail(path, "expected offset datetime");
156
+ return parsed;
157
+ }
158
+
159
+ function integer(value: unknown, path: string, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number {
160
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
161
+ fail(path, `expected integer ${minimum}..${maximum}`);
162
+ }
163
+ return value;
164
+ }
165
+
166
+ function boolean(value: unknown, path: string): boolean {
167
+ if (typeof value !== "boolean") fail(path, "expected boolean");
168
+ return value;
169
+ }
170
+
171
+ function identity(value: unknown, path: string): ActivePlanAgentIdentity {
172
+ const row = record(value, path);
173
+ exactKeys(row, ["kind", "connection_id", "agent_name", "codename"], path);
174
+ if (row.kind !== "dev" && row.kind !== "connection") fail(`${path}.kind`, "expected dev | connection");
175
+ const connectionId = row.connection_id === null ? null : uuid(row.connection_id, `${path}.connection_id`);
176
+ if ((row.kind === "dev") !== (connectionId === null)) fail(`${path}.connection_id`, "identity kind contradicts connection id");
177
+ return {
178
+ kind: row.kind,
179
+ connection_id: connectionId,
180
+ agent_name: boundedString(row.agent_name, `${path}.agent_name`, ACTIVE_PLAN_MAX_IDENTITY_CHARS),
181
+ codename: row.codename === null ? null : boundedString(row.codename, `${path}.codename`, ACTIVE_PLAN_MAX_IDENTITY_CHARS),
182
+ };
183
+ }
184
+
185
+ function step(value: unknown, path: string): ActiveSessionPlanStep {
186
+ const row = record(value, path);
187
+ const status = row.status;
188
+ if (!["pending", "in_progress", "completed", "failed", "skipped"].includes(String(status))) {
189
+ fail(`${path}.status`, "unknown step status");
190
+ }
191
+ const failed = status === "failed";
192
+ exactKeys(
193
+ row,
194
+ failed
195
+ ? row.failure_reason === undefined
196
+ ? ["id", "position", "title", "status", "retryable"]
197
+ : ["id", "position", "title", "status", "failure_reason", "retryable"]
198
+ : ["id", "position", "title", "status"],
199
+ path,
200
+ );
201
+ return {
202
+ id: uuid(row.id, `${path}.id`),
203
+ position: integer(row.position, `${path}.position`, Number.MIN_SAFE_INTEGER),
204
+ title: boundedString(row.title, `${path}.title`, ACTIVE_PLAN_MAX_TITLE_CHARS),
205
+ status: status as ActivePlanStepStatus,
206
+ ...(failed ? {
207
+ ...(row.failure_reason === undefined ? {} : {
208
+ failure_reason: boundedString(row.failure_reason, `${path}.failure_reason`, ACTIVE_PLAN_MAX_FAILURE_REASON_CHARS),
209
+ }),
210
+ retryable: boolean(row.retryable, `${path}.retryable`),
211
+ } : {}),
212
+ };
213
+ }
214
+
215
+ function plan(value: unknown, path: string): ActiveSessionPlan {
216
+ const row = record(value, path);
217
+ exactKeys(row, ["id", "title", "revision", "status", "created_at", "origin", "steward", "owner", "orphaned", "progress", "steps"], path);
218
+ if (row.status !== "active") fail(`${path}.status`, "expected active");
219
+ const owner = record(row.owner, `${path}.owner`);
220
+ exactKeys(owner, ["user_id", "display_name"], `${path}.owner`);
221
+ const progress = record(row.progress, `${path}.progress`);
222
+ exactKeys(progress, ["terminal", "total", "completed", "skipped"], `${path}.progress`);
223
+ if (!Array.isArray(row.steps) || row.steps.length > ACTIVE_PLAN_MAX_STEPS) fail(`${path}.steps`, `expected at most ${ACTIVE_PLAN_MAX_STEPS} steps`);
224
+ const steps = row.steps.map((value, index) => step(value, `${path}.steps[${index}]`));
225
+ const parsedProgress = {
226
+ terminal: integer(progress.terminal, `${path}.progress.terminal`),
227
+ total: integer(progress.total, `${path}.progress.total`),
228
+ completed: integer(progress.completed, `${path}.progress.completed`),
229
+ skipped: integer(progress.skipped, `${path}.progress.skipped`),
230
+ };
231
+ const completed = steps.filter((item) => item.status === "completed").length;
232
+ const skipped = steps.filter((item) => item.status === "skipped").length;
233
+ if (parsedProgress.total !== steps.length || parsedProgress.completed !== completed ||
234
+ parsedProgress.skipped !== skipped || parsedProgress.terminal !== completed + skipped) {
235
+ fail(`${path}.progress`, "progress does not match canonical steps");
236
+ }
237
+ return {
238
+ id: uuid(row.id, `${path}.id`),
239
+ title: boundedString(row.title, `${path}.title`, ACTIVE_PLAN_MAX_TITLE_CHARS),
240
+ revision: integer(row.revision, `${path}.revision`, 1),
241
+ status: "active",
242
+ created_at: datetime(row.created_at, `${path}.created_at`),
243
+ origin: identity(row.origin, `${path}.origin`),
244
+ steward: identity(row.steward, `${path}.steward`),
245
+ owner: {
246
+ user_id: uuid(owner.user_id, `${path}.owner.user_id`),
247
+ display_name: boundedString(owner.display_name, `${path}.owner.display_name`, ACTIVE_PLAN_MAX_IDENTITY_CHARS),
248
+ },
249
+ orphaned: boolean(row.orphaned, `${path}.orphaned`),
250
+ progress: parsedProgress,
251
+ steps,
252
+ };
253
+ }
254
+
255
+ /** Parse the complete strict v1 inventory or throw without producing a partial projection. */
256
+ export function parseActiveSessionPlansProjection(value: unknown, path = "ingress.active_session_plans"): ActiveSessionPlansProjection {
257
+ const row = record(value, path);
258
+ exactKeys(row, ["version", "advisory", "authority_note", "inventory", "plans"], path);
259
+ if (row.version !== ACTIVE_PLAN_PROJECTION_VERSION) fail(`${path}.version`, "unknown projection version");
260
+ if (row.advisory !== true) fail(`${path}.advisory`, "projection must be advisory");
261
+ if (row.authority_note !== ACTIVE_PLAN_AUTHORITY_NOTE) fail(`${path}.authority_note`, "unknown authority note");
262
+ const inventory = record(row.inventory, `${path}.inventory`);
263
+ exactKeys(inventory, ["returned", "total_known", "truncated"], `${path}.inventory`);
264
+ if (inventory.truncated !== false) fail(`${path}.inventory.truncated`, "complete projection cannot be truncated");
265
+ if (!Array.isArray(row.plans) || row.plans.length < 1 || row.plans.length > ACTIVE_PLAN_MAX_PLANS) {
266
+ fail(`${path}.plans`, `expected 1..${ACTIVE_PLAN_MAX_PLANS} plans`);
267
+ }
268
+ const plans = row.plans.map((value, index) => plan(value, `${path}.plans[${index}]`));
269
+ const returned = integer(inventory.returned, `${path}.inventory.returned`, 1, ACTIVE_PLAN_MAX_PLANS);
270
+ const totalKnown = integer(inventory.total_known, `${path}.inventory.total_known`, 1, ACTIVE_PLAN_MAX_PLANS);
271
+ if (returned !== plans.length || totalKnown !== plans.length) fail(`${path}.inventory`, "inventory metadata must describe the complete plans array");
272
+ const totalTextChars = plans.reduce((total, item) => total + item.title.length +
273
+ item.origin.agent_name.length + (item.origin.codename?.length ?? 0) +
274
+ item.steward.agent_name.length + (item.steward.codename?.length ?? 0) +
275
+ item.owner.display_name.length + item.steps.reduce((sum, entry) => sum + entry.title.length + (entry.failure_reason?.length ?? 0), 0), 0);
276
+ if (totalTextChars > ACTIVE_PLAN_MAX_TOTAL_TEXT_CHARS) fail(`${path}.plans`, "active plan projection text exceeds safe bound");
277
+ return {
278
+ version: ACTIVE_PLAN_PROJECTION_VERSION,
279
+ advisory: true,
280
+ authority_note: ACTIVE_PLAN_AUTHORITY_NOTE,
281
+ inventory: { returned, total_known: totalKnown, truncated: false },
282
+ plans,
283
+ };
284
+ }
285
+
286
+ const STEP_MARK: Record<ActivePlanStepStatus, string> = {
287
+ pending: "[ ]",
288
+ in_progress: "[>]",
289
+ completed: "[x]",
290
+ failed: "[!]",
291
+ skipped: "[-]",
292
+ };
293
+
294
+ function agentLabel(value: ActivePlanAgentIdentity): string {
295
+ return value.kind === "dev"
296
+ ? "Dev"
297
+ : `${value.agent_name}${value.codename ? ` · ${value.codename}` : ""} (${value.connection_id})`;
298
+ }
299
+
300
+ /** Compact deterministic delivery-tail serialization. Other agents' plans remain read-only. */
301
+ export function serializeActiveSessionPlansProjection(
302
+ projection: ActiveSessionPlansProjection | null,
303
+ connectionId: string,
304
+ ): string {
305
+ if (!projection) return "";
306
+ const lines = ["== ACTIVE SESSION PLANS ==", projection.authority_note];
307
+ for (const [index, item] of projection.plans.entries()) {
308
+ const own = item.steward.connection_id === connectionId;
309
+ lines.push(
310
+ "",
311
+ `${index + 1}. ${own ? "[OWN] " : "[ROOM · READ-ONLY] "}\"${item.title}\" | plan_id=${item.id} | revision=${item.revision} | active | steward=${agentLabel(item.steward)} | origin=${agentLabel(item.origin)} | owner=${item.owner.display_name} (${item.owner.user_id}) | orphaned=${item.orphaned} | progress=${item.progress.terminal}/${item.progress.total}`,
312
+ );
313
+ for (const entry of item.steps) {
314
+ lines.push(` ${STEP_MARK[entry.status]} ${entry.position}. ${entry.title} | step_id=${entry.id} | status=${entry.status}${entry.status === "failed" ? ` | retryable=${entry.retryable} | failure=${JSON.stringify(entry.failure_reason)}` : ""}`);
315
+ }
316
+ if (!own) continue;
317
+ const allTerminal = item.progress.total > 0 && item.progress.terminal === item.progress.total;
318
+ const inProgress = item.steps.find((entry) => entry.status === "in_progress");
319
+ lines.push(
320
+ ` Own-plan guidance: first decide whether to complete, continue, hand off for adoption where supported, or abandon this plan; never silently drop, restart, or infer mutation authority from this projection. Every manage_plan mutation must use plan_id=${item.id} and expected_revision=${item.revision}.`,
321
+ allTerminal
322
+ ? " All steps are terminal: complete only if the outcome was achieved; otherwise abandon with a specific reason."
323
+ : inProgress
324
+ ? ` To continue, resume the in_progress step step_id=${inProgress.id} before starting any pending step. If the user pivoted, abandon with a specific reason; if unsure, ask before another tool action.`
325
+ : " To continue, start the next pending step before acting. If the user pivoted, abandon with a specific reason; if another supported connection should continue, leave the plan for explicit adoption; if unsure, ask before another tool action.",
326
+ );
327
+ const failed = item.steps.filter((entry) => entry.status === "failed");
328
+ if (failed.some((entry) => entry.retryable)) lines.push(" Failed-step guidance: a retryable failed step may be restarted once; if it fails again, stop and ask rather than looping.");
329
+ if (failed.some((entry) => !entry.retryable)) lines.push(" Failed-step guidance: do not retry a non-retryable failed step; explain it and ask whether to skip, amend, or abandon.");
330
+ }
331
+ return lines.join("\n");
332
+ }
333
+
334
+ /**
335
+ * Calculate the one-shot tail for an authoritative inventory transition.
336
+ * Never-had-plan → none stays silent; active → none emits one clearing tombstone.
337
+ */
338
+ export function activePlanTransitionTail(previousCurrentTail: string, nextCurrentTail: string): string {
339
+ if (previousCurrentTail === nextCurrentTail) return "";
340
+ if (previousCurrentTail && !nextCurrentTail) return ACTIVE_PLAN_CLEAR_TOMBSTONE;
341
+ return nextCurrentTail;
342
+ }
343
+
344
+ /**
345
+ * Authoritative transition across pump replacement/reload/rebond. A fresh pump
346
+ * has no plan content, so only the global injected hash can prove that a no-plan
347
+ * tombstone is needed. The hash is not cleared until delivery.
348
+ */
349
+ export function authoritativeActivePlanTransitionTail(
350
+ localSessionId: string,
351
+ previousCurrentTail: string,
352
+ nextCurrentTail: string,
353
+ ): string {
354
+ const localTransition = activePlanTransitionTail(previousCurrentTail, nextCurrentTail);
355
+ if (localTransition) return localTransition;
356
+ return !nextCurrentTail && activePlanAwarenessHash(localSessionId)
357
+ ? ACTIVE_PLAN_CLEAR_TOMBSTONE
358
+ : "";
359
+ }
360
+
361
+ /** A canonical queued turn uses the newest state; a local turn consumes pending change only. */
362
+ export function activePlanTailForDelivery(
363
+ hasCanonicalOwnerTurn: boolean,
364
+ currentTail: string,
365
+ pendingTail: string,
366
+ ): string {
367
+ return hasCanonicalOwnerTurn ? currentTail || pendingTail : pendingTail;
368
+ }
369
+
370
+ /** Append only at the current delivery tail; never splice stable/history bytes. */
371
+ export function appendActivePlanTail(prefix: string, tail: string): string {
372
+ if (!tail) return prefix;
373
+ return prefix ? `${prefix}\n\n${tail}` : tail;
374
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * DevSpec Pi — detect and persist-disable an adapter-mounted DevSpec MCP
3
+ * server (item d2f4f25c).
4
+ *
5
+ * Native tools already speak /api/mcp. If pi-mcp-adapter also loads a
6
+ * `devspec` entry from project `.mcp.json`, the model pays the catalog
7
+ * twice and sees pump/delivery verbs we hid. Foreign servers (Supabase,
8
+ * browsers) are left alone. The shared `.mcp.json` is never rewritten —
9
+ * other hosts in the same checkout still need it.
10
+ *
11
+ * Pure helpers, unit-tested. Persistence writes only `{ disabled: true }`
12
+ * into the Pi-local adapter overlay (`.pi/mcp-adapter.json`).
13
+ */
14
+
15
+ import {
16
+ DEFAULT_MCP_URL,
17
+ STAGING_MCP_URL,
18
+ mcpUrl,
19
+ } from "./config.ts";
20
+ import {
21
+ MODEL_VERB_NAMES,
22
+ NEVER_MODEL_VERBS,
23
+ PUMP_VERBS,
24
+ } from "../generated/mcp-catalog.ts";
25
+
26
+ export const ALL_DEVSPEC_VERBS: readonly string[] = [
27
+ ...MODEL_VERB_NAMES,
28
+ ...PUMP_VERBS,
29
+ ...NEVER_MODEL_VERBS,
30
+ ];
31
+
32
+ const VERB_SET = new Set<string>(ALL_DEVSPEC_VERBS);
33
+
34
+ /** Prefix modes pi-mcp-adapter uses when registering direct tools. */
35
+ export const ADAPTER_PREFIX_MODES = ["server", "none", "short", "mcp"] as const;
36
+ export type AdapterPrefixMode = (typeof ADAPTER_PREFIX_MODES)[number];
37
+
38
+ export interface McpServerEntry {
39
+ url?: unknown;
40
+ disabled?: unknown;
41
+ [key: string]: unknown;
42
+ }
43
+
44
+ export interface McpConfigFile {
45
+ mcpServers?: Record<string, McpServerEntry>;
46
+ "mcp-servers"?: Record<string, McpServerEntry>;
47
+ }
48
+
49
+ export interface DevspecServerHit {
50
+ name: string;
51
+ /** Absolute path of the config file that declared it (not the overlay). */
52
+ source: string;
53
+ url: string;
54
+ alreadyDisabled: boolean;
55
+ }
56
+
57
+ export interface AdapterGuardState {
58
+ /** Servers we decided to hide this process. */
59
+ suppressed: DevspecServerHit[];
60
+ /** Whether the Pi-owned duplicate settings were prepared successfully. */
61
+ persisted: boolean;
62
+ /** Overlay path we wrote / would write. */
63
+ overlayPath: string | null;
64
+ }
65
+
66
+ const GUARD_KEY = "__devspecPiAdapterGuard";
67
+
68
+ function guardStore(): AdapterGuardState {
69
+ const g = globalThis as unknown as Record<string, AdapterGuardState | undefined>;
70
+ if (!g[GUARD_KEY]) {
71
+ g[GUARD_KEY] = { suppressed: [], persisted: false, overlayPath: null };
72
+ }
73
+ return g[GUARD_KEY] as AdapterGuardState;
74
+ }
75
+
76
+ export function getAdapterGuardState(): AdapterGuardState {
77
+ return guardStore();
78
+ }
79
+
80
+ /** Test hook — do not call from production code. */
81
+ export function resetAdapterGuardState(): void {
82
+ const g = globalThis as unknown as Record<string, AdapterGuardState | undefined>;
83
+ g[GUARD_KEY] = { suppressed: [], persisted: false, overlayPath: null };
84
+ }
85
+
86
+ export function recordAdapterGuard(next: Partial<AdapterGuardState>): AdapterGuardState {
87
+ const cur = guardStore();
88
+ if (next.suppressed) cur.suppressed = next.suppressed;
89
+ if (next.persisted !== undefined) cur.persisted = next.persisted;
90
+ if (next.overlayPath !== undefined) cur.overlayPath = next.overlayPath;
91
+ return cur;
92
+ }
93
+
94
+ /**
95
+ * Apex domains DevSpec deploys under: production (`api.devspec.ai`) and
96
+ * staging (`api.devspecstaging.com`). Matching the apex and any subdomain also
97
+ * keeps recognising the retiring `staging.devspec.ai`, which a checkout's
98
+ * `.mcp.json` may still name — that entry is exactly what this guard disables.
99
+ */
100
+ const DEVSPEC_APEX_DOMAINS: readonly string[] = ["devspec.ai", "devspecstaging.com"];
101
+
102
+ /** Hostname is DevSpec's own MCP (prod, staging, or any deploy under either apex). */
103
+ export function isDevspecMcpHost(hostname: string): boolean {
104
+ const host = hostname.trim().toLowerCase();
105
+ return DEVSPEC_APEX_DOMAINS.some((apex) => host === apex || host.endsWith(`.${apex}`));
106
+ }
107
+
108
+ /**
109
+ * A configured MCP url is DevSpec's own verb surface — not a foreign server.
110
+ * Matches prod/staging hostnames, or whatever endpoint THIS package is
111
+ * currently speaking (DEVSPEC_MCP_URL / DEVSPEC_ENV).
112
+ */
113
+ export function isDevspecMcpUrl(url: unknown, effectiveEndpoint: string = mcpUrl()): boolean {
114
+ if (typeof url !== "string" || !url.trim()) return false;
115
+ let parsed: URL;
116
+ try {
117
+ parsed = new URL(url.trim());
118
+ } catch {
119
+ return false;
120
+ }
121
+ if (isDevspecMcpHost(parsed.hostname)) return true;
122
+ return normalizeEndpoint(url) === normalizeEndpoint(effectiveEndpoint);
123
+ }
124
+
125
+ export function normalizeEndpoint(url: string): string {
126
+ try {
127
+ const u = new URL(url.trim());
128
+ u.hash = "";
129
+ u.search = "";
130
+ let path = u.pathname.replace(/\/+$/, "");
131
+ if (!path) path = "";
132
+ return `${u.protocol}//${u.host.toLowerCase()}${path}`;
133
+ } catch {
134
+ return url.trim().replace(/\/+$/, "").toLowerCase();
135
+ }
136
+ }
137
+
138
+ export function serversFromConfig(raw: unknown): Record<string, McpServerEntry> {
139
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
140
+ const obj = raw as McpConfigFile;
141
+ const block = obj.mcpServers ?? obj["mcp-servers"];
142
+ if (!block || typeof block !== "object" || Array.isArray(block)) return {};
143
+ return block;
144
+ }
145
+
146
+ export function parseConfigJson(text: string): unknown {
147
+ return JSON.parse(text) as unknown;
148
+ }
149
+
150
+ /** Known DevSpec endpoints this package might be talking to. */
151
+ export function knownDevspecEndpoints(): string[] {
152
+ return [mcpUrl(), DEFAULT_MCP_URL, STAGING_MCP_URL];
153
+ }
154
+
155
+ export function findDevspecServers(
156
+ servers: Record<string, McpServerEntry>,
157
+ source: string,
158
+ effectiveEndpoint: string = mcpUrl(),
159
+ ): DevspecServerHit[] {
160
+ const hits: DevspecServerHit[] = [];
161
+ for (const [name, entry] of Object.entries(servers)) {
162
+ if (!entry || typeof entry !== "object") continue;
163
+ if (typeof entry.url !== "string") continue;
164
+ if (!isDevspecMcpUrl(entry.url, effectiveEndpoint)) continue;
165
+ hits.push({
166
+ name,
167
+ source,
168
+ url: entry.url,
169
+ alreadyDisabled: entry.disabled === true,
170
+ });
171
+ }
172
+ return hits;
173
+ }
174
+
175
+ export function adapterPrefix(serverName: string, mode: AdapterPrefixMode): string {
176
+ if (mode === "none") return "";
177
+ if (mode === "short") {
178
+ const short = serverName.replace(/-?mcp$/i, "").replace(/-/g, "_");
179
+ return short || "mcp";
180
+ }
181
+ if (mode === "mcp") return `mcp__${serverName.replace(/-/g, "_")}`;
182
+ return serverName.replace(/-/g, "_");
183
+ }
184
+
185
+ export function adapterToolName(verb: string, serverName: string, mode: AdapterPrefixMode): string {
186
+ const p = adapterPrefix(serverName, mode);
187
+ const sanitized = verb.replace(/\./g, "_");
188
+ return p ? `${p}_${sanitized}` : sanitized;
189
+ }
190
+
191
+ /** Every name the adapter might have registered for this server. */
192
+ export function adapterToolNamesFor(serverName: string): string[] {
193
+ const names = new Set<string>();
194
+ for (const mode of ADAPTER_PREFIX_MODES) {
195
+ for (const verb of ALL_DEVSPEC_VERBS) {
196
+ names.add(adapterToolName(verb, serverName, mode));
197
+ }
198
+ }
199
+ return [...names];
200
+ }
201
+
202
+ export function isDevspecVerb(name: string): boolean {
203
+ return VERB_SET.has(name);
204
+ }
205
+
206
+ /**
207
+ * Tools we may deactivate THIS session. Prefixed adapter names are always
208
+ * safe. Bare catalog names are only safe when `sourceInfo` says they came
209
+ * from the adapter — otherwise they are our native registrations.
210
+ */
211
+ export function toolsToDeactivate(
212
+ registered: Array<{ name: string; sourceInfo?: { source?: string; path?: string } }>,
213
+ serverNames: string[],
214
+ ): string[] {
215
+ const prefixed = new Set<string>();
216
+ for (const server of serverNames) {
217
+ for (const n of adapterToolNamesFor(server)) prefixed.add(n);
218
+ }
219
+ const out: string[] = [];
220
+ for (const tool of registered) {
221
+ const namespaceMatch = serverNames.some((server) => {
222
+ const namespace = `mcp__${server.replace(/[^a-zA-Z0-9_]/g, "_")}`;
223
+ return tool.name === namespace || tool.name.startsWith(`${namespace}__`);
224
+ });
225
+ if ((namespaceMatch || prefixed.has(tool.name)) && !isDevspecVerb(tool.name)) {
226
+ out.push(tool.name);
227
+ continue;
228
+ }
229
+ if (isDevspecVerb(tool.name) && isAdapterSource(tool.sourceInfo)) {
230
+ out.push(tool.name);
231
+ }
232
+ }
233
+ return out;
234
+ }
235
+
236
+ export function isAdapterSource(sourceInfo: { source?: string; path?: string } | undefined): boolean {
237
+ if (!sourceInfo) return false;
238
+ const blob = `${sourceInfo.source ?? ""} ${sourceInfo.path ?? ""}`.toLowerCase();
239
+ return blob.includes("pi-mcp-adapter") || blob.includes("mcp-adapter");
240
+ }
241
+
242
+ export const SUPPRESSION_NOTICE =
243
+ "DevSpec MCP is disabled in Pi to avoid duplicate tools. This plugin provides DevSpec instead. Other agents’ settings and your other MCP servers are unchanged.";
244
+
245
+ /** Authored guidance, never interpolated config names, paths or credentials. */
246
+ export const SUPPRESSION_AGENT_GUIDANCE =
247
+ "The DevSpec Pi plugin replaces a separately configured DevSpec MCP server. Its duplicate protection can mark that server disabled in Pi; this is expected, not a sign-in or connection failure. Explain this only if asked, and do not re-enable the duplicate or delete shared MCP configuration to fix it. Do not announce this proactively. The plugin's override is Pi-only: it does not change shared configuration, Claude Code settings or foreign MCP servers. Use the native DevSpec tools. Suppression does not prove that those tools are signed in or healthy: if they fail, diagnose the plugin connection with /devspec.status and /devspec.login separately.";
@@ -0,0 +1,44 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { extractAssistantParts } from "./trail.ts";
3
+
4
+ /** Emptiness predicate only; the delivered string is never normalized. */
5
+ export function hasAssistantSpeech(text: string): boolean {
6
+ return text.replace(/[\s\p{Cf}\p{Default_Ignorable_Code_Point}]/gu, "").length > 0;
7
+ }
8
+
9
+ export interface AssistantSpeechRecord {
10
+ readonly assistantSourceId: string;
11
+ readonly text: string;
12
+ }
13
+
14
+ /** Created before provider work; one finalization, one stable retry identity. */
15
+ export class AssistantSpeechSource {
16
+ readonly assistantSourceId = randomUUID();
17
+ private text: string | undefined;
18
+ private record: AssistantSpeechRecord | null = null;
19
+ finalize(message: unknown): AssistantSpeechRecord | null {
20
+ if (!message || typeof message !== "object" || !("role" in message) || message.role !== "assistant") return null;
21
+ const { text } = extractAssistantParts(message);
22
+ if (this.text !== undefined) {
23
+ if (this.text !== text) throw new Error("Assistant speech source text conflict.");
24
+ return this.record;
25
+ }
26
+ this.text = text;
27
+ if (hasAssistantSpeech(text)) this.record = Object.freeze({ assistantSourceId: this.assistantSourceId, text });
28
+ return this.record;
29
+ }
30
+ }
31
+
32
+ export function assistantSpeechWithinBudget(text: string): boolean {
33
+ return [...text].length <= 100_000 && Buffer.byteLength(JSON.stringify(text), "utf8") <= 512_000;
34
+ }
35
+
36
+ /** Serial source delivery only; errors do not poison later independent speech. */
37
+ export class AssistantSpeechQueue {
38
+ private tail: Promise<void> = Promise.resolve();
39
+ enqueue(post: () => Promise<void>): Promise<void> {
40
+ const next = this.tail.then(post);
41
+ this.tail = next.catch(() => undefined);
42
+ return next;
43
+ }
44
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * DevSpec Pi — the bonded session's display identity (item 9dcbd157).
3
+ *
4
+ * After an attach, Pi reads the room's transcript only to learn its title for
5
+ * the status line. Conversational and model context arrive exclusively through
6
+ * negotiated canonical ingress; the transcript never becomes model context here.
7
+ * (The attach-time tail and agenda block this module used to build had no caller
8
+ * left and clipped what it showed, so it was removed: DevSpec item 5a9eaa57.)
9
+ */
10
+
11
+ import { isRealSessionTitle } from "./status-line.ts";
12
+
13
+ export interface SessionIdentity {
14
+ sessionId: string;
15
+ title: string | null;
16
+ }
17
+
18
+ /** The session's real title, or null for a brand-new room whose title is not generated yet. */
19
+ export function shapeTranscript(sessionId: string, raw: unknown): SessionIdentity {
20
+ const t = (raw ?? {}) as { title?: unknown };
21
+ return {
22
+ sessionId,
23
+ title: isRealSessionTitle(t.title) ? t.title.trim() : null,
24
+ };
25
+ }