muse-crew 0.14.11 → 0.14.13

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.
@@ -6,6 +6,12 @@ history lives here. One canonical section per `<a id>` anchor — a critic
6
6
  re-verified that every `docs/decisions/*.md#anchor` reference in
7
7
  `workflows/*.js` resolves (45 unique references).
8
8
 
9
+ ## dispatch-decision-log.md — Decision history: dispatch decision log (hoverboat)
10
+
11
+ - `#checksum-vs-hoverboat` — The ferry-corruption problem and the two candidates
12
+ - `#why-hoverboat-won` — Availability, trust, subtraction, primary-source rationale
13
+ - `#what-was-built` — Declaration shape, skip counters, observer ingestion, retirements
14
+
9
15
  ## publish-path.md — Decision history: publish path
10
16
 
11
17
  - `#fire-and-forget-trigger` — Fire-and-forget trigger + workflow-owned observation
@@ -0,0 +1,69 @@
1
+ # Dispatch decision log (hoverboat) — 2026-09-24
2
+
3
+ <a id="checksum-vs-hoverboat"></a>
4
+ ## The problem
5
+
6
+ The Gate 2 observer attributed dispatch decisions by ferrying platform
7
+ `scheduler.job_runs` rows through an LLM paste (tick worker: `muse.db`
8
+ result → heredoc → `record_platform.sh --dispatch` → `dispatch_sample`
9
+ rows → `attribute_dispatch.sh` → `dispatch_decision` rows). The paste
10
+ mutated plausible values: a 2026-09-24 consistency scan found 275
11
+ same-`run_id` contradictory `dispatch_sample` rows and 65 derived
12
+ `dispatch_decision` rows based on corrupt data (5.7% of samples
13
+ quarantined; 63.6% of ticks touched). The older `quarantine_suspects.sh`
14
+ handled near-neighbor UUID mutations, not plausible value mutation.
15
+
16
+ ## The two candidates
17
+
18
+ **Checksum ferry** (containment): keep the ferry; the worker checksums the
19
+ raw tool-result JSON before the paste and `record_platform.sh` verifies
20
+ after. Corrupt pastes become loud absences instead of silent corruption.
21
+
22
+ **Hoverboat** (local declaration): the dispatcher writes its decision to
23
+ `$CREW_HOME/.dispatch-decisions.jsonl` in its own bytes (deterministic JS,
24
+ same courier shape as the proven §4b tick-release writer); the observer
25
+ reads the file directly and verifies launch declarations mechanically
26
+ against crew DB effects (`platform_run_tasks`, `dispatch_reservations`).
27
+
28
+ <a id="why-hoverboat-won"></a>
29
+ ## Why hoverboat won
30
+
31
+ 1. **Availability.** Corruption touched ~63.6% of ticks. Checksum converts
32
+ corruption into absence, but the entry demo needs ten consecutive ticks
33
+ each recording a decision — absence fails the demo as surely as
34
+ corruption. Hoverboat's channel has no LLM re-emission at all.
35
+ 2. **Trust.** The checksum would be computed by the same worker agent that
36
+ corrupts the paste — asking the corrupting agent to honestly report its
37
+ own corruption. Hoverboat's declaration is composed by deterministic
38
+ workflow JS and checked against independent DB effects.
39
+ 3. **Subtraction.** Checksum keeps the ferry, the rolling 25h re-record
40
+ window, and the quarantine machinery permanently busy. Hoverboat deletes
41
+ the sample→decision attribution entirely; the platform ferry remains
42
+ only as platform-health telemetry.
43
+ 4. **The crew already knows.** Having the platform tell the observer what
44
+ the crew decided, through an LLM paste, when the crew can write it
45
+ directly, is the ferry. The declaration is the primary source; the DB
46
+ effects are the independent verification.
47
+
48
+ Rejected alternative considered: a deterministic Crew API action for the
49
+ write. It would not remove the agent from the path (workflows reach the
50
+ CLI only through `agent()` shell calls), so it adds API surface for no
51
+ integrity gain. The embedded courier is the proven seam.
52
+
53
+ <a id="what-was-built"></a>
54
+ ## What was built
55
+
56
+ - `workflows/crew-dispatch.js` §4c: per-tick declaration
57
+ `{seq, tick_seq, release, decision, launched[], completed[], parked[],
58
+ board{seen,eligible}, skipped{reason:count sparse}, partial}`.
59
+ `tick_seq` couples each line to the `.tick-releases.jsonl` line for the
60
+ same poll — a tick-release line with no decision line means the tick died
61
+ after poll-ack, visibly.
62
+ - Per-reason ineligibility counters (`countSkipped`) at every skip site in
63
+ the eligibility loop + the simultaneity-limit skip, so stand-downs are
64
+ auditable without re-deriving eligibility.
65
+ - Observer: `ingest_decisions.sh` replaces `attribute_dispatch.sh`;
66
+ `snapshot.sh` dispatch section rewritten around decision rows.
67
+ - Retired: sample→decision attribution, `state/attributed.json` watermark.
68
+ Kept: `record_platform.sh --dispatch` as platform-health telemetry,
69
+ `quarantine_suspects.sh` for the health samples' ID mutations.
@@ -988,7 +988,8 @@ cmd_integration_target() {
988
988
  integration_target
989
989
  }
