@tea-agent/loop-agent 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/AGENTS.md +42 -108
  2. package/CHANGELOG.md +85 -0
  3. package/README.md +8 -5
  4. package/bin/agent-worker.js +0 -0
  5. package/dist/application/context-usage/skill-resolution-stats.js +263 -0
  6. package/dist/application/dag/generate-task-dag.js +17 -3
  7. package/dist/cli/command-definitions.js +8 -7
  8. package/dist/cli/program.js +17 -15
  9. package/dist/commands/doctor.js +269 -18
  10. package/dist/commands/init.js +101 -86
  11. package/dist/commands/stats.js +40 -11
  12. package/dist/executors/shell-executor.js +20 -7
  13. package/dist/shared/operator/capabilities.js +486 -3
  14. package/dist/worker/console/app-data.js +6 -0
  15. package/dist/worker/console/chat/artifact-card.js +23 -0
  16. package/dist/worker/console/chat/chat-event-store.js +495 -0
  17. package/dist/worker/console/chat/chat-ui-policy.js +25 -0
  18. package/dist/worker/console/chat/composer-draft-store.js +45 -0
  19. package/dist/worker/console/chat/context-panel.js +54 -0
  20. package/dist/worker/console/chat/contract-apply-receipt-store.js +174 -0
  21. package/dist/worker/console/chat/explore-tools.js +299 -0
  22. package/dist/worker/console/chat/human-gate-card.js +37 -0
  23. package/dist/worker/console/chat/instruction-skills.js +217 -0
  24. package/dist/worker/console/chat/interview-adapter.js +136 -0
  25. package/dist/worker/console/chat/model-resolver.js +106 -0
  26. package/dist/worker/console/chat/operation-card.js +23 -0
  27. package/dist/worker/console/chat/pi-console-config.js +158 -0
  28. package/dist/worker/console/chat/pi-runtime.js +1143 -0
  29. package/dist/worker/console/chat/repo-browser.js +140 -0
  30. package/dist/worker/console/chat/repo-walk.js +116 -0
  31. package/dist/worker/console/chat/resource-loader.js +67 -0
  32. package/dist/worker/console/chat/routes.js +1646 -0
  33. package/dist/worker/console/chat/runtime-context.js +24 -0
  34. package/dist/worker/console/chat/runtime-selection.js +37 -0
  35. package/dist/worker/console/chat/session-store.js +437 -0
  36. package/dist/worker/console/chat/shortcuts.js +15 -0
  37. package/dist/worker/console/chat/tool-adapter.js +125 -0
  38. package/dist/worker/console/chat/tools.js +195 -0
  39. package/dist/worker/console/chat/usage.js +37 -0
  40. package/dist/worker/console/chat/workspace-landing.js +56 -0
  41. package/dist/worker/console/dag-confirmation.js +42 -8
  42. package/dist/worker/console/human-gate-token.js +130 -0
  43. package/dist/worker/console/mutation-gate-receipt-store.js +184 -0
  44. package/dist/worker/console/operation-runner.js +6 -2
  45. package/dist/worker/console/operation-sse.js +26 -0
  46. package/dist/worker/console/operator-actions.js +420 -7
  47. package/dist/worker/console/server.js +68 -1
  48. package/dist/worker/console/static/assets/index-BTbrEHnO.css +1 -0
  49. package/dist/worker/console/static/assets/index-D9qLevoP.js +27 -0
  50. package/dist/worker/console/static/index.html +2 -2
  51. package/dist/worker/feature/profile-schema.js +1 -1
  52. package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -5
  53. package/dist/workflows/dag/backend-test-result-contract.js +229 -0
  54. package/dist/workflows/dag/frontend-lint-baseline.js +4 -4
  55. package/dist/workflows/dag/init-hybrid.js +71 -22
  56. package/dist/workflows/dag/node-execution.js +38 -1
  57. package/dist/workflows/dag/output-protocol.js +89 -0
  58. package/dist/workflows/dag/prompt.js +35 -1
  59. package/dist/workflows/dag/recovery-recommendation.js +45 -0
  60. package/dist/workflows/dag/report.js +28 -1
  61. package/dist/workflows/dag/rerun-task.js +1 -1
  62. package/dist/workflows/dag/scheduler.js +9 -0
  63. package/dist/workflows/dag/types.js +12 -0
  64. package/dist/workflows/dag/validate.js +55 -0
  65. package/docs/README.md +73 -156
  66. package/docs/architecture/README.md +7 -6
  67. package/docs/architecture/dag-execution.md +2 -2
  68. package/docs/architecture/evolution.md +16 -14
  69. package/docs/architecture/system-overview.md +1 -1
  70. package/docs/architecture/worker-and-feature.md +3 -3
  71. package/docs/governance/README.md +15 -0
  72. package/docs/{harness-methodology-debugging.md → governance/harness-methodology-debugging.md} +27 -3
  73. package/docs/init-surface.manifest.json +22 -4
  74. package/docs/operations/README.md +12 -0
  75. package/docs/{local-development-environment.md → operations/local-development-environment.md} +1 -1
  76. package/docs/skills/vetted-skill-registry.md +23 -3
  77. package/docs/templates/README.md +55 -0
  78. package/docs/templates/backend-test-dag.json +2 -2
  79. package/docs/templates/evaluation/agents-map-slim-v1.candidate.json +9 -0
  80. package/docs/templates/evaluation/agents-map-slim-v1.md +87 -0
  81. package/docs/templates/evaluation/agents-map-verbose-v0.candidate.json +9 -0
  82. package/docs/templates/evaluation/agents-map-verbose-v0.md +153 -0
  83. package/docs/templates/hybrid-dag.json +1 -1
  84. package/docs/templates/progress-log.md +9 -2
  85. package/harness.json +4 -4
  86. package/package.json +5 -5
  87. package/scripts/kb-bootstrap-init-skeleton.sh +2 -2
  88. package/skills/agent-worker/SKILL.md +1 -1
  89. package/skills/grill-with-docs/SKILL.md +44 -52
  90. package/skills/grill-with-docs/adr-format.md +37 -26
  91. package/skills/grill-with-docs/context-format.md +18 -26
  92. package/skills/loop-agent/SKILL.md +28 -112
  93. package/skills/loop-agent/references/command-reference.md +9 -3
  94. package/skills/loop-agent/references/harness-policy.md +3 -3
  95. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  96. package/skills/loop-agent/references/orchestrator-and-interventions.md +1 -1
  97. package/skills/loop-agent/references/task-workflow.md +2 -0
  98. package/skills/systematic-debugging/SKILL.md +20 -4
  99. package/skills/test-driven-development/SKILL.md +10 -3
  100. package/dist/worker/console/static/assets/index-CUDke82y.js +0 -18
  101. package/dist/worker/console/static/assets/index-wSEksVSO.css +0 -1
  102. /package/docs/{harness-methodology-tdd.md → governance/harness-methodology-tdd.md} +0 -0
  103. /package/docs/{harness-methodology-verification.md → governance/harness-methodology-verification.md} +0 -0
