@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
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Console PRD Intake Bridge (design 2026-08-11-console-prd-intake-bridge).
3
+ * Maps task-side intake artifacts / projected source into Console DraftStore
4
+ * fields. Does not run Semantic Intake itself — that stays in task advance.
5
+ */
6
+ import { access, readFile } from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { getTaskPaths } from "../../task/runtime.js";
9
+ import { SEMANTIC_INTAKE_ARTIFACT_REL, } from "../../task/source-prepare/semantic-intake.js";
10
+ import { extractRequirementFactsFromMarkdown } from "../../task/source-prepare/parse-intent.js";
11
+ import { emptyDraft } from "./interview/grill-me.js";
12
+ import { normalizeConsoleWorkflowKind } from "./workflow-kinds.js";
13
+ function asStringList(value) {
14
+ if (!Array.isArray(value))
15
+ return [];
16
+ return value
17
+ .map((item) => {
18
+ if (typeof item === "string")
19
+ return item.trim();
20
+ if (item && typeof item === "object") {
21
+ const record = item;
22
+ if (typeof record.text === "string")
23
+ return record.text.trim();
24
+ if (typeof record.message === "string")
25
+ return record.message.trim();
26
+ }
27
+ return String(item ?? "").trim();
28
+ })
29
+ .filter(Boolean);
30
+ }
31
+ function asAcceptance(value) {
32
+ if (!Array.isArray(value))
33
+ return [];
34
+ const out = [];
35
+ for (let i = 0; i < value.length; i += 1) {
36
+ const item = value[i];
37
+ if (typeof item === "string" && item.trim()) {
38
+ out.push({ id: `AC-${i + 1}`, text: item.trim() });
39
+ continue;
40
+ }
41
+ if (item && typeof item === "object") {
42
+ const record = item;
43
+ const text = typeof record.text === "string" ? record.text.trim() : "";
44
+ if (!text)
45
+ continue;
46
+ const id = typeof record.id === "string" && record.id.trim()
47
+ ? record.id.trim()
48
+ : `AC-${i + 1}`;
49
+ out.push({ id, text });
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+ function warningList(json) {
55
+ if (!json || typeof json !== "object")
56
+ return [];
57
+ const root = json;
58
+ const raw = Array.isArray(root.warnings)
59
+ ? root.warnings
60
+ : Array.isArray(root.result?.warnings)
61
+ ? root.result?.warnings
62
+ : [];
63
+ const out = [];
64
+ for (const item of raw) {
65
+ if (!item || typeof item !== "object")
66
+ continue;
67
+ const record = item;
68
+ const code = typeof record.code === "string" && record.code.trim()
69
+ ? record.code.trim()
70
+ : "UNKNOWN";
71
+ const message = typeof record.message === "string" ? record.message : code;
72
+ out.push({ code, message });
73
+ }
74
+ return out;
75
+ }
76
+ function lifecycleFromAdvanceJson(json) {
77
+ if (!json || typeof json !== "object")
78
+ return undefined;
79
+ const root = json;
80
+ const value = root.result?.lifecycleState ?? root.lifecycleState;
81
+ return typeof value === "string" ? value : undefined;
82
+ }
83
+ async function readJsonIfExists(filePath) {
84
+ try {
85
+ await access(filePath);
86
+ const raw = await readFile(filePath, "utf8");
87
+ return JSON.parse(raw);
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
93
+ function hasRequirementStructure(draft) {
94
+ const req = draft.requirement ?? {};
95
+ return Boolean(typeof req.objective === "string" &&
96
+ req.objective.trim() &&
97
+ Array.isArray(req.scope) &&
98
+ req.scope.length > 0 &&
99
+ Array.isArray(req.acceptanceCriteria) &&
100
+ req.acceptanceCriteria.length > 0);
101
+ }
102
+ /** Normalize path / verify lists from operator params (arrays or newline/comma text). */
103
+ export function parseEngineeringBoundaryFromParams(p) {
104
+ const asList = (value) => {
105
+ if (Array.isArray(value)) {
106
+ return value
107
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
108
+ .filter(Boolean);
109
+ }
110
+ if (typeof value === "string") {
111
+ return value
112
+ .split(/[\n,;,;]+/)
113
+ .map((s) => s.trim())
114
+ .filter(Boolean);
115
+ }
116
+ return [];
117
+ };
118
+ const allowedPaths = asList(p.allowedPaths ?? p.allowedPath);
119
+ const forbiddenPaths = asList(p.forbiddenPaths ?? p.forbiddenPath);
120
+ const verifyRaw = p.verifyCommands ?? p.verify;
121
+ const verifyCommands = [];
122
+ if (Array.isArray(verifyRaw)) {
123
+ for (let i = 0; i < verifyRaw.length; i += 1) {
124
+ const item = verifyRaw[i];
125
+ if (typeof item === "string" && item.trim()) {
126
+ const trimmed = item.trim();
127
+ const idx = trimmed.indexOf(":");
128
+ if (idx > 0 && idx < trimmed.length - 1 && !trimmed.slice(0, idx).includes(" ")) {
129
+ verifyCommands.push({
130
+ label: trimmed.slice(0, idx),
131
+ command: trimmed.slice(idx + 1).trim(),
132
+ });
133
+ }
134
+ else {
135
+ verifyCommands.push({
136
+ label: `verify-${i + 1}`,
137
+ command: trimmed,
138
+ });
139
+ }
140
+ }
141
+ else if (item && typeof item === "object") {
142
+ const rec = item;
143
+ const command = typeof rec.command === "string" ? rec.command.trim() : "";
144
+ if (!command)
145
+ continue;
146
+ verifyCommands.push({
147
+ label: typeof rec.label === "string" && rec.label.trim()
148
+ ? rec.label.trim()
149
+ : `verify-${i + 1}`,
150
+ command,
151
+ ...(typeof rec.timeoutMs === "number"
152
+ ? { timeoutMs: rec.timeoutMs }
153
+ : {}),
154
+ });
155
+ }
156
+ }
157
+ }
158
+ else if (typeof verifyRaw === "string" && verifyRaw.trim()) {
159
+ for (const [i, line] of asList(verifyRaw).entries()) {
160
+ const idx = line.indexOf(":");
161
+ if (idx > 0 && idx < line.length - 1 && !line.slice(0, idx).includes(" ")) {
162
+ verifyCommands.push({
163
+ label: line.slice(0, idx),
164
+ command: line.slice(idx + 1).trim(),
165
+ });
166
+ }
167
+ else {
168
+ verifyCommands.push({ label: `verify-${i + 1}`, command: line });
169
+ }
170
+ }
171
+ }
172
+ return {
173
+ ...(allowedPaths.length > 0 ? { allowedPaths } : {}),
174
+ ...(forbiddenPaths.length > 0 ? { forbiddenPaths } : {}),
175
+ ...(verifyCommands.length > 0 ? { verifyCommands } : {}),
176
+ };
177
+ }
178
+ export function appendEngineeringCliArgs(args, boundary) {
179
+ for (const pathGlob of boundary.allowedPaths ?? []) {
180
+ args.push("--allowed-path", pathGlob);
181
+ }
182
+ for (const pathGlob of boundary.forbiddenPaths ?? []) {
183
+ args.push("--forbidden-path", pathGlob);
184
+ }
185
+ for (const cmd of boundary.verifyCommands ?? []) {
186
+ args.push("--verify", `${cmd.label}:${cmd.command}`);
187
+ }
188
+ }
189
+ export function applyEngineeringBoundaryToDraft(draft, boundary) {
190
+ if (!(boundary.allowedPaths?.length) &&
191
+ !(boundary.forbiddenPaths?.length) &&
192
+ !(boundary.verifyCommands?.length)) {
193
+ return draft;
194
+ }
195
+ return {
196
+ ...draft,
197
+ constraints: {
198
+ invariants: draft.constraints?.invariants ?? [],
199
+ allowedPaths: boundary.allowedPaths?.length
200
+ ? boundary.allowedPaths
201
+ : (draft.constraints?.allowedPaths ?? []),
202
+ forbiddenPaths: boundary.forbiddenPaths?.length
203
+ ? boundary.forbiddenPaths
204
+ : (draft.constraints?.forbiddenPaths ?? []),
205
+ },
206
+ verification: {
207
+ commands: boundary.verifyCommands?.length
208
+ ? boundary.verifyCommands
209
+ : (draft.verification?.commands ?? []),
210
+ },
211
+ };
212
+ }
213
+ export async function buildDraftFromTaskIntake(input) {
214
+ const taskKind = normalizeConsoleWorkflowKind(input.taskKind, "standard");
215
+ const paths = getTaskPaths(input.repoRoot, input.taskId);
216
+ const warnings = warningList(input.advanceJson);
217
+ const lifecycleState = lifecycleFromAdvanceJson(input.advanceJson);
218
+ const semanticApplied = warnings.some((w) => w.code === "SEMANTIC_INTAKE_APPLIED");
219
+ const semanticFailed = warnings.find((w) => w.code === "SEMANTIC_INTAKE_PI_FAILED" ||
220
+ w.code === "SEMANTIC_INTAKE_INVALID_OUTPUT" ||
221
+ w.code === "SEMANTIC_INTAKE_FAILED" ||
222
+ w.code === "SEMANTIC_INTAKE_NO_DOCUMENTS" ||
223
+ w.code === "SEMANTIC_INTAKE_REFUSED_NON_EXECUTABLE");
224
+ const semanticSkipped = warnings.find((w) => w.code === "SEMANTIC_INTAKE_SKIPPED");
225
+ const artifactPath = path.join(paths.taskDir, SEMANTIC_INTAKE_ARTIFACT_REL);
226
+ const artifact = await readJsonIfExists(artifactPath);
227
+ let draft = {
228
+ ...emptyDraft(input.taskId, input.title, { taskKind }),
229
+ requirement: {
230
+ objective: input.title,
231
+ },
232
+ };
233
+ let parseStatus = "pending";
234
+ let semanticIntake = {
235
+ applied: false,
236
+ code: semanticFailed?.code ?? semanticSkipped?.code,
237
+ message: semanticFailed?.message ?? semanticSkipped?.message,
238
+ };
239
+ const semanticReq = artifact?.draft?.requirement ??
240
+ (artifact?.semantic
241
+ ? {
242
+ objective: artifact.semantic.objective,
243
+ scope: artifact.semantic.scope,
244
+ nonGoals: artifact.semantic.nonGoals,
245
+ acceptanceCriteria: artifact.semantic.acceptanceCriteria,
246
+ }
247
+ : undefined);
248
+ // An artifact is authoritative only when this exact advance invocation reports
249
+ // SEMANTIC_INTAKE_APPLIED. Tasks may import newer immutable PRD revisions while
250
+ // an older per-task semantic artifact remains on disk; never project it as the
251
+ // current Console draft.
252
+ if (semanticApplied && semanticReq?.objective?.trim()) {
253
+ const title = artifact?.draft?.title?.trim() ||
254
+ artifact?.semantic?.title?.trim() ||
255
+ input.title;
256
+ draft = {
257
+ ...draft,
258
+ title,
259
+ requirement: {
260
+ objective: semanticReq.objective.trim(),
261
+ scope: asStringList(semanticReq.scope),
262
+ nonGoals: asStringList(semanticReq.nonGoals),
263
+ acceptanceCriteria: asAcceptance(semanticReq.acceptanceCriteria),
264
+ },
265
+ openQuestions: asStringList(artifact?.draft?.openQuestions ?? artifact?.semantic?.openQuestions) || undefined,
266
+ assumptions: asStringList(artifact?.draft?.assumptions ?? artifact?.semantic?.assumptions) || undefined,
267
+ references: artifact?.draft?.references,
268
+ };
269
+ parseStatus = "semantic";
270
+ semanticIntake = {
271
+ applied: true,
272
+ code: "SEMANTIC_INTAKE_APPLIED",
273
+ message: "structured from artifacts/intake/semantic-draft.json",
274
+ };
275
+ }
276
+ else {
277
+ // Deterministic path. When the current intake is incomplete/failed/skipped,
278
+ // prefer the newest imported reference over an older projected 需求.md.
279
+ // Successful deterministic projection still prefers canonical 需求.md.
280
+ const projectedRequirementPath = path.join(paths.sourceDir, "需求.md");
281
+ const referenceCandidates = [];
282
+ try {
283
+ const manifestPath = path.join(paths.sourceDir, "source-manifest.json");
284
+ const manifest = await readJsonIfExists(manifestPath);
285
+ for (const doc of manifest?.documents ?? []) {
286
+ if ((doc.role === "requirement" || doc.role === "acceptance") &&
287
+ typeof doc.materializedPath === "string") {
288
+ referenceCandidates.push({
289
+ filePath: path.join(paths.sourceDir, doc.materializedPath),
290
+ importedAt: typeof doc.importedAt === "string" ? doc.importedAt : "",
291
+ });
292
+ }
293
+ }
294
+ }
295
+ catch {
296
+ // ignore manifest read errors
297
+ }
298
+ referenceCandidates.sort((left, right) => right.importedAt.localeCompare(left.importedAt));
299
+ const preferImportedReferences = Boolean(semanticFailed ||
300
+ semanticSkipped ||
301
+ lifecycleState === "intake-incomplete");
302
+ const candidates = preferImportedReferences
303
+ ? [
304
+ ...referenceCandidates.map((candidate) => candidate.filePath),
305
+ projectedRequirementPath,
306
+ ]
307
+ : [
308
+ projectedRequirementPath,
309
+ ...referenceCandidates.map((candidate) => candidate.filePath),
310
+ ];
311
+ for (const filePath of candidates) {
312
+ try {
313
+ const markdown = await readFile(filePath, "utf8");
314
+ const facts = extractRequirementFactsFromMarkdown(markdown);
315
+ if (!facts.objective?.trim())
316
+ continue;
317
+ draft = {
318
+ ...draft,
319
+ title: facts.title?.trim() || input.title,
320
+ requirement: {
321
+ objective: facts.objective.trim(),
322
+ scope: facts.scope,
323
+ nonGoals: facts.nonGoals,
324
+ acceptanceCriteria: facts.acceptanceCriteria,
325
+ },
326
+ openQuestions: facts.openQuestions.length > 0
327
+ ? facts.openQuestions
328
+ : undefined,
329
+ assumptions: facts.assumptions.length > 0
330
+ ? facts.assumptions
331
+ : undefined,
332
+ };
333
+ parseStatus = hasRequirementStructure(draft)
334
+ ? "deterministic"
335
+ : "incomplete";
336
+ if (hasRequirementStructure(draft))
337
+ break;
338
+ }
339
+ catch {
340
+ // try next candidate
341
+ }
342
+ }
343
+ // Fallback: title-only objective (never invent scope/AC templates).
344
+ if (!draft.requirement?.objective?.trim()) {
345
+ draft = {
346
+ ...draft,
347
+ requirement: { objective: input.title },
348
+ };
349
+ parseStatus = "incomplete";
350
+ }
351
+ else if (parseStatus === "pending") {
352
+ parseStatus = hasRequirementStructure(draft)
353
+ ? "deterministic"
354
+ : "incomplete";
355
+ }
356
+ }
357
+ // Engineering gaps remain as draft gaps; do not invent paths/AC templates.
358
+ const gaps = warnings.filter((w) => w.code === "EMPTY_ALLOWED_PATHS" ||
359
+ w.code === "EMPTY_ACCEPTANCE" ||
360
+ w.code === "EMPTY_OBJECTIVE" ||
361
+ w.code === "EMPTY_FORBIDDEN_PATHS" ||
362
+ w.code === "INTAKE_INCOMPLETE" ||
363
+ w.code === "BOUNDARY_REQUIRED" ||
364
+ w.code.startsWith("SEMANTIC_INTAKE_") ||
365
+ w.code === "NO_PARSEABLE_REQUIREMENT");
366
+ if (semanticFailed && !semanticIntake.applied) {
367
+ if (!hasRequirementStructure(draft)) {
368
+ parseStatus = "failed";
369
+ }
370
+ semanticIntake = {
371
+ applied: false,
372
+ code: semanticFailed.code,
373
+ message: semanticFailed.message,
374
+ };
375
+ }
376
+ else if (!hasRequirementStructure(draft) && parseStatus !== "semantic") {
377
+ parseStatus = "incomplete";
378
+ }
379
+ if (input.engineering) {
380
+ draft = applyEngineeringBoundaryToDraft(draft, input.engineering);
381
+ }
382
+ return {
383
+ draft,
384
+ parseStatus,
385
+ semanticIntake,
386
+ gaps,
387
+ lifecycleState,
388
+ };
389
+ }
390
+ /** True when requirement structure is filled enough that Skip should not invent AC/scope. */
391
+ export function requirementStructurallyReady(draft) {
392
+ return hasRequirementStructure(draft);
393
+ }
@@ -37,9 +37,10 @@ const CTA = {
37
37
  id: "reconcile",
38
38
  label: "对账 / reconcile",
39
39
  requiresReason: true,
40
+ action: "dagReconcileRun",
40
41
  lane: "dag",
41
- guide: "状态不一致时先对账,再决定是否重跑(需原因)",
42
- commandHint: "loop-agent dag reconcile-run --run-id <run-id> --reason <structured-reason>",
42
+ guide: "Orphan/中断时先对账归档(默认 supersede);runner 仍存活时勿盲 supersede",
43
+ commandHint: "loop-agent dag reconcile-run --run-id <run-id> --action supersede --reason <structured-reason>",
43
44
  },
44
45
  regenerate: {
45
46
  id: "regenerate",
@@ -97,7 +98,9 @@ const MATRIX = {
97
98
  "resume",
98
99
  "regenerate",
99
100
  ],
100
- "orphan-unknown": ["report", "doctor", "reconcile"],
101
+ // Stall/quiet/unknown: observe + doctor only — do not offer supersede while
102
+ // runner may still be alive (eligibility fail-closes, but UI should not tease).
103
+ "orphan-unknown": ["report", "doctor"],
101
104
  "terminal-failed": [
102
105
  "report",
103
106
  "doctor",
@@ -137,8 +140,9 @@ export function partitionRecoveryCtas(ctas) {
137
140
  }
138
141
  /**
139
142
  * Console only shows CTAs that actually dispatch an operator action.
140
- * Pure CLI-hint rows (regenerate / decision / resume / reconcile) stay in the
141
- * policy matrix for docs, but must not appear as dead buttons.
143
+ * Pure CLI-hint rows (regenerate / decision / resume) stay in the policy
144
+ * matrix for docs, but must not appear as dead buttons. reconcile is wired
145
+ * to dagReconcileRun (structured reason required).
142
146
  */
143
147
  export function isConsoleExecutableRecoveryCta(cta) {
144
148
  return typeof cta.action === "string" && cta.action.trim().length > 0;
@@ -167,6 +171,8 @@ export function recommendedRecoveryCtaId(factClass) {
167
171
  case "pause-on-human":
168
172
  return pick("doctor", "report");
169
173
  case "needs-reconcile":
174
+ // Prefer doctor first; reconcile is available when operator confirms reason.
175
+ return pick("doctor", "reconcile", "report");
170
176
  case "runtime-mismatch":
171
177
  case "contract-drift":
172
178
  return pick("doctor", "report");
@@ -201,6 +207,7 @@ export function classifyRecoveryFact(input) {
201
207
  const code = (input.errorCode ?? "").toLowerCase();
202
208
  const state = (input.operationState ?? "").toLowerCase();
203
209
  const dag = (input.dagStatus ?? "").toLowerCase();
210
+ const failure = (input.failureCategory ?? "").toLowerCase();
204
211
  if (code.includes("validation") ||
205
212
  code.includes("invalid") ||
206
213
  state.includes("validation")) {
@@ -225,15 +232,37 @@ export function classifyRecoveryFact(input) {
225
232
  if (code.includes("verif") || state.includes("verif")) {
226
233
  return "verification-failed";
227
234
  }
235
+ // Controller interrupt / orphaned mid-run: reconcile first (not blind rerun).
236
+ if (failure === "controller-interrupted" ||
237
+ code.includes("controller-interrupted") ||
238
+ code.includes("interrupted") ||
239
+ dag === "interrupted" ||
240
+ dag === "orphaned" ||
241
+ state.includes("controller-interrupted") ||
242
+ state.includes("interrupted")) {
243
+ return "needs-reconcile";
244
+ }
245
+ // Live stall / quiet while runner lease may still be fresh — observe/doctor only
246
+ // (Console executable CTAs: report + doctor; reconcile stays CLI-hint).
247
+ if (dag === "running-suspected-stall" ||
248
+ dag === "running-quiet" ||
249
+ dag === "suspected-stall" ||
250
+ dag.includes("suspected-stall") ||
251
+ state.includes("suspected-stall")) {
252
+ return "orphan-unknown";
253
+ }
228
254
  if (state === "succeeded" ||
229
255
  state === "completed" ||
256
+ state === "finished" ||
230
257
  dag === "succeeded" ||
231
- dag === "completed") {
258
+ dag === "completed" ||
259
+ dag === "finished") {
232
260
  return "terminal-succeeded";
233
261
  }
234
262
  if (state === "failed" ||
235
263
  state.includes("fail") ||
236
264
  dag === "failed" ||
265
+ dag === "partial_failed" ||
237
266
  code.includes("fail")) {
238
267
  return "terminal-failed";
239
268
  }
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { LoopAgentClient } from "../loop-agent/loop-agent-client.js";
7
7
  import { openConsoleAppData } from "./app-data.js";
8
8
  import { DagConfirmationStore } from "./dag-confirmation.js";
9
+ import { DagExecutionReceiptStore } from "./dag-execution-receipt.js";
9
10
  import { DraftStore } from "./draft-store.js";
10
11
  import { AssessmentStore } from "./interview/assessment.js";
11
12
  import { InterviewSessionStore } from "./interview/session.js";
@@ -59,6 +60,7 @@ export async function createConsoleServer(options) {
59
60
  });
60
61
  const drafts = new DraftStore(appData);
61
62
  const confirmations = new DagConfirmationStore(appData, bootToken.confirmationToken);
63
+ const dagExecutionReceipts = new DagExecutionReceiptStore(appData);
62
64
  const interviews = new InterviewSessionStore(appData);
63
65
  const assessments = new AssessmentStore(appData);
64
66
  const readiness = await probePiReadiness({
@@ -94,6 +96,7 @@ export async function createConsoleServer(options) {
94
96
  events,
95
97
  drafts,
96
98
  confirmations,
99
+ dagExecutionReceipts,
97
100
  humanGateSecret: bootToken.confirmationToken,
98
101
  interviews,
99
102
  assessments,
@@ -111,16 +114,18 @@ export async function createConsoleServer(options) {
111
114
  // custom-tool execute() calls dispatch into the operator action layer (Gate 3).
112
115
  // Chat session dir is dedicated (app-data/chat-sessions), NOT user default.
113
116
  const chatStore = new ChatSessionStore(appData);
114
- const chatEvents = createChatEventStore({ appData });
117
+ // M5.1: every Console boot owns a fresh event-log generation. A browser
118
+ // reconnecting with an old SSE cursor must reconcile from snapshot rather
119
+ // than silently consume a different process' event namespace.
120
+ const chatEventEpoch = randomBytes(12).toString("hex");
121
+ const chatEvents = createChatEventStore({ appData, epoch: chatEventEpoch });
115
122
  const chatOperationLinker = createChatOperationLinker({
116
123
  chatEvents,
117
124
  operations,
118
125
  operationEvents: events,
119
126
  });
120
- const skillsDir = resolveLoopAgentSkillsDir(repoRoot);
121
127
  const chatRuntime = new ConsolePiRuntime({
122
128
  cwd: repoRoot,
123
- skillsDir,
124
129
  sessionDir: appData.chatSessions,
125
130
  actionContext,
126
131
  });
@@ -137,7 +142,6 @@ export async function createConsoleServer(options) {
137
142
  bootToken,
138
143
  getConsoleOrigin,
139
144
  allowNonLoopbackAccess: !loopbackBind,
140
- skillsDir,
141
145
  };
142
146
  const nightAuxTicker = new NightAuxTicker({
143
147
  repoRoot,
@@ -292,17 +296,6 @@ export async function createConsoleServer(options) {
292
296
  });
293
297
  });
294
298
  }
295
- /** Resolve the loop-agent repo skills/ directory (source repo or npm pack asset). */
296
- function resolveLoopAgentSkillsDir(repoRoot) {
297
- // When running from dist (npm pack), skills/ is a sibling of dist/ at the
298
- // package root. When running from source (tsx), it's the repo root.
299
- const fromDist = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "skills");
300
- const fromRepo = path.resolve(repoRoot, "skills");
301
- // Prefer whichever exists; dist path wins if present (npm-installed runtime).
302
- if (existsSync(fromDist))
303
- return fromDist;
304
- return fromRepo;
305
- }
306
299
  function requestSource(req) {
307
300
  const forwarded = req.headers["x-forwarded-for"];
308
301
  if (typeof forwarded === "string" && forwarded.trim()) {