dsh-workflow-map 0.3.2 → 0.3.5

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.
@@ -175,6 +175,22 @@ function wfPollUrl(sessionId) {
175
175
  return "/dsh-workflow/state?sessionId=" + encodeURIComponent(String(sessionId));
176
176
  }
177
177
 
178
+ /**
179
+ * May the poll loop re-arm after this result?
180
+ *
181
+ * A tick aborts the request it replaces, and an aborted request resolves as status -1.
182
+ * That is the ORDINARY path whenever a poll outlives its interval, so -1 must not end
183
+ * the loop: it only defers to the replacement that is already in the air. Returning
184
+ * false there is what keeps two loops from running; returning false for anything else
185
+ * (a real error, a 4xx) would stop the panel from ever updating again.
186
+ *
187
+ * Extracted from the hook so the rule is testable without a renderer.
188
+ */
189
+ function wfPollContinues(result, inflight) {
190
+ if (!result || result.status !== -1) return true;
191
+ return wfFinite(inflight) === null ? true : inflight <= 0;
192
+ }
193
+
178
194
  /**
179
195
  * One conditional GET. Never throws: every outcome is a value, so the loop can
180
196
  * always decide what to render and how long to wait.
@@ -365,6 +381,9 @@ function wfPlanSummary(plan) {
365
381
  }
366
382
  }
367
383
  return {
384
+ // A finished plan must not decide a card's status any more; the summary carries
385
+ // the flag so the status policy can stand down without re-reading the phase.
386
+ terminal: plan.terminal === true,
368
387
  statuses: plan.statuses && typeof plan.statuses === "object" ? plan.statuses : {},
369
388
  anchorSeqs: plan.anchorSeqs && typeof plan.anchorSeqs === "object" ? plan.anchorSeqs : {},
370
389
  skipped: Array.isArray(plan.skipped) ? plan.skipped.slice() : [],
@@ -878,7 +897,10 @@ function wfBuildModel(view, prefs, nowMs) {
878
897
  errorText: typeof found.error === "string" ? found.error.slice(0, 200) : "",
879
898
  seq: wfFinite(found.seq),
880
899
  error: typeof found.error === "string" && found.error !== "",
881
- result: typeof found.resultHead === "string" && found.resultHead !== "",
900
+ // The host's result boolean is the settled fact: a call that returned with an EMPTY body is
901
+ // settled. Falling back to resultHead alone marked such a call unsettled, so the panel kept
902
+ // it in the "calling right now" state and its dot pulsed after the call had already returned.
903
+ result: found.result === true || (typeof found.resultHead === "string" && found.resultHead !== ""),
882
904
  durationMs: wfFinite(found.durationMs),
883
905
  at: wfFinite(found.at),
884
906
  });
@@ -169,7 +169,15 @@ var WF_CSS = [
169
169
  // the three branch hues live on the ROOT, so the dashed branches and their round
170
170
  // icons share one palette with the rest of the panel (and one place to change it).
171
171
  "--wf-chain-read:#5b8cff;--wf-chain-edit:#e0a13a;--wf-chain-tool:#8f7bea;" +
172
+ // `--dsw-alias-bg-layer-1` and `-2` carry the SAME value in the host's light theme (both
173
+ // neutral-bluish-00, i.e. #fff), so a card filled with layer-2 is invisible on the page. Every
174
+ // raised surface in the panel therefore paints one translucent lift over that fill. The lift goes
175
+ // towards ink on a light page and towards white on a dark one; the host marks dark by putting
176
+ // `data-ds-dark-theme` on <body>, so the light values are the defaults and the dark block below
177
+ // only wins when that attribute is present.
178
+ "--wf-lift:rgba(15,23,42,.045);--wf-lift-2:rgba(15,23,42,.085);" +
172
179
  "font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI','Microsoft YaHei',sans-serif;font-size:12px;line-height:1.5}",
180
+ "body[data-ds-dark-theme] .wf-root{--wf-lift:rgba(255,255,255,.035);--wf-lift-2:rgba(255,255,255,.07)}",
173
181
 
174
182
  /* card: fills the main area; the header and the settings card keep their own height,
175
183
  the two-column body takes the rest. No drag grips: the column has no free edges.
@@ -195,6 +203,10 @@ var WF_CSS = [
195
203
  ".wf-btn:hover{background:var(--dsw-alias-bg-layer-2,#2c3140);color:var(--dsw-alias-label-primary,#e9ecf3)}",
196
204
  ".wf-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:1px}",
197
205
  ".wf-btn[data-active='true']{color:var(--dsw-alias-brand-primary,#5b8cff);border-color:var(--dsw-alias-brand-primary,#5b8cff)}",
206
+ // The refresh button's one-second acknowledgement. Width is pinned to the glyph's own
207
+ // box so swapping "↻" for "已刷新" cannot shift the header row underneath the pointer.
208
+ ".wf-btn-ok{color:var(--dsw-alias-state-success-primary,#3fb950);border-color:var(--dsw-alias-state-success-primary,#3fb950);" +
209
+ "width:auto;min-width:23px;padding:0 6px;font-size:11px}",
198
210
  // Hot injection is the one header action that injects text into the RUNNING turn, so it is the
199
211
  // one that must never be hunted for: a named, filled chip that keeps its name in Chinese.
200
212
  // User feedback: "too small, still have to open it first" - so it grows to 96x30 (the other two in the row stay
@@ -252,13 +264,14 @@ var WF_CSS = [
252
264
  // `grid-area:1/1` is what makes the triangle and the card share the cell: without it the
253
265
  // auto-placement cursor would drop the card into row 2, below the triangle.
254
266
  ".wf-step-card{grid-area:1/1;display:flex;gap:8px;align-items:flex-start;width:100%;padding:7px 8px;border-radius:8px;text-align:left;" +
255
- "font:inherit;color:inherit;cursor:pointer;border:1px solid transparent;" +
256
- "background:var(--dsw-alias-bg-layer-2,#282d3a);border-left:3px solid transparent}",
267
+ "font:inherit;color:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
268
+ "background:var(--dsw-alias-bg-layer-2,#282d3a);background-image:linear-gradient(var(--wf-lift),var(--wf-lift));" +
269
+ "border-left:3px solid transparent}",
257
270
  ".wf-step-card:hover{border-color:var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
258
- "background:var(--dsw-alias-bg-layer-1,#232733)}",
271
+ "background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
259
272
  ".wf-step-card:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:1px}",
260
273
  ".wf-step-card[data-selected='true']{border-color:var(--dsw-alias-brand-primary,#5b8cff);" +
261
- "background:var(--dsw-alias-bg-layer-1,#232733)}",
274
+ "background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
262
275
  ".wf-step-card[data-active='true']{border-left-color:var(--dsw-alias-brand-primary,#5b8cff)}",
263
276
  ".wf-step-ring{flex:0 0 auto;width:18px;height:18px;margin-top:1px;border-radius:50%;" +
264
277
  "display:inline-flex;align-items:center;justify-content:center;font-size:11px;line-height:1;" +
@@ -412,7 +425,7 @@ var WF_CSS = [
412
425
  ".wf-branch-icon{flex:0 0 auto;width:" + WF_CHAIN_ICON + "px;height:" + WF_CHAIN_ICON + "px;padding:0;" +
413
426
  "border-radius:50%;display:inline-flex;align-items:center;justify-content:center;" +
414
427
  "font:inherit;font-size:11px;line-height:1;cursor:pointer;position:relative;" +
415
- "background:var(--dsw-alias-bg-layer-2,#282d3a);color:var(--dsw-alias-label-secondary,#9aa3b2);" +
428
+ "background:var(--dsw-alias-bg-layer-2,#282d3a);background-image:linear-gradient(var(--wf-lift),var(--wf-lift));color:var(--dsw-alias-label-secondary,#9aa3b2);" +
416
429
  "border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.28))}",
417
430
  ".wf-branch-icon:hover{color:var(--dsw-alias-label-primary,#e9ecf3)}",
418
431
  ".wf-branch-icon:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:2px}",
@@ -442,12 +455,14 @@ var WF_CSS = [
442
455
  /* node card: icon · title · meta · status dot */
