@oneie/claude 0.8.0 → 0.10.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 (230) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/abm-strategist.md +67 -1
  3. package/agents/ads-meta.md +67 -1
  4. package/agents/analyst.md +67 -1
  5. package/agents/animator.md +108 -0
  6. package/agents/architect.md +269 -20
  7. package/agents/brand-guardian.md +67 -1
  8. package/agents/brand-strategist.md +67 -1
  9. package/agents/campaign-content.md +67 -1
  10. package/agents/campaign-email.md +67 -1
  11. package/agents/campaign-sms.md +67 -1
  12. package/agents/campaign-social.md +67 -1
  13. package/agents/cco.md +83 -2
  14. package/agents/ceo.md +108 -11
  15. package/agents/chairman.md +197 -0
  16. package/agents/cmo.md +82 -2
  17. package/agents/community-greeter.md +67 -1
  18. package/agents/community-moderator.md +67 -1
  19. package/agents/compliance.md +67 -1
  20. package/agents/copywriter.md +67 -1
  21. package/agents/creative-strategist.md +67 -1
  22. package/agents/cro.md +81 -1
  23. package/agents/cto.md +266 -28
  24. package/agents/customer-interviewer.md +67 -1
  25. package/agents/customer-researcher.md +67 -1
  26. package/agents/customer-success-manager.md +67 -1
  27. package/agents/customer-trainer.md +67 -1
  28. package/agents/cxo.md +82 -1
  29. package/agents/demand-creator.md +67 -1
  30. package/agents/demo-mover.md +67 -1
  31. package/agents/demo-specialist.md +67 -1
  32. package/agents/demo-thai-family-law.md +67 -1
  33. package/agents/designer.md +67 -1
  34. package/agents/discovery-caller.md +67 -1
  35. package/agents/doctor.md +269 -0
  36. package/agents/educate-coach.md +67 -1
  37. package/agents/elevate-tutor.md +67 -1
  38. package/agents/email-lifecycle-marketer.md +67 -1
  39. package/agents/engage-specialist.md +67 -1
  40. package/agents/events-coordinator.md +67 -1
  41. package/agents/foundation-builder.md +67 -1
  42. package/agents/funnel-architect.md +67 -1
  43. package/agents/gift-creator.md +67 -1
  44. package/agents/google-ads.md +67 -1
  45. package/agents/guide.md +67 -1
  46. package/agents/helpdesk-dispatcher.md +67 -1
  47. package/agents/hook-specialist.md +67 -1
  48. package/agents/identify-optimizer.md +67 -1
  49. package/agents/implementer.md +313 -45
  50. package/agents/incident-commander.md +67 -1
  51. package/agents/insights-lead.md +87 -1
  52. package/agents/journey-runner.md +67 -1
  53. package/agents/linkedin-ads.md +67 -1
  54. package/agents/live-sales-chat.md +67 -1
  55. package/agents/market-researcher.md +67 -1
  56. package/agents/media-buyer.md +67 -1
  57. package/agents/memory-keeper.md +195 -0
  58. package/agents/movers-customer-researcher.md +67 -1
  59. package/agents/movers-foundation-builder.md +67 -1
  60. package/agents/movers-market-researcher.md +67 -1
  61. package/agents/movers-pricing-strategist.md +67 -1
  62. package/agents/nurture-architect.md +67 -1
  63. package/agents/offer-architect.md +67 -1
  64. package/agents/onboarder.md +67 -1
  65. package/agents/onboarding-specialist.md +67 -1
  66. package/agents/operations-dashboard.md +87 -1
  67. package/agents/perf-engineer.md +333 -37
  68. package/agents/playbook-writer.md +67 -1
  69. package/agents/plg-strategist.md +67 -1
  70. package/agents/positioning-architect.md +67 -1
  71. package/agents/press-officer.md +67 -1
  72. package/agents/pricing-strategist.md +67 -1
  73. package/agents/privacy-officer.md +67 -1
  74. package/agents/referral-manager.md +67 -1
  75. package/agents/refine-analyst.md +67 -1
  76. package/agents/release-manager.md +446 -39
  77. package/agents/renewals-upsell-rep.md +67 -1
  78. package/agents/review-engineer.md +319 -45
  79. package/agents/rewards-steward.md +67 -1
  80. package/agents/sales-call-coach.md +67 -1
  81. package/agents/sales-closer.md +67 -1
  82. package/agents/security-auditor.md +343 -48
  83. package/agents/sell-closer.md +67 -1
  84. package/agents/share-amplifier.md +67 -1
  85. package/agents/social-media-manager.md +67 -1
  86. package/agents/storyteller.md +301 -0
  87. package/agents/strategist.md +67 -1
  88. package/agents/strategy-aligner.md +67 -1
  89. package/agents/support-agent.md +67 -1
  90. package/agents/tagger.md +327 -0
  91. package/agents/tech-writer.md +195 -22
  92. package/agents/test-engineer.md +398 -29
  93. package/agents/tiktok-ads.md +67 -1
  94. package/agents/tracking-engineer.md +67 -1
  95. package/agents/trailkeeper.md +181 -0
  96. package/agents/upsell-strategist.md +67 -1
  97. package/agents/voice.md +67 -1
  98. package/agents/w1-recon.md +1 -1
  99. package/agents/w2-decide.md +1 -1
  100. package/agents/w3-edit.md +8 -2
  101. package/agents/w4-verify.md +13 -0
  102. package/agents/workflow-optimiser.md +81 -1
  103. package/commands/close.md +916 -160
  104. package/commands/deploy.md +102 -724
  105. package/commands/do.md +58 -2
  106. package/commands/sweep.md +159 -0
  107. package/commands/tasks.md +222 -0
  108. package/hooks/scripts/dev-only.sh +135 -0
  109. package/hooks/scripts/git-add-guard.sh +37 -2
  110. package/hooks/scripts/session-start.sh +32 -4
  111. package/package.json +1 -1
  112. package/rules/scripts.md +85 -0
  113. package/scripts/CLAUDE.md +315 -0
  114. package/scripts/ad-copy-lint.sh +656 -0
  115. package/scripts/agent-actor-parity.sh +129 -0
  116. package/scripts/blocks-manifest-cached.sh +100 -0
  117. package/scripts/chat-context-check.sh +89 -0
  118. package/scripts/chrome.mjs +18 -0
  119. package/scripts/close-metrics.sh +587 -0
  120. package/scripts/close-owner.sh +326 -0
  121. package/scripts/db-sync-lock-check.sh +116 -0
  122. package/scripts/deploy-emit.sh +311 -0
  123. package/scripts/deploy-gate-check.sh +155 -0
  124. package/scripts/deploy-ready.sh +78 -0
  125. package/scripts/deploy-record.sh +605 -0
  126. package/scripts/deploy-schema-check.sh +58 -0
  127. package/scripts/deploy.sh +393 -243
  128. package/scripts/do-auto.sh +127 -26
  129. package/scripts/do-board.sh +429 -0
  130. package/scripts/do-close.sh +1184 -0
  131. package/scripts/do-consumer-sweep.sh +18 -1
  132. package/scripts/do-decide.sh +476 -0
  133. package/scripts/do-fleet.sh +8 -2
  134. package/scripts/do-plan-json.mjs +110 -12
  135. package/scripts/do-prove-selftest.sh +108 -0
  136. package/scripts/do-prove.sh +86 -10
  137. package/scripts/do-rank.py +200 -3
  138. package/scripts/do-reconcile.sh +73 -12
  139. package/scripts/do-signal.sh +101 -23
  140. package/scripts/do-smoke.sh +18 -1
  141. package/scripts/do-w4-gates.sh +11 -1
  142. package/scripts/do-world-check.sh +153 -0
  143. package/scripts/download-stats.sh +172 -0
  144. package/scripts/factory-brief-check.sh +330 -0
  145. package/scripts/factory-check.sh +18 -1
  146. package/scripts/factory-close-check.sh +257 -0
  147. package/scripts/factory-emit.sh +211 -0
  148. package/scripts/factory-executor-check.mjs +353 -0
  149. package/scripts/factory-peak.sh +301 -0
  150. package/scripts/factory-repo.sh +71 -0
  151. package/scripts/factory-review-check.mjs +61 -0
  152. package/scripts/factory-tasks-check.sh +18 -1
  153. package/scripts/fixtures/factory-brief-real.md +44 -0
  154. package/scripts/flywheel-outcome.sh +63 -0
  155. package/scripts/gate-reaper-check.sh +98 -0
  156. package/scripts/gate-reaper.sh +9 -0
  157. package/scripts/gate-watchdog.sh +619 -0
  158. package/scripts/gc-content-check.sh +142 -0
  159. package/scripts/gh-traffic-capture.sh +153 -0
  160. package/scripts/govern-order-check.sh +202 -0
  161. package/scripts/governor-doors-check.sh +86 -5
  162. package/scripts/health.sh +448 -0
  163. package/scripts/id-inventory.mjs +418 -0
  164. package/scripts/incident.sh +212 -0
  165. package/scripts/land.sh +755 -45
  166. package/scripts/lib/gc-finished.sh +77 -0
  167. package/scripts/livekit-ratchet.sh +18 -1
  168. package/scripts/machine-check.sh +1 -1
  169. package/scripts/memory-index-budget.sh +79 -0
  170. package/scripts/npm-downloads.sh +109 -0
  171. package/scripts/one-agents.mjs +204 -8
  172. package/scripts/one-resume.sh +31 -3
  173. package/scripts/pr-body.sh +335 -0
  174. package/scripts/preview-fd-check.sh +289 -0
  175. package/scripts/redirect-lint.sh +169 -0
  176. package/scripts/release.sh +40 -6
  177. package/scripts/resume-lost-sessions.sh +68 -0
  178. package/scripts/shoot-pages.mjs +140 -0
  179. package/scripts/signal-meta-backfill.ts +451 -0
  180. package/scripts/signal-watch.sh +63 -6
  181. package/scripts/speed-cache-check.sh +12 -2
  182. package/scripts/sweep.sh +426 -0
  183. package/scripts/task-titles-dump.ts +101 -0
  184. package/scripts/test-cached.sh +47 -10
  185. package/scripts/test-lanes.sh +14 -0
  186. package/scripts/thread-name-backfill.ts +215 -0
  187. package/scripts/triage-shape-check.sh +149 -0
  188. package/scripts/tsc-cached.sh +155 -8
  189. package/scripts/typedb-flake-check.sh +3 -1
  190. package/scripts/urls-lint.sh +8 -0
  191. package/scripts/verify-board-doors.sh +80 -0
  192. package/scripts/verify-fast.sh +159 -6
  193. package/scripts/worktree-up.sh +21 -3
  194. package/skills/astro/SKILL.md +9 -3
  195. package/skills/astro/optimize-performance.md +3 -2
  196. package/skills/cloudflare/SKILL.md +3 -2
  197. package/skills/cloudflare-security-audit/AI-AND-LLM.md +83 -0
  198. package/skills/cloudflare-security-audit/ATTACK-CLASSES.md +130 -0
  199. package/skills/cloudflare-security-audit/CLIENT-SIDE.md +83 -0
  200. package/skills/cloudflare-security-audit/CLOUD-AND-DEPLOYMENT.md +86 -0
  201. package/skills/cloudflare-security-audit/DATA-ISOLATION-AND-LIFECYCLE.md +84 -0
  202. package/skills/cloudflare-security-audit/DESKTOP-MOBILE-AND-LOCAL-IPC.md +89 -0
  203. package/skills/cloudflare-security-audit/HUNTING.md +251 -0
  204. package/skills/cloudflare-security-audit/LICENSE +21 -0
  205. package/skills/cloudflare-security-audit/MEMORY-SAFETY-AND-BINARY.md +101 -0
  206. package/skills/cloudflare-security-audit/PROTOCOLS-RPC-AND-MESSAGING.md +81 -0
  207. package/skills/cloudflare-security-audit/PROVENANCE.md +78 -0
  208. package/skills/cloudflare-security-audit/RECONNAISSANCE.md +156 -0
  209. package/skills/cloudflare-security-audit/RESOURCE-EXHAUSTION-AND-AVAILABILITY.md +78 -0
  210. package/skills/cloudflare-security-audit/SKILL.md +192 -0
  211. package/skills/cloudflare-security-audit/SUPPLY-CHAIN-AND-RELEASE.md +73 -0
  212. package/skills/cloudflare-security-audit/VALIDATION-AND-REPORTING.md +186 -0
  213. package/skills/cloudflare-security-audit/WEB-PROTOCOL-AND-AUTH.md +105 -0
  214. package/skills/cloudflare-security-audit/report-schema.json +461 -0
  215. package/skills/cloudflare-security-audit/validate-coverage-ledger.cjs +872 -0
  216. package/skills/cloudflare-security-audit/validate-coverage-ledger.test.cjs +740 -0
  217. package/skills/cloudflare-security-audit/validate-findings.cjs +773 -0
  218. package/skills/cloudflare-security-audit/validate-findings.test.cjs +652 -0
  219. package/skills/deploy/REFERENCE.md +713 -0
  220. package/skills/deploy/SKILL.md +140 -0
  221. package/skills/fleet-audit/SKILL.md +58 -0
  222. package/skills/meeting/SKILL.md +220 -0
  223. package/skills/planning/SKILL.md +256 -0
  224. package/skills/shadcn/SKILL.md +1 -1
  225. package/skills/typedb/SKILL.md +7 -0
  226. package/skills/voice/SKILL.md +94 -6
  227. package/skills/voice/corpus-check.sh +87 -0
  228. package/templates/template-agent.md +7 -1
  229. package/templates/template-feature.md +9 -0
  230. package/templates/template-todo.md +29 -0