@@ -0,0 +1,24 @@
1
+ import { createHash } from "node:crypto";
2
+ import { scrubSecrets } from "./explore-tools.js";
3
+ function summary(text, max = 320) {
4
+ if (!text)
5
+ return undefined;
6
+ return scrubSecrets(text).scrubbed
7
+ .replace(/\bghp_[A-Za-z0-9]{20,}\b/g, "[REDACTED]")
8
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, "Bearer [REDACTED]")
9
+ .slice(0, max);
10
+ }
11
+ export function projectRuntimeContext(input) {
12
+ return {
13
+ readOnly: true,
14
+ systemPrompt: {
15
+ hash: createHash("sha256").update(input.systemPrompt).digest("hex"),
16
+ summary: summary(input.systemPrompt),
17
+ },
18
+ skills: input.skills.map((skill) => ({ ...skill, description: summary(skill.description, 200) ?? "" })),
19
+ resources: { mode: "closed", noContextFiles: true, noSkills: true, noExtensions: true, hasBash: false, activeToolCount: input.activeTools.length },
20
+ model: input.model,
21
+ thinkingLevel: input.thinkingLevel,
22
+ suffix: input.systemPromptSuffix ? { present: true, summary: summary(input.systemPromptSuffix) } : { present: false },
23
+ };
24
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Pure runtime-selection helpers shared by the Operator Chat UI and tests.
3
+ *
4
+ * These functions encode the merge/match semantics between persisted session
5
+ * runtime state (model + thinking level) and the read-only runtime context
6
+ * snapshot. They are intentionally side-effect-free so they can be unit-tested
7
+ * in a plain Node environment (the React component owns the network race
8
+ * guards; these helpers only define what "matches" and "merged" mean).
9
+ */
10
+ /** Format a model reference for `<option value>` keys and equality checks. */
11
+ export function formatModelRef(provider, modelId) {
12
+ return `${provider}/${modelId}`;
13
+ }
14
+ /** Normalize a server record into the optional model/thinking patch fields. */
15
+ export function runtimeSelectionFromRecord(record) {
16
+ return {
17
+ ...(record.modelProvider && record.modelId
18
+ ? { model: { provider: record.modelProvider, modelId: record.modelId } }
19
+ : {}),
20
+ ...(record.thinkingLevel ? { thinkingLevel: record.thinkingLevel } : {}),
21
+ };
22
+ }
23
+ /** Merge a patch onto a session, preferring patch values and keeping the id. */
24
+ export function mergeSessionRuntime(current, patch) {
25
+ return {
26
+ ...current,
27
+ model: patch.model ?? current.model,
28
+ thinkingLevel: patch.thinkingLevel ?? current.thinkingLevel,
29
+ };
30
+ }
31
+ /** True when `current` already carries the values in `patch`. */
32
+ export function sessionRuntimeMatches(current, patch) {
33
+ const next = mergeSessionRuntime(current, patch);
34
+ return (next.model?.provider === current.model?.provider &&
35
+ next.model?.modelId === current.model?.modelId &&
36
+ next.thinkingLevel === current.thinkingLevel);
37
+ }
@@ -0,0 +1,437 @@
1
+ /**
2
+ * Operator Chat — session store + tool dispatcher (plan W7 / W5).
3
+ *
4
+ * Owns:
5
+ * - Chat session metadata persistence (app-data/chats)
6
+ * - Tool-call dispatch from the Pi runtime into the operator action layer
7
+ * (Gate 3 per-call authorization)
8
+ * - History record (for degraded-mode replay, V13)
9
+ *
10
+ * The dispatcher is the SINGLE place where a Chat tool invocation becomes an
11
+ * operator action call. Every invocation is re-authorized here.
12
+ */
13
+ import path from "node:path";
14
+ import { readdir, rm } from "node:fs/promises";
15
+ import { readJsonIfExists, writeSecureJson, } from "../app-data.js";
16
+ import { dispatchOperatorAction } from "../operator-actions.js";
17
+ import { authorizeOperatorChatTool } from "./tools.js";
18
+ import { projectOperationForChat } from "./chat-event-store.js";
19
+ import { scrubSecrets } from "./explore-tools.js";
20
+ function recordPath(appData, sessionId) {
21
+ return path.join(appData.chats, `${sessionId}.json`);
22
+ }
23
+ export class ChatSessionStore {
24
+ appData;
25
+ /**
26
+ * Per-session write lock. Each mutating op chains onto the previous one so
27
+ * concurrent appendMessage / recordToolInvocation calls do not lose updates
28
+ * via last-write-wins on the same JSON record.
29
+ */
30
+ writeChains = new Map();
31
+ constructor(appData) {
32
+ this.appData = appData;
33
+ }
34
+ /** Read + migrate legacy V1 records into the V2 in-memory shape. */
35
+ async get(sessionId) {
36
+ const raw = await readJsonIfExists(recordPath(this.appData, sessionId));
37
+ if (!raw || raw.sessionId !== sessionId)
38
+ return undefined;
39
+ return {
40
+ schemaVersion: 2,
41
+ sessionId,
42
+ repoFingerprint: raw.repoFingerprint ?? this.appData.fingerprint,
43
+ repoRootDisplay: raw.repoRootDisplay ?? this.appData.repoRoot,
44
+ state: raw.state === "archived" ? "archived" : "active",
45
+ ...(typeof raw.title === "string" ? { title: raw.title } : {}),
46
+ ...(typeof raw.sessionFile === "string" ? { sessionFile: raw.sessionFile } : {}),
47
+ createdAt: raw.createdAt ?? new Date(0).toISOString(),
48
+ updatedAt: raw.updatedAt ?? raw.createdAt ?? new Date(0).toISOString(),
49
+ ...(typeof raw.modelProvider === "string" ? { modelProvider: raw.modelProvider } : {}),
50
+ ...(typeof raw.modelId === "string" ? { modelId: raw.modelId } : {}),
51
+ ...(typeof raw.thinkingLevel === "string" ? { thinkingLevel: raw.thinkingLevel } : {}),
52
+ messages: Array.isArray(raw.messages) ? raw.messages : [],
53
+ toolInvocations: Array.isArray(raw.toolInvocations) ? raw.toolInvocations : [],
54
+ };
55
+ }
56
+ async listMessages(sessionId, options = {}) {
57
+ const record = await this.get(sessionId);
58
+ if (!record)
59
+ throw new Error(`chat session not found: ${sessionId}`);
60
+ const limit = Math.max(1, Math.min(50, Math.floor(options.limit ?? 30)));
61
+ const beforeIndex = options.beforeId
62
+ ? record.messages.findIndex((message) => message.id === options.beforeId)
63
+ : record.messages.length;
64
+ const end = beforeIndex < 0 ? record.messages.length : beforeIndex;
65
+ const start = Math.max(0, end - limit);
66
+ const messages = record.messages.slice(start, end);
67
+ return {
68
+ messages,
69
+ hasMore: start > 0,
70
+ ...(messages[0] ? { nextCursor: messages[0].id } : {}),
71
+ };
72
+ }
73
+ /** List repo-bound sessions, newest first. Deleted records are absent. */
74
+ async list(options) {
75
+ let names;
76
+ try {
77
+ names = await readdir(this.appData.chats);
78
+ }
79
+ catch {
80
+ return [];
81
+ }
82
+ const records = await Promise.all(names
83
+ .filter((name) => name.endsWith(".json"))
84
+ .map((name) => this.get(name.slice(0, -".json".length))));
85
+ return records
86
+ .filter((record) => Boolean(record) && (options?.includeArchived || record.state === "active"))
87
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
88
+ }
89
+ /** Serialize a mutating op per session. */
90
+ serialize(sessionId, op) {
91
+ const prev = this.writeChains.get(sessionId) ?? Promise.resolve();
92
+ const next = prev.then(op, op);
93
+ // Keep the chain reference for the next serializer; swallow this op's
94
+ // rejection so it never breaks the chain (callers still see the result).
95
+ this.writeChains.set(sessionId, next.then(() => undefined, () => undefined));
96
+ return next;
97
+ }
98
+ async create(input) {
99
+ return this.serialize(input.sessionId, async () => {
100
+ const now = new Date().toISOString();
101
+ const record = {
102
+ schemaVersion: 2,
103
+ sessionId: input.sessionId,
104
+ repoFingerprint: this.appData.fingerprint,
105
+ repoRootDisplay: this.appData.repoRoot,
106
+ state: "active",
107
+ ...(input.sessionFile ? { sessionFile: input.sessionFile } : {}),
108
+ createdAt: now,
109
+ updatedAt: now,
110
+ modelProvider: input.modelProvider,
111
+ modelId: input.modelId,
112
+ messages: [],
113
+ toolInvocations: [],
114
+ };
115
+ await writeSecureJson(recordPath(this.appData, input.sessionId), record);
116
+ return record;
117
+ });
118
+ }
119
+ async forkFrom(input) {
120
+ const source = await this.get(input.sourceSessionId);
121
+ if (!source)
122
+ throw new Error(`chat session not found: ${input.sourceSessionId}`);
123
+ return this.serialize(input.targetSessionId, async () => {
124
+ const now = new Date().toISOString();
125
+ const record = {
126
+ ...source,
127
+ sessionId: input.targetSessionId,
128
+ state: "active",
129
+ ...(input.targetSessionFile ? { sessionFile: input.targetSessionFile } : { sessionFile: undefined }),
130
+ createdAt: now,
131
+ updatedAt: now,
132
+ messages: structuredClone(source.messages),
133
+ toolInvocations: structuredClone(source.toolInvocations),
134
+ };
135
+ await writeSecureJson(recordPath(this.appData, input.targetSessionId), record);
136
+ return record;
137
+ });
138
+ }
139
+ async rename(sessionId, title) {
140
+ return this.serialize(sessionId, async () => {
141
+ const record = await this.get(sessionId);
142
+ if (!record)
143
+ throw new Error(`chat session not found: ${sessionId}`);
144
+ record.title = title.trim().slice(0, 160) || undefined;
145
+ record.updatedAt = new Date().toISOString();
146
+ await writeSecureJson(recordPath(this.appData, sessionId), record);
147
+ return record;
148
+ });
149
+ }
150
+ async setRuntimeSelection(sessionId, selection) {
151
+ return this.serialize(sessionId, async () => {
152
+ const record = await this.get(sessionId);
153
+ if (!record)
154
+ throw new Error(`chat session not found: ${sessionId}`);
155
+ if (selection.modelProvider !== undefined)
156
+ record.modelProvider = selection.modelProvider;
157
+ if (selection.modelId !== undefined)
158
+ record.modelId = selection.modelId;
159
+ if (selection.thinkingLevel !== undefined)
160
+ record.thinkingLevel = selection.thinkingLevel;
161
+ record.updatedAt = new Date().toISOString();
162
+ await writeSecureJson(recordPath(this.appData, sessionId), record);
163
+ return record;
164
+ });
165
+ }
166
+ async setState(sessionId, state) {
167
+ return this.serialize(sessionId, async () => {
168
+ const record = await this.get(sessionId);
169
+ if (!record)
170
+ throw new Error(`chat session not found: ${sessionId}`);
171
+ record.state = state;
172
+ record.updatedAt = new Date().toISOString();
173
+ await writeSecureJson(recordPath(this.appData, sessionId), record);
174
+ return record;
175
+ });
176
+ }
177
+ async remove(sessionId) {
178
+ return this.serialize(sessionId, async () => {
179
+ const record = await this.get(sessionId);
180
+ if (!record)
181
+ return false;
182
+ await rm(recordPath(this.appData, sessionId), { force: true });
183
+ return true;
184
+ });
185
+ }
186
+ async appendMessage(sessionId, message) {
187
+ return this.serialize(sessionId, async () => {
188
+ const record = await this.get(sessionId);
189
+ if (!record)
190
+ throw new Error(`chat session not found: ${sessionId}`);
191
+ const stored = {
192
+ ...message,
193
+ id: `m_${record.messages.length + 1}_${Math.random().toString(36).slice(2, 8)}`,
194
+ at: new Date().toISOString(),
195
+ };
196
+ record.messages.push(stored);
197
+ record.updatedAt = stored.at;
198
+ await writeSecureJson(recordPath(this.appData, sessionId), record);
199
+ return stored;
200
+ });
201
+ }
202
+ async recordToolInvocation(sessionId, entry) {
203
+ await this.serialize(sessionId, async () => {
204
+ const record = await this.get(sessionId);
205
+ if (!record)
206
+ return;
207
+ record.toolInvocations.push({ ...entry, at: new Date().toISOString() });
208
+ record.updatedAt = new Date().toISOString();
209
+ await writeSecureJson(recordPath(this.appData, sessionId), record);
210
+ });
211
+ }
212
+ }
213
+ /**
214
+ * Dispatch a Chat tool invocation into the operator action layer.
215
+ *
216
+ * Gate 3 (per-call): authorizeOperatorChatTool is re-checked here. Even if a
217
+ * malicious or drifted session somehow requested a denied tool, this gate
218
+ * refuses before any operator action runs (V12 / V15).
219
+ *
220
+ * High-risk actions (contractApply / runDag / etc.) are NOT registered as
221
+ * tools, so they never reach here; but we still deny defensively.
222
+ */
223
+ function redactHumanGateToken(value) {
224
+ const clone = structuredClone(value);
225
+ const confirmation = clone.confirmation;
226
+ if (confirmation && typeof confirmation === "object") {
227
+ delete confirmation.humanGateToken;
228
+ delete confirmation.dagBytesPath;
229
+ }
230
+ delete clone.humanGateToken;
231
+ return clone;
232
+ }
233
+ export async function dispatchChatToolCall(ctx, toolName, args, clientRequestId) {
234
+ const decision = authorizeOperatorChatTool(toolName);
235
+ if (!decision.ok) {
236
+ return {
237
+ ok: false,
238
+ toolName,
239
+ errorCode: decision.code,
240
+ message: decision.message,
241
+ };
242
+ }
243
+ const result = await dispatchOperatorAction(ctx, {
244
+ action: decision.toolId,
245
+ actionParams: args,
246
+ clientRequestId,
247
+ });
248
+ if (result.kind === "accepted") {
249
+ const canonical = await ctx.operations.get(result.body.operationId);
250
+ return {
251
+ ok: true,
252
+ toolName: decision.toolId,
253
+ result: {
254
+ schemaVersion: 1,
255
+ accepted: true,
256
+ operationId: result.body.operationId,
257
+ state: result.body.state,
258
+ action: result.body.action,
259
+ ...(canonical ? { operation: projectOperationForChat(canonical) } : {}),
260
+ note: "operation accepted; a durable Chat operation reference will follow canonical status",
261
+ },
262
+ };
263
+ }
264
+ const body = result.body;
265
+ const ok = body.ok === true;
266
+ if (ok) {
267
+ const raw = body.result ?? body;
268
+ return {
269
+ ok: true,
270
+ toolName: decision.toolId,
271
+ result: decision.toolId === "prepareDagConfirmation" || decision.toolId === "prepareMutationGate"
272
+ ? redactHumanGateToken(raw)
273
+ : raw,
274
+ };
275
+ }
276
+ const error = body.error ?? {};
277
+ return {
278
+ ok: false,
279
+ toolName: decision.toolId,
280
+ errorCode: typeof error.code === "string" ? error.code : "INTERNAL_ERROR",
281
+ message: typeof error.message === "string"
282
+ ? error.message
283
+ : `operator action ${decision.toolId} failed`,
284
+ };
285
+ }
286
+ /**
287
+ * Drive one full Chat turn: persist the user message, run the Pi prompt, and
288
+ * surface events to the caller (SSE). Tool-call events are dispatched through
289
+ * dispatchChatToolCall (Gate 3). The assistant's final message is persisted.
290
+ *
291
+ * The dispatcher wiring (connecting the Pi session's tool registry to
292
+ * dispatchChatToolCall) is performed by the runtime's customTools binding,
293
+ * configured at session-create time by chat-session.ts. This helper is the
294
+ * orchestrator the HTTP route calls.
295
+ */
296
+ export async function runChatTurn(options) {
297
+ const { runtime, store, sessionId, text } = options;
298
+ if (!runtime.hasSession(sessionId)) {
299
+ return {
300
+ ok: false,
301
+ error: {
302
+ code: "NOT_FOUND",
303
+ message: `chat session not active: ${sessionId}`,
304
+ },
305
+ };
306
+ }
307
+ await store.appendMessage(sessionId, { role: "user", text });
308
+ let turnError;
309
+ // Collect persistence promises so runChatTurn does not resolve before the
310
+ // chat history / audit trail is durably written (also makes the outcome
311
+ // observable to tests and to a reconnecting client reading the record).
312
+ const pending = [];
313
+ let settledEvent;
314
+ try {
315
+ await runtime.prompt(sessionId, text, (event) => {
316
+ if (event.type === "agent_settled") {
317
+ // The durable operation-ref linker may perform an async canonical
318
+ // operation read. Hold settled until those writes finish so replay
319
+ // never observes agent_settled before its accepted operation ref.
320
+ settledEvent = event;
321
+ }
322
+ else {
323
+ options.onEvent?.(event);
324
+ }
325
+ switch (event.type) {
326
+ case "message_update":
327
+ case "message_end":
328
+ if (event.type === "message_end") {
329
+ pending.push(store.appendMessage(sessionId, {
330
+ role: "assistant",
331
+ text: event.text,
332
+ }));
333
+ }
334
+ break;
335
+ case "tool_call":
336
+ // Forwarded to the caller (SSE) for UI feedback. The actual tool
337
+ // execution happens in the SDK's customTools[].execute(), which
338
+ // routes to dispatchChatToolCall (Gate 3). Do NOT dispatch here —
339
+ // that would double-execute every tool call (P0.2).
340
+ break;
341
+ case "tool_result": {
342
+ const acceptedOperationId = acceptedOperationIdFromResult(event.toolName, event.result);
343
+ if (acceptedOperationId && options.onOperationAccepted) {
344
+ pending.push(Promise.resolve(options.onOperationAccepted({
345
+ sessionId,
346
+ toolCallId: event.toolCallId,
347
+ toolName: event.toolName,
348
+ operationId: acceptedOperationId,
349
+ })));
350
+ }
351
+ // tool_execution_end carries the real result of the tool call.
352
+ // Persist exactly once (the tool message + audit trail). This is
353
+ // the single point of record for tool outcomes (P0.2).
354
+ const resultText = safeStringifyResult(event.result);
355
+ // ChatSessionStore serializes writes per session, so these two calls
356
+ // are safe to fire concurrently — neither clobbers the other.
357
+ pending.push(store.appendMessage(sessionId, {
358
+ role: "tool",
359
+ text: resultText,
360
+ toolCallId: event.toolCallId,
361
+ toolName: event.toolName,
362
+ isError: event.isError,
363
+ }));
364
+ pending.push(store.recordToolInvocation(sessionId, {
365
+ toolCallId: event.toolCallId,
366
+ toolName: event.toolName,
367
+ ok: !event.isError,
368
+ }));
369
+ break;
370
+ }
371
+ case "error":
372
+ turnError = { code: "CHAT_TURN_ERROR", message: event.message };
373
+ break;
374
+ }
375
+ }, { signal: options.signal, images: options.images });
376
+ }
377
+ catch (error) {
378
+ await Promise.allSettled(pending);
379
+ const message = error instanceof Error ? error.message : String(error);
380
+ return { ok: false, error: { code: "CHAT_TURN_FAILED", message } };
381
+ }
382
+ await Promise.allSettled(pending);
383
+ if (settledEvent)
384
+ options.onEvent?.(settledEvent);
385
+ return { ok: !turnError, error: turnError };
386
+ }
387
+ /**
388
+ * Stringify a tool result for persistence. Bounds the stored text length so a
389
+ * huge operator-action payload (e.g. a full dagReport) cannot bloat the chat
390
+ * history record.
391
+ */
392
+ function acceptedOperationIdFromResult(toolName, result) {
393
+ const candidates = [result];
394
+ if (result && typeof result === "object") {
395
+ const record = result;
396
+ candidates.push(record.details);
397
+ if (Array.isArray(record.content)) {
398
+ for (const item of record.content) {
399
+ if (!item || typeof item !== "object")
400
+ continue;
401
+ const text = item.text;
402
+ if (typeof text !== "string" || text.length > 16_000)
403
+ continue;
404
+ try {
405
+ candidates.push(JSON.parse(text));
406
+ }
407
+ catch {
408
+ // Only the controlled JSON tool-result envelope is accepted.
409
+ }
410
+ }
411
+ }
412
+ }
413
+ for (const candidate of candidates) {
414
+ if (!candidate || typeof candidate !== "object")
415
+ continue;
416
+ const accepted = candidate;
417
+ if (accepted.schemaVersion === 1 &&
418
+ accepted.accepted === true &&
419
+ accepted.action === toolName &&
420
+ typeof accepted.operationId === "string" &&
421
+ accepted.operationId.trim()) {
422
+ return accepted.operationId.trim();
423
+ }
424
+ }
425
+ return undefined;
426
+ }
427
+ function safeStringifyResult(result) {
428
+ try {
429
+ const raw = JSON.stringify(result ?? {});
430
+ const { scrubbed } = scrubSecrets(raw);
431
+ return scrubbed.length > 4000 ? `${scrubbed.slice(0, 4000)}…[truncated]` : scrubbed;
432
+ }
433
+ catch {
434
+ const { scrubbed } = scrubSecrets(String(result));
435
+ return scrubbed.length > 4000 ? `${scrubbed.slice(0, 4000)}…[truncated]` : scrubbed;
436
+ }
437
+ }
@@ -0,0 +1,15 @@
1
+ export const SHORTCUTS = [
2
+ { command: "/task", label: "Task context", description: "查看当前任务、Draft 与 assessment", action: "focus-panel:task" },
3
+ { command: "/contract", label: "Contract", description: "查看 contract diff 与 apply gate", action: "focus-panel:contract" },
4
+ { command: "/dag", label: "DAG", description: "查看 DAG spine、确认与运行状态", action: "focus-panel:dag" },
5
+ { command: "/interview", label: "Interview", description: "打开 Requirement Interview", action: "focus:interview" },
6
+ { command: "/help", label: "Shortcuts", description: "显示可用 shortcut", action: "show-help" },
7
+ ];
8
+ /** Pure navigation/display resolver. It deliberately has no mutation decisions. */
9
+ export function resolveShortcut(input) {
10
+ const token = input.trim().split(/\s+/, 1)[0]?.toLowerCase();
11
+ if (!token?.startsWith("/"))
12
+ return undefined;
13
+ const found = SHORTCUTS.find((shortcut) => shortcut.command === token);
14
+ return found ? { command: found.command, action: found.action } : { command: token, action: "unknown" };
15
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Operator Chat — operator action → Pi tool schema adapter (roadmap M0-C).
3
+ *
4
+ * The capabilities registry (src/shared/operator/capabilities.ts) is the
5
+ * SINGLE source of truth for each action's typed input schema AND its model
6
+ * policy (modelCallable / humanConfirmation). This adapter renders that
7
+ * registry into the descriptor shape the Pi model invokes; it no longer
8
+ * maintains a second hand-written INPUT_PARAMS table (that drift risk is gone).
9
+ *
10
+ * High-risk / long-running actions ARE mapped as tools (2026-07-25 widening),
11
+ * but the registry now declares their modelCallable policy:
12
+ * - "always": model can invoke; dispatch runs server-side.
13
+ * - "prepare-only": model can invoke but the mutation needs a human-origin
14
+ * confirmation (e.g. runDag needs a confirmationId consumed by a human
15
+ * confirm via the browser mutation gate — M0-B).
16
+ * - "never": the action is the human confirmation itself (confirmDagConfirmation)
17
+ * and MUST NOT appear as a model-callable tool (M0-B / roadmap G03).
18
+ *
19
+ * The adapter does NOT register tools with the SDK directly — that is the
20
+ * session layer's job (chat/pi-runtime.ts). This keeps the adapter pure and
21
+ * unit-testable against the registry.
22
+ */
23
+ import { buildOperatorCapabilitiesDocument } from "../../../shared/operator/capabilities.js";
24
+ /**
25
+ * Executable mutation faces reserved for browser HumanGate routes.
26
+ *
27
+ * Derived from the capabilities registry: any action that still requires human
28
+ * confirmation (and is not itself the confirm step with modelCallable=never)
29
+ * must NOT be registered as a model-executable tool. The model may only call
30
+ * prepare helpers (prepareDagConfirmation / prepareMutationGate); the browser
31
+ * mutation gate + HMAC token perform the consuming dispatch.
32
+ *
33
+ * `contractApply` is also gate-only even though its registry face remains
34
+ * `humanConfirmation: "conditional"` (assessment soft-gate for workspace UI);
35
+ * Chat executes it only via the contract-apply Human Gate route.
36
+ */
37
+ const EXPLICIT_EXECUTABLE_GATE_ONLY = ["contractApply"];
38
+ export function listExecutableGateOnlyActions() {
39
+ const derived = buildOperatorCapabilitiesDocument()
40
+ .actions.filter((action) => action.humanConfirmation === "required" &&
41
+ action.modelCallable !== "never")
42
+ .map((action) => action.action);
43
+ return [...new Set([...EXPLICIT_EXECUTABLE_GATE_ONLY, ...derived])].sort();
44
+ }
45
+ /** Frozen snapshot for tests / drift checks (recomputed from registry). */
46
+ export const EXECUTABLE_GATE_ONLY_ACTIONS = Object.freeze(listExecutableGateOnlyActions());
47
+ const EXECUTABLE_GATE_ONLY = new Set(EXECUTABLE_GATE_ONLY_ACTIONS);
48
+ /**
49
+ * Build the FULL Chat tool schema set from the capabilities registry — every
50
+ * registry action, INCLUDING modelCallable="never" ones (e.g.
51
+ * confirmDagConfirmation). Used by internal audits / capability surfaces that
52
+ * need to see the complete registry. The session layer uses
53
+ * buildModelCallableToolSchemas() to render only model-callable tools.
54
+ */
55
+ export function buildOperatorChatToolSchemas() {
56
+ const doc = buildOperatorCapabilitiesDocument();
57
+ const out = [];
58
+ for (const action of doc.actions) {
59
+ // Fail-closed: every registry action MUST carry inputParams. An action
60
+ // missing inputParams is a contract gap (the model could call it but we
61
+ // don't know which params its dispatcher reads). Throw at boot so the gap
62
+ // is caught in tests and never ships.
63
+ if (!Array.isArray(action.inputParams)) {
64
+ throw new Error(`Operator Chat tool schema gap: registry action "${action.action}" has no inputParams. Add inputParams (must match the dispatcher's paramsOf reads) in src/shared/operator/capabilities.ts.`);
65
+ }
66
+ out.push({
67
+ toolId: action.action,
68
+ action: action.action,
69
+ description: action.description,
70
+ cli: action.cli,
71
+ kind: action.kind,
72
+ inputParams: action.inputParams.map((p) => ({
73
+ name: p.name,
74
+ type: p.type,
75
+ required: p.required,
76
+ description: p.description,
77
+ ...(p.itemsType ? { itemsType: p.itemsType } : {}),
78
+ })),
79
+ modelCallable: action.modelCallable,
80
+ humanConfirmation: action.humanConfirmation,
81
+ ...(action.resultPolicy ? { resultPolicy: action.resultPolicy } : {}),
82
+ });
83
+ }
84
+ return out;
85
+ }
86
+ /**
87
+ * Build ONLY the model-callable tool schemas — i.e. exclude
88
+ * modelCallable="never" actions (roadmap M0-B / G03). This is the set the Pi
89
+ * session actually registers as custom tools: the human confirmation action
90
+ * (confirmDagConfirmation) is NOT a model tool; it can only be invoked by the
91
+ * browser mutation gate with a server-signed confirmation token.
92
+ */
93
+ export function buildModelCallableToolSchemas() {
94
+ return buildOperatorChatToolSchemas().filter((s) => s.modelCallable !== "never" && !EXECUTABLE_GATE_ONLY.has(s.action));
95
+ }
96
+ /**
97
+ * Names of actions that MUST NOT be model-callable (roadmap G03 / G14).
98
+ * Derived from the registry so adding a "never" policy there is the only edit
99
+ * needed. Used by the three-gate tool whitelist to deny model invocation.
100
+ */
101
+ export function modelForbiddenActions() {
102
+ return buildOperatorChatToolSchemas()
103
+ .filter((s) => s.modelCallable === "never" || EXECUTABLE_GATE_ONLY.has(s.action))
104
+ .map((s) => s.action);
105
+ }
106
+ /**
107
+ * Verify the adapter covers exactly the registry and nothing more.
108
+ * Used by boot/red-team checks (V15 — the full registry is schema'd).
109
+ */
110
+ export function verifyOperatorChatToolSurface() {
111
+ const schemas = buildOperatorChatToolSchemas();
112
+ const doc = buildOperatorCapabilitiesDocument();
113
+ const covered = schemas.map((s) => s.action).sort();
114
+ const expected = doc.actions.map((a) => a.action).sort();
115
+ const coveredSet = new Set(covered);
116
+ const expectedSet = new Set(expected);
117
+ const missing = expected.filter((a) => !coveredSet.has(a));
118
+ const extra = covered.filter((a) => !expectedSet.has(a));
119
+ return {
120
+ ok: missing.length === 0 && extra.length === 0,
121
+ covered,
122
+ missing,
123
+ extra,
124
+ };
125
+ }