990
990
 
991
- # Branch-state classification for Review entry (blocker 34, 2026-09-21).
991
+ # Branch-state classification for Review entry (blocker 34, 2026-09-21;
992
+ # no-branch path blocker 50, 2026-09-23).
992
993
  # Usage: classify-branch <task_id>
993
994
  # Stdout: exactly one successful line —
994
995
  # BRANCH_STATE: has-work | already-merged:<40-hex> | empty-no-work
@@ -1005,17 +1006,72 @@ cmd_integration_target() {
1005
1006
  # 1. current-tip identity — the live merge carrying the current branch
1006
1007
  # tip (current_branch_merge, blocker 35's identity primitive). If the
1007
1008
  # tip is on the live line via a merge, that merge IS the deliverable.
1009
+ # Branch-present path only — a gone branch has no tip to identify.
1008
1010
  # 2. the task's own durable merge records, newest first (at most 20
1009
1011
  # candidates scanned; garbage/unresolvable entries skipped). The
1010
1012
  # first record that resolves to a MERGE commit and is an ancestor of
1011
1013
  # the live target wins; the SHA is canonicalized to full 40-hex.
1012
1014
  # Poison rule: the newest RESOLVABLE non-ancestor record poisons the
1013
1015
  # fallback — an older record is never accepted beneath a newer known-
1014
- # stale one. The answer then is empty-no-work (stderr notes the poison),
1015
- # never a fabricated already-merged.
1016
+ # stale one. On the branch-present path the answer is empty-no-work
1017
+ # (stderr notes the poison), never a fabricated already-merged. On the
1018
+ # no-branch path the poison fails closed (exit 1): without a branch,
1019
+ # "empty" is indistinguishable from evidence loss.
1016
1020
  # The branch is empty here, so the blocker-35 identity/ancestry
1017
1021
  # distinction does not apply: there is no moved branch to go stale
1018
1022
  # against, and every candidate is this task's own record.
1023
+ #
1024
+ # No-branch path (blocker 50): a missing branch is the normal
1025
+ # post-integrate state — Integrate merges, the worktree lifecycle reclaims
1026
+ # the branch, and QA/rework run after. The classifier resolves the
1027
+ # integration target independently of the branch and runs the durable
1028
+ # merge-record scan (step 2 below). A hit prints already-merged:<sha> with
1029
+ # the real DIAG counts, exit 0. No hit — no records, none resolvable, none
1030
+ # an ancestor, or poisoned — exits 1 with diagnostics, never empty-no-work.
1031
+
1032
+ # Durable merge-record scan, shared by classify-branch's branch-present
1033
+ # and no-branch paths (blocker 50). Newest first, at most 20 candidates:
1034
+ # valid-hex merge_commit= lines only; a candidate must resolve to a MERGE
1035
+ # commit (^2 exists) to count as resolvable. The first resolvable record
1036
+ # that is an ancestor of $2 wins. Results in SCAN_MERGED (full 40-hex or
1037
+ # empty) and SCAN_POISONED (1 when the newest resolvable record is a
1038
+ # non-ancestor — an older ancestor beneath it is refused, stderr-noted).
1039
+ # Updates the caller's diag_records/diag_resolvable (dynamic scope; the
1040
+ # defaults keep set -u safe). No stdout of its own.
1041
+ scan_merge_records() {
1042
+ local task_id="$1" target="$2"
1043
+ SCAN_MERGED=""; SCAN_POISONED=0
1044
+ : "${diag_records:=0}" "${diag_resolvable:=0}"
1045
+ local rec="$MERGE_RECORDS_DIR/$task_id"
1046
+ [ -f "$rec" ] || return 0
1047
+ local count=0 sha full poisoned=""
1048
+ while IFS= read -r line && [ "$count" -lt 20 ]; do
1049
+ case "$line" in
1050
+ merge_commit=*) sha="${line#merge_commit=}" ;;
1051
+ *) continue ;;
1052
+ esac
1053
+ case "$sha" in ""|*[!0-9a-f]*) continue ;; esac
1054
+ count=$((count + 1))
1055
+ diag_records=$((diag_records + 1))
1056
+ full=$(git rev-parse --verify --quiet "$sha" 2>/dev/null) || continue
1057
+ # A merge commit: the frozen first-parent diff the workflow reviews
1058
+ # only exists for merges. A non-merge ancestor is never the task's
1059
+ # merge — skipped, not poison (garbage must not veto delivery).
1060
+ git rev-parse --verify --quiet "$full^2" >/dev/null 2>&1 || continue
1061
+ diag_resolvable=$((diag_resolvable + 1))
1062
+ if git merge-base --is-ancestor "$full" "$target" 2>/dev/null; then
1063
+ if [ -n "$poisoned" ]; then
1064
+ echo "classify-branch: newest resolvable record $poisoned is stale (not an ancestor of $target); refusing older record $full" >&2
1065
+ SCAN_POISONED=1
1066
+ return 0
1067
+ fi
1068
+ SCAN_MERGED="$full"
1069
+ return 0
1070
+ fi
1071
+ [ -z "$poisoned" ] && poisoned="$full"
1072
+ done < <(tac "$rec" 2>/dev/null)
1073
+ return 0
1074
+ }
1019
1075
  cmd_classify_branch() {
1020
1076
  local task_id="$1"
1021
1077
  validate_task_id "$task_id"
@@ -1035,7 +1091,29 @@ cmd_classify_branch() {
1035
1091
  branch=$(resolve_branch "$task_id") || { echo "classify-branch: cannot resolve task branch" >&2; diag; return 1; }
1036
1092
 
1037
1093
  cd "$REPO"
1038
- git rev-parse --verify "$branch" >/dev/null 2>&1 || { echo "classify-branch: branch $branch does not exist" >&2; diag; return 1; }
1094
+
1095
+ # Blocker 50: a missing branch is the normal post-integrate state, not a
1096
+ # classification failure — Integrate merges, the worktree lifecycle
1097
+ # reclaims the branch, and QA/rework run after. Resolve the target
1098
+ # independently of the branch and let the durable merge records plus
1099
+ # trunk ancestry decide. Never empty-no-work here: without a branch,
1100
+ # "empty" is indistinguishable from evidence loss.
1101
+ if ! git rev-parse --verify "$branch" >/dev/null 2>&1; then
1102
+ echo "classify-branch: branch $branch does not exist; scanning durable merge records" >&2
1103
+ local nb_target
1104
+ nb_target=$(integration_target) || { echo "classify-branch: cannot resolve integration target" >&2; diag; return 1; }
1105
+ scan_merge_records "$task_id" "$nb_target"
1106
+ if [ -n "$SCAN_MERGED" ]; then
1107
+ diag
1108
+ echo "BRANCH_STATE: already-merged:$SCAN_MERGED"
1109
+ return 0
1110
+ fi
1111
+ if [ "$SCAN_POISONED" != "1" ]; then
1112
+ echo "classify-branch: no merge record for $task_id is an ancestor of $nb_target" >&2
1113
+ fi
1114
+ diag
1115
+ return 1
1116
+ fi
1039
1117
 
1040
1118
  local target
1041
1119
  target=$(integration_target) || { echo "classify-branch: cannot resolve integration target" >&2; diag; return 1; }
@@ -1059,36 +1137,13 @@ cmd_classify_branch() {
1059
1137
  fi
1060
1138
  fi
1061
1139
 
1062
- # 2. Durable merge records, newest first. Empty-file-safe: no pipelines
1063
- # under set -e/pipefail, at most 20 candidate lines read.
1064
- local rec="$MERGE_RECORDS_DIR/$task_id"
1065
- if [ -f "$rec" ]; then
1066
- local count=0 sha full poisoned=""
1067
- while IFS= read -r line && [ "$count" -lt 20 ]; do
1068
- case "$line" in
1069
- merge_commit=*) sha="${line#merge_commit=}" ;;
1070
- *) continue ;;
1071
- esac
1072
- case "$sha" in ""|*[!0-9a-f]*) continue ;; esac
1073
- count=$((count + 1))
1074
- diag_records=$((diag_records + 1))
1075
- full=$(git rev-parse --verify --quiet "$sha" 2>/dev/null) || continue
1076
- # A merge commit: the frozen first-parent diff the workflow reviews
1077
- # only exists for merges. A non-merge ancestor is never the task's
1078
- # merge — skipped, not poison (garbage must not veto delivery).
1079
- git rev-parse --verify --quiet "$full^2" >/dev/null 2>&1 || continue
1080
- diag_resolvable=$((diag_resolvable + 1))
1081
- if git merge-base --is-ancestor "$full" "$target" 2>/dev/null; then
1082
- if [ -n "$poisoned" ]; then
1083
- echo "classify-branch: newest resolvable record $poisoned is stale (not an ancestor of $target); refusing older record $full" >&2
1084
- break
1085
- fi
1086
- diag
1087
- echo "BRANCH_STATE: already-merged:$full"
1088
- return 0
1089
- fi
1090
- [ -z "$poisoned" ] && poisoned="$full"
1091
- done < <(tac "$rec" 2>/dev/null)
1140
+ # 2. Durable merge records, newest first (scan_merge_records; blocker 50
1141
+ # shares this scan with the no-branch path).
1142
+ scan_merge_records "$task_id" "$target"
1143
+ if [ -n "$SCAN_MERGED" ]; then
1144
+ diag
1145
+ echo "BRANCH_STATE: already-merged:$SCAN_MERGED"
1146
+ return 0
1092
1147
  fi