@@ -42,30 +42,123 @@ while [ "$#" -gt 0 ]; do
42
42
  esac
43
43
  done
44
44
 
45
- # Standalone GC: sweep all worktrees whose do/<slug> branch is fully merged into
46
- # trunk (main; DO_BASE_REF overrides). Safe-only: `worktree remove` refuses
47
- # dirty, `branch -d` refuses unmerged. Unmerged/dirty worktrees are listed,
48
- # never touched.
45
+ # Standalone GC: sweep every worktree whose branch is fully merged into the
46
+ # RELEASE trunk. Safe-only: `worktree remove` refuses a dirty tree and
47
+ # `branch -d` refuses an unmerged branch, so live work is listed, never touched.
48
+ #
49
+ # TWO DIRECTORIES, not one. `.do-worktrees/<slug>` carries branch `do/<slug>`;
50
+ # `.claude/worktrees/<name>` carries whatever branch is checked out there, which
51
+ # is the door root CLAUDE.md sends every session through. Only the first was ever
52
+ # swept, and the canon said so out loud — "swept by nothing" — for as long as the
53
+ # second existed. Measured 2026-09-06: 36 worktrees, 24 of them fully landed,
54
+ # 6.4G, and the box at load 16 funding ONE concurrent gate because each one costs
55
+ # a tsserver. Sweeping 17 took load to 3.5.
56
+ #
57
+ # THE BASE IS `main`, DELIBERATELY, AND NOT `dev`. Branches are cut from dev and
58
+ # land into dev (§ the loop), so measuring against dev would delete a worktree
59
+ # the moment its work integrated — which is BEFORE the dev→main PR is reviewed.
60
+ # A reviewer asking for changes would find the branch already gone. A branch is
61
+ # finished when it is released, not when it is integrated. DO_BASE_REF overrides.
49
62
  if $GC_ONLY; then
