@tea-agent/loop-agent 0.35.4-beta.0 → 0.36.1-beta.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 (53) hide show
  1. package/AGENTS.md +4 -2
  2. package/CHANGELOG.md +67 -0
  3. package/dist/application/task-lifecycle/advance.js +1 -0
  4. package/dist/application/task-lifecycle/observe.js +15 -0
  5. package/dist/application/task-lifecycle/plan-transitions.js +15 -0
  6. package/dist/build-stamp.json +3 -3
  7. package/dist/commands/init-upgrade.js +351 -19
  8. package/dist/commands/init.js +14 -67
  9. package/dist/commands/run-dag-progress.js +14 -0
  10. package/dist/commands/task-advance.js +33 -3
  11. package/dist/executors/dag-pi-executor.js +3 -1
  12. package/dist/shared/operator/capabilities.js +125 -11
  13. package/dist/task/source-prepare/completeness.js +17 -0
  14. package/dist/task/source-prepare/parse-intent.js +15 -1
  15. package/dist/worker/console/chat/artifact-card.js +8 -1
  16. package/dist/worker/console/chat/chat-event-store.js +61 -0
  17. package/dist/worker/console/chat/human-gate-card.js +9 -1
  18. package/dist/worker/console/chat/operation-card.js +71 -2
  19. package/dist/worker/console/chat/pi-runtime.js +69 -30
  20. package/dist/worker/console/chat/routes.js +27 -4
  21. package/dist/worker/console/chat/semantic-activity.js +465 -0
  22. package/dist/worker/console/chat/turn-process.js +31 -12
  23. package/dist/worker/console/operation-run-facts.js +190 -0
  24. package/dist/worker/console/operation-runner.js +107 -6
  25. package/dist/worker/console/operation-wait.js +314 -0
  26. package/dist/worker/console/operator-actions.js +153 -2
  27. package/dist/worker/console/static/assets/index-2OeZODxk.js +57 -0
  28. package/dist/worker/console/static/assets/index-DVJlUL8X.css +1 -0
  29. package/dist/worker/console/static/index.html +2 -2
  30. package/dist/worker/console/static-src/operator-chat/activity-journey.js +125 -0
  31. package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +73 -0
  32. package/dist/worker/console/static-src/operator-chat/activity-references.js +20 -0
  33. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +69 -31
  34. package/dist/worker/console/static-src/operator-chat/slash-palette-layout.js +24 -0
  35. package/dist/worker/console/static-src/operator-chat/slash-palette-nav.js +141 -0
  36. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +2 -1
  37. package/dist/worker/console/static-src/operator-chat/useActivityRailTransition.js +59 -0
  38. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +16 -5
  39. package/dist/worker/console/static-src/operator-chat/useComposer.js +30 -7
  40. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +7 -3
  41. package/dist/worker/observe/static/operator-chrome.js +1 -1
  42. package/dist/workflows/dag/init-hybrid.js +78 -4
  43. package/dist/workflows/dag/node-execution.js +16 -6
  44. package/dist/workflows/dag/retry-policy.js +11 -0
  45. package/docs/README.md +3 -3
  46. package/docs/architecture/evolution.md +102 -67
  47. package/docs/templates/init-managed-agents.md +5 -2
  48. package/harness.json +2 -2
  49. package/package.json +1 -1
  50. package/skills/loop-agent/SKILL.md +1 -0
  51. package/skills/loop-agent/references/command-reference.md +2 -0
  52. package/dist/worker/console/static/assets/index-DRqZiQ7J.css +0 -1
  53. package/dist/worker/console/static/assets/index-DuVLjCIT.js +0 -57
@@ -1,5 +1,6 @@
1
1
  import { advanceTaskLifecycle, } from "../application/task-lifecycle/index.js";
2
2
  import { buildOperatorResult, operatorFailed, processExitCodeForOutcome, writeOperatorJson, } from "../shared/operator/index.js";
3
+ import { createRunDagProgressObserver, validateRunDagProgressIntervalMs, } from "./run-dag-progress.js";
3
4
  const COMMAND = "task advance";
