@tea-agent/loop-agent 0.33.7-beta.0 → 0.34.1

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 (101) hide show
  1. package/CHANGELOG.md +67 -16
  2. package/dist/application/task-lifecycle/advance.js +309 -9
  3. package/dist/application/task-lifecycle/gates.js +50 -0
  4. package/dist/application/task-lifecycle/observe.js +11 -2
  5. package/dist/application/task-lifecycle/plan-transitions.js +13 -21
  6. package/dist/commands/init-upgrade.js +32 -1
  7. package/dist/commands/init.js +94 -3
  8. package/dist/executors/dag-pi-executor.js +18 -3
  9. package/dist/executors/shell-executor.js +4 -91
  10. package/dist/executors/shell-write-guard.js +26 -8
  11. package/dist/shared/operator/capabilities.js +98 -44
  12. package/dist/shared/resilient-git.js +133 -0
  13. package/dist/task/source-prepare/artifact-meta.js +137 -0
  14. package/dist/task/source-prepare/index.js +2 -0
  15. package/dist/task/source-prepare/parse-intent.js +58 -10
  16. package/dist/task/source-prepare/prepare.js +229 -18
  17. package/dist/task/source-prepare/reference-integrity.js +18 -2
  18. package/dist/task/source-prepare/semantic-intake.js +404 -0
  19. package/dist/worker/console/app-data.js +2 -0
  20. package/dist/worker/console/chat/chat-event-store.js +190 -25
  21. package/dist/worker/console/chat/model-resolver.js +17 -0
  22. package/dist/worker/console/chat/pi-console-config.js +250 -32
  23. package/dist/worker/console/chat/pi-runtime.js +1007 -188
  24. package/dist/worker/console/chat/resource-loader.js +5 -4
  25. package/dist/worker/console/chat/routes.js +495 -157
  26. package/dist/worker/console/chat/runtime-context.js +48 -12
  27. package/dist/worker/console/chat/runtime-selection.js +59 -0
  28. package/dist/worker/console/chat/session-store.js +39 -0
  29. package/dist/worker/console/chat/shortcuts.js +1 -0
  30. package/dist/worker/console/chat/tool-adapter.js +9 -3
  31. package/dist/worker/console/chat/tools.js +5 -1
  32. package/dist/worker/console/dag-execution-receipt.js +380 -0
  33. package/dist/worker/console/interview/grill-me.js +7 -6
  34. package/dist/worker/console/operator-actions.js +785 -85
  35. package/dist/worker/console/prd-intake-bridge.js +393 -0
  36. package/dist/worker/console/recovery-cta.js +35 -6
  37. package/dist/worker/console/server.js +8 -15
  38. package/dist/worker/console/static/assets/index-BQkhJpV8.css +1 -0
  39. package/dist/worker/console/static/assets/index-BpuHmlSP.js +29 -0
  40. package/dist/worker/console/static/index.html +2 -2
  41. package/dist/worker/console/static-src/app/console-types.js +1 -0
  42. package/dist/worker/console/static-src/app/usePrdImport.js +2 -1
  43. package/dist/worker/console/static-src/app/useRecoveryActions.js +24 -1
  44. package/dist/worker/console/static-src/app/useRecoveryConsole.js +19 -1
  45. package/dist/worker/console/static-src/app/useTaskWizard.js +57 -2
  46. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
  47. package/dist/worker/console/static-src/operator-chat/landing-density.js +23 -0
  48. package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
  49. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
  50. package/dist/worker/console/static-src/operator-chat/session-title-watcher.js +128 -0
  51. package/dist/worker/console/static-src/operator-chat/sidebar-split.js +90 -0
  52. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +37 -0
  53. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +125 -22
  54. package/dist/worker/console/static-src/operator-chat/useChatStream.js +215 -184
  55. package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
  56. package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
  57. package/dist/worker/console/static-src/operator-chat/useOverlayFocus.js +84 -0
  58. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
  59. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
  60. package/dist/worker/console/static-src/operator-chat/useWorkspaceLayout.js +58 -0
  61. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +31 -0
  62. package/dist/worker/delivery/final-verification.js +13 -5
  63. package/dist/worker/delivery/package.js +31 -19
  64. package/dist/worker/delivery/verification-bundle.js +6 -4
  65. package/dist/worker/observability/read-model.js +3 -0
  66. package/dist/worker/observe/static/operator-chrome.css +5 -2
  67. package/dist/worker/observe/static/operator-chrome.js +6 -1
  68. package/dist/worker/observe/static/styles.css +39 -9
  69. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +33 -462
  70. package/dist/workflows/dag/backend-test-case-manifest.js +0 -4
  71. package/dist/workflows/dag/backend-test-markdown-workflow.js +1 -25
  72. package/dist/workflows/dag/backend-test-module-stem.js +0 -5
  73. package/dist/workflows/dag/backend-test-pytest-collection.js +24 -345
  74. package/dist/workflows/dag/backend-test-scenario-param.js +82 -269
  75. package/dist/workflows/dag/backend-test-writer-completeness.js +16 -47
  76. package/dist/workflows/dag/dynamic-runtime/map.js +8 -24
  77. package/dist/workflows/dag/failure-category.js +3 -0
  78. package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
  79. package/dist/workflows/dag/init-hybrid.js +49 -55
  80. package/dist/workflows/dag/node-execution.js +3 -2
  81. package/dist/workflows/dag/retry-policy.js +44 -11
  82. package/dist/workflows/dag/runner.js +6 -0
  83. package/dist/workflows/dag/scheduler.js +49 -1
  84. package/dist/workflows/dag/types.js +0 -7
  85. package/dist/workflows/dag/validate.js +16 -2
  86. package/dist/workflows/dag/workspace-checkpoint.js +8 -27
  87. package/docs/templates/agent-dag.schema.json +4 -4
  88. package/docs/templates/backend-test-dag.json +29 -32
  89. package/harness.json +1 -1
  90. package/package.json +1 -1
  91. package/skills/local-jacoco-coverage/SKILL.md +281 -0
  92. package/skills/local-jacoco-coverage/references/requirement-to-source-mapping.md +85 -0
  93. package/skills/local-jacoco-coverage/references/runtime-alignment.md +106 -0
  94. package/skills/local-jacoco-coverage/scripts/run-coverage-analysis.sh +148 -0
  95. package/skills/local-jacoco-coverage/scripts/start-jacoco-agent.sh +110 -0
  96. package/skills/loop-agent/references/command-reference.md +3 -1
  97. package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
  98. package/skills/loop-agent/references/task-workflow.md +4 -0
  99. package/dist/worker/console/chat/instruction-skills.js +0 -217
  100. package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
  101. package/dist/worker/console/static/assets/index-CteJFFL2.js +0 -29
