@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,373 @@
1
+ /**
2
+ * Privacy-safe projections for Pi's shared work trail.
3
+ *
4
+ * Tool inputs and outputs are private execution data. The room timeline gets
5
+ * only explicit, tool-aware metadata from this module; unknown shapes fail
6
+ * closed. A final redactor is defense in depth, not the primary projection.
7
+ */
8
+
9
+ import { MODEL_VERB_NAMES } from "../generated/mcp-catalog.ts";
10
+
11
+ const MAX_SUMMARY_CHARS = 600;
12
+ const MAX_LABEL_CHARS = 240;
13
+
14
+ const FILE_TOOLS = new Set(["read", "write", "edit", "apply_patch", "multiedit"]);
15
+ const SEARCH_TOOLS = new Set(["grep", "find", "ls"]);
16
+ const SHELL_TOOLS = new Set(["bash", "shell", "exec", "execute"]);
17
+ const WEB_TOOLS = new Set(["fetch", "web_fetch", "web_search", "browser", "navigate"]);
18
+ const DATABASE_TOOLS = new Set(["supabase_execute_sql", "execute_sql", "query_database"]);
19
+ const DEVSPEC_TOOLS = new Set<string>([...MODEL_VERB_NAMES, "search_devspec_tools"]);
20
+
21
+ const DEVSPEC_ID_KEYS = [
22
+ "task_id",
23
+ "brief_id",
24
+ "criterion_id",
25
+ "goal_id",
26
+ "id",
27
+ "memory_id",
28
+ "plan_id",
29
+ "poll_id",
30
+ "project_id",
31
+ "question_id",
32
+ "resource_id",
33
+ "session_id",
34
+ "signal_id",
35
+ "step_id",
36
+ ] as const;
37
+
38
+ const SECRET_NAME = /(?:^|[_-])(?:access[_-]?token|api[_-]?key|apikey|auth|authorization|bearer|client[_-]?secret|credential|jwt|password|passwd|private[_-]?key|secret|session[_-]?token|token)(?:$|[_-])/i;
39
+
40
+ function asRecord(value: unknown): Record<string, unknown> | null {
41
+ return value && typeof value === "object" && !Array.isArray(value)
42
+ ? (value as Record<string, unknown>)
43
+ : null;
44
+ }
45
+
46
+ function oneLine(value: string): string {
47
+ return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
48
+ }
49
+
50
+ /** Defense-in-depth for the small strings selected by explicit projectors. */
51
+ export function redactTrailText(value: string, maxChars: number = MAX_SUMMARY_CHARS): string {
52
+ let text = oneLine(value);
53
+ // URL identity, userinfo, hosts, paths, and query strings are never useful
54
+ // in a shared activity row. HTTP(S) retains only its transport class; every
55
+ // other URI scheme fails closed.
56
+ text = text.replace(/[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s"'`<>]+/g, (candidate) => safeUrl(candidate) ?? "[redacted URL]");
57
+ // Credential-like assignments and long-form CLI flags.
58
+ text = text.replace(/\b([A-Za-z][A-Za-z0-9_-]*)(\s*=\s*)([^\s;&|]+)/g, (whole, key: string, eq: string) =>
59
+ SECRET_NAME.test(key) ? `${key}${eq}[REDACTED]` : whole,
60
+ );
61
+ text = text.replace(/((?:--?)(?:access[-_]?token|api[-_]?key|apikey|auth|authorization|bearer|client[-_]?secret|credential|jwt|password|passwd|private[-_]?key|secret|session[-_]?token|token))(?:=|\s+)\S+/gi, "$1=[REDACTED]");
62
+ text = text.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]");
63
+ // Common high-confidence credential formats.
64
+ text = text.replace(/\b(?:gh[opusr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})\b/g, "[REDACTED]");
65
+ if (text.length > maxChars) return `${text.slice(0, maxChars)}…`;
66
+ return text || "[redacted]";
67
+ }
68
+
69
+ export function safeTrailLabel(value: unknown, maxChars: number = MAX_LABEL_CHARS): string | undefined {
70
+ if (typeof value !== "string" || !value.trim()) return undefined;
71
+ const redacted = redactTrailText(value, maxChars)
72
+ .replace(/[\u0000-\u001f\u007f]/g, "")
73
+ .replace(/[`\\*_{}\[\]<>|]/g, " ")
74
+ .replace(/\s+/g, " ")
75
+ .trim();
76
+ return redacted || undefined;
77
+ }
78
+
79
+ /** DevSpec entity identifiers are UUIDs (short hex refs are accepted for UI hints). */
80
+ export function safeTrailId(value: unknown): string | undefined {
81
+ if (typeof value !== "string") return undefined;
82
+ const id = value.trim();
83
+ return /^(?:[0-9a-f]{8}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.test(id)
84
+ ? id
85
+ : undefined;
86
+ }
87
+
88
+ function safePath(value: unknown): string | undefined {
89
+ if (typeof value !== "string" || !value.trim()) return undefined;
90
+ const path = oneLine(value);
91
+ // Paths shown in a shared trail must be literal operands, never unevaluated
92
+ // shell syntax, globs, substitutions, expansions, or command separators.
93
+ if (/[\0`*?$(){}[\]<>|;&!\\:@]/.test(path)) return undefined;
94
+ return redactTrailText(path, 320);
95
+ }
96
+
97
+ function safeUrl(value: string): string | undefined {
98
+ try {
99
+ const url = new URL(value);
100
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
101
+ // Even hostnames can be secret-bearing tenant IDs, internal names, or signed
102
+ // subdomains. Without a complete public-suffix policy, fail closed and expose
103
+ // only the transport class; the tool name still says what kind of work ran.
104
+ return `${url.protocol.slice(0, -1)} request`;
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ }
109
+
110
+ function shellTokens(command: string): string[] {
111
+ return oneLine(command).match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
112
+ }
113
+
114
+ function bareToken(value: string): string {
115
+ return value.replace(/^["']|["']$/g, "");
116
+ }
117
+
118
+ interface ParsedPositionals {
119
+ positionals: string[];
120
+ seenOptions: Set<string>;
121
+ safe: boolean;
122
+ }
123
+
124
+ function parsePositionals(
125
+ tokens: string[],
126
+ valueOptions: ReadonlySet<string>,
127
+ flagOptions: ReadonlySet<string>,
128
+ ): ParsedPositionals {
129
+ const positionals: string[] = [];
130
+ const seenOptions = new Set<string>();
131
+ let optionsEnded = false;
132
+ for (let i = 0; i < tokens.length; i++) {
133
+ const token = bareToken(tokens[i]);
134
+ if (!optionsEnded && token === "--") {
135
+ optionsEnded = true;
136
+ continue;
137
+ }
138
+ if (!optionsEnded && token.startsWith("-") && token !== "-") {
139
+ const [option] = token.split("=", 1);
140
+ seenOptions.add(option);
141
+ if (valueOptions.has(option)) {
142
+ if (!token.includes("=")) i++;
143
+ continue;
144
+ }
145
+ if (flagOptions.has(option) || /^-[A-Za-z]{2,}$/.test(option)) continue;
146
+ return { positionals: [], seenOptions, safe: false };
147
+ }
148
+ positionals.push(token);
149
+ }
150
+ return { positionals, seenOptions, safe: true };
151
+ }
152
+
153
+ const COMMON_FLAG_OPTIONS = new Set([
154
+ "-0", "--null", "-a", "--all", "-A", "--almost-all", "-b", "--byte-offset", "--binary",
155
+ "--color", "--column", "-F", "--fixed-strings", "-h", "--help", "-H", "--with-filename", "-i",
156
+ "--ignore-case", "-l", "--files-with-matches", "-L", "--dereference", "-n", "--line-number", "-P",
157
+ "--perl-regexp", "-q", "--quiet", "-R", "--recursive", "-s", "--no-messages", "-S", "--smart-case",
158
+ "-u", "--unrestricted", "-v", "--invert-match", "-w", "--word-regexp", "-x", "--line-regexp",
159
+ ]);
160
+ const SEARCH_VALUE_OPTIONS = new Set([
161
+ "-A", "--after-context", "-B", "--before-context", "-C", "--context", "-e", "--regexp", "-f", "--file",
162
+ "-g", "--glob", "--iglob", "-j", "--threads", "-m", "--max-count", "--encoding", "--engine", "--pre",
163
+ "--pre-glob", "-r", "--replace", "--sort", "--sortr", "-t", "--type", "--type-add", "--type-clear",
164
+ ]);
165
+ const FILE_VALUE_OPTIONS = new Set([
166
+ "-c", "--bytes", "--format", "--printf", "-n", "--lines", "--relative-base", "--relative-to", "--sleep-interval",
167
+ ]);
168
+
169
+ function safeFileOperand(tokens: string[]): string | undefined {
170
+ const parsed = parsePositionals(tokens, FILE_VALUE_OPTIONS, COMMON_FLAG_OPTIONS);
171
+ if (!parsed.safe || parsed.positionals.length !== 1) return undefined;
172
+ return safePath(parsed.positionals[0]);
173
+ }
174
+
175
+ function safeSearchPath(command: "rg" | "grep" | "find", tokens: string[]): string | undefined {
176
+ const parsed = parsePositionals(tokens, SEARCH_VALUE_OPTIONS, COMMON_FLAG_OPTIONS);
177
+ if (!parsed.safe) return undefined;
178
+ if (command === "find") {
179
+ const firstExpression = parsed.positionals.findIndex((token) => token.startsWith("-") || token === "(" || token === "!");
180
+ const paths = firstExpression < 0 ? parsed.positionals : parsed.positionals.slice(0, firstExpression);
181
+ return paths.length === 1 ? safePath(paths[0]) : undefined;
182
+ }
183
+ const patternFromOption = parsed.seenOptions.has("-e") || parsed.seenOptions.has("--regexp")
184
+ || parsed.seenOptions.has("-f") || parsed.seenOptions.has("--file");
185
+ const paths = patternFromOption ? parsed.positionals : parsed.positionals.slice(1);
186
+ return paths.length === 1 ? safePath(paths[0]) : undefined;
187
+ }
188
+
189
+ function safeShellCommand(value: unknown): string | undefined {
190
+ if (typeof value !== "string" || !value.trim()) return undefined;
191
+ const tokens = shellTokens(value);
192
+ while (tokens[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens.shift();
193
+ const executable = bareToken(tokens.shift() ?? "").split("/").pop();
194
+ if (!executable || !/^[A-Za-z0-9_.+-]+$/.test(executable)) return "command";
195
+
196
+ const lower = executable.toLowerCase();
197
+ const knownCommands = new Set([
198
+ "agent-browser", "cargo", "deno", "docker", "node", "npx", "python", "python3", "supabase", "tsc",
199
+ ]);
200
+ let selected: string[] = knownCommands.has(lower) ? [lower] : ["command"];
201
+ if (["npm", "pnpm", "yarn", "bun"].includes(lower)) {
202
+ selected = [lower];
203
+ const operation = bareToken(tokens[0] ?? "").toLowerCase();
204
+ const allowed = new Set(["build", "ci", "exec", "install", "lint", "run", "start", "test", "typecheck"]);
205
+ if (allowed.has(operation)) {
206
+ selected.push(operation);
207
+ const conventionalScripts = new Set(["build", "check", "dev", "lint", "start", "test", "typecheck"]);
208
+ const script = bareToken(tokens[1] ?? "").toLowerCase();
209
+ if (operation === "run" && conventionalScripts.has(script)) selected.push(script);
210
+ }
211
+ } else if (lower === "git") {
212
+ const operation = bareToken(tokens[0] ?? "").toLowerCase();
213
+ const allowed = new Set(["branch", "commit", "diff", "fetch", "log", "merge", "pull", "push", "rebase", "status"]);
214
+ if (allowed.has(operation)) selected.push(operation);
215
+ const flags = tokens
216
+ .slice(1)
217
+ .map(bareToken)
218
+ .filter((token) => /^--(?:check|short|stat|oneline|decorate|name-only)$/.test(token))
219
+ .slice(0, 2);
220
+ selected = selected.concat(flags);
221
+ } else if (["ls", "cat", "head", "tail", "wc", "stat", "realpath"].includes(lower)) {
222
+ selected = [lower];
223
+ const path = safeFileOperand(tokens);
224
+ if (path) selected.push(path);
225
+ } else if (lower === "rg" || lower === "grep" || lower === "find") {
226
+ selected = [lower];
227
+ const path = safeSearchPath(lower, tokens);
228
+ selected.push("…");
229
+ if (path) selected.push(path);
230
+ } else if (lower === "curl" || lower === "wget") {
231
+ selected = ["http request"];
232
+ }
233
+ return redactTrailText(selected.join(" "), 400);
234
+ }
235
+
236
+ function sqlSummary(value: unknown): string | undefined {
237
+ if (typeof value !== "string" || !value.trim()) return undefined;
238
+ const operation = value.match(/^\s*(select|insert|update|delete|with|alter|create|drop|explain)\b/i)?.[1]?.toUpperCase();
239
+ if (!operation) return "SQL query";
240
+ const table = value.match(/\b(?:from|into|update|table|join)\s+([A-Za-z_][A-Za-z0-9_.]*)/i)?.[1];
241
+ return table ? `${operation} ${table}` : `${operation} query`;
242
+ }
243
+
244
+ function devspecArgs(name: string, args: Record<string, unknown>): string | undefined {
245
+ const parts: string[] = [];
246
+ for (const key of DEVSPEC_ID_KEYS) {
247
+ const id = safeTrailId(args[key]);
248
+ if (id) parts.push(`${key}=${id}`);
249
+ }
250
+ if (name === "manage_plan" || name === "manage_poll" || name === "manage_directed_question") {
251
+ const actionPattern = name === "manage_plan"
252
+ ? /^(?:create|list|get|update|start_step|complete_step|skip_step|fail_step|advance|complete|abandon|adopt)$/
253
+ : name === "manage_poll"
254
+ ? /^(?:create|list|get|update|end|retract|supersede)$/
255
+ : /^(?:create|list|get|cancel)$/;
256
+ const action = typeof args.action === "string" && actionPattern.test(args.action)
257
+ ? args.action
258
+ : undefined;
259
+ const revision = typeof args.expected_revision === "number" && Number.isSafeInteger(args.expected_revision) && args.expected_revision > 0
260
+ ? args.expected_revision
261
+ : undefined;
262
+ return [action ? `action=${action}` : null, ...parts, revision ? `expected_revision=${revision}` : null]
263
+ .filter(Boolean).slice(0, 4).join("; ") || undefined;
264
+ }
265
+ if (name === "search_devspec_tools") {
266
+ const limit = typeof args.limit === "number" ? Math.max(0, Math.floor(args.limit)) : undefined;
267
+ return limit == null ? "capability search" : `capability search; limit=${limit}`;
268
+ }
269
+ if (name.startsWith("search_") || name.startsWith("list_")) {
270
+ const limit = typeof args.limit === "number" ? Math.max(0, Math.floor(args.limit)) : undefined;
271
+ if (limit != null) parts.push(`limit=${limit}`);
272
+ }
273
+ return parts.length ? parts.slice(0, 4).join("; ") : undefined;
274
+ }
275
+
276
+ /** Select safe input metadata for a known tool. Unknown tools expose no args. */
277
+ export function projectTrailArgs(name: string, args: unknown): string | undefined {
278
+ const a = asRecord(args);
279
+ if (!a) return undefined;
280
+ if (SHELL_TOOLS.has(name)) return safeShellCommand(a.command);
281
+ if (FILE_TOOLS.has(name)) return safePath(a.path ?? a.file_path);
282
+ if (SEARCH_TOOLS.has(name)) {
283
+ const path = safePath(a.path);
284
+ return path ? `${name} in ${path}` : name;
285
+ }
286
+ if (WEB_TOOLS.has(name)) {
287
+ const url = typeof a.url === "string" ? safeUrl(a.url) : undefined;
288
+ return url ? redactTrailText(url, 400) : name === "web_search" ? "web search" : undefined;
289
+ }
290
+ if (DATABASE_TOOLS.has(name)) return sqlSummary(a.query);
291
+ if (DEVSPEC_TOOLS.has(name)) return devspecArgs(name, a);
292
+ return undefined;
293
+ }
294
+
295
+ function resultText(result: unknown): string | undefined {
296
+ const outer = asRecord(result);
297
+ if (!outer || !Array.isArray(outer.content)) return undefined;
298
+ const text = outer.content
299
+ .filter((item) => asRecord(item)?.type === "text")
300
+ .map((item) => asRecord(item)?.text)
301
+ .find((value): value is string => typeof value === "string" && value.trim().length > 0);
302
+ return text;
303
+ }
304
+
305
+ function parsedResult(result: unknown): Record<string, unknown> | null {
306
+ const outer = asRecord(result);
307
+ const details = asRecord(outer?.details);
308
+ if (details) return details;
309
+ const text = resultText(result);
310
+ if (text?.trim().startsWith("{")) {
311
+ try {
312
+ return asRecord(JSON.parse(text));
313
+ } catch {
314
+ return null;
315
+ }
316
+ }
317
+ return outer;
318
+ }
319
+
320
+ function wrappedEntity(parsed: Record<string, unknown>): Record<string, unknown> {
321
+ const item = asRecord(parsed.item);
322
+ return asRecord(parsed.task)
323
+ ?? asRecord(parsed.resource)
324
+ ?? asRecord(parsed.memory)
325
+ ?? asRecord(parsed.plan)
326
+ ?? asRecord(parsed.poll)
327
+ ?? asRecord(parsed.question)
328
+ ?? asRecord(item?.task)
329
+ ?? asRecord(item?.resource)
330
+ ?? asRecord(item?.memory)
331
+ ?? item
332
+ ?? parsed;
333
+ }
334
+
335
+ function safeOutcome(result: unknown): string | undefined {
336
+ const parsed = parsedResult(result);
337
+ if (!parsed) return undefined;
338
+ const nested = wrappedEntity(parsed);
339
+ const parts: string[] = [];
340
+ const allowedStates = new Set([
341
+ "accepted", "active", "achieved", "archived", "blocked", "cancelled", "claimed", "deferred", "dismissed",
342
+ "diverged", "done", "error", "failed", "finished", "healthy", "idle", "implemented", "implementing",
343
+ "in_progress", "merged", "open", "passed", "pending", "recorded", "reporting", "resolved", "retracted",
344
+ "skipped", "staged", "success", "superseded", "verified",
345
+ ]);
346
+ for (const key of ["lifecycle", "state", "status"] as const) {
347
+ const value = typeof nested[key] === "string" ? nested[key].trim().toLowerCase() : undefined;
348
+ if (value && allowedStates.has(value)) parts.push(`${key}=${value}`);
349
+ }
350
+ for (const key of ["claim_success", "implementation_recorded", "recorded", "success"] as const) {
351
+ const value = parsed[key] ?? nested[key];
352
+ if (typeof value === "boolean") parts.push(`${key}=${String(value)}`);
353
+ }
354
+ for (const key of ["affected_items", "created_count", "updated_count", "count", "revision"] as const) {
355
+ const value = parsed[key] ?? nested[key];
356
+ if (typeof value === "number" && Number.isFinite(value)) parts.push(`${key}=${value}`);
357
+ }
358
+ return parts.length ? redactTrailText(parts.slice(0, 4).join("; "), 240) : undefined;
359
+ }
360
+
361
+ /** Select safe output metadata. Raw tool text is never copied into the trail. */
362
+ export function projectTrailResult(name: string, result: unknown): string | undefined {
363
+ if (!DEVSPEC_TOOLS.has(name)) return undefined;
364
+ if (name === "manage_plan" || name === "manage_poll" || name === "manage_directed_question" || /^(?:create_|update_|claim_|record_|resolve_|supersede_|retract_|archive_)/.test(name)) {
365
+ return safeOutcome(result);
366
+ }
367
+ if (name.startsWith("search_") || name.startsWith("list_")) {
368
+ const text = resultText(result);
369
+ const match = text?.match(/^\w[\w-]*:\s*(\d+)\s+results?(?:;\s*showing\s*(\d+))?/i);
370
+ if (match) return match[2] ? `${match[1]} results; showing ${match[2]}` : `${match[1]} results`;
371
+ }
372
+ return undefined;
373
+ }