50
- BASE="${DO_BASE_REF:-main}"
51
- git show-ref --verify --quiet "refs/heads/$BASE" \
52
- || BASE="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)"
63
+ BASE="${DO_GC_BASE:-${DO_BASE_REF_GC:-main}}"
64
+ # RESOLVE ANY COMMIT-ISH, AND NEVER SUBSTITUTE ONE SILENTLY. This used to be
65
+ # `show-ref --verify refs/heads/$BASE`, which accepts only a LOCAL branch — so
66
+ # `DO_GC_BASE=origin/main`, the obvious way to sweep against the real trunk,
67
+ # failed the check and fell back to the invoking branch. That is the branch the
68
+ # override exists to escape, and the run reported `base=main` as if it had
69
+ # worked. Measured 2026-09-07: local main sat 131 commits behind origin/main,
70
+ # so every landed branch read as "142 unmerged commit(s)" and the sweep hoarded
71
+ # 1.0 GB of finished worktrees. A base that cannot be resolved is now a REFUSAL,
72
+ # not a fallback — the whole point of --gc is that it never deletes live work,
73
+ # and measuring against the wrong ref is how it would.
74
+ if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null 2>&1; then
75
+ echo "[do-auto] gc: base '$BASE' does not resolve to a commit — refusing to sweep" >&2
76
+ echo "[do-auto] (a wrong base makes landed work look unmerged, and unlanded work look landed)" >&2
77
+ exit 2
78
+ fi
79
+ # Say what was measured against, so a STALE base is visible in the output
80
+ # rather than inferred from a surprising verdict. `main` 131 commits behind its
81
+ # upstream is not a wrong ref — it is a right ref pointing at the wrong commit,
82
+ # and nothing in the old output could tell those apart.
83
+ BASE_SHA="$(git rev-parse --short "${BASE}^{commit}")"
84
+ BASE_NOTE=""
85
+ if _gc_up="$(git rev-parse --abbrev-ref --symbolic-full-name "${BASE}@{upstream}" 2>/dev/null)"; then
86
+ _gc_behind="$(git rev-list --count "${BASE}..${_gc_up}" 2>/dev/null || echo 0)"
87
+ [ "${_gc_behind:-0}" -gt 0 ] && BASE_NOTE=" — STALE: ${_gc_behind} commit(s) behind ${_gc_up}"
88
+ fi
89
+ echo "[do-auto] gc: measuring against ${BASE} (${BASE_SHA})${BASE_NOTE}"
90
+ # Never sweep the branches the loop itself stands on, whatever their state.
91
+ GC_KEEP="${GC_KEEP:-main dev release}"
53
92
  removed=0; kept=0