1093
1148
 
1094
1149
  diag
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.14.11",
3
+ "version": "0.14.13",
4
4
  "description": "Opinionated orchestration for Muse — workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -561,6 +561,12 @@ const eligible = [];
561
561
  const retryCandidates = [];
562
562
  const rejectionCandidates = [];
563
563
 
564
+ // Dispatch-decision log (2026-09-24): per-reason ineligibility counters for
565
+ // the .dispatch-decisions.jsonl declaration. Sparse — only non-zero keys are
566
+ // emitted. Additive only; the eligibility decisions below are unchanged.
567
+ var decisionSkipped = {};
568
+ function countSkipped(reason) { decisionSkipped[reason] = (decisionSkipped[reason] || 0) + 1; }
569
+
564
570
  // ── Retry cap ────────────────────────────────────────────────────────
565
571
  // Symphony owns phase-redispatch policy (coordination layer): the spec
566
572
  // defines backoff but no attempt cap ("implementation-defined"), so the
@@ -591,12 +597,13 @@ var MAX_CONSECUTIVE_REJECTIONS = configInt(config, "maxConsecutiveRejections", 2
591
597
 
592
598
  for (var t = 0; t < allTasks.length; t++) {
593
599
  var task = allTasks[t];
594
- if (task.blocked) continue;
600
+ if (task.blocked) { countSkipped("blocked"); continue; }
595
601
 
596
602
  // Skip tasks with an active dispatch reservation: the worker launched a
597
603
  // workflow for this task on a previous tick, but the workflow has not yet
598
604
  // self-claimed (claims can take 15+ minutes for cron-launched runs).
599
605
  if (reservedTaskIds.has(task.id)) {
606
+ countSkipped("reserved");
600
607
  log("Skipped \"" + task.title + "\" — active dispatch reservation (workflow launched, claim pending)");
601
608
  continue;
602
609
  }
@@ -604,6 +611,7 @@ for (var t = 0; t < allTasks.length; t++) {
604
611
  // Skip tasks from quiesced projects
605
612
  var taskProject = task.project || DEFAULT_PROJECT;
606
613
  if (quiescedProjects[taskProject]) {
614
+ countSkipped("quiesced");
607
615
  log("Skipped \"" + task.title + "\" — project " + taskProject + " is quiesced");
608
616
  continue;
609
617
  }
@@ -614,6 +622,7 @@ for (var t = 0; t < allTasks.length; t++) {
614
622
  // task never moves. Set repo_path via updateproject to re-enable.
615
623
  var taskProjCfg = PROJECTS[taskProject];
616
624
  if (!taskProjCfg || !taskProjCfg.repo_path) {
625
+ countSkipped("no_repo");
617
626
  log("Skipped \"" + task.title + "\" — project " + taskProject + " has no repo_path configured");
618
627
  continue;
619
628
  }
@@ -633,12 +642,14 @@ for (var t = 0; t < allTasks.length; t++) {
633
642
  // (clears) next_phase on its successful self-claim, exactly once.
634
643
  if (task.next_phase) {
635
644
  if (task.state !== "todo" && task.state !== "in_progress") {
645
+ countSkipped("next_phase_state");
636
646
  log("Skipped \"" + task.title + "\" — next_phase \"" + task.next_phase + "\" set but state is " + task.state + "; left set for inspection");
637
647
  continue;
638
648
  }
639
- if (latest && latest.status === "running") continue; // work in flight
649
+ if (latest && latest.status === "running") { countSkipped("in_flight"); continue; } // work in flight
640
650
  var npIdx = steps.indexOf(task.next_phase);
641
651
  if (npIdx < 0) {
652
+ countSkipped("next_phase_bad_step");
642
653
  log("Skipped \"" + task.title + "\" — next_phase \"" + task.next_phase + "\" not in " + workflow + " step registry; left set for a corrected recover-task");
643
654
  continue;
644
655
  }
@@ -654,19 +665,20 @@ for (var t = 0; t < allTasks.length; t++) {
654
665
  continue;
655
666
  }
656
667
 
657
- if (task.state !== "in_progress") continue;
668
+ if (task.state !== "in_progress") { countSkipped("terminal_state"); continue; }
658
669
 
659
670
  if (!latest) {
660
671
  eligible.push({ task: task, startStep: 0, reason: "no_session", workflow: workflow });
661
672
  continue;
662
673
  }
663
674
 
664
- if (latest.status === "running") continue; // work in flight
675
+ if (latest.status === "running") { countSkipped("in_flight"); continue; } // work in flight
665
676
 
666
677
  if (latest.status === "completed") {
667
678
  var stepName = latest.step || "";
668
679
  var stepIndex = steps.indexOf(stepName);
669
680
  if (stepIndex < 0) {
681
+ countSkipped("unknown_step");
670
682
  log("Skipped \"" + task.title + "\" — step \"" + stepName + "\" not in " + workflow + " step registry");
671
683
  continue;
672
684
  }
@@ -695,6 +707,7 @@ for (var t = 0; t < allTasks.length; t++) {
695
707
  var failedStep = latest.step || "";
696
708
  var retryIdx = steps.indexOf(failedStep);
697
709
  if (retryIdx < 0) {
710
+ countSkipped("unknown_step");
698
711
  log("Skipped \"" + task.title + "\" — step \"" + failedStep + "\" not in " + workflow + " step registry");
699
712
  continue;
700
713
  }
@@ -756,6 +769,7 @@ if (rejectionCandidates.length > 0) {
756
769
  // the task stays retryable and is re-parked next tick, so this is
757
770
  // fail-safe. The dashboard stamps retry_reset_at on parked→todo, which
758
771
  // restarts both counters mechanically.
772
+ var parkedTaskIds = [];
759
773
  if (parkJobs.length > 0) {
760
774
  var parkSteps = [];
761
775
  for (var pji = 0; pji < parkJobs.length; pji++) {
@@ -775,6 +789,7 @@ if (parkJobs.length > 0) {
775
789
  { key: "park-batch", label: "Parking " + parkJobs.length + " task(s) at retry cap" }
776
790
  );
777
791
  log("Parked " + parkJobs.length + " task(s)");
792
+ parkedTaskIds = parkJobs.map(function(pj) { return pj.task.id; });
778
793
  } catch (parkErr) {
779
794
  log("WARNING: park batch failed (" + (parkErr.message || String(parkErr)).slice(0, 200) + ") — tasks remain retryable");
780
795
  }
@@ -959,6 +974,7 @@ for (var ei = 0; ei < eligible.length; ei++) {
959
974
  toProcess.push(eitem);
960
975
  inFlightByProject[ep] = current + 1;
961
976
  } else {
977
+ countSkipped("at_limit");
962
978
  log("Skipped \"" + eitem.task.title + "\" — project " + ep + " at simultaneity limit (" + limit + ")");
963
979
  }
964
980
  }
@@ -1164,6 +1180,63 @@ if (recommended.length > 0) {
1164
1180
  recommended = acquired;
1165
1181
  }
1166
1182
 
1183
+ // ── 4c. Dispatch-decision log ──────────────────────────────────
1184
+ // Hoverboat (2026-09-24): the dispatcher declares its own decision, in its
1185
+ // own bytes, on crew-home disk — one append-only line per completed tick in
1186
+ // $CREW_HOME/.dispatch-decisions.jsonl. The observer reads the file
1187
+ // directly (no LLM re-emission, no platform ferry), so the plausible-value
1188
+ // mutation that poisoned the old dispatch_sample attribution cannot occur.
1189
+ // Launch declarations are verified mechanically against crew DB effects
1190
+ // (platform_run_tasks / dispatch_reservations); a stand-down is the
1191
+ // dispatcher's explicit declaration with board context, auditable without
1192
+ // re-deriving eligibility. tick_seq couples each line to the .tick-releases
1193
+ // line for the same poll — a tick-release line with no decision line means
1194
+ // the tick died after poll-ack and is visible as such.
1195
+ // seq is the non-empty line count + 1 of this file (file order is the
1196
+ // proof — no wall-clock calls, see tests/determinism.test.js).
1197
+ // Fire-and-forget: the script swallows its own errors and exits 0, the
1198
+ // agent call carries no schema, and the whole call is wrapped in
1199
+ // try/catch — a failed write can never fail the tick. The script echoes
1200
+ // the appended line (same rooms #12–#14 empty-result contract as §4b).
1201
+ var dispatchDecisionScript = [
1202
+ "var crewHome=process.argv[1];",
1203
+ "var decisionJson=process.argv[2];",
1204
+ "try{",
1205
+ "var fs=require(\"fs\"),path=require(\"path\");",
1206
+ "var decision=JSON.parse(decisionJson);",
1207
+ "if(!decision||typeof decision.decision!==\"string\"||!Array.isArray(decision.launched)){throw new Error(\"bad decision payload\");}",
1208
+ "var release=path.basename(path.resolve(crewHome,fs.readlinkSync(path.join(crewHome,\"current\"))));",
1209
+ "var file=path.join(crewHome,\".dispatch-decisions.jsonl\");",
1210
+ "var count=0;",
1211
+ "try{var lines=fs.readFileSync(file,\"utf8\").split(\"\\n\");for(var i=0;i<lines.length;i++){if(lines[i].trim()!==\"\"){count++;}}}catch(e){}",
1212
+ "var tickSeq=0;",
1213
+ "try{var tlines=fs.readFileSync(path.join(crewHome,\".tick-releases.jsonl\"),\"utf8\").split(\"\\n\");for(var j=0;j<tlines.length;j++){if(tlines[j].trim()!==\"\"){tickSeq++;}}}catch(e){}",
1214
+ "var line=JSON.stringify({seq:count+1,tick_seq:tickSeq,release:release,decision:decision.decision,launched:decision.launched,completed:decision.completed||[],parked:decision.parked||[],board:decision.board||null,skipped:decision.skipped||{},partial:!!decision.partial});",
1215
+ "fs.appendFileSync(file,line+\"\\n\");",
1216
+ "process.stdout.write(line+\"\\n\");",
1217
+ "}catch(e){}",
1218
+ "process.exit(0);"
1219
+ ].join("");
1220
+ var launchedRecs = recommended.map(function(r) { return { task_id: r.task_id, workflow: r.workflow, step: r.step }; });
1221
+ var decisionObj = {
1222
+ decision: launchedRecs.length > 0 ? "launch" : "stand_down",
1223
+ launched: launchedRecs,
1224
+ completed: completed.map(function(r) { return r.task_id; }),
1225
+ parked: parkedTaskIds,
1226
+ board: { seen: allTasks.length, eligible: eligible.length },
1227
+ skipped: decisionSkipped,
1228
+ partial: partial
1229
+ };
1230
+ var dispatchDecisionCmd = "node -e '" + dispatchDecisionScript + "' '" + crewHome.replace(/'/g, "'\\''") + "' '" + JSON.stringify(decisionObj).replace(/'/g, "'\\''") + "'";
1231
+ try {
1232
+ await agent(
1233
+ "Record this tick's dispatch decision.\nRun in shell and return the stdout verbatim:\n" + dispatchDecisionCmd,
1234
+ { key: "dispatch-decision", label: "Recording dispatch decision" }
1235
+ );
1236
+ } catch (e) {
1237
+ log("WARNING: dispatch-decision log write failed (tick continues): " + e.message);
1238
+ }
1239
+
1167
1240
  var msg = "Dispatch complete.";
1168
1241
  if (recommended.length > 0) {
1169
1242
  msg += " Recommended: " + recommended.map(function(r) { return r.workflow + "/" + r.step + " for " + r.task_id; }).join(", ") + ".";