4
5
  const USAGE = `usage:
5
6
  task advance <task-id> [title]
@@ -24,6 +25,8 @@ const USAGE = `usage:
24
25
  [--dag-output <path>]
25
26
  [--skip-finalize]
26
27
  [--no-strict-models]
28
+ [--quiet]
29
+ [--progress-interval-ms <ms>]
27
30
  [--dry-run]
28
31
  [--json]`;
29
32
  function pushList(target, value) {
@@ -170,6 +173,19 @@ export function parseTaskAdvanceArgs(args) {
170
173
  options.strictModels = false;
171
174
  continue;
172
175
  }
176
+ if (token === "--quiet") {
177
+ options.quiet = true;
178
+ continue;
179
+ }
180
+ if (token === "--progress-interval-ms") {
181
+ const raw = next();
182
+ const parsed = Number(raw);
183
+ if (!Number.isFinite(parsed)) {
184
+ throw new Error(`progress-interval-ms must be an integer >= 1000\n${USAGE}`);
185
+ }
186
+ options.progressIntervalMs = validateRunDagProgressIntervalMs(parsed);
187
+ continue;
188
+ }
173
189
  if (token === "--help" || token === "-h") {
174
190
  throw new Error(USAGE);
175
191
  }
@@ -208,7 +224,7 @@ function mapOutcome(result) {
208
224
  return "blocked";
209
225
  return "succeeded";
210
226
  }
211
- function toUseCaseInput(repoRoot, taskId, options) {
227
+ export function toUseCaseInput(repoRoot, taskId, options, observer) {
212
228
  const timeouts = new Map((options.verifyTimeout ?? []).map((entry) => {
213
229
  const parsed = parseVerifyTimeout(entry);
214
230
  return [parsed.label, parsed.timeoutMs];
@@ -271,7 +287,8 @@ function toUseCaseInput(repoRoot, taskId, options) {
271
287
  dagOutputPath: options.dagOutputPath,
272
288
  skipFinalize: options.skipFinalize,
273
289
  strictModels: options.strictModels,
274
- onProgress: options.json
290
+ ...(observer ? { observer } : {}),
291
+ onProgress: options.quiet
275
292
  ? undefined
276
293
  : (message) => {
277
294
  process.stderr.write(`[task advance] ${message}\n`);
@@ -296,8 +313,18 @@ export async function runTaskAdvance(repoRoot, args) {
296
313
  process.exitCode = processExitCodeForOutcome(envelope.outcome);
297
314
  return;
298
315
  }
316
+ let progress;
299
317
  try {
300
- const result = await advanceTaskLifecycle(toUseCaseInput(repoRoot, taskId, options));
318
+ // AC-006/AC-007: periodic DAG progress goes to stderr only (stdout stays
319
+ // the single final OperatorCommandResultV1 JSON). The observer is created
320
+ // ONLY for the approve-gate execution path and disposed on every exit;
321
+ // its timer starts only after onRunStart (double guard, no stray timer).
322
+ if (options.approveGate && !options.dryRun && !options.quiet) {
323
+ progress = createRunDagProgressObserver({
324
+ intervalMs: options.progressIntervalMs,
325
+ });
326
+ }
327
+ const result = await advanceTaskLifecycle(toUseCaseInput(repoRoot, taskId, options, progress?.observer));
301
328
  const outcome = mapOutcome(result);
302
329
  const gateStop = result.lifecycleState === "awaiting-write-set-approval" &&
303
330
  result.blockers.length === 0;
@@ -333,4 +360,7 @@ export async function runTaskAdvance(repoRoot, args) {
333
360
  writeOperatorJson(envelope);
334
361
  process.exitCode = processExitCodeForOutcome(envelope.outcome);
335
362
  }
363
+ finally {
364
+ progress?.dispose();
365
+ }
336
366
  }
@@ -194,7 +194,7 @@ export function buildDagPiUserMessage(task, persona, step) {
194
194
  if (isDagPiWriteTask(task)) {
195
195
  const outcomeInstruction = task.writerOutcomePolicy
196
196
  ? [
197
- "The first non-empty response line must be exactly IMPLEMENTATION_OUTCOME: changed, IMPLEMENTATION_OUTCOME: already-satisfied, or IMPLEMENTATION_OUTCOME: blocked. Use changed only after producing a non-empty bounded diff; use already-satisfied only when the contract is already met and no file changed; use blocked when implementation cannot proceed.",
197
+ "The first non-empty response line must be exactly IMPLEMENTATION_OUTCOME: changed, IMPLEMENTATION_OUTCOME: already-satisfied, or IMPLEMENTATION_OUTCOME: blocked. The runner measures your diff mechanically from git status snapshots taken before and after this node: code pasted into the response text is NOT an implementation and yields an empty diff. Use changed only after actually calling write/edit tools that persist files to disk; use already-satisfied only when the contract is already met and no file changed; use blocked when implementation cannot proceed. Claiming changed without a persisted diff fails this node as invalid-output.",
198
198
  task.writerOutcomePolicy.requireChangedFiles
199
199
  ? "This generation node requires a non-empty bounded diff; already-satisfied cannot complete it successfully."
200
200
  : undefined,
@@ -206,6 +206,8 @@ export function buildDagPiUserMessage(task, persona, step) {
206
206
  `You are executing hybrid DAG node "${task.id}" (role=${role}, piStep=${step}, writePolicy=${writePolicy}).`,
207
207
  "The system prompt contains the full DAG envelope: objective, constraints, upstream context, and task.",
208
208
  "Use write tools only for the bounded implementation requested by this node.",
209
+ "The upstream plan node's output is a BLUEPRINT, not a deliverable: reading it never satisfies this node. You MUST materialize the plan into actual working-tree changes by calling write/edit tools before claiming changed. A detailed plan that names every file, test, or snippet still requires you to write those files yourself.",
210
+ "Forbidden paths bound the writeSet, not the task: when the task only requires writing inside the writeSet (e.g. adding tests that call existing public APIs), a forbidden src/** does not make the task impossible — do NOT claim blocked merely because some path you never need to write is forbidden. blocked is only for when you genuinely cannot produce the required writeSet changes.",
209
211
  outcomeInstruction,
210
212
  `Allowed paths: ${(task.allowedPaths ?? []).join(", ") || "(none declared)"}.`,
211
213
  `Write set: ${(task.writeSet ?? []).join(", ") || "(none declared)"}.`,
@@ -13,6 +13,13 @@ const CONTROLLER_PROTOCOL_CAPABILITIES = [
13
13
  "worker-attempt-revision-v1",
14
14
  "recovery-decision-v2",
15
15
  ];
16
+ function officialModelCallable(coverage, kind) {
17
+ if (coverage === "human-gated-required")
18
+ return "prepare-only";
19
+ if (coverage === "advanced" && kind !== "read")
20
+ return "never";
21
+ return "always";
22
+ }
16
23
  const officialAction = (action, cli, kind, coverage) => ({
17
24
  action,
18
25
  cli,
@@ -51,11 +58,7 @@ const officialAction = (action, cli, kind, coverage) => ({
51
58
  ]
52
59
  : []),
53
60
  ],
54
- modelCallable: coverage === "human-gated-required"
55
- ? "prepare-only"
56
- : coverage === "advanced" && kind !== "read"
57
- ? "never"
58
- : "always",
61
+ modelCallable: officialModelCallable(coverage, kind),
59
62
  humanConfirmation: coverage === "human-gated-required" ? "required" : "none",
60
63
  });
61
64
  const OFFICIAL_ACTIONS = [
@@ -142,13 +145,13 @@ const OFFICIAL_ACTIONS = [
142
145
  ["stats", "loop-agent stats", "read", "model-callable"],
143
146
  [
144
147
  "promoteRun",
145
- "loop-agent task advance <id> --json",
148
+ "loop-agent task advance",
146
149
  "mutation",
147
150
  "model-callable",
148
151
  ],
149
152
  [
150
153
  "closeoutTask",
151
- "loop-agent task advance <id> --json",
154
+ "loop-agent task advance",
152
155
  "mutation",
153
156
  "model-callable",
154
157
  ],
@@ -445,6 +448,49 @@ export function buildOperatorCapabilitiesDocument() {
445
448
  modelCallable: "always",
446
449
  humanConfirmation: "none",
447
450
  },
451
+ {
452
+ action: "operationWait",
453
+ cli: "console canonical operation wait (read-only event-driven long poll)",
454
+ kind: "read",
455
+ inputSchemaVersion: 1,
456
+ resultSchemaVersion: 1,
457
+ resultPolicy: { readOnly: true, bounded: true, redacted: true },
458
+ envelopeSchemaVersion: 1,
459
+ requiredErrorCodes: [
460
+ "NOT_FOUND",
461
+ "INVALID_INPUT",
462
+ "EVENT_CURSOR_EXPIRED",
463
+ ],
464
+ description: "Read-only event-driven wait on the canonical operation event ring. Default wakeOn=meaningful: heartbeats never settle the wait and are never returned as event payload (nextSeq still covers them); state/result/stdout/stderr/reconcile/error settle immediately; terminal or needs-reconcile flushes immediately. Every subsequent wait MUST pass afterSeq equal to the previous response nextSeq; omitting or reusing an older cursor replays already-consumed startup events. The response includes recommendedNextCall with the safe cursor. wakeOn=all (diagnostic compatibility) resolves on the first new event including heartbeats. maxWaitMs elapsing returns timedOut:true (a success summary, not a command failure).",
465
+ inputParams: [
466
+ {
467
+ name: "operationId",
468
+ type: "string",
469
+ required: true,
470
+ description: "canonical operation id",
471
+ },
472
+ {
473
+ name: "afterSeq",
474
+ type: "number",
475
+ required: false,
476
+ description: "event cursor; first wait may use 0, every later wait MUST use the previous response nextSeq (>= 0)",
477
+ },
478
+ {
479
+ name: "maxWaitMs",
480
+ type: "number",
481
+ required: false,
482
+ description: "bounded wait budget; clamped server-side (default 180000, floor 60000)",
483
+ },
484
+ {
485
+ name: "wakeOn",
486
+ type: "string",
487
+ required: false,
488
+ description: "wake policy: meaningful (default; heartbeats are cursor-only liveness) | all (first event settles, diagnostics)",
489
+ },
490
+ ],
491
+ modelCallable: "always",
492
+ humanConfirmation: "none",
493
+ },
448
494
  {
449
495
  action: "contractShow",
450
496
  cli: "loop-agent task status <taskId> --json",
@@ -896,6 +942,74 @@ export function buildOperatorCapabilitiesDocument() {
896
942
  modelCallable: "always",
897
943
  humanConfirmation: "none",
898
944
  },
945
+ {
946
+ action: "taskAdvance",
947
+ cli: "loop-agent task advance <taskId> [title] --task-kind <kind> --allowed-path <glob> --forbidden-path <glob> --verify <label:command> --json",
948
+ kind: "long-running",
949
+ inputSchemaVersion: 1,
950
+ resultSchemaVersion: 1,
951
+ envelopeSchemaVersion: 1,
952
+ requiredErrorCodes: [
953
+ ...COMMON_MUTATION_ERRORS,
954
+ "INVALID_INPUT",
955
+ ],
956
+ description: "Canonical typed task advance mutation. Use after task creation/PRD import to apply explicit taskKind and engineering boundaries, generate + strict-validate the DAG, and stop at the writeSet gate. Never pass placeholder ids such as <id>. A successful child process may still return a blocked business outcome; inspect lifecycleState, blockers, gate, and next.",
957
+ inputParams: [
958
+ {
959
+ name: "taskId",
960
+ type: "string",
961
+ required: true,
962
+ description: "canonical YYYY-MM-DD-<slug> task id; placeholders are rejected",
963
+ },
964
+ {
965
+ name: "title",
966
+ type: "string",
967
+ required: false,
968
+ description: "optional task title for create/first advance",
969
+ },
970
+ {
971
+ name: "taskKind",
972
+ type: "string",
973
+ required: false,
974
+ description: "business DAG template; ordinary fixes/docs use standard",
975
+ },
976
+ {
977
+ name: "featureId",
978
+ type: "string",
979
+ required: false,
980
+ description: "required only by feature-scoped task kinds such as knowledge-sync",
981
+ },
982
+ {
983
+ name: "profile",
984
+ type: "string",
985
+ required: false,
986
+ description: "governance profile auto|minimal|standard|reviewed|supervised",
987
+ },
988
+ {
989
+ name: "allowedPaths",
990
+ type: "array",
991
+ itemsType: "string",
992
+ required: false,
993
+ description: "explicit writer boundary globs",
994
+ },
995
+ {
996
+ name: "forbiddenPaths",
997
+ type: "array",
998
+ itemsType: "string",
999
+ required: false,
1000
+ description: "explicit forbidden path globs",
1001
+ },
1002
+ {
1003
+ name: "verifyCommands",
1004
+ type: "array",
1005
+ itemsType: "string",
1006
+ required: false,
1007
+ description: "verification entries as label:command strings",
1008
+ },
1009
+ ],
1010
+ modelCallable: "always",
1011
+ humanConfirmation: "none",
1012
+ },
899
1013
  {
900
1014
  action: "dagRunTask",
901
1015
  cli: "loop-agent task advance <taskId> --profile auto --dag-output <path> --json",
@@ -965,7 +1079,7 @@ export function buildOperatorCapabilitiesDocument() {
965
1079
  "CONTROLLER_MISMATCH",
966
1080
  "INVALID_INPUT",
967
1081
  ],
968
- description: "Consume a single-use execution receipt and execute the reviewed DAG (prefer task advance --approve-gate when gate token present). accepted/queued/running/operationId are NOT completion — supervise via operationGet/status/dagReport/dagDoctor.",
1082
+ description: "Consume a single-use execution receipt and execute the reviewed DAG (prefer task advance --approve-gate when gate token present). accepted/queued/running/operationId are NOT completion — supervise via operationGet/status/dagReport/dagDoctor/operationWait.",
969
1083
  inputParams: [
970
1084
  {
971
1085
  name: "executionId",
@@ -988,7 +1102,7 @@ export function buildOperatorCapabilitiesDocument() {
988
1102
  resultSchemaVersion: 1,
989
1103
  envelopeSchemaVersion: 1,
990
1104
  requiredErrorCodes: ["INVALID_INPUT", "NOT_FOUND"],
991
- description: "Read-only R1 subgraph plan (no mutation). Call before dagRerun. Prefer primaryFailure.nodeId from dagReport. If eligible=false (writer/decision/fingerprint), do not force execute — fall back to standaloneTaskRerun or same-task advance. Returns planHash required by execute.",
1105
+ description: "Read-only R1 subgraph plan (no mutation). Call before dagRerun. Prefer primaryFailure.nodeId from dagReport. If eligible=false (writer/decision/fingerprint, or subgraph contains unsafe shell nodes), do not force execute — fall back to standaloneTaskRerun or same-task advance after fixing the root cause (contract changes must be adopted first). Returns planHash required by execute.",
992
1106
  inputParams: [
993
1107
  {
994
1108
  name: "runId",
@@ -1317,7 +1431,7 @@ export function buildOperatorCapabilitiesDocument() {
1317
1431
  "INVALID_INPUT",
1318
1432
  "HUMAN_CONFIRMATION_REQUIRED",
1319
1433
  ],
1320
- description: "Full standalone task regenerate → validate → execute with parent lineage. Only when node rerun plan is ineligible or primaryRecovery recommends rerun-task; the server checks read-only run facts before accepting.",
1434
+ description: "Full standalone task regenerate → validate → execute with parent lineage. Only when node rerun plan is ineligible or primaryRecovery recommends rerun-task; the server checks read-only run facts before accepting. Regeneration reuses the CURRENT contract snapshot: if the failure requires contract changes (e.g. allowing a catalog index path), first update the contract (re-import PRD, or adopt externally-edited sources via `task contract adopt`) BEFORE calling this action, otherwise the regenerated DAG repeats the same failure.",
1321
1435
  inputParams: [
1322
1436
  {
1323
1437
  name: "runId",
@@ -1438,7 +1552,7 @@ export const OPERATOR_COMMAND_COVERAGE = Object.freeze([
1438
1552
  {
1439
1553
  command: "loop-agent task advance",
1440
1554
  coverage: "model-callable",
1441
- action: "newTask",
1555
+ action: "taskAdvance",
1442
1556
  source: "loop-agent",
1443
1557
  },
1444
1558
  {
@@ -2,6 +2,13 @@ const FRESH_SOURCE_MISSING_CODES = new Set([
2
2
  "MISSING_REQUIREMENT",
3
3
  "MISSING_CONSTRAINTS",
4
4
  ]);
5
+ function hasKnowledgeSyncFeatureSurface(featureId, allowedPaths) {
6
+ const featureRoot = `features/${featureId}/`;
7
+ return allowedPaths.some((pathGlob) => {
8
+ const normalized = pathGlob.replaceAll("\\", "/").replace(/^\.\//, "");
9
+ return normalized === `features/${featureId}` || normalized.startsWith(featureRoot);
10
+ });
11
+ }
5
12
  export function isFreshLegacyUnversioned(state, sourceFilesPresent) {
6
13
  if (state.effectiveStatus !== "legacy-unversioned")
7
14
  return false;
@@ -105,6 +112,16 @@ export function listPrepareGaps(input) {
105
112
  message: "featureId is required when taskKind is knowledge-sync",
106
113
  });
107
114
  }
115
+ if (draft.taskKind === "knowledge-sync" &&
116
+ draft.featureId &&
117
+ !hasKnowledgeSyncFeatureSurface(draft.featureId, draft.constraints.allowedPaths)) {
118
+ gaps.push({
119
+ code: "KNOWLEDGE_SYNC_PATH_MISMATCH",
120
+ level: "blocking",
121
+ field: "allowedPaths",
122
+ message: `knowledge-sync for ${draft.featureId} requires an allowed path under features/${draft.featureId}/; ordinary docs-only work should use taskKind standard`,
123
+ });
124
+ }
108
125
  if (draft.requirement.scope.length === 0) {
109
126
  gaps.push({
110
127
  code: "EMPTY_SCOPE",
@@ -15,7 +15,21 @@ const HEADING_ALIASES = {
15
15
  "已确认需求",
16
16
  ]),
17
17
  nonGoals: new Set(["非目标", "out of scope", "non-goals", "non goals"]),
18
- acceptance: new Set(["验收标准", "acceptance criteria", "acceptance"]),
18
+ acceptance: new Set([
19
+ "验收标准",
20
+ "acceptance criteria",
21
+ "acceptance",
22
+ // Common zh-CN product-writing variants seen in real PRDs; without
23
+ // these a PRD re-import flips to deterministic parse and loses all AC
24
+ // (EMPTY_ACCEPTANCE deadlock on the pendingPrdRefresh path).
25
+ "完成定义",
26
+ "完成标准",
27
+ "验收",
28
+ "验收条件",
29
+ "验收准则",
30
+ "dod",
31
+ "definition of done",
32
+ ]),
19
33
  constraints: new Set(["约束", "constraints", "不变式", "invariants"]),
20
34
  openQuestions: new Set(["待决问题", "open questions", "open-questions"]),
21
35
  assumptions: new Set(["假设", "assumptions"]),
@@ -19,5 +19,12 @@ export function mergeArtifactCardState(current, incoming) {
19
19
  const index = current.findIndex((item) => item.artifactId === incoming.artifactId);
20
20
  if (index < 0)
21
21
  return [...current, incoming];
22
- return current.map((item, candidate) => candidate === index ? { ...item, ...incoming } : item);
22
+ return current.map((item, candidate) => candidate === index
23
+ ? {
24
+ ...item,
25
+ ...incoming,
26
+ eventSeq: item.eventSeq ?? incoming.eventSeq,
27
+ activityRef: item.activityRef ?? incoming.activityRef,
28
+ }
29
+ : item);
23
30
  }
@@ -50,6 +50,62 @@ function controllerSummary(operation) {
50
50
  .join("@");
51
51
  return safeIdentity(label);
52
52
  }
53
+ /** Re-project an already-safe stored run summary through a bounded re-check. */
54
+ function safeRunSummaryProjection(value) {
55
+ if (!value || typeof value !== "object")
56
+ return undefined;
57
+ const record = value;
58
+ const runId = safeIdentity(record.runId);
59
+ const status = safeIdentity(record.status);
60
+ if (!runId || !status)
61
+ return undefined;
62
+ const nodes = Array.isArray(record.nodes)
63
+ ? record.nodes
64
+ .filter((node) => Boolean(node && typeof node === "object"))
65
+ .map((node) => ({
66
+ id: safeIdentity(node.id) ?? "",
67
+ status: safeIdentity(node.status) ?? "",
68
+ }))
69
+ .filter((node) => node.id && node.status)
70
+ .slice(0, 20)
71
+ : [];
72
+ const verdict = typeof record.verdict === "string"
73
+ ? boundedRedactedText(record.verdict)
74
+ : undefined;
75
+ const failureCategory = typeof record.failureCategory === "string"
76
+ ? boundedRedactedText(record.failureCategory)
77
+ : undefined;
78
+ const outOfBounds = Array.isArray(record.outOfBounds)
79
+ ? record.outOfBounds
80
+ .filter((entry) => Boolean(entry && typeof entry === "object"))
81
+ .map((entry) => ({
82
+ nodeId: safeIdentity(entry.nodeId) ?? "",
83
+ failureCategory: safeIdentity(entry.failureCategory) ?? "",
84
+ }))
85
+ .filter((entry) => entry.nodeId && entry.failureCategory)
86
+ .slice(0, 20)
87
+ : undefined;
88
+ const nextRec = record.next && typeof record.next === "object"
89
+ ? record.next
90
+ : undefined;
91
+ const next = nextRec
92
+ ? {
93
+ kind: safeIdentity(nextRec.kind) ?? "",
94
+ description: typeof nextRec.description === "string"
95
+ ? boundedRedactedText(nextRec.description)
96
+ : undefined,
97
+ }
98
+ : undefined;
99
+ return {
100
+ runId,
101
+ status,
102
+ nodes,
103
+ ...(verdict ? { verdict } : {}),
104
+ ...(failureCategory ? { failureCategory } : {}),
105
+ ...(outOfBounds && outOfBounds.length > 0 ? { outOfBounds } : {}),
106
+ ...(next && next.kind ? { next } : {}),
107
+ };
108
+ }
53
109
  /** Chat/browser-safe projection. Never includes actionParams, CLI args, paths or raw results. */
54
110
  export function projectOperationForChat(operation) {
55
111
  const params = operation.actionParams ?? {};
@@ -61,12 +117,16 @@ export function projectOperationForChat(operation) {
61
117
  const featureId = safeIdentity(params.featureId);
62
118
  const workerRunId = safeIdentity(params.workerRunId);
63
119
  const controller = controllerSummary(operation);
120
+ const runSummary = safeRunSummaryProjection(operation.runSummary);
64
121
  const previewHash = createHash("sha256")
65
122
  .update(JSON.stringify({
66
123
  stdoutPreview,
67
124
  stderrPreview,
68
125
  summaryPreview,
69
126
  state: operation.state,
127
+ // runSummary participates in the dedupe hash: terminal summary updates
128
+ // must not be swallowed by state+previewHash dedup (2026-08-14).
129
+ runSummary: runSummary ?? null,
70
130
  }))
71
131
  .digest("hex");
72
132
  return {
@@ -88,6 +148,7 @@ export function projectOperationForChat(operation) {
88
148
  ...(operation.errorCode
89
149
  ? { errorCode: safeIdentity(operation.errorCode) }
90
150
  : {}),
151
+ ...(runSummary ? { runSummary } : {}),
91
152
  previewHash,
92
153
  };
93
154
  }
@@ -32,6 +32,14 @@ export function mergeHumanGateCardState(cards, next) {
32
32
  if (index < 0)
33
33
  return [...cards, next];
34
34
  const copy = [...cards];
35
- copy[index] = next;
35
+ const current = copy[index];
36
+ if (!current)
37
+ return [...cards, next];
38
+ copy[index] = {
39
+ ...current,
40
+ ...next,
41
+ eventSeq: current.eventSeq ?? next.eventSeq,
42
+ activityRef: current.activityRef ?? next.activityRef,
43
+ };
36
44
  return copy;
37
45
  }
@@ -2,7 +2,24 @@ export function mergeOperationCardState(current, incoming) {
2
2
  const index = current.findIndex((operation) => operation.operationId === incoming.operationId);
3
3
  if (index < 0)
4
4
  return [...current, incoming];
5
- return current.map((operation, candidateIndex) => candidateIndex === index ? { ...operation, ...incoming } : operation);
5
+ return current.map((operation, candidateIndex) => {
6
+ if (candidateIndex !== index)
7
+ return operation;
8
+ let eventSeq = operation.eventSeq;
9
+ if (eventSeq === undefined)
10
+ eventSeq = incoming.eventSeq;
11
+ else if (incoming.eventSeq !== undefined) {
12
+ eventSeq = Math.min(eventSeq, incoming.eventSeq);
13
+ }
14
+ return {
15
+ ...operation,
16
+ ...incoming,
17
+ ...(eventSeq === undefined ? {} : { eventSeq }),
18
+ updatedSeq: Math.max(operation.updatedSeq ?? operation.eventSeq ?? 0, incoming.updatedSeq ?? incoming.eventSeq ?? 0),
19
+ toolCallId: operation.toolCallId ?? incoming.toolCallId,
20
+ activityRef: operation.activityRef ?? incoming.activityRef,
21
+ };
22
+ });
6
23
  }
7
24
  export function operationFromEventPayload(payload) {
8
25
  const operation = payload.operation;
@@ -19,5 +36,57 @@ export function operationFromEventPayload(payload) {
19
36
  typeof candidate.previewHash !== "string") {
20
37
  return undefined;
21
38
  }
22
- return candidate;
39
+ // Optional runSummary stays backward-compatible: validate shape field by
40
+ // field and drop it entirely when malformed (legacy events simply lack it).
41
+ const safe = safeRunSummary(candidate.runSummary);
42
+ const { runSummary: _unsafeRunSummary, ...base } = candidate;
43
+ return {
44
+ ...base,
45
+ ...(safe ? { runSummary: safe } : {}),
46
+ };
47
+ }
48
+ /** Field-by-field validation for the optional browser-side runSummary. */
49
+ function safeRunSummary(value) {
50
+ if (!value || typeof value !== "object" || Array.isArray(value))
51
+ return undefined;
52
+ const rec = value;
53
+ if (typeof rec.runId !== "string" || !rec.runId)
54
+ return undefined;
55
+ if (typeof rec.status !== "string" || !rec.status)
56
+ return undefined;
57
+ let nodes = [];
58
+ if (Array.isArray(rec.nodes)) {
59
+ nodes = rec.nodes
60
+ .flatMap((node) => {
61
+ if (!node || typeof node !== "object")
62
+ return [];
63
+ const id = String(node.id ?? "");
64
+ const status = String(node.status ?? "");
65
+ if (!id || !status)
66
+ return [];
67
+ return [{ id, status }];
68
+ })
69
+ .slice(0, 20);
70
+ }
71
+ let next;
72
+ if (rec.next && typeof rec.next === "object") {
73
+ const nextRecord = rec.next;
74
+ next = {
75
+ kind: String(nextRecord.kind ?? ""),
76
+ ...(typeof nextRecord.description === "string"
77
+ ? { description: nextRecord.description }
78
+ : {}),
79
+ };
80
+ }
81
+ const out = {
82
+ runId: rec.runId,
83
+ status: rec.status,
84
+ nodes,
85
+ ...(typeof rec.verdict === "string" ? { verdict: rec.verdict } : {}),
86
+ ...(typeof rec.failureCategory === "string"
87
+ ? { failureCategory: rec.failureCategory }
88
+ : {}),
89
+ ...(next && next.kind ? { next } : {}),
90
+ };
91
+ return out;
23
92
  }