54
- for wt in .do-worktrees/*/; do
55
- [ -d "$wt" ] || continue
56
- s=$(basename "$wt"); b="do/${s}"
57
- git show-ref --verify --quiet "refs/heads/$b" || { echo "[do-auto] gc: $wt has no branch $b — inspect manually"; kept=$((kept+1)); continue; }
58
- if [ "$(git rev-list --count "${BASE}..${b}" 2>/dev/null || echo 1)" -eq 0 ]; then
59
- if git worktree remove "$wt" 2>/dev/null && git branch -d "$b" 2>/dev/null; then
60
- echo "[do-auto] gc: removed merged worktree $wt + branch $b"; removed=$((removed+1))
61
- else
62
- echo "[do-auto] gc: $wt merged but dirty — inspect manually"; kept=$((kept+1))
63
- fi
93
+
94
+ # The predicate lives in lib/gc-finished.sh shared, so its red proof
95
+ # (gc-content-check.sh) drives the same code the sweep runs.
96
+ . "$(dirname "${BASH_SOURCE[0]}")/lib/gc-finished.sh"
97
+
98
+ _gc_one() { # <worktree-dir> <branch>
99
+ local wt="$1" b="$2"
100
+ case " $GC_KEEP " in *" $b "*) echo "[do-auto] gc: $wt is $b — infrastructure, kept"; kept=$((kept+1)); return ;; esac
101
+
102
+ if ! gc_finished "$BASE" "$b"; then
103
+ echo "[do-auto] gc: $b carries $(gc_carries "$BASE" "$b") path(s) $BASE does not have — kept"; kept=$((kept+1)); return
104
+ fi
105
+
106
+ # Finished. The only thing that still earns a keep is UNCOMMITTED work — that
107
+ # is content no merge has seen and no ref points at, so losing it loses it.
108
+ local real; real="$(gc_real_dirt "$wt")"
109
+ if [ -n "$real" ]; then
110
+ echo "[do-auto] gc: $b is in $BASE but its worktree has uncommitted work — kept:"
111
+ echo "$real" | sed "s/^/[do-auto] gc: /"
112
+ kept=$((kept+1)); return
113
+ fi
114
+
115
+ # A DRY RUN THAT DELETES IS NOT A DRY RUN. `--dry-run` is parsed by the same
116
+ # flag loop that serves the cycle path, where it is honoured in four places —
117
+ # but nothing here read it, so `--gc --dry-run` removed the worktree AND
118
+ # `branch -D`'d the ref while printing a line that reads like a preview.
119
+ # Measured 2026-09-10: it reported `removed=1 kept=2` and `feat/panel-resize`
120
+ # and its worktree were actually gone. The content was safely in the trunk
121
+ # that time; the flag composes in the usage string, so next time it need not
122
+ # be. Report exactly what it WOULD do, then return without touching the disk.
123
+ if $DRY_RUN; then
124
+ echo "[do-auto] gc: WOULD remove $wt + branch $b (content already in $BASE) — dry run, nothing deleted"
125
+ removed=$((removed+1)); return
126
+ fi
127
+
128
+ # --force gets past ephemeral dirt only: the check above already refused
129
+ # anything that was work. `branch -d` asks "is it merged into HEAD", which is
130
+ # the wrong question here (HEAD is usually main and $BASE is usually dev), so
131
+ # -D is correct once content identity is proven — and it is proven, above.
132
+ if git worktree remove --force "$wt" 2>/dev/null; then
133
+ git branch -D "$b" >/dev/null 2>&1 \
134
+ && echo "[do-auto] gc: removed $wt + branch $b (content already in $BASE)" \
135
+ || echo "[do-auto] gc: removed $wt (branch $b kept — could not delete ref)"
136
+ removed=$((removed+1))
64
137
  else
65
- echo "[do-auto] gc: $b has $(git rev-list --count "${BASE}..${b}") unmerged commit(s)kept"; kept=$((kept+1))
138
+ echo "[do-auto] gc: $b is in $BASE but its worktree would not remove inspect $wt"; kept=$((kept+1))
66
139
  fi
67
- done
68
- echo "[do-auto] gc done: removed=$removed kept=$kept"
140
+ }
141
+
142
+ # ENUMERATE FROM GIT, NOT FROM A GLOB. `for wt in .claude/worktrees/*/` is
143
+ # relative to the INVOKING tree, and a worktree contains no worktrees — so run
144
+ # from anywhere but main and the sweep matches nothing and reports success.
145
+ # Measured 2026-09-06 on the first run of this code: `removed=0 kept=0` with 19
146
+ # worktrees live. It is the same trap the canon already records for land.sh and
147
+ # release.sh ("Run MAIN's copy"), and a glob cannot be told apart from a clean
148
+ # sweep by its output. `git worktree list` answers with absolute paths from any
149
+ # tree, so there is no copy to run.
150
+ SELF="$(git rev-parse --show-toplevel 2>/dev/null)"
151
+ while IFS= read -r wt; do
152
+ [ -d "$wt" ] || continue
153
+ case "$wt" in *"/.do-worktrees/"*|*"/.claude/worktrees/"*) ;; *) continue ;; esac
154
+ # Never saw off the branch you are sitting on.
155
+ [ "$wt" = "$SELF" ] && { echo "[do-auto] gc: $wt is the invoking tree — kept"; kept=$((kept+1)); continue; }
156
+ b="$(git -C "$wt" branch --show-current 2>/dev/null)"
157
+ [ -n "$b" ] || { echo "[do-auto] gc: $wt is detached — kept"; kept=$((kept+1)); continue; }
158
+ _gc_one "$wt" "$b"
159
+ done < <(git worktree list --porcelain | sed -n 's/^worktree //p')
160
+
161
+ echo "[do-auto] gc done: removed=$removed kept=$kept (base=$BASE ${BASE_SHA}${BASE_NOTE})"
69
162
  exit 0
70
163
  fi
71
164
 
@@ -587,7 +680,9 @@ POLL_SECS="${DO_POLL_SECS:-30}"
587
680
  # branch would otherwise seed every new do/<slug> with that branch's commits
588
681
  # (plans cross-contaminating across concurrent sessions, 2026-07-21).
589
682
  # DO_BASE_REF overrides for a deliberate stack on another branch.
590
- BASE="${DO_BASE_REF:-main}"
683
+ # `dev` since 2026-09-06: it is the integration branch branches land into, so
684
+ # cutting from main would start every cycle missing what dev already holds.
685
+ BASE="${DO_BASE_REF:-dev}"
591
686
  git show-ref --verify --quiet "refs/heads/$BASE" \
592
687
  || BASE="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)"
593
688
  BR="do/${SLUG}"
@@ -1325,6 +1420,10 @@ if [ "$i" -ge "$MAX_CYCLES" ]; then
1325
1420
  echo "[do-auto] hit --max-cycles $MAX_CYCLES — halting"
1326
1421
  echo "[do-auto] WIP preserved on branch $BR (worktree $WT)."
1327
1422
  _signal halt "$SLUG" "$(_tier)" "$(_plan_cycle "$i")" "reason=max-cycles"
1423
+ # A halt still closes. --status timeout is the honest word: the lease stays
1424
+ # held and the task is NOT marked done, so the next run resumes rather than
1425
+ # re-opening finished work. Rule 1 has no exception for halts.
1426
+ bash "$(dirname "$0")/do-close.sh" "$SLUG" --status timeout || true
1328
1427
  exit 1
1329
1428
  fi
1330
1429
 
@@ -1340,11 +1439,13 @@ _merge_digest
1340
1439
  # not per cycle (a plan's cycles all serve the same origin task). Asserted:
1341
1440
  # a failed link is logged with its exit, never disguised as ok; the plan
1342
1441
  # still completed its code.
1343
- _link_rc=0
1344
- bash "$(dirname "$0")/do-signal.sh" --task-link "$SLUG" "docs=${SLUG}" >/dev/null || _link_rc=$?
1345
- if [ "$_link_rc" -ne 0 ]; then
1346
- echo "board-write: link FAILED (exit ${_link_rc})" >&2
1347
- fi
1442
+ #
1443
+ # do-close.sh is the whole close, not just the link: the board write (through
1444
+ # this same slug door), the receipt check that gates it, the rubric dims, the
1445
+ # feedback pheromone and the promise settle — bounded, in ~5s, and whatever it
1446
+ # cannot close it FILES with the command that finishes it. It exits 0 by design
1447
+ # even with open legs; a close that can fail a plan is a close plans skip.
1448
+ bash "$(dirname "$0")/do-close.sh" "$SLUG" --composite "${_composite:-}" || true
1348
1449
  echo ""
1349
1450
  echo "[do-auto] ✓ plan complete on branch $BR — trunk ($BASE) untouched."
1350
1451
  echo "[do-auto] read: .do-digest.md # what landed + gate verdict (reasoning was discarded)"
@@ -0,0 +1,429 @@
1
+ #!/usr/bin/env bash
2
+ # do-board.sh <slug> [--dry-run | --status | --self-test] — the ▸board stop's executor,
3
+ # and the ONE minter of a plan's cycle rows.
4
+ #
5
+ # manifest: needs-env (reads GATEWAY_API_KEY; writes rows through one.ie)
6
+ #
7
+ # Mirrors a plan's batch DAG onto the board as CHAINED rows and writes the tids
8
+ # back into the todo's frontmatter (`anchor:` + `cycle_rows:`), so the plan and
9
+ # the board are one graph rendered twice.
10
+ #
11
+ # WHY THE JOIN MATTERS. `tasks:claim` and `tasks:status picked` are BLOCKER-GATED:
12
+ # they read the very `blocks` edge this script writes. A mirrored plan cannot be
13
+ # claimed out of order; an unmirrored one hands every head every cycle at once.
14
+ #
15
+ # ONE MINTER. do-signal.sh's `--task-plan` used to file the same rows flat
16
+ # (tasks:create, no edges, tagged `slug:<slug>-C1` in UPPER case) while
17
+ # `--task-cycle` closed rows named `slug:<slug>-c1` in lower case — SLUG_RE is
18
+ # lowercase-only, so the armed rows never deduped and never closed. Both now meet
19
+ # here: `--task-plan` delegates to this script, and the rows it mints carry the
20
+ # exact lower-case slug tag `--task-cycle` resolves, so the engine's existing
21
+ # close (do-tick.sh, do-engine.js, do-close.sh) moves THESE rows to done and the
22
+ # gate unblocks the next batch by itself. No new return leg was written.
23
+ #
24
+ # IDEMPOTENT AND RESUMABLE. tasks:subtask does not dedupe, so before minting a
25
+ # cycle the walk looks the row up by its slug tag (tasks:list) and, if it exists,
26
+ # only re-asserts the edges with tasks:depend (a negated-pattern insert — safe to
27
+ # repeat). Frontmatter is written after EVERY batch, so a walk that dies at C7
28
+ # resumes at C7 instead of twinning C1..C6.
29
+ #
30
+ # SEQUENTIAL BY CONTRACT. blockedBy takes tids, so batch N+1 needs batch N's rows
31
+ # to exist. Within a batch nothing depends on anything, but 14 rows cost ~20s in
32
+ # series and the order is the proof — not worth a parallel shape.
33
+ #
34
+ # THE LEASE MOVES TO THE CYCLE. Containment children gate their parent's claim
35
+ # (blockersResolved reads `containment` too), so once cycles hang off the anchor
36
+ # the plan-level lease (`tasks:claim` on the anchor) is refused as `blocked`.
37
+ # do-signal.sh --task-claim and do-fleet.sh claim_task therefore claim the FIRST
38
+ # OPEN CYCLE's row when the plan has cycle_rows — the row actually in flight.
39
+ #
40
+ # The deterministic-fact rule (.claude/CLAUDE.md): a fact the spine needs gets a
41
+ # script; the model executes it, never substitutes for it.
42
+ #
43
+ # DO_BOARD_FAKE=1 swaps the door for an in-process stub that mints deterministic
44
+ # tids — the dry run and the self-test walk the REAL loop, not a summary of it.
45
+ set -euo pipefail
46
+
47
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
48
+ API="${ONE_API_URL:-https://one.ie}"
49
+ FAKE="${DO_BOARD_FAKE:-0}"
50
+
51
+ die() { echo "do-board: $*" >&2; exit 1; }
52
+
53
+ # ---------------------------------------------------------------- the door
54
+ _key() {
55
+ [ -n "${GATEWAY_API_KEY:-}" ] && { printf '%s' "$GATEWAY_API_KEY"; return; }
56
+ # .dev.vars, NOT .env — prod refuses the .env key (recorded trap).
57
+ local f="${ONE_ENV_FILE:-${DO_ENV_FILE:-$ROOT/one.ie/web/.dev.vars}}"
58
+ [ -f "$f" ] || die "no GATEWAY_API_KEY and no $f"
59
+ grep -o 'GATEWAY_API_KEY=.*' "$f" | head -1 | cut -d= -f2-
60
+ }
61
+
62
+ # ask <receiver> <json-data> -> stdout is the raw envelope. No `-f`: a refusal's
63
+ # body is the diagnosis, and a silent empty string is how a mint reads as a hang.
64
+ ask() {
65
+ if [ "$FAKE" = 1 ]; then
66
+ case "$1" in
67
+ # deterministic per payload (ask runs in a $(...) subshell, so a counter cannot climb)
68
+ tasks:create|tasks:subtask) printf '{"ok":true,"tid":"task:%024x"}' "$(printf '%s' "$2" | cksum | cut -d' ' -f1)" ;;
69
+ tasks:list) printf '{"ok":true,"tasks":[]}' ;;
70
+ # The bulk door answers a ref→tid map, one entry per `creates` row, so the
71
+ # self-test walks the REAL bulk loop (chunking, cross-chunk ref resolution,
72
+ # the frontmatter write) instead of a summary of it. A stub that only knew
73
+ # the one-at-a-time receivers would have left the whole bulk path unproven
74
+ # while printing PASS.
75
+ tasks:bulk)
76
+ local _r _t _out=""
77
+ for _r in $(jq -r '.creates[]?.ref // empty' <<<"$2"); do
78
+ _t=$(printf '%s%s' "$_r" "$2" | cksum | cut -d' ' -f1)
79
+ _out="$_out,\"$_r\":\"$(printf 'task:%024x' "$_t")\""
80
+ done
81
+ printf '{"ok":true,"attempted":%s,"applied":%s,"failed":0,"created":{%s}}' \
82
+ "$(jq -r '.creates|length' <<<"$2")" "$(jq -r '.creates|length' <<<"$2")" "${_out#,}"
83
+ ;;
84
+ *) printf '{"ok":true}' ;;
85
+ esac
86
+ return
87
+ fi
88
+ curl -sS -m 30 -X POST "$API/api/ask/$1" \
89
+ -H "Authorization: Bearer $(_key)" -H 'Content-Type: application/json' \
90
+ -d "{\"data\":$2}"
91
+ }
92
+
93
+ # tid_of <envelope> — the id a task receiver returns, whatever it calls it.
94
+ tid_of() { jq -r '.result.tid // .result.id // .result.taskId // .tid // empty' <<<"$1"; }
95
+
96
+ # row_by_slug <cslug> <ws> — an existing row's tid by its exact `slug:` tag, or empty.
97
+ # Same rule as resolveTaskBySlug: exact tag, and the sort-last tid wins if there are twins.
98
+ row_by_slug() {
99
+ local env; env=$(ask tasks:list "{\"workspace\":\"$2\",\"tag\":\"slug:$1\"}")
100
+ jq -r '[(.result.tasks // .tasks // [])[].tid] | sort | last // empty' <<<"$env"
101
+ }
102
+
103
+ # cycle_notes <todo> <cid> <slug> — what a head reads when it opens the row: the
104
+ # cycle's goal delta + deliverable, and where the whole frame lives. Capped so a
105
+ # silent 4000-byte truncation (tasks:comment's, and notes' by the same discipline)
106
+ # can never eat the pointer at the end.
107
+ cycle_notes() {
108
+ local body
109
+ body=$(awk -v c="$2" '
110
+ $0 ~ "^## "c" — " {on=1; next}
111
+ on && /^## / {exit}
112
+ on && /^\*\*(Goal delta|Deliverable|Files|Accept)/ {print}
113
+ ' "$1" | sed 's/\*\*//g' | head -c 1200)
114
+ printf '%s\n\nPlan: text/%s-todo.md § %s\nClose: bash .claude/scripts/do-signal.sh --task-cycle %s %s done' "$body" "$3" "$2" "$3" "$2"
115
+ }
116
+
117
+ # write_front <todo> <anchor> <ws> <rows-block> — replace-or-insert the BOARD
118
+ # CONTRACT block. ONE definition, used by the walk AND the self-test, so the red
119
+ # proof exercises the writer that ships and not a copy of it.
120
+ write_front() {
121
+ python3 - "$1" "$2" "$3" "$4" <<'PY'
122
+ import re,sys
123
+ p,anchor,ws,rows = sys.argv[1],sys.argv[2],sys.argv[3],sys.argv[4]
124
+ s=open(p).read()
125
+ # strip any previous block first — running ▸board twice must not leave two answers
126
+ # `[ \t]*(?:#.*)?` on the cycle_rows line is load-bearing: template-todo.md ships
127
+ # `cycle_rows: [] # WRITTEN BY do-board.sh, never by hand`, and without
128
+ # the comment clause that group does not match. The group is OPTIONAL, so the strip
129
+ # then silently removed only anchor+board_workspace and LEFT the empty list — and a
130
+ # second `cycle_rows:` is last-wins in YAML, so every YAML consumer read ZERO tids
131
+ # while the file visibly held six. --status never saw it (it parses by line, not by
132
+ # YAML), so writer and checker agreed and only the consumers were wrong. Measured on
133
+ # workflow-genesis, 2026-09-12.
134
+ s=re.sub(r'\n(anchor:.*\n)(board_workspace:.*\n)?(cycle_rows:[ \t]*(?:\[\])?[ \t]*(?:#.*)?\n(?:[ \t]+-.*\n)*)?','\n',s,count=1)
135
+ blk=f'anchor: "{anchor}"\nboard_workspace: {ws}\ncycle_rows:\n{rows}'
136
+ if not blk.endswith('\n'): blk += '\n'
137
+ if re.search(r'^depends_on:.*\n',s,flags=re.M):
138
+ s=re.sub(r'^(depends_on:.*\n)',lambda m:m.group(1)+blk,s,count=1,flags=re.M)
139
+ else:
140
+ s=re.sub(r'^(slug:.*\n)',lambda m:m.group(1)+blk,s,count=1,flags=re.M)
141
+ open(p,'w').write(s)
142
+ PY
143
+ }
144
+
145
+ # ---------------------------------------------------------------- self-test
146
+ if [ "${1:-}" = "--self-test" ]; then
147
+ fail=0
148
+ ok() { if [ "$2" = 1 ]; then echo "ok $1"; else echo "FAIL $1"; fail=1; fi; }
149
+
150
+ ok "tid_of reads .result.tid" "$([ "$(tid_of '{"result":{"tid":"task:a"}}')" = "task:a" ] && echo 1 || echo 0)"
151
+ ok "tid_of reads .result.id" "$([ "$(tid_of '{"result":{"id":"task:b"}}')" = "task:b" ] && echo 1 || echo 0)"
152
+ ok "tid_of reads bare .tid" "$([ "$(tid_of '{"ok":true,"tid":"task:c"}')" = "task:c" ] && echo 1 || echo 0)"
153
+ ok "tid_of empty on an error" "$([ -z "$(tid_of '{"error":"forbidden"}')" ] && echo 1 || echo 0)"
154
+
155
+ tmp="$(mktemp -d)"; f="$tmp/x-todo.md"
156
+ # A fixture do-plan-json accepts: frontmatter, three cycles in two batches, a kanban.
157
+ cat > "$f" <<'FIX'
158
+ ---
159
+ title: fixture plan
160
+ slug: x
161
+ outcome: "three rows chained"
162
+ depends_on: []
163
+ batches:
164
+ - [C1]
165
+ - [C2, C3]
166
+ ---
167
+ ## Status
168
+ - [ ] C1 — first
169
+ - [ ] C2 — second
170
+ - [ ] C3 — third
171
+
172
+ ## C1 — first [tier: trivial]
173
+ **Goal delta:** one is true.
174
+ **Deliverable:** row one.
175
+
176
+ ## C2 — second [tier: trivial]
177
+ **Goal delta:** two is true.
178
+
179
+ ## C3 — third [tier: trivial]
180
+ **Goal delta:** three is true.
181
+ FIX
182
+ # A pristine copy: the walk below rewrites $f, and the bulk cases need a plan
183
+ # that is still UNMIRRORED (bulk is gated on drift == the cycle count).
184
+ cp "$f" "$tmp/orig-todo.md"
185
+ # THE WALK, under /bin/bash 3.2 — the shell this box actually runs. The first
186
+ # cut used `mapfile` (bash 4), died on the first batch, and was never caught
187
+ # because --dry-run exited before the loop. The fake door makes the loop the thing
188
+ # under test.
189
+ out=$(cd "$ROOT" && DO_PLAN_TODO="$f" DO_BOARD_FAKE=1 /bin/bash "$ROOT/.claude/scripts/do-board.sh" x 2>&1) || true
190
+ ok "walk runs under /bin/bash 3.2" "$(grep -q 'drift is now 0' <<<"$out" && echo 1 || echo 0)"
191
+ ok "C1 minted with no blockers" "$(grep -q '^ C1 .*blockedBy=0' <<<"$out" && echo 1 || echo 0)"
192
+ ok "C2 and C3 both blocked by batch 1" "$([ "$(grep -c '^ C[23] .*blockedBy=1' <<<"$out")" = 2 ] && echo 1 || echo 0)"
193
+ ok "anchor + 3 rows written to frontmatter" "$([ "$(grep -c 'cycle: C[123], tid: "task:' "$f")" = 3 ] && grep -q '^anchor: "task:' "$f" && echo 1 || echo 0)"
194
+ # Second run: every cycle has a row → nothing minted, drift 0, ONE block.
195
+ out2=$(cd "$ROOT" && DO_PLAN_TODO="$f" DO_BOARD_FAKE=1 /bin/bash "$ROOT/.claude/scripts/do-board.sh" x 2>&1) || true
196
+ ok "second run mints nothing (idempotent)" "$(grep -q 'already mirrored' <<<"$out2" && echo 1 || echo 0)"
197
+ ok "second run leaves ONE anchor:" "$([ "$(grep -c '^anchor:' "$f")" = 1 ] && echo 1 || echo 0)"
198
+ ok "second run leaves ONE cycle_rows:" "$([ "$(grep -c '^cycle_rows:' "$f")" = 1 ] && echo 1 || echo 0)"
199
+ # Resume: drop C3's row from the frontmatter → only C3 is re-minted, chained to batch 1.
200
+ sed -i '' '/cycle: C3,/d' "$f"
201
+ out3=$(cd "$ROOT" && DO_PLAN_TODO="$f" DO_BOARD_FAKE=1 /bin/bash "$ROOT/.claude/scripts/do-board.sh" x 2>&1) || true
202
+ ok "resume re-mints ONLY the missing cycle" "$([ "$(grep '^ C[0-9]* ' <<<"$out3" | grep -vc existing)" = 1 ] && grep -q '^ C3 .*blockedBy=1' <<<"$out3" && echo 1 || echo 0)"
203
+ ok "fake tids are distinct per row" "$([ "$(grep -o 'task:[0-9a-f]\{24\}' "$f" | sort -u | wc -l | tr -d ' ')" = 4 ] && echo 1 || echo 0)"
204
+ # Notes carry the pointer a head needs.
205
+ ok "cycle_notes ends with the close command" "$(cycle_notes "$f" C1 x | grep -q -- '--task-cycle x C1 done' && echo 1 || echo 0)"
206
+
207
+ # ── the BULK path, walked end to end against the fake bulk door ────────────
208
+ # A fresh fixture, because bulk is gated on drift == the cycle count. This
209
+ # drives the real loop: the creates array, the ref chain, the receipt's
210
+ # ref→tid map, and the SAME write_front the per-row walk uses.
211
+ g="$tmp/bz-todo.md"; sed 's/^slug: x$/slug: bz/' "$tmp/orig-todo.md" > "$g"
212
+ outb=$(cd "$ROOT" && DO_PLAN_TODO="$g" DO_BOARD_FAKE=1 DO_BOARD_BULK=1 /bin/bash "$ROOT/.claude/scripts/do-board.sh" bz 2>&1) || true
213
+ ok "bulk mints the tree in ONE tasks:bulk call" "$(grep -q 'in 1 tasks:bulk call' <<<"$outb" && echo 1 || echo 0)"
214
+ ok "bulk chains batch 2 behind batch 1" "$([ "$(grep -c '^ C[23] .*blockedBy=1' <<<"$outb")" = 2 ] && grep -q '^ C1 .*blockedBy=0' <<<"$outb" && echo 1 || echo 0)"
215
+ ok "bulk writes all 3 tids to frontmatter" "$([ "$(grep -c 'cycle: C[123], tid: \"task:' "$g")" = 3 ] && echo 1 || echo 0)"
216
+ ok "bulk tids are distinct per row" "$([ "$(grep -o 'task:[0-9a-f]\{24\}' "$g" | sort -u | wc -l | tr -d ' ')" = 4 ] && echo 1 || echo 0)"
217
+ # drift=0 is not the proof, and the script must say so rather than imply it.
218
+ ok "bulk points at the claim REFUSAL as proof" "$(grep -q -- '--task-claim bz' <<<"$outb" && echo 1 || echo 0)"
219
+ # RED HALF: a half-mirrored plan must DECLINE bulk — `creates` does not dedupe,
220
+ # so a second bulk run over these rows would twin every one of them.
221
+ outb2=$(cd "$ROOT" && DO_PLAN_TODO="$g" DO_BOARD_FAKE=1 DO_BOARD_BULK=1 /bin/bash "$ROOT/.claude/scripts/do-board.sh" bz 2>&1) || true
222
+ ok "fully mirrored plan mints nothing under --bulk" "$(grep -q 'already mirrored' <<<"$outb2" && echo 1 || echo 0)"
223
+ sed -i '' '/cycle: C3,/d' "$g"
224
+ outb3=$(cd "$ROOT" && DO_PLAN_TODO="$g" DO_BOARD_FAKE=1 DO_BOARD_BULK=1 /bin/bash "$ROOT/.claude/scripts/do-board.sh" bz 2>&1) || true
225
+ ok "half-mirrored plan DECLINES bulk (no twins)" "$(grep -q 'bulk declined' <<<"$outb3" && [ "$(grep -c '^ C[0-9]* ' <<<"$outb3" | tr -d ' ')" = 3 ] && echo 1 || echo 0)"
226
+ # Empty block form (`cycle_rows: []`, the template's default) is replaced, not doubled.
227
+ printf -- '---\ntitle: t\nslug: y\ndepends_on: []\nanchor: ""\nboard_workspace: one\ncycle_rows: []\n---\nbody\n' > "$f"
228
+ write_front "$f" "task:aaa" "one" ' - {cycle: C1, tid: "task:111"}'
229
+ ok "template default block is replaced" "$([ "$(grep -c '^cycle_rows' "$f")" = 1 ] && grep -q 'tid: "task:111"' "$f" && echo 1 || echo 0)"
230
+
231
+ # THE TEMPLATE'S ACTUAL SHAPE — every one of the three keys carries a trailing
232
+ # `# comment`, which is what text/template-todo.md ships and therefore what every
233
+ # real plan looks like. The comment-free fixture above passed while this one wrote
234
+ # a DUPLICATE cycle_rows (last-wins in YAML ⇒ consumers read zero tids). A fixture
235
+ # tidier than the artefact it stands for is not a fixture. Parsed with a real YAML
236
+ # reader, not grep, because grep is the parser that missed it.
237
+ printf -- '---\ntitle: t\nslug: y\ndepends_on: []\nanchor: "" # filled by do-board.sh\nboard_workspace: one # where the rows live\ncycle_rows: [] # WRITTEN BY do-board.sh, never by hand\n---\nbody\n' > "$f"
238
+ write_front "$f" "task:bbb" "one" ' - {cycle: C1, tid: "task:222"}'
239
+ ok "commented template form leaves ONE cycle_rows" "$([ "$(grep -c '^cycle_rows' "$f")" = 1 ] && echo 1 || echo 0)"
240
+ ok "and a YAML reader sees the tid, not []" "$(python3 -c "
241
+ import yaml,sys
242
+ d=yaml.safe_load(open(sys.argv[1]).read().split('---')[1])
243
+ r=d.get('cycle_rows') or []
244
+ print(1 if len(r)==1 and r[0]['tid']=='task:222' else 0)" "$f")"
245
+ # RED PROOF: a naive appender fails the idempotence check the way the real writer must not.
246
+ printf -- '---\ntitle: t\nslug: y\ndepends_on: []\n---\nbody\n' > "$f"
247
+ printf 'anchor: "task:aaa"\n' >> "$f"; printf 'anchor: "task:aaa"\n' >> "$f"
248
+ ok "gutted writer is caught (2 anchors reads red)" "$([ "$(grep -c '^anchor:' "$f")" = 2 ] && echo 1 || echo 0)"
249
+
250
+ rm -rf "$tmp"
251
+ [ "$fail" = 0 ] && echo "PASS" || echo "FAIL"
252
+ exit "$fail"
253
+ fi
254
+
255
+ # ---------------------------------------------------------------- read the plan
256
+ SLUG="${1:-}"; [ -n "$SLUG" ] || die "usage: do-board.sh <slug> [--dry-run|--status|--bulk]"
257
+ MODE="${2:-}"
258
+
259
+ PLAN="$(bash "$ROOT/.claude/scripts/do-plan-json.sh" "$SLUG")" || die "plan will not parse — fix it before mirroring it"
260
+ TODO="${DO_PLAN_TODO:-$ROOT/$(jq -r '.todo' <<<"$PLAN")}"
261
+ WS="$(jq -r '.boardWorkspace' <<<"$PLAN")"
262
+ ANCHOR="$(jq -r '.boardAnchor' <<<"$PLAN")"
263
+ DRIFT="$(jq -r '.boardDrift | length' <<<"$PLAN")"
264
+ TITLE="$(sed -n 's/^title:[ \t]*//p' "$TODO" | head -1 | tr -d '"')"
265
+
266
+ # ---------------------------------------------------------------- --status: the instrument
267
+ if [ "$MODE" = "--status" ]; then
268
+ # ONE read for every cycle row (they all carry plan:<slug>), one for the anchor.
269
+ rows=$(ask tasks:list "{\"workspace\":\"$WS\",\"tag\":\"plan:$SLUG\"}")
270
+ arow=$(ask tasks:list "{\"workspace\":\"$WS\",\"tag\":\"slug:$SLUG\"}")
271
+ echo "do-board: $SLUG — workspace=$WS anchor=${ANCHOR:-none} $(jq -r --arg a "$ANCHOR" '(.result.tasks//.tasks//[])[] | select(.tid==$a) | "(\(.status))"' <<<"$arow")"
272
+ printf ' %-4s %-30s %-9s %s\n' cycle tid status title
273
+ jq -r '.batches | to_entries[] | .value[] as $c | "\(.key+1)\t\($c)"' <<<"$PLAN" | while IFS=$'\t' read -r b c; do
274
+ tid=$(jq -r --arg c "$c" '.cycleRows[$c] // "-"' <<<"$PLAN")
275
+ st=$(jq -r --arg t "$tid" '(.result.tasks//.tasks//[])[] | select(.tid==$t) | .status' <<<"$rows" | head -1)
276
+ nm=$(jq -r --arg t "$tid" '(.result.tasks//.tasks//[])[] | select(.tid==$t) | .name // ""' <<<"$rows" | head -1)
277
+ [ "$tid" = "-" ] && st="NO ROW"
278
+ [ -n "$st" ] || st="not-on-board"
279
+ printf ' %-4s %-30s %-9s %s\n' "$c" "$tid" "$st" "${nm:-$(sed -n "s/^## $c — //p" "$TODO" | head -1 | sed 's/ *\[.*//')}"
280
+ done
281
+ n_open=$(jq -r '[(.result.tasks//.tasks//[])[] | select(.status=="open")] | length' <<<"$rows")
282
+ n_done=$(jq -r '[(.result.tasks//.tasks//[])[] | select(.status=="done" or .status=="verified")] | length' <<<"$rows")
283
+ n_pick=$(jq -r '[(.result.tasks//.tasks//[])[] | select(.status=="picked")] | length' <<<"$rows")
284
+ echo " rows: open=$n_open picked=$n_pick done=$n_done drift=$DRIFT (door: tasks:list tag=plan:$SLUG — exact, uncapped)"
285
+ exit 0
286
+ fi
287
+
288
+ echo "do-board: $SLUG — workspace=$WS drift=$DRIFT cycle(s) with no row"
289
+ [ "$DRIFT" = 0 ] && { echo "do-board: already mirrored — nothing to mint"; exit 0; }
290
+
291
+ if [ "$MODE" = "--dry-run" ]; then
292
+ # The dry run walks the SAME loop against the fake door, in a scratch copy of the
293
+ # todo, so what it prints is what the live run does — not a summary of it.
294
+ tmp="$(mktemp -d)"; cp "$TODO" "$tmp/$SLUG-todo.md"
295
+ echo "do-board: DRY RUN (fake door, scratch copy) —"
296
+ DO_PLAN_TODO="$tmp/$SLUG-todo.md" DO_BOARD_FAKE=1 bash "$ROOT/.claude/scripts/do-board.sh" "$SLUG" | sed 's/^/ /'
297
+ rm -rf "$tmp"; exit 0
298
+ fi
299
+ [ -z "$MODE" ] || [ "$MODE" = "--bulk" ] || die "unknown mode $MODE"
300
+
301
+ # ---------------------------------------------------------------- the walk
302
+ # The anchor IS the plan row do-auto.sh already files (tasks:create dedupes on the
303
+ # `slug:` tag and returns the existing tid) — never a second parent.
304
+ if [ -z "$ANCHOR" ]; then
305
+ env=$(ask tasks:create "{\"title\":$(jq -Rn --arg t "$TITLE" '$t'),\"workspace\":\"$WS\",\"tags\":[\"slug:$SLUG\",\"do:cycle\"]}")
306
+ ANCHOR="$(tid_of "$env")"
307
+ [ -n "$ANCHOR" ] || die "anchor mint failed: $env"
308
+ echo " anchor $ANCHOR"
309
+ fi
310
+
311
+ # ---------------------------------------------------------------- the bulk mint
312
+ # ONE call files a whole plan tree. tasks:bulk's `creates` resolves `ref` handles
313
+ # INSIDE the call, so C2's blockedBy names C1's ref and the receiver wires the
314
+ # edge itself — the batch DAG arrives as one request instead of one per row plus
315
+ # one per edge (14 rows cost ~20s in series; this costs one round trip per 25).
316
+ #
317
+ # GATED ON AN UNMIRRORED PLAN, and that is correctness, not caution: `creates`
318
+ # does NOT dedupe — which is the same reason `row_by_slug` exists at all, since
319
+ # tasks:subtask never deduped either. Run over a half-mirrored plan it would TWIN
320
+ # every row that already exists. So bulk fires only when drift == the cycle count
321
+ # (nothing on the board yet); every other state — including the resume after a
322
+ # bulk run that died mid-way — takes the per-row walk below, which looks each row
323
+ # up by its slug tag first. The fast path never becomes the only path.
324
+ #
325
+ # Opt in with `--bulk` or DO_BOARD_BULK=1. The default is unchanged.
326
+ BULK="${DO_BOARD_BULK:-0}"
327
+ [ "$MODE" = "--bulk" ] && BULK=1
328
+ NCYC=$(jq -r '[.batches[][]] | length' <<<"$PLAN")
329
+ if [ "$BULK" = 1 ] && [ "$DRIFT" = "$NCYC" ]; then
330
+ echo " bulk $NCYC row(s) with no board row — minting the tree with tasks:bulk creates"
331
+ rowsf="$(mktemp)"; ordf="$(mktemp)"
332
+ prevrefs='[]'
333
+ nb=$(jq -r '.batches | length' <<<"$PLAN"); b=0
334
+ while [ "$b" -lt "$nb" ]; do
335
+ currefs='[]'
336
+ while IFS= read -r c; do
337
+ [ -n "$c" ] || continue
338
+ n="${c#C}"; cslug="$(printf '%s-c%s' "$SLUG" "$n" | tr '[:upper:]' '[:lower:]')"
339
+ ctitle="$(sed -n "s/^## $c — //p" "$TODO" | head -1 | sed 's/ *\[.*//' | tr -d '"')"
340
+ notes="$(cycle_notes "$TODO" "$c" "$SLUG")"
341
+ jq -cn --arg ref "c$n" --arg p "$ANCHOR" --arg t "$c — $ctitle" --argjson bb "$prevrefs" \
342
+ --arg ws "$WS" --arg s "slug:$cslug" --arg pl "plan:$SLUG" --arg cy "cycle:$n" --arg notes "$notes" \
343
+ '{ref:$ref,parent:$p,title:$t,blockedBy:$bb,tags:[$s,$pl,"do:cycle",$cy],notes:$notes,workspace:$ws}' >> "$rowsf"
344
+ currefs=$(jq -c --arg r "c$n" '. + [$r]' <<<"$currefs")
345
+ printf '%s\tc%s\n' "$c" "$n" >> "$ordf"
346
+ done < <(jq -r ".batches[$b][]" <<<"$PLAN")
347
+ prevrefs="$currefs"; b=$((b+1))
348
+ done
349
+
350
+ ALL=$(jq -cs '.' "$rowsf"); MAP='{}'; off=0; ncall=0
351
+ # 25 rows per call is the receiver's declared ceiling. A ref can only be named
352
+ # by a row LATER IN THE SAME array, so a blockedBy pointing at an earlier CHUNK
353
+ # is rewritten to the real tid that chunk returned — otherwise the edge would
354
+ # silently not form and the plan would hand every head every cycle at once,
355
+ # which is the exact failure this whole script exists to prevent.
356
+ while [ "$off" -lt "$NCYC" ]; do
357
+ chunk=$(jq -c --argjson o "$off" '.[$o:$o+25]' <<<"$ALL")
358
+ chunk=$(jq -c --argjson here "$(jq -c '[.[].ref]' <<<"$chunk")" --argjson map "$MAP" \
359
+ 'map(.blockedBy = [ .blockedBy[]? | if (. as $r | $here | index($r)) then . else ($map[.] // .) end ])' <<<"$chunk")
360
+ env=$(ask tasks:bulk "$(jq -cn --argjson c "$chunk" --arg ws "$WS" '{creates:$c,workspace:$ws}')")
361
+ # ok is the CALL's outcome, never the rows' — read applied/failed (receivers.ts).
362
+ fail=$(jq -r '(.result//.).failed // 0' <<<"$env")
363
+ [ "$fail" = 0 ] || die "tasks:bulk: $fail row(s) failed — $(jq -c '(.result//.).results // .' <<<"$env")"
364
+ created=$(jq -c '(.result//.).created // {}' <<<"$env")
365
+ [ "$(jq -r 'length' <<<"$created")" -gt 0 ] || die "tasks:bulk returned no created map: $env"
366
+ MAP=$(jq -cn --argjson a "$MAP" --argjson b "$created" '$a * $b')
367
+ off=$((off+25)); ncall=$((ncall+1))
368
+ done
369
+
370
+ ROWS=""
371
+ while IFS=$'\t' read -r c ref; do
372
+ tid=$(jq -r --arg r "$ref" '.[$r] // empty' <<<"$MAP")
373
+ [ -n "$tid" ] || die "$c: tasks:bulk never returned a tid for ref $ref"
374
+ nb_=$(jq -r --arg r "$ref" '.[] | select(.ref==$r) | .blockedBy | length' <<<"$ALL")
375
+ echo " $c $tid blockedBy=$nb_"
376
+ ROWS="$ROWS - {cycle: $c, tid: \"$tid\"}
377
+ "
378
+ done < "$ordf"
379
+ write_front "$TODO" "$ANCHOR" "$WS" "$ROWS"
380
+ rm -f "$rowsf" "$ordf"
381
+ echo "do-board: wrote anchor + $NCYC cycle row(s) into $(basename "$TODO") in $ncall tasks:bulk call(s)"
382
+ DO_PLAN_TODO="$TODO" bash "$ROOT/.claude/scripts/do-plan-json.sh" "$SLUG" | jq -r '"do-board: drift is now \(.boardDrift|length)"'
383
+ # drift=0 is NOT proof the chain formed — it only says every cycle has a tid.
384
+ # The edge is proven by a REFUSAL: tasks:claim on a blocked cycle answers
385
+ # `blocked`, and that is the check to run (recorded trap, 2026-09-12).
386
+ echo "do-board: prove the chain with a refusal, not a count —"
387
+ echo " bash .claude/scripts/do-signal.sh --task-claim $SLUG # a later cycle must answer 'blocked'"
388
+ exit 0
389
+ fi
390
+ [ "$BULK" != 1 ] || echo " bulk declined — $DRIFT of $NCYC cycle(s) missing; creates does not dedupe, taking the per-row walk"
391
+
392
+ PREV=""; ROWS=""
393
+ nb=$(jq -r '.batches | length' <<<"$PLAN")
394
+ b=0
395
+ while [ "$b" -lt "$nb" ]; do
396
+ CUR=""
397
+ while IFS= read -r c; do
398
+ [ -n "$c" ] || continue
399
+ n="${c#C}"; cslug="$(printf '%s-c%s' "$SLUG" "$n" | tr '[:upper:]' '[:lower:]')"
400
+ tid=$(jq -r --arg c "$c" '.cycleRows[$c] // empty' <<<"$PLAN")
401
+ blocked=$(printf '%s' "$PREV" | tr ' ' '\n' | jq -Rsc 'split("\n")|map(select(length>0))')
402
+ if [ -z "$tid" ]; then tid="$(row_by_slug "$cslug" "$WS")"; fi
403
+ if [ -n "$tid" ]; then
404
+ # existing row: only the edges are (re)asserted — depend is idempotent
405
+ k=0
406
+ for p in $PREV; do ask tasks:depend "{\"tid\":\"$tid\",\"blockedBy\":\"$p\",\"workspace\":\"$WS\"}" >/dev/null; k=$((k+1)); done
407
+ echo " $c $tid (existing) blockedBy=$k"
408
+ else
409
+ ctitle="$(sed -n "s/^## $c — //p" "$TODO" | head -1 | sed 's/ *\[.*//' | tr -d '"')"
410
+ notes="$(cycle_notes "$TODO" "$c" "$SLUG")"
411
+ body=$(jq -cn --arg p "$ANCHOR" --arg t "$c — $ctitle" --argjson b "$blocked" --arg ws "$WS" \
412
+ --arg s "slug:$cslug" --arg pl "plan:$SLUG" --arg cy "cycle:$n" --arg notes "$notes" \
413
+ '{parent:$p,title:$t,blockedBy:$b,tags:[$s,$pl,"do:cycle",$cy],notes:$notes,workspace:$ws}')
414
+ env=$(ask tasks:subtask "$body")
415
+ tid="$(tid_of "$env")"
416
+ [ -n "$tid" ] || die "$c mint failed: $env"
417
+ echo " $c $tid blockedBy=$(jq -r 'length' <<<"$blocked")"
418
+ fi
419
+ CUR="$CUR $tid"
420
+ ROWS="$ROWS - {cycle: $c, tid: \"$tid\"}
421
+ "
422
+ done < <(jq -r ".batches[$b][]" <<<"$PLAN")
423
+ # write after EVERY batch — a walk that dies at batch 3 resumes at batch 3
424
+ write_front "$TODO" "$ANCHOR" "$WS" "$ROWS"
425
+ PREV="$CUR"; b=$((b+1))
426
+ done
427
+
428
+ echo "do-board: wrote anchor + $(printf '%s' "$ROWS" | grep -c 'cycle:') cycle row(s) into $(basename "$TODO")"
429
+ DO_PLAN_TODO="$TODO" bash "$ROOT/.claude/scripts/do-plan-json.sh" "$SLUG" | jq -r '"do-board: drift is now \(.boardDrift|length)"'