443
456
  ".wf-node{display:flex;gap:7px;align-items:flex-start;width:100%;padding:6px 8px;border-radius:8px;" +
444
457
  "text-align:left;font:inherit;color:inherit;cursor:pointer;" +
445
- "background:var(--dsw-alias-bg-layer-2,#282d3a);border:1px solid transparent;" +
458
+ "background:var(--dsw-alias-bg-layer-2,#282d3a);background-image:linear-gradient(var(--wf-lift),var(--wf-lift));" +
459
+ "border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
446
460
  "border-left:3px solid var(--dsw-alias-border-l2,rgba(255,255,255,.2))}",
447
- ".wf-node:hover{border-color:var(--dsw-alias-border-l2,rgba(255,255,255,.24));background:var(--dsw-alias-bg-layer-1,#232733)}",
461
+ ".wf-node:hover{border-color:var(--dsw-alias-border-l2,rgba(255,255,255,.24));" +
462
+ "background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
448
463
  ".wf-node:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:1px}",
449
464
  ".wf-node[data-selected='true']{border-color:var(--dsw-alias-brand-primary,#5b8cff);" +
450
- "background:var(--dsw-alias-bg-layer-1,#232733)}",
465
+ "background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
451
466
  ".wf-node[data-selected='true'] .wf-node-title{font-weight:600}",
452
467
  ".wf-node[data-kind='tool']{border-left-color:var(--dsw-alias-brand-primary,#5b8cff)}",
453
468
  ".wf-node[data-kind='report']{border-left-color:var(--dsw-alias-state-success-primary,#45c07a)}",