@@ -24,9 +24,41 @@ function fullText(text, max = RUNTIME_CONTEXT_TEXT_MAX) {
24
24
  return cleaned;
25
25
  return `${cleaned.slice(0, max)}\n\n…[truncated ${cleaned.length - max} chars for inspect UI]`;
26
26
  }
27
+ /**
28
+ * Secret-scrub + length-limit a runtime text (system prompt / resource paths)
29
+ * before it leaves the server. Used by the runtime snapshot projection.
30
+ */
31
+ export function redactRuntimeText(text, max = RUNTIME_CONTEXT_TEXT_MAX) {
32
+ if (!text)
33
+ return "";
34
+ const cleaned = redact(text);
35
+ if (cleaned.length <= max)
36
+ return cleaned;
37
+ return `${cleaned.slice(0, max)}\n\n…[truncated ${cleaned.length - max} chars for inspect UI]`;
38
+ }
27
39
  export function projectRuntimeContext(input) {
28
40
  const systemText = fullText(input.systemPrompt) ?? "";
29
41
  const suffixText = fullText(input.systemPromptSuffix);
42
+ const projectResources = (resources) => (resources ?? []).map((resource) => ({
43
+ name: redactRuntimeText(resource.name, 200),
44
+ description: redactRuntimeText(resource.description, 600),
45
+ path: redactRuntimeText(resource.path, 400),
46
+ baseDir: resource.baseDir ? redactRuntimeText(resource.baseDir, 400) : undefined,
47
+ scope: resource.scope,
48
+ source: redactRuntimeText(resource.source, 200),
49
+ enabled: resource.enabled,
50
+ version: resource.version ? redactRuntimeText(resource.version, 80) : undefined,
51
+ cwd: resource.cwd ? redactRuntimeText(resource.cwd, 400) : undefined,
52
+ resolved: resource.resolved?.map((item) => ({
53
+ type: item.type,
54
+ name: redactRuntimeText(item.name, 200),
55
+ path: redactRuntimeText(item.path, 400),
56
+ source: redactRuntimeText(item.source, 200),
57
+ scope: item.scope,
58
+ })),
59
+ error: resource.error ? redactRuntimeText(resource.error, 400) : undefined,
60
+ diagnostics: (resource.diagnostics ?? []).map((diag) => redactRuntimeText(diag, 400)),
61
+ }));
30
62
  return {
31
63
  readOnly: true,
32
64
  systemPrompt: {
@@ -35,22 +67,26 @@ export function projectRuntimeContext(input) {
35
67
  text: systemText,
36
68
  charCount: input.systemPrompt.length,
37
69
  },
38
- skills: input.skills.map((skill) => ({
39
- name: skill.name,
40
- description: summary(skill.description, 200) ?? "",
41
- charCount: skill.charCount,
42
- ...(skill.bodyText
43
- ? { body: fullText(skill.bodyText, 40_000) }
44
- : {}),
45
- })),
46
70
  resources: {
47
- mode: "closed",
48
- noContextFiles: true,
49
- noSkills: true,
50
- noExtensions: true,
71
+ mode: "open",
72
+ noContextFiles: false,
73
+ noSkills: false,
74
+ noExtensions: false,
51
75
  hasBash: true,
52
76
  activeToolCount: input.activeTools.length,
53
77
  activeTools: [...input.activeTools],
78
+ contextFiles: (input.contextFiles ?? []).map((file) => ({
79
+ path: redactRuntimeText(file.path, 400),
80
+ characters: file.characters,
81
+ })),
82
+ skills: projectResources(input.skills),
83
+ extensions: projectResources(input.extensions),
84
+ packages: projectResources(input.packages),
85
+ diagnostics: (input.diagnostics ?? []).map((diag) => ({
86
+ type: diag.type,
87
+ message: redactRuntimeText(diag.message, 400),
88
+ ...(diag.path ? { path: redactRuntimeText(diag.path, 400) } : {}),
89
+ })),
54
90
  },
55
91
  model: input.model,
56
92
  thinkingLevel: input.thinkingLevel,
@@ -7,6 +7,65 @@
7
7
  * in a plain Node environment (the React component owns the network race
8
8
  * guards; these helpers only define what "matches" and "merged" mean).
9
9
  */
10
+ /** Human-readable thinking-level labels (auto is always the first option). */
11
+ export const THINKING_LEVEL_LABELS = {
12
+ auto: "auto(跟随 Pi 默认等级)",
13
+ off: "off · 关闭",
14
+ minimal: "minimal · 极简",
15
+ low: "low · 低",
16
+ medium: "medium · 中",
17
+ high: "high · 高",
18
+ xhigh: "xhigh · 极高",
19
+ max: "max · 最高",
20
+ };
21
+ /**
22
+ * Group (and optionally filter) models by provider, preserving the provider
23
+ * order of first appearance and the model order within each provider.
24
+ * The query matches provider id, model id and display name (case-insensitive).
25
+ * Empty query returns all models grouped — never a fake "no models" list.
26
+ */
27
+ export function groupModelsByProvider(models, query) {
28
+ const q = query.trim().toLowerCase();
29
+ const groups = new Map();
30
+ for (const model of models) {
31
+ if (q &&
32
+ !model.provider.toLowerCase().includes(q) &&
33
+ !model.id.toLowerCase().includes(q) &&
34
+ !(model.name ?? "").toLowerCase().includes(q)) {
35
+ continue;
36
+ }
37
+ const list = groups.get(model.provider) ?? [];
38
+ list.push(model);
39
+ groups.set(model.provider, list);
40
+ }
41
+ return [...groups.entries()].map(([provider, models]) => ({
42
+ provider,
43
+ models,
44
+ }));
45
+ }
46
+ /**
47
+ * Build the thinking menu options: `auto` is always the first option, followed
48
+ * by the runtime-supported levels in their canonical order. `effective` is the
49
+ * Pi actual thinking level (shown as a badge on the auto entry when provided).
50
+ */
51
+ export function buildThinkingMenuOptions(levels, effective) {
52
+ const seen = new Set();
53
+ const options = [];
54
+ const push = (value) => {
55
+ if (seen.has(value))
56
+ return;
57
+ seen.add(value);
58
+ options.push({
59
+ value,
60
+ label: THINKING_LEVEL_LABELS[value] ?? value,
61
+ ...(value === "auto" && effective ? { effective } : {}),
62
+ });
63
+ };
64
+ push("auto");
65
+ for (const level of levels)
66
+ push(level);
67
+ return options;
68
+ }
10
69
  /** Format a model reference for `<option value>` keys and equality checks. */
11
70
  export function formatModelRef(provider, modelId) {
12
71
  return `${provider}/${modelId}`;
@@ -17,6 +17,23 @@ import { dispatchOperatorAction } from "../operator-actions.js";
17
17
  import { authorizeOperatorChatTool } from "./tools.js";
18
18
  import { projectOperationForChat } from "./chat-event-store.js";
19
19
  import { scrubSecrets } from "./explore-tools.js";
20
+ const CHAT_TITLE_MAX_CHARS = 80;
21
+ /** Safe, one-line bounded title used for placeholders and model output. */
22
+ export function cleanChatTitle(value) {
23
+ const cleaned = value
24
+ .replace(/```[\s\S]*?```/g, " ")
25
+ .replace(/^\s*(?:title|标题)\s*[::-]\s*/i, "")
26
+ .split(/\r?\n/, 1)[0]
27
+ .trim()
28
+ .replace(/^["'“”‘’`]+|["'“”‘’`]+$/g, "")
29
+ .replace(/[。.!!??]+$/u, "")
30
+ .replace(/\s+/g, " ")
31
+ .slice(0, CHAT_TITLE_MAX_CHARS)
32
+ .trim();
33
+ return cleaned && /[\p{L}\p{N}\p{Script=Han}]/u.test(cleaned)
34
+ ? cleaned
35
+ : undefined;
36
+ }
20
37
  function recordPath(appData, sessionId) {
21
38
  return path.join(appData.chats, `${sessionId}.json`);
22
39
  }
@@ -43,6 +60,7 @@ export class ChatSessionStore {
43
60
  repoRootDisplay: raw.repoRootDisplay ?? this.appData.repoRoot,
44
61
  state: raw.state === "archived" ? "archived" : "active",
45
62
  ...(typeof raw.title === "string" ? { title: raw.title } : {}),
63
+ ...(typeof raw.displayTitle === "string" ? { displayTitle: raw.displayTitle } : {}),
46
64
  ...(typeof raw.sessionFile === "string" ? { sessionFile: raw.sessionFile } : {}),
47
65
  createdAt: raw.createdAt ?? new Date(0).toISOString(),
48
66
  updatedAt: raw.updatedAt ?? raw.createdAt ?? new Date(0).toISOString(),
@@ -147,6 +165,21 @@ export class ChatSessionStore {
147
165
  return record;
148
166
  });
149
167
  }
168
+ /** Persist an automatic title only while the original record remains active and unnamed. */
169
+ async setAutomaticTitle(sessionId, title) {
170
+ const cleaned = cleanChatTitle(title);
171
+ if (!cleaned)
172
+ return undefined;
173
+ return this.serialize(sessionId, async () => {
174
+ const record = await this.get(sessionId);
175
+ if (!record || record.state !== "active" || record.title)
176
+ return undefined;
177
+ record.title = cleaned;
178
+ record.updatedAt = new Date().toISOString();
179
+ await writeSecureJson(recordPath(this.appData, sessionId), record);
180
+ return record;
181
+ });
182
+ }
150
183
  async setRuntimeSelection(sessionId, selection) {
151
184
  return this.serialize(sessionId, async () => {
152
185
  const record = await this.get(sessionId);
@@ -194,6 +227,9 @@ export class ChatSessionStore {
194
227
  at: new Date().toISOString(),
195
228
  };
196
229
  record.messages.push(stored);
230
+ if (stored.role === "user" && !record.displayTitle) {
231
+ record.displayTitle = cleanChatTitle(stored.text);
232
+ }
197
233
  record.updatedAt = stored.at;
198
234
  await writeSecureJson(recordPath(this.appData, sessionId), record);
199
235
  return stored;
@@ -305,6 +341,9 @@ export async function runChatTurn(options) {
305
341
  };
306
342
  }
307
343
  await store.appendMessage(sessionId, { role: "user", text });
344
+ const afterUserMessage = await store.get(sessionId);
345
+ if (afterUserMessage)
346
+ await options.onUserMessagePersisted?.(afterUserMessage);
308
347
  let turnError;
309
348
  // Collect persistence promises so runChatTurn does not resolve before the
310
349
  // chat history / audit trail is durably written (also makes the outcome
@@ -3,6 +3,7 @@ export const SHORTCUTS = [
3
3
  { command: "/contract", label: "Contract", description: "查看 contract diff 与 apply gate", action: "focus-panel:contract" },
4
4
  { command: "/dag", label: "DAG", description: "查看 DAG spine、确认与运行状态", action: "focus-panel:dag" },
5
5
  { command: "/interview", label: "Interview", description: "打开 Requirement Interview", action: "focus:interview" },
6
+ { command: "/reload", label: "Reload session", description: "重新加载当前 Pi 会话的设置、扩展、技能、提示词、主题与上下文文件(不产生消息)", action: "reload" },
6
7
  { command: "/help", label: "Shortcuts", description: "显示可用 shortcut", action: "show-help" },
7
8
  ];
8
9
  /** Pure navigation/display resolver. It deliberately has no mutation decisions. */
@@ -9,10 +9,12 @@
9
9
  *
10
10
  * High-risk / long-running actions ARE mapped as tools (2026-07-25 widening),
11
11
  * but the registry now declares their modelCallable policy:
12
- * - "always": model can invoke; dispatch runs server-side.
12
+ * - "always": model can invoke; dispatch runs server-side (2026-08-11:
13
+ * runDag/dagRerun/standaloneTaskRerun/workerTaskRetry are autonomous —
14
+ * runDag consumes a server-issued single-use execution receipt, and the
15
+ * recovery actions pass server-side read-only run-facts checks).
13
16
  * - "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).
17
+ * confirmation (Night Scheduler mutation-gate faces).
16
18
  * - "never": the action is the human confirmation itself (confirmDagConfirmation)
17
19
  * and MUST NOT appear as a model-callable tool (M0-B / roadmap G03).
18
20
  *
@@ -30,6 +32,10 @@ import { buildOperatorCapabilitiesDocument } from "../../../shared/operator/capa
30
32
  * prepare helpers (prepareDagConfirmation / prepareMutationGate); the browser
31
33
  * mutation gate + HMAC token perform the consuming dispatch.
32
34
  *
35
+ * 2026-08-11: runDag / dagRerun / standaloneTaskRerun / workerTaskRetry are no
36
+ * longer gate-only (autonomous execution + run-facts checks) and drop out of
37
+ * this derived list automatically.
38
+ *
33
39
  * `contractApply` is also gate-only even though its registry face remains
34
40
  * `humanConfirmation: "conditional"` (assessment soft-gate for workspace UI);
35
41
  * Chat executes it only via the contract-apply Human Gate route.
@@ -4,10 +4,14 @@
4
4
  * Operator Chat registers and activates full Pi builtins
5
5
  * (read/write/edit/bash/grep/find/ls) plus model-callable operator actions and
6
6
  * optional safe-* explore custom tools. Discipline is primarily soft
7
- * (system prompt: prefer DAG / Human Gate). Hard denies remain for non-Pi
7
+ * (system prompt: prefer DAG paths). Hard denies remain for non-Pi
8
8
  * write channels (apply_patch / full-tools / shell / coding-chat) and for
9
9
  * modelCallable="never" actions (e.g. confirmDagConfirmation).
10
10
  *
11
+ * 2026-08-11: runDag / dagRerun / standaloneTaskRerun / workerTaskRetry are
12
+ * model-callable (autonomous DAG execution); the browser Human Gate still
13
+ * covers Night Scheduler mutations, contractApply and confirmDagConfirmation.
14
+ *
11
15
  * Interview / Official surfaces keep their own OFFICIAL_DENIED path; this
12
16
  * module only filters Chat-side denial so Interview is not widened.
13
17
  */
@@ -0,0 +1,380 @@
1
+ /**
2
+ * DAG execution receipt — session-bound, TTL, single-use autonomous DAG
3
+ * execution (2026-08-11 design §5 / §8.3).
4
+ *
5
+ * `prepareDagExecution` issues an execution receipt ONLY when the server-side
6
+ * G2 autonomous-execution assessment passes (bounded writeSet ⊆ allowedPaths,
7
+ * no forbidden overlap, no broad/destructive risk, structured verification
8
+ * present, valid bindings). The receipt carries no human-gate token — the
9
+ * browser Human Gate is not part of this flow.
10
+ *
11
+ * `runDag` consumes the receipt exactly once. Consumption re-reads the staged
12
+ * DAG bytes/hash and re-validates every binding (dag hash, source binding,
13
+ * task contract binding, controller fingerprint, validation result); any
14
+ * expiry, cross-session use, replay, or drift fails closed. Error codes reuse
15
+ * the existing confirmation lifecycle codes (CONFIRMATION_* /
16
+ * HUMAN_CONFIRMATION_REQUIRED / NOT_FOUND) because registry error codes are
17
+ * immutable in this task.
18
+ */
19
+ import { randomBytes } from "node:crypto";
20
+ import { readFile } from "node:fs/promises";
21
+ import path from "node:path";
22
+ import { pathMatchesPattern } from "../../shared/git-progress.js";
23
+ import { readJsonIfExists, sha256Utf8, writeSecureJson, } from "./app-data.js";
24
+ import { hashDagBytes, } from "./dag-confirmation.js";
25
+ export const DEFAULT_EXECUTION_RECEIPT_TTL_MS = 30 * 60 * 1000;
26
+ function normalizePathEntry(entry) {
27
+ return entry.trim().replace(/\\/g, "/").replace(/^\.\//, "");
28
+ }
29
+ function isBroadWriteSetEntry(entry) {
30
+ const normalized = normalizePathEntry(entry);
31
+ if (!normalized || normalized === "." || normalized === "./")
32
+ return true;
33
+ if (normalized === "**")
34
+ return true;
35
+ // Placeholder marker (`REPLACE/WITH`) means the writeSet is not concrete yet.
36
+ return normalized.includes("REPLACE/WITH");
37
+ }
38
+ function isContainedInAllowed(candidate, allowedPaths) {
39
+ const normalized = normalizePathEntry(candidate);
40
+ if (!normalized)
41
+ return false;
42
+ return allowedPaths.some((allowed) => {
43
+ const pattern = normalizePathEntry(allowed);
44
+ if (!pattern || pattern === "." || pattern === "./")
45
+ return false;
46
+ return pathMatchesPattern(normalized, pattern);
47
+ });
48
+ }
49
+ function overlapsForbidden(candidate, forbiddenPaths) {
50
+ const normalized = normalizePathEntry(candidate);
51
+ if (!normalized)
52
+ return true;
53
+ return forbiddenPaths.some((forbidden) => {
54
+ const pattern = normalizePathEntry(forbidden);
55
+ if (!pattern)
56
+ return false;
57
+ return (pathMatchesPattern(normalized, pattern) ||
58
+ pathMatchesPattern(pattern, normalized));
59
+ });
60
+ }
61
+ /**
62
+ * G2 deterministic autonomous-execution assessment (design §5.2). Pure and
63
+ * exported so the dispatcher and unit tests share the exact same code path.
64
+ * Every condition must pass — any missing fact fails closed.
65
+ */
66
+ export function assessAutonomousExecutionEligibility(input) {
67
+ const dangerous = input.dangerousMarkers ?? {};
68
+ if (dangerous.credentials || dangerous.destructive || dangerous.irreversible) {
69
+ return {
70
+ ok: false,
71
+ reason: "DAG involves credentials, destructive operations, or irreversible migration; human authorization required",
72
+ };
73
+ }
74
+ if (!input.hasStructuredVerification) {
75
+ return {
76
+ ok: false,
77
+ reason: "DAG has no structured shell verification; autonomous execution not permitted",
78
+ };
79
+ }
80
+ if (input.broadWriteSetRisk) {
81
+ return {
82
+ ok: false,
83
+ reason: "writeSet contains broad/placeholder entries; autonomous execution not permitted",
84
+ };
85
+ }
86
+ if (input.forbiddenOverlapRisk) {
87
+ return {
88
+ ok: false,
89
+ reason: "writeSet overlaps forbidden paths; autonomous execution not permitted",
90
+ };
91
+ }
92
+ if (!Array.isArray(input.allowedPaths) || !Array.isArray(input.forbiddenPaths)) {
93
+ return {
94
+ ok: false,
95
+ reason: "task boundary (allowedPaths/forbiddenPaths) unavailable; autonomous execution not permitted",
96
+ };
97
+ }
98
+ for (const writer of input.writersWriteSets ?? []) {
99
+ if (!Array.isArray(writer.writeSet))
100
+ continue;
101
+ for (const entry of writer.writeSet) {
102
+ if (!isContainedInAllowed(entry, input.allowedPaths)) {
103
+ return {
104
+ ok: false,
105
+ reason: `writer ${writer.nodeId} writeSet entry "${entry}" is outside allowedPaths`,
106
+ };
107
+ }
108
+ if (overlapsForbidden(entry, input.forbiddenPaths)) {
109
+ return {
110
+ ok: false,
111
+ reason: `writer ${writer.nodeId} writeSet entry "${entry}" overlaps forbiddenPaths`,
112
+ };
113
+ }
114
+ }
115
+ }
116
+ return { ok: true };
117
+ }
118
+ /**
119
+ * Extract the deterministic G2 facts from a generated DAG spec (staged bytes).
120
+ * Mirrors the CLI review-packet semantics (generate-task-dag.ts) so the
121
+ * console never trusts client-supplied claims. Missing facts fail closed.
122
+ */
123
+ export function deriveEligibilityFactsFromDagSpec(input) {
124
+ const spec = (input.dagSpec ?? {});
125
+ const tasks = Array.isArray(spec.tasks) ? spec.tasks : [];
126
+ const defaultWritePolicy = spec.defaults?.writePolicy;
127
+ const resolvePolicy = (task) => task.writePolicy ?? defaultWritePolicy ?? "none";
128
+ const stringList = (value) => Array.isArray(value)
129
+ ? value.filter((v) => typeof v === "string")
130
+ : [];
131
+ const writers = tasks
132
+ .filter((task) => resolvePolicy(task) === "exclusive" &&
133
+ stringList(task.writeSet).length > 0)
134
+ .map((task) => ({
135
+ nodeId: task.id ?? "unknown",
136
+ writeSet: stringList(task.writeSet),
137
+ }));
138
+ const allWriteSetEntries = writers.flatMap((writer) => writer.writeSet);
139
+ const broadWriteSetRisk = allWriteSetEntries.some(isBroadWriteSetEntry);
140
+ const forbiddenOverlapRisk = tasks.some((task) => stringList(task.writeSet).some((entry) => [...stringList(task.forbiddenPaths), ...input.forbiddenPaths].some((forbidden) => pathMatchesPattern(entry, forbidden) ||
141
+ pathMatchesPattern(forbidden, entry))));
142
+ const hasStructuredVerification = tasks.some((task) => {
143
+ if (task.executor !== "shell" || !task.shell)
144
+ return false;
145
+ if (/verify|verification/i.test(task.id ?? ""))
146
+ return true;
147
+ const commands = stringList(task.shell.commands);
148
+ return commands.some((command) => /(vitest|npm run (lint|typecheck|test)|check-repo\.sh|loop-agent-standard-verify)/.test(command));
149
+ });
150
+ return {
151
+ writersWriteSets: writers,
152
+ allowedPaths: input.allowedPaths,
153
+ forbiddenPaths: input.forbiddenPaths,
154
+ broadWriteSetRisk,
155
+ forbiddenOverlapRisk,
156
+ hasStructuredVerification,
157
+ };
158
+ }
159
+ function receiptPath(appData, executionId) {
160
+ const dir = appData.dagReceipts ??
161
+ path.join(path.dirname(appData.confirmations), "dag-receipts");
162
+ return path.join(dir, `${executionId}.json`);
163
+ }
164
+ export function newExecutionId() {
165
+ return `exec_${randomBytes(12).toString("hex")}`;
166
+ }
167
+ export class DagExecutionReceiptStore {
168
+ appData;
169
+ constructor(appData) {
170
+ this.appData = appData;
171
+ }
172
+ async get(executionId) {
173
+ const rec = await readJsonIfExists(receiptPath(this.appData, executionId));
174
+ if (!rec)
175
+ return undefined;
176
+ return this.materializeExpiry(rec);
177
+ }
178
+ async materializeExpiry(rec, now = Date.now()) {
179
+ if (rec.state === "prepared" && Date.parse(rec.expiresAt) <= now) {
180
+ const expired = {
181
+ ...rec,
182
+ state: "expired",
183
+ staleReason: rec.staleReason ?? "ttl exceeded (30min default)",
184
+ };
185
+ await writeSecureJson(receiptPath(this.appData, rec.executionId), expired);
186
+ return expired;
187
+ }
188
+ return rec;
189
+ }
190
+ /**
191
+ * Issue a single-use execution receipt. G2 eligibility is re-checked here
192
+ * (fail closed) in addition to the dispatcher's pre-check.
193
+ */
194
+ async prepare(input) {
195
+ const assessment = assessAutonomousExecutionEligibility(input.eligibility);
196
+ if (!assessment.ok) {
197
+ return {
198
+ ok: false,
199
+ code: "HUMAN_CONFIRMATION_REQUIRED",
200
+ reason: assessment.reason,
201
+ };
202
+ }
203
+ if (!input.dagBytes ||
204
+ !input.dagBytesPath ||
205
+ !input.sourceBindingSha256 ||
206
+ !input.controllerFingerprint ||
207
+ !input.validationResultSha256 ||
208
+ !input.taskContractBinding?.taskId) {
209
+ return {
210
+ ok: false,
211
+ code: "INVALID_INPUT",
212
+ reason: "missing server-derived DAG/binding facts for execution receipt",
213
+ };
214
+ }
215
+ const now = input.now ?? new Date();
216
+ const ttl = input.ttlMs ?? DEFAULT_EXECUTION_RECEIPT_TTL_MS;
217
+ const executionId = newExecutionId();
218
+ const rec = {
219
+ schemaVersion: 1,
220
+ executionId,
221
+ state: "prepared",
222
+ preparedAt: now.toISOString(),
223
+ expiresAt: new Date(now.getTime() + ttl).toISOString(),
224
+ operatorSessionId: input.operatorSessionId,
225
+ taskId: input.taskId,
226
+ dagHandle: input.dagHandle,
227
+ dagSha256: hashDagBytes(input.dagBytes),
228
+ dagBytesPath: input.dagBytesPath,
229
+ sourceBindingSha256: input.sourceBindingSha256,
230
+ taskContractBinding: input.taskContractBinding,
231
+ controllerFingerprint: input.controllerFingerprint,
232
+ validationResultSha256: input.validationResultSha256,
233
+ reviewPacket: input.reviewPacket,
234
+ };
235
+ await writeSecureJson(receiptPath(this.appData, executionId), rec);
236
+ return { ok: true, receipt: rec };
237
+ }
238
+ /**
239
+ * Atomic single-use consume for runDag. Re-reads the staged DAG bytes and
240
+ * re-validates every binding; any drift/expiry/session/replay fails closed.
241
+ */
242
+ async consume(input) {
243
+ const rec = await this.get(input.executionId);
244
+ if (!rec) {
245
+ return {
246
+ ok: false,
247
+ code: "NOT_FOUND",
248
+ message: `execution receipt not found: ${input.executionId}`,
249
+ };
250
+ }
251
+ if (rec.state === "expired") {
252
+ return {
253
+ ok: false,
254
+ code: "CONFIRMATION_EXPIRED",
255
+ message: "execution receipt expired",
256
+ receipt: rec,
257
+ };
258
+ }
259
+ if (rec.state === "consumed") {
260
+ return {
261
+ ok: false,
262
+ code: "CONFIRMATION_CONSUMED",
263
+ message: "execution receipt already consumed (single-use)",
264
+ receipt: rec,
265
+ };
266
+ }
267
+ if (rec.state === "stale") {
268
+ return {
269
+ ok: false,
270
+ code: "CONFIRMATION_STALE",
271
+ message: rec.staleReason ?? "execution receipt is stale",
272
+ receipt: rec,
273
+ };
274
+ }
275
+ if (rec.state !== "prepared") {
276
+ return {
277
+ ok: false,
278
+ code: "HUMAN_CONFIRMATION_REQUIRED",
279
+ message: `execution receipt is ${rec.state}; re-prepare required`,
280
+ receipt: rec,
281
+ };
282
+ }
283
+ if (rec.operatorSessionId !== input.operatorSessionId) {
284
+ return {
285
+ ok: false,
286
+ code: "HUMAN_CONFIRMATION_REQUIRED",
287
+ message: "session mismatch; execution receipt cannot be consumed across operator sessions",
288
+ receipt: rec,
289
+ };
290
+ }
291
+ // Re-read staged DAG bytes and re-hash (fail closed on any drift).
292
+ let dagBytes;
293
+ try {
294
+ dagBytes = await readFile(rec.dagBytesPath, "utf8");
295
+ }
296
+ catch (error) {
297
+ const stale = await this.markStale(rec.executionId, `cannot re-read staged DAG bytes: ${error instanceof Error ? error.message : String(error)}`);
298
+ return {
299
+ ok: false,
300
+ code: "CONFIRMATION_STALE",
301
+ message: "cannot re-read staged DAG bytes",
302
+ receipt: stale,
303
+ };
304
+ }
305
+ const dagSha256 = hashDagBytes(dagBytes);
306
+ if (dagSha256 !== rec.dagSha256) {
307
+ const stale = await this.markStale(rec.executionId, "staged DAG bytes drift (hash mismatch on consume)");
308
+ return {
309
+ ok: false,
310
+ code: "CONFIRMATION_STALE",
311
+ message: "staged DAG bytes drift (hash mismatch on consume)",
312
+ receipt: stale,
313
+ };
314
+ }
315
+ const drift = detectReceiptBindingDrift(rec, {
316
+ sourceBindingSha256: input.sourceBindingSha256,
317
+ taskContractBinding: input.taskContractBinding,
318
+ controllerFingerprint: input.controllerFingerprint,
319
+ validationResultSha256: input.validationResultSha256,
320
+ });
321
+ if (drift) {
322
+ const stale = await this.markStale(rec.executionId, drift);
323
+ return {
324
+ ok: false,
325
+ code: "CONFIRMATION_STALE",
326
+ message: drift,
327
+ receipt: stale,
328
+ };
329
+ }
330
+ const now = input.now ?? new Date();
331
+ const consumed = {
332
+ ...rec,
333
+ state: "consumed",
334
+ consumedAt: now.toISOString(),
335
+ };
336
+ await writeSecureJson(receiptPath(this.appData, rec.executionId), consumed);
337
+ return { ok: true, receipt: consumed };
338
+ }
339
+ /** Record the operation id that consumed this receipt (post-accept). */
340
+ async markDispatched(executionId, operationId) {
341
+ const rec = await this.get(executionId);
342
+ if (!rec || rec.state !== "consumed")
343
+ return rec;
344
+ const dispatched = {
345
+ ...rec,
346
+ consumedOperationId: operationId,
347
+ };
348
+ await writeSecureJson(receiptPath(this.appData, executionId), dispatched);
349
+ return dispatched;
350
+ }
351
+ async markStale(executionId, reason) {
352
+ const rec = await this.get(executionId);
353
+ if (!rec) {
354
+ throw new Error(`execution receipt not found: ${executionId}`);
355
+ }
356
+ const stale = {
357
+ ...rec,
358
+ state: "stale",
359
+ staleReason: reason,
360
+ };
361
+ await writeSecureJson(receiptPath(this.appData, executionId), stale);
362
+ return stale;
363
+ }
364
+ }
365
+ function detectReceiptBindingDrift(rec, current) {
366
+ if (current.sourceBindingSha256 !== rec.sourceBindingSha256) {
367
+ return "sourceBindingSha256 binding drift";
368
+ }
369
+ if (current.controllerFingerprint !== rec.controllerFingerprint) {
370
+ return "controllerFingerprint binding drift";
371
+ }
372
+ if (current.validationResultSha256 !== rec.validationResultSha256) {
373
+ return "validationResultSha256 binding drift";
374
+ }
375
+ const a = sha256Utf8(JSON.stringify(rec.taskContractBinding));
376
+ const b = sha256Utf8(JSON.stringify(current.taskContractBinding));
377
+ if (a !== b)
378
+ return "taskContractBinding drift";
379
+ return undefined;
380
+ }