@@ -1024,11 +1039,12 @@ var WF_CSS_PAGE = [
1024
1039
 
1025
1040
  /* ---- the unit card ---- */
1026
1041
  ".wf-unit-card{display:grid;grid-template-columns:auto auto minmax(0,1fr);align-items:flex-start;gap:8px;" +
1027
- "width:100%;padding:10px 12px;border-radius:10px;text-align:left;border:1px solid transparent;" +
1028
- "background:var(--dsw-alias-bg-layer-2,#282d3a);color:inherit;font:inherit;cursor:pointer;" +
1042
+ "width:100%;padding:10px 12px;border-radius:10px;text-align:left;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
1043
+ "background:var(--dsw-alias-bg-layer-2,#282d3a);background-image:linear-gradient(var(--wf-lift),var(--wf-lift));" +
1044
+ "color:inherit;font:inherit;cursor:pointer;" +
1029
1045
  "border-left:3px solid transparent;box-sizing:border-box}",
1030
1046
  ".wf-unit-card:hover{border-color:var(--dsw-alias-border-l2,rgba(255,255,255,.22));" +
1031
- "background:var(--dsw-alias-bg-layer-1,#232733)}",
1047
+ "background:var(--dsw-alias-bg-layer-1,#232733);background-image:linear-gradient(var(--wf-lift-2),var(--wf-lift-2))}",
1032
1048
  ".wf-unit-card:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5b8cff);outline-offset:1px}",
1033
1049
  ".wf-unit-card[data-selected='true']{border-color:var(--dsw-alias-brand-primary,#5b8cff)}",
1034
1050
  ".wf-unit-card[data-active='true']{border-left-color:var(--dsw-alias-brand-primary,#5b8cff)}",
package/src/fold.mjs CHANGED
@@ -63,7 +63,12 @@
63
63
  // own `inboxProjectionDefinition` replays. A checkpoint written before this bump has no ledger,
64
64
  // so the registry is told to drop it and rebuild from the log (the same contract as v1→v2: a
65
65
  // missing field must never be silently answered with a wrong index).
66
- export const STATE_VERSION = 3;
66
+ //
67
+ // STATE_VERSION 4 : the step ledger now follows the todo table's own statuses (a `pending` row
68
+ // demotes a step that still carried `done`/`running`, and a step any table no longer names is
69
+ // dropped). Checkpoints written before the bump hold ledgers from the older mapping — wrong
70
+ // statuses and leftover rows — so the same contract applies: drop and rebuild from the log.
71
+ export const STATE_VERSION = 5;
67
72
 
68
73
  export const SCHEMA_VERSION = 1;
69
74
 
@@ -515,6 +520,13 @@ function percentFor(state, step, now) {
515
520
  const pinned = step.percent === null ? 100 : step.percent;
516
521
  return { percent: clamp(pinned, 0, 100), source: step.percentSource === "none" ? "closed" : step.percentSource };
517
522
  }
523
+ // Only the step that is actually running can be partly done. A pending row is future work and a
524
+ // skipped one never will be done, so NEITHER carries a number. The old code let the todo table's
525
+ // completion ratio paint a bar on every waiting row as well, which is where the wall of bars
526
+ // below 100 % in an already finished session came from.
527
+ if (step.status !== STEP_STATUS.running) {
528
+ return { percent: null, source: "none" };
529
+ }
518
530
  // Priority 2 when a self-report exists: the todo table's completion ratio is a measurement (no
519
531
  // clock, no extrapolation), so it determines the number whenever it has rows to report on.
520
532
  // The self-report can only lower the result, never raise it above the measured ratio.
@@ -536,7 +548,11 @@ function percentFor(state, step, now) {
536
548
  const value = measured;
537
549
  return { percent: clamp(value, 0, 99), source: "todos" };
538
550
  }
539
- return { percent: clamp(Math.max(reported.percent, step.maxPercent), 0, 100), source: "model" };
551
+ // The model's latest statement is the number, and an open step is 0..99: 100 % on a running
552
+ // row is a claim the log does not support. The high-water record is deliberately not applied
553
+ // here, as the comment above states; applying it freezes a row at the highest number it ever
554
+ // reached, so later reports cannot move the bar.
555
+ return { percent: clamp(reported.percent, 0, 99), source: "model" };
540
556
  }
541
557
  // The checklist's own completion ratio — a real measurement, so it sits above the time baseline.
542
558
  // It never claims 100 % for an OPEN step: the remaining row is still in progress.
@@ -608,6 +624,10 @@ function newNode(kind, tool, title, at, extra) {
608
624
  argsText: "",
609
625
  resultHead: "",
610
626
  error: "",
627
+ // Whether the call returned. Separate from the body text: a call that succeeds with
628
+ // no output has an empty resultHead and is still settled. The browser half colours by
629
+ // this flag, so deriving it from resultHead leaves every silent call "running".
630
+ settled: false,
611
631
  at,
612
632
  durationMs: null,
613
633
  // filled by appendNode when the caller did not pass one (see toolKey).
@@ -739,6 +759,25 @@ function argsDigest(args) {
739
759
  // fold
740
760
  // ---------------------------------------------------------------------------
741
761
 
762
+ /**
763
+ * Reopen a closed row without letting its 100 % speak for the reopened one.
764
+ *
765
+ * A row taken back to in_progress used to keep the percent and the high-water record of its
766
+ * closed state, so the panel showed an in-progress step pinned at 100 %: a bar that can never
767
+ * move again, and a later self-report cannot lower it. A closed record's number belongs to
768
+ * that closure, not to the row that reopens it.
769
+ */
770
+ function reopenStep(step, time) {
771
+ const reopened = step.status === STEP_STATUS.done || step.status === STEP_STATUS.failed;
772
+ const base = {
773
+ ...step,
774
+ status: STEP_STATUS.running,
775
+ endedAt: null,
776
+ startedAt: reopened || !step.startedAt ? time : step.startedAt,
777
+ };
778
+ return reopened ? { ...base, percent: null, percentSource: "none", maxPercent: 0 } : base;
779
+ }
780
+
742
781
  function foldTodos(state, event, time) {
743
782
  const data = dataOf(event);
744
783
  const raw = data && Array.isArray(data.todos) ? data.todos : [];
@@ -752,12 +791,22 @@ function foldTodos(state, event, time) {
752
791
  // session (no PAE plan at all) still produces a step list.
753
792
  let steps = [...state.steps];
754
793
  let durations = state.durations;
794
+ // Every row that matched an existing step, and the status that row states. The table is the one
795
+ // authority on a step's lifecycle, so these are applied in a final pass instead of inline: a row
796
+ // can match a step that a LATER row also matches (slot reuse after the model rewrites the table),
797
+ // and an inline write could then be overwritten by a stale copy of the old status.
798
+ const authority = new Map();
799
+ // The ledger as it stood BEFORE this table. Slot binding has to read this snapshot: by the time a
800
+ // later row is examined, `steps` already holds the rows this very table created, so "the row in
801
+ // this position" would point at a row from the NEW table instead of the one being replaced.
802
+ const prior = state.steps;
755
803
 
756
804
  for (const item of todos) {
757
805
  if (item.content === "") continue;
758
806
  const planIndex = planIndexOf(item.content);
759
807
  let at = planIndex === null ? -1 : steps.findIndex((step) => step.planIndex === planIndex);
760
808
  if (at === -1) at = steps.findIndex((step) => normalizeTitle(step.title) === normalizeTitle(item.content));
809
+ const matchedByTitle = at !== -1;
761
810
  // REWORDED ROW at the source. The model often rewrites its own todo text between two
762
811
  // `todo/write` events (measured: "read-only inventory: read plugin sources..." -> "read-only inventory: sources/config..."). Matching on the
763
812
  // title alone then misses the row that already exists, a SECOND row is created for the new
@@ -765,8 +814,13 @@ function foldTodos(state, event, time) {
765
814
  // sync then drops. That was "only the running step shows tool types". The fix belongs HERE:
766
815
  // reword in place when the slot and lifecycle agree, so the row keeps its ledger.
767
816
  if (at === -1) {
768
- const slotRow = steps[steps.length > todos.indexOf(item) ? todos.indexOf(item) : -1];
769
- const sameSlot = slotRow && (slotRow.status === item.status || slotRow.status === STEP_STATUS.running);
817
+ const slotRow = prior[todos.indexOf(item)];
818
+ // A running step is never taken over by a row that is not running: that is how a pending row
819
+ // used to hijack the live step and leave the real one with no running row at all.
820
+ const sameSlot =
821
+ slotRow &&
822
+ (slotRow.status === item.status ||
823
+ (slotRow.status === STEP_STATUS.running && item.status === "in_progress"));
770
824
  if (sameSlot) at = steps.indexOf(slotRow);
771
825
  }
772
826
 
@@ -796,6 +850,12 @@ function foldTodos(state, event, time) {
796
850
  // its old key, so the nodes it already owns can follow the rename. Both the rename and the
797
851
  // lifecycle change are applied in one assignment, so neither can lose the other.
798
852
  let next = step;
853
+ // The row's ordinal follows the TABLE, not the row's history. A matched row that used to be
854
+ // unnumbered (or numbered differently) must take the ordinal this table now states, or the
855
+ // view keeps a stale planIndex and `applyPlanToView` patches the wrong plan step onto it.
856
+ // Measured 2026-10-29 on session 164e339f: leftover unnumbered rows kept planIndex 2 / 3 /
857
+ // 4... from an earlier PAE plan and the map rendered plan steps 3-7 twice.
858
+ if (planIndex !== null && next.planIndex !== planIndex) next = { ...next, planIndex };
799
859
  const shown = text(stripOrdinal(item.content) || item.content, TITLE_LIMIT);
800
860
  const shownKey = normalizeTitle(shown);
801
861
  if (shownKey !== "" && shownKey !== next.stepKey) {
@@ -811,12 +871,53 @@ function foldTodos(state, event, time) {
811
871
  if (next.startedAt > 0) durations = [...durations, time - next.startedAt].filter((value) => value > 0);
812
872
  } else if (item.status === "in_progress" && next.status !== STEP_STATUS.running) {
813
873
  // A step re-opened by a re-plan or a retry: keep its ledger row, restart the clock.
814
- steps[at] = { ...next, status: STEP_STATUS.running, endedAt: null, startedAt: next.startedAt || time };
874
+ steps[at] = reopenStep(next, time);
815
875
  } else if (next !== step) {
816
876
  steps[at] = next;
817
877
  }
878
+ authority.set(at, { status: item.status, byTitle: matchedByTitle });
879
+ }
880
+
881
+ // The final pass: one status per step, exactly as the table now states it. `pending` is a real
882
+ // state — a row the model has not started — and a step that still carried `done` or `running`
883
+ // from the previous wording of the table has to be demoted, or the panel shows finished work the
884
+ // table does not claim (and a bar at 100 % next to an empty todo). Demotion is only safe when the
885
+ // row matched on its own title; a slot match after a reword could hit a different row's step.
886
+ for (const [at, claim] of authority) {
887
+ const step = steps[at];
888
+ if (!step) continue;
889
+ if (claim.status === "completed" && step.status !== STEP_STATUS.done) {
890
+ if (step.startedAt > 0) durations = [...durations, time - step.startedAt].filter((value) => value > 0);
891
+ steps[at] = closeStep(step, time);
892
+ } else if (claim.status === "in_progress" && step.status !== STEP_STATUS.running) {
893
+ steps[at] = reopenStep(step, time);
894
+ } else if (claim.status === "pending" && step.status !== STEP_STATUS.pending) {
895
+ // Same rule for a demotion: the row is not done, so it cannot keep a done row's number.
896
+ steps[at] = {
897
+ ...step,
898
+ status: STEP_STATUS.pending,
899
+ endedAt: null,
900
+ startedAt: 0,
901
+ percent: null,
902
+ percentSource: "none",
903
+ maxPercent: 0,
904
+ };
905
+ }
818
906
  }
819
907
 
908
+ // A row the table no longer names, that never started and owns no node, is the leftover of an
909
+ // earlier wording of the same list. Keeping it leaves a permanently empty card in the step
910
+ // column, which is how every rewritten table used to grow the panel by one or two rows.
911
+ const named = new Set(todos.map((item) => normalizeTitle(item.content)).filter((key) => key !== ""));
912
+ const nodeKeys = new Set(state.nodes.map((node) => node.stepKey));
913
+ const kept = steps.filter((step) => {
914
+ if (step.status !== STEP_STATUS.pending || step.startedAt > 0) return true;
915
+ const keys = [step.stepKey, ...(Array.isArray(step.aliasKeys) ? step.aliasKeys : [])].filter(Boolean);
916
+ if (keys.some((key) => nodeKeys.has(key))) return true;
917
+ return keys.some((key) => named.has(key));
918
+ });
919
+ if (kept.length !== steps.length) steps = kept;
920
+
820
921
 
821
922
  return { ...state, asOf: time, todos, steps: reindex(steps), durations };
822
923
  }
@@ -900,6 +1001,8 @@ function foldToolResult(state, event, time, seq) {
900
1001
  ...previous,
901
1002
  resultHead: isError ? "" : line(body),
902
1003
  error: isError ? line(body) : "",
1004
+ // Settled means "the call came back", whatever its body: empty output is not a pending call.
1005
+ settled: true,
903
1006
  durationMs: previous.at > 0 && time > previous.at ? time - previous.at : null,
904
1007
  };
905
1008
  const answered = { ...state, asOf: time, nodes };
@@ -914,7 +1017,14 @@ function foldToolResult(state, event, time, seq) {
914
1017
  ),
915
1018
  }
916
1019
  : answered;
917
- const next = withActive(settled, previous.id, time);
1020
+ // A result is not an activity. Re-asserting the node that just finished as the active one
1021
+ // makes the panel claim "calling right now" about a call that already returned. Keep the
1022
+ // node a newer call opened, and otherwise hand the highlight back to the step.
1023
+ const keepActive =
1024
+ state.active && typeof state.active.nodeId === "string" && state.active.nodeId !== "" && state.active.nodeId !== previous.id
1025
+ ? state.active.nodeId
1026
+ : null;
1027
+ const next = withActive(settled, keepActive, time);
918
1028
  // A tool result is the moment two branch decisions become possible: a job that is STILL
919
1029
  // open (background) is promoted to a lane, and a launcher's acknowledgement attaches its
920
1030
  // run id. Both are no-ops for ordinary calls.
@@ -1134,7 +1244,14 @@ function parsePlanRows(blockText) {
1134
1244
  for (const raw of blockText.split("\n")) {
1135
1245
  const match = /^\s*(\d+)\.\s*(.+)$/.exec(raw);
1136
1246
  if (!match) continue;
1137
- rows.push({ planIndex: Number(match[1]), title: text(match[2], TITLE_LIMIT) });
1247
+ // PAE appends each row's own step FILE to the title ("1. title(…)(step-01-x.md)"). That
1248
+ // annotation is not title text: keeping it leaves `normalizeTitle` one parenthetical group short
1249
+ // of the todo row the model writes ("1. title(…)"), so a plan step can carry a title that no
1250
+ // row of the todo table has. Measured 2026-10-30 on session 1bb4baa0: the fold opened step 1 with
1251
+ // the annotated title while its `stepKey` still held the table's wording, `foldTodos` therefore
1252
+ // saw "no change" and the view kept a step the conversation does not list (audit-real ①).
1253
+ const title = match[2].replace(/\s*[((][^(())]*\.(?:md|markdown|mjs|cjs|js|json|ya?ml|txt)[))]\s*$/, "");
1254
+ rows.push({ planIndex: Number(match[1]), title: text(title, TITLE_LIMIT) });
1138
1255
  if (rows.length >= 80) break;
1139
1256
  }
1140
1257
  return rows;
@@ -1195,10 +1312,26 @@ function openPlanStep(state, time, seq, planIndex, title, blockText) {
1195
1312
  steps.push(created);
1196
1313
  } else {
1197
1314
  const step = steps[targetAt];
1315
+ // A plan title must not replace the row's identity key on its own: `stepKey` is what `foldTodos`
1316
+ // compares the live table against and what node attribution follows. Adopting a title without its
1317
+ // key left the pair inconsistent, so the next todo/write judged the row unchanged and a stale
1318
+ // title survived into the view (measured 2026-10-30, session 1bb4baa0). The old key is parked in
1319
+ // `aliasKeys` exactly as a rename does, so nodes already carrying it still migrate.
1320
+ const adopted = title || step.title;
1321
+ const adoptedKey = normalizeTitle(adopted);
1322
+ const renamed = adoptedKey !== "" && adoptedKey !== step.stepKey;
1323
+ const seenKeys = Array.isArray(step.aliasKeys) ? step.aliasKeys : [];
1198
1324
  steps[targetAt] = {
1199
1325
  ...step,
1200
1326
  planIndex,
1201
- title: title || step.title,
1327
+ title: adopted,
1328
+ ...(renamed
1329
+ ? {
1330
+ prevKey: step.stepKey,
1331
+ stepKey: adoptedKey,
1332
+ aliasKeys: [...new Set([...seenKeys, step.stepKey, adoptedKey])].filter((key) => key !== ""),
1333
+ }
1334
+ : {}),
1202
1335
  anchorSeq: step.anchorSeq === undefined ? anchorOf(state, planIndex, seq) : step.anchorSeq,
1203
1336
  status: STEP_STATUS.running,
1204
1337
  endedAt: null,
@@ -1261,7 +1394,20 @@ function foldTurnStart(state, _event, time) {
1261
1394
  }
1262
1395
 
1263
1396
  function foldTurnEnd(state, _event, time) {
1264
- return { ...state, asOf: time, turnActive: false };
1397
+ const next = { ...state, asOf: time, turnActive: false };
1398
+ // The turn is over, so the step it was running is over too. Leaving it `running` kept a live,
1399
+ // forever-partial bar on the last step of every finished session.
1400
+ // The one exception is a step the todo table still names as its `in_progress` row: that step
1401
+ // really does continue into the next turn, and closing it here would make the next turn reopen
1402
+ // a step that never stopped.
1403
+ const running = runningStep(next);
1404
+ if (!running) return next;
1405
+ const inProgress = inProgressStepIndex(next);
1406
+ if (isFiniteNumber(inProgress) && inProgress === running.index) return next;
1407
+ const durations =
1408
+ running.startedAt > 0 ? [...next.durations, time - running.startedAt].filter((value) => value > 0) : next.durations;
1409
+ const steps = next.steps.map((step) => (step.index === running.index ? closeStep(step, time) : step));
1410
+ return withPercents({ ...next, steps, durations }, time);
1265
1411
  }
1266
1412
 
1267
1413
  function foldReport(state, event, time) {
@@ -2225,6 +2371,8 @@ function nodeView(node) {
2225
2371
  files: node.files.map((file) => ({ path: file.path, action: file.action })),
2226
2372
  resultHead: node.resultHead,
2227
2373
  error: node.error,
2374
+ // the boolean the browser half colours by; see newNode.settled
2375
+ result: node.settled === true,
2228
2376
  at: node.at,
2229
2377
  durationMs: node.durationMs,
2230
2378
  // the detail's meta line names the event that produced the node. Null for the
@@ -2341,9 +2489,41 @@ function syncToTodos(state) {
2341
2489
  const key = normalizeTitle(item.content);
2342
2490
  if (key === "" || seen.has(key)) continue;
2343
2491
  seen.add(key);
2344
- wanted.push({ key, title, status: item.status });
2492
+ // `planIndex` MUST be parsed from the RAW content, not from `title`: `stripOrdinal`
2493
+ // already removed the "3." prefix, so `planIndexOf(title)` always returned null and every
2494
+ // created row lost its ordinal. Measured 2026-10-29 on session 164e339f: a 14-row numbered
2495
+ // table produced rows whose planIndex fell back to POSITION, so row 9 ("9. n8n 主工作流…")
2496
+ // was stamped planIndex 2 and `applyPlanToView` then rewrote it with plan step 2's title and
2497
+ // status — the map showed steps 3–7 twice and the wrong step as running.
2498
+ wanted.push({ key, title, status: item.status, planIndex: planIndexOf(item.content) });
2345
2499
  }
2346
2500
  if (wanted.length === 0) return state;
2501
+ // The row's status is the ONE statement of a step's lifecycle: `todo/write` replaces the whole
2502
+ // table every time, so a matched row must not inherit the status of whatever record the matcher
2503
+ // bound it to. Without this pass a row the model has not started kept the `done` of an old record
2504
+ // (a card at 100 % next to an empty todo), while an `in_progress` row showed as `pending` because
2505
+ // the record it landed on was still pending — both seen in one session on 2026-10-30.
2506
+ const rowStatus = (step, row) => {
2507
+ if (row.status === "completed") return step.status === STEP_STATUS.done ? step : closeStep(step, state.asOf || 0);
2508
+ // A closed record cannot speak for an open row. When the table takes a row back to
2509
+ // in_progress/pending, the record it is bound to may be a step this table already closed,
2510
+ // or an older row a reworded title landed on. Keeping that percent and high-water renders
2511
+ // a brand-new first row at 100 % the instant it appears.
2512
+ const reopened = step.status === STEP_STATUS.done || step.status === STEP_STATUS.failed;
2513
+ const carried = reopened
2514
+ ? { percent: null, percentSource: "none", maxPercent: 0 }
2515
+ : { percent: step.percent, percentSource: step.percentSource, maxPercent: step.maxPercent };
2516
+ if (row.status === "in_progress") {
2517
+ return {
2518
+ ...step,
2519
+ ...carried,
2520
+ status: STEP_STATUS.running,
2521
+ endedAt: null,
2522
+ startedAt: reopened || !step.startedAt ? state.asOf || 0 : step.startedAt,
2523
+ };
2524
+ }
2525
+ return { ...step, ...carried, status: STEP_STATUS.pending, endedAt: null, startedAt: 0, percent: null, percentSource: "none" };
2526
+ };
2347
2527
 
2348
2528
  const byKey = new Map();
2349
2529
  for (const step of state.steps) {
@@ -2390,14 +2570,30 @@ function syncToTodos(state) {
2390
2570
  // (2) TEXT SHARE as the fallback for a re-numbered or re-ordered table.
2391
2571
  let candidate = null;
2392
2572
  const slot = wanted.indexOf(row);
2393
- const sameSlot = orphans.find((step) => !used.has(step.index) && step.index === slot);
2573
+ // An explicit ordinal OUTRANKS position. A numbered table states which plan step each row
2574
+ // IS; letting the slot win anyway pairs row 9 with whatever orphan happens to sit at
2575
+ // position 8, and the row inherits that orphan's planIndex. Measured 2026-10-29 on session
2576
+ // 164e339f: after a 7-row unnumbered table was replaced by a 14-row numbered one, row 9
2577
+ // took the leftover row 2 and the map rendered plan steps 3-7 twice.
2578
+ const ordinalConflict = (step) =>
2579
+ row.planIndex !== null && Number.isInteger(step.planIndex) && step.planIndex !== row.planIndex;
2580
+ const sameSlot = orphans.find(
2581
+ (step) => !used.has(step.index) && step.index === slot && !ordinalConflict(step),
2582
+ );
2394
2583
  if (sameSlot && (sameSlot.status === row.status || sameSlot.status === STEP_STATUS.running)) {
2395
2584
  candidate = sameSlot;
2396
2585
  }
2586
+ if (candidate === null && row.planIndex !== null) {
2587
+ const byOrdinal = orphans.find(
2588
+ (step) => !used.has(step.index) && Number.isInteger(step.planIndex) && step.planIndex === row.planIndex,
2589
+ );
2590
+ if (byOrdinal) candidate = byOrdinal;
2591
+ }
2397
2592
  if (candidate === null) {
2398
2593
  let bestShare = 0;
2399
2594
  for (const step of orphans) {
2400
2595
  if (used.has(step.index)) continue;
2596
+ if (ordinalConflict(step)) continue;
2401
2597
  const other = normalizeTitle(step.title);
2402
2598
  const score = commonPrefix(other, row.key);
2403
2599
  if (score < REWORD_PREFIX_MIN) continue;
@@ -2417,7 +2613,7 @@ function syncToTodos(state) {
2417
2613
  // reworded title - the two did not match, so the nodes were dropped as orphans in the view.
2418
2614
  const oldKey = candidate.stepKey || normalizeTitle(candidate.title);
2419
2615
  const newKey = normalizeTitle(row.title);
2420
- const nextStep = { ...candidate, title: text(row.title, TITLE_LIMIT), stepKey: newKey };
2616
+ const nextStep = rowStatus({ ...candidate, title: text(row.title, TITLE_LIMIT), stepKey: newKey }, row);
2421
2617
  reworded.set(oldKey, newKey);
2422
2618
  ordered.push(nextStep);
2423
2619
  origins.push(candidate.index);
@@ -2430,13 +2626,8 @@ function syncToTodos(state) {
2430
2626
  // node-remap nor the node attribution can ever find it.
2431
2627
  created.stepKey = row.key;
2432
2628
  created.startedAt = 0;
2433
- created.planIndex = planIndexOf(row.title) ?? undefined;
2434
- created.status = row.status === "completed" ? STEP_STATUS.done : STEP_STATUS.pending;
2435
- if (created.status === STEP_STATUS.done) {
2436
- created.percent = 100;
2437
- created.percentSource = "closed";
2438
- created.maxPercent = 100;
2439
- }
2629
+ created.planIndex = row.planIndex ?? undefined;
2630
+ Object.assign(created, rowStatus(created, row));
2440
2631
  ordered.push(created);
2441
2632
  origins.push(-1);
2442
2633
  }
@@ -37,6 +37,21 @@ export const PLAN_VERSION = 1;
37
37
  export const PLAN_ROOT = ".pae";
38
38
  export const PLAN_FILE = "orchestrator.json";
39
39
 
40
+ /**
41
+ * Plan phases that are history rather than a live view.
42
+ *
43
+ * PAE's own vocabulary (dsh-plan-and-execute): none / planning / scheduled /
44
+ * executing / paused / completed / aborted. The last two never resume, and the
45
+ * file that carries them is never removed, so they must not speak for a row the
46
+ * session created after the plan ended.
47
+ */
48
+ const TERMINAL_PHASES = new Set(["completed", "aborted"]);
49
+
50
+ /** Is this normalized plan finished? True for completed and aborted. */
51
+ export function planIsTerminal(plan) {
52
+ return isRecord(plan) && TERMINAL_PHASES.has(String(plan.phase ?? "").trim().toLowerCase());
53
+ }
54
+
40
55
  /** Bounds. The plan is display data; a runaway file must not grow the session cache. */
41
56
  export const MAX_PLAN_STEPS = 200;
42
57
  export const MAX_STEP_REPORTS = 200;
@@ -197,9 +212,14 @@ export function normalizePlanDocument(raw, meta) {
197
212
  }
198
213
 
199
214
  const rawTask = typeof doc.task === "string" ? doc.task : "";
215
+ const phase = text(doc.phase, MAX_PHASE);
200
216
  const result = {
201
217
  ver: PLAN_VERSION,
202
- phase: text(doc.phase, MAX_PHASE),
218
+ phase,
219
+ // Published as a boolean as well: the browser half decides card statuses from this
220
+ // section alone, and "is this plan finished" must not be re-derived from a string
221
+ // vocabulary in two places.
222
+ terminal: TERMINAL_PHASES.has(phase.trim().toLowerCase()),
203
223
  task: text(rawTask, MAX_TASK),
204
224
  // The first non-empty line, before flattening: it is the panel's header.
205
225
  taskTitle: line(rawTask, MAX_TASK_TITLE),
@@ -263,6 +283,10 @@ export function applyPlanToView(view, plan) {
263
283
  const normalized = isRecord(plan) ? plan : null;
264
284
  const result = { ...view, plan: normalized };
265
285
  if (normalized === null) return result;
286
+ // A finished plan is published for the detail page but patches nothing: its
287
+ // rows belong to a turn that is over, and the current todo table is the newer
288
+ // statement. See planIsTerminal.
289
+ if (planIsTerminal(normalized)) return result;
266
290
 
267
291
  if (normalized.taskTitle !== "") result.task = normalized.taskTitle;
268
292
 
@@ -300,6 +324,24 @@ export function applyPlanToView(view, plan) {
300
324
  }
301
325
  }
302
326
  }
327
+ // The plan's own `requiresConfirmation` is the OTHER half of the needs-confirmation flag:
328
+ // `buildView` derives the log half (an open approval, an unanswered question), and this
329
+ // adds the plan half. It belongs HERE because this is the only place that has both the
330
+ // normalised plan step and the view row it lands on. Measured 2026-10-29 on session
331
+ // 164e339f: plan steps 2 / 12 / 13 carry requiresConfirmation and the map showed none of
332
+ // them, while `pendingConfirmSteps` in fold.mjs only ever consulted the log.
333
+ //
334
+ // A finished step never carries the flag (the same rule `buildView` applies): a decision a
335
+ // done step was waiting on is history, not a request.
336
+ if (planStep.requiresConfirmation === true && step.status !== "done" && step.status !== "failed") {
337
+ const rows = Array.isArray(step.pendingConfirm) ? step.pendingConfirm : [];
338
+ if (!rows.some((row) => isRecord(row) && row.kind === "plan")) {
339
+ patch.pendingConfirm = [
340
+ ...rows,
341
+ { kind: "plan", label: planStep.title !== "" ? planStep.title : `第 ${planStep.index} 步`, at: 0 },
342
+ ];
343
+ }
344
+ }
303
345
  if (Object.keys(patch).length === 0) continue;
304
346
  steps[position] = { ...step, ...patch };
305
347
  changed = true;