crewforth 0.0.1 → 3.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CHANGELOG.md +3784 -0
  2. package/LICENSE +21 -0
  3. package/README.md +37 -7
  4. package/VERSION +1 -0
  5. package/adopt.sh +1670 -0
  6. package/bin/cli.js +497 -0
  7. package/kit/AGENT_TEMPLATE.md +92 -0
  8. package/kit/CLAUDE.md +228 -0
  9. package/kit/README.md +107 -0
  10. package/kit/agents/crew-backend-expert.md +95 -0
  11. package/kit/agents/crew-commit-agent.md +96 -0
  12. package/kit/agents/crew-database-expert.md +84 -0
  13. package/kit/agents/crew-devops-expert.md +82 -0
  14. package/kit/agents/crew-frontend-expert.md +85 -0
  15. package/kit/agents/crew-performance-expert.md +78 -0
  16. package/kit/agents/crew-planner.md +60 -0
  17. package/kit/agents/crew-privacy-agent.md +71 -0
  18. package/kit/agents/crew-review-agent.md +95 -0
  19. package/kit/agents/crew-security-expert.md +73 -0
  20. package/kit/agents/crew-session-manager.md +77 -0
  21. package/kit/agents/crew-test-expert.md +64 -0
  22. package/kit/eval/doctor.sh +465 -0
  23. package/kit/eval/gate-report.sh +137 -0
  24. package/kit/eval/golden-holdout.txt +53 -0
  25. package/kit/eval/golden-routing.txt +199 -0
  26. package/kit/eval/lib/crew-env.sh +14 -0
  27. package/kit/eval/lib/settings-json.awk +154 -0
  28. package/kit/eval/lib/star.sh +70 -0
  29. package/kit/eval/parser-conformance.sh +612 -0
  30. package/kit/eval/preflight.sh +186 -0
  31. package/kit/eval/routing-eval.sh +209 -0
  32. package/kit/eval/scan-skill.sh +162 -0
  33. package/kit/eval/smoke-test.sh +6693 -0
  34. package/kit/eval/update-guard.sh +81 -0
  35. package/kit/eval/utilization.sh +130 -0
  36. package/kit/hooks/board-sync.sh +106 -0
  37. package/kit/hooks/board.sh +962 -0
  38. package/kit/hooks/commit-msg +49 -0
  39. package/kit/hooks/context-usage.sh +353 -0
  40. package/kit/hooks/floor-blocklist.txt +124 -0
  41. package/kit/hooks/guard-bash.sh +1431 -0
  42. package/kit/hooks/guard-commit-scan.sh +511 -0
  43. package/kit/hooks/guard-write.sh +513 -0
  44. package/kit/hooks/pre-commit +577 -0
  45. package/kit/hooks/route-hint.sh +185 -0
  46. package/kit/hooks/secret-blocklist.txt +82 -0
  47. package/kit/hooks/session-guard.sh +149 -0
  48. package/kit/hooks/session-rehydrate.sh +41 -0
  49. package/kit/hooks/session-stats.sh +252 -0
  50. package/kit/hooks/session-update-check.sh +236 -0
  51. package/kit/hooks/skill-trust.sh +150 -0
  52. package/kit/hooks/trace-blocklist.txt +53 -0
  53. package/kit/settings.json +132 -0
  54. package/kit/skills/a11y/SKILL.md +44 -0
  55. package/kit/skills/adr/SKILL.md +74 -0
  56. package/kit/skills/api-design/SKILL.md +50 -0
  57. package/kit/skills/automode-policy/SKILL.md +91 -0
  58. package/kit/skills/automode-policy/references/policy.json +13 -0
  59. package/kit/skills/automode-policy/scripts/apply.sh +84 -0
  60. package/kit/skills/automode-policy/scripts/check.sh +102 -0
  61. package/kit/skills/backend-architecture/SKILL.md +103 -0
  62. package/kit/skills/brainstorm/SKILL.md +47 -0
  63. package/kit/skills/ci-pipeline/SKILL.md +38 -0
  64. package/kit/skills/commit-message/SKILL.md +82 -0
  65. package/kit/skills/commit-message/references/examples.md +8 -0
  66. package/kit/skills/confidence-check/SKILL.md +86 -0
  67. package/kit/skills/crew-board/SKILL.md +55 -0
  68. package/kit/skills/crew-brainstorm/SKILL.md +19 -0
  69. package/kit/skills/crew-code-review/SKILL.md +151 -0
  70. package/kit/skills/crew-code-review/references/history-search.md +19 -0
  71. package/kit/skills/crew-code-review/references/panel-mode.md +32 -0
  72. package/kit/skills/crew-code-review/references/receiving.md +23 -0
  73. package/kit/skills/crew-doctor/SKILL.md +36 -0
  74. package/kit/skills/crew-gates/SKILL.md +25 -0
  75. package/kit/skills/crew-handoff/SKILL.md +14 -0
  76. package/kit/skills/crew-plan/SKILL.md +18 -0
  77. package/kit/skills/crew-review/SKILL.md +23 -0
  78. package/kit/skills/crew-ship/SKILL.md +19 -0
  79. package/kit/skills/crew-skill/SKILL.md +61 -0
  80. package/kit/skills/crew-studio/SKILL.md +129 -0
  81. package/kit/skills/crew-update/SKILL.md +43 -0
  82. package/kit/skills/db-migration/SKILL.md +120 -0
  83. package/kit/skills/db-migration/references/backup-restore.md +33 -0
  84. package/kit/skills/db-migration/references/raw-sql.md +30 -0
  85. package/kit/skills/db-migration/references/tool-matrix.md +35 -0
  86. package/kit/skills/dependency-audit/SKILL.md +61 -0
  87. package/kit/skills/dependency-upgrade/SKILL.md +82 -0
  88. package/kit/skills/deploy/SKILL.md +100 -0
  89. package/kit/skills/deploy/references/proxy-ssl.md +56 -0
  90. package/kit/skills/deploy/references/rollback.md +18 -0
  91. package/kit/skills/deploy/references/scripts.md +9 -0
  92. package/kit/skills/deploy/references/topology-a.md +128 -0
  93. package/kit/skills/deploy/references/topology-b.md +44 -0
  94. package/kit/skills/docs-writer/SKILL.md +53 -0
  95. package/kit/skills/docs-writer/references/coauthoring.md +34 -0
  96. package/kit/skills/eval-grader/SKILL.md +75 -0
  97. package/kit/skills/eval-grader/references/method.md +51 -0
  98. package/kit/skills/frontend/SKILL.md +56 -0
  99. package/kit/skills/frontend/references/verify-contract.md +41 -0
  100. package/kit/skills/frontend-design/SKILL.md +71 -0
  101. package/kit/skills/frontend-design/references/design-review.md +34 -0
  102. package/kit/skills/frontend-rn-expo/SKILL.md +25 -0
  103. package/kit/skills/handoff/SKILL.md +71 -0
  104. package/kit/skills/handoff/references/cold-reader.md +51 -0
  105. package/kit/skills/i18n-integrity/SKILL.md +31 -0
  106. package/kit/skills/incident-runbook/SKILL.md +40 -0
  107. package/kit/skills/incident-runbook/references/postmortem.md +16 -0
  108. package/kit/skills/iterate/SKILL.md +40 -0
  109. package/kit/skills/mcp-builder/SKILL.md +54 -0
  110. package/kit/skills/mcp-builder/references/building.md +71 -0
  111. package/kit/skills/observability/SKILL.md +50 -0
  112. package/kit/skills/performance/SKILL.md +38 -0
  113. package/kit/skills/performance/references/bottlenecks.md +10 -0
  114. package/kit/skills/privacy-compliance/SKILL.md +75 -0
  115. package/kit/skills/red-team/SKILL.md +48 -0
  116. package/kit/skills/reflect/SKILL.md +57 -0
  117. package/kit/skills/release/SKILL.md +44 -0
  118. package/kit/skills/security-scan/SKILL.md +99 -0
  119. package/kit/skills/security-scan/references/ai-agents.md +107 -0
  120. package/kit/skills/security-scan/references/fronts.md +99 -0
  121. package/kit/skills/security-scan/references/prompting.md +23 -0
  122. package/kit/skills/security-scan/references/reporting.md +75 -0
  123. package/kit/skills/security-scan/references/verify.md +131 -0
  124. package/kit/skills/sonarqube-check/SKILL.md +108 -0
  125. package/kit/skills/sonarqube-check/references/local-engines.md +32 -0
  126. package/kit/skills/sonarqube-check/references/stand-up-server.md +25 -0
  127. package/kit/skills/spec-planning/SKILL.md +92 -0
  128. package/kit/skills/systematic-debugging/SKILL.md +57 -0
  129. package/kit/skills/systematic-debugging/references/techniques.md +44 -0
  130. package/kit/skills/teamboard/SKILL.md +88 -0
  131. package/kit/skills/teamboard/references/setup.md +37 -0
  132. package/kit/skills/testing/SKILL.md +49 -0
  133. package/kit/skills/testing/references/flaky-triage.md +51 -0
  134. package/kit/skills/threat-model/SKILL.md +57 -0
  135. package/kit/skills/threat-model/references/interview.md +51 -0
  136. package/kit/skills/threat-model/references/schema.md +42 -0
  137. package/kit/skills/token-budget/SKILL.md +51 -0
  138. package/kit/skills/trace-scan/SKILL.md +43 -0
  139. package/kit/skills/worktree/SKILL.md +40 -0
  140. package/kit/studio/README.md +266 -0
  141. package/kit/studio/ensure-node.sh +471 -0
  142. package/kit/studio/package.json +14 -0
  143. package/kit/studio/server/hooks/studio-gate.sh +169 -0
  144. package/kit/studio/server/index.js +902 -0
  145. package/kit/studio/server/lib/crew-env.js +11 -0
  146. package/kit/studio/server/lib/fleet.js +146 -0
  147. package/kit/studio/server/lib/graph.js +533 -0
  148. package/kit/studio/server/lib/instance.js +135 -0
  149. package/kit/studio/server/lib/kit-telemetry.js +182 -0
  150. package/kit/studio/server/lib/kit.js +158 -0
  151. package/kit/studio/server/lib/palette.js +118 -0
  152. package/kit/studio/server/lib/peers.js +72 -0
  153. package/kit/studio/server/lib/permissions.js +150 -0
  154. package/kit/studio/server/lib/projects.js +294 -0
  155. package/kit/studio/server/lib/roster.js +191 -0
  156. package/kit/studio/server/lib/session.js +309 -0
  157. package/kit/studio/server/lib/terminal.js +91 -0
  158. package/kit/studio/server/lib/transcript.js +100 -0
  159. package/kit/studio/web/app.js +1098 -0
  160. package/kit/studio/web/canvas.js +1179 -0
  161. package/kit/studio/web/chat.js +543 -0
  162. package/kit/studio/web/index.html +86 -0
  163. package/kit/studio/web/md.js +110 -0
  164. package/kit/studio/web/storage-migrate.js +23 -0
  165. package/kit/studio/web/style.css +1453 -0
  166. package/package.json +46 -8
  167. package/start.sh +748 -0
  168. package/bin/crewforth.js +0 -10
@@ -0,0 +1,962 @@
1
+ #!/usr/bin/env bash
2
+ # Team board engine — a distributed claim lock and shared item memory for a team sharing one repository.
3
+ #
4
+ # WHY THIS EXISTS
5
+ # Every kit install is local: docs/PLAN.md and docs/SESSION_STATE.md are gitignored, so item #1 being taken
6
+ # is invisible to everyone else and two people start the same work. This engine puts the item list, the
7
+ # claims and the per-item handover notes on a git ref that the whole team pushes to.
8
+ #
9
+ # WHY A GIT REF IS THE LOCK (measured, not assumed)
10
+ # `git push` to a ref is fast-forward-only unless forced. Two clones that build a claim commit on the SAME
11
+ # base and push it: the first wins, the second is rejected non-fast-forward (exit 1) and must re-read the
12
+ # board. That is an atomic compare-and-swap with zero infrastructure — no server, no token, no daemon.
13
+ # Verified against a bare remote: loser exit=1, remote kept the winner's claim.
14
+ #
15
+ # WHY PLUMBING AND NOT A CHECKOUT
16
+ # Commits are built with hash-object/read-tree/write-tree/commit-tree against a private index file. The
17
+ # working tree, the index, the current branch and any stash are untouched — you can claim an item in the
18
+ # middle of a feature branch with dirty files and nothing moves.
19
+ #
20
+ # NO NETWORK ON THE HOT PATH
21
+ # Only claim/done/drop/sync/probe talk to the remote, because those are the operations whose whole point is
22
+ # being atomic — reporting "you own it" without a confirmed push would be a lie. Session start and the
23
+ # commit gate read a local cache / the local ref and never open a socket.
24
+ #
25
+ # PORTABILITY
26
+ # No jq (Windows Git Bash has none). No `date -d` / `date -j` (GNU and BSD disagree) — timestamps are stored
27
+ # as BOTH an ISO string for humans and an epoch integer for arithmetic, so nothing ever has to be re-parsed.
28
+ set -uo pipefail
29
+ # The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
30
+ _crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
31
+ [ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
32
+
33
+ # ---------------------------------------------------------------- repo primitives
34
+
35
+ _die(){ printf 'board: %s\n' "$1" >&2; exit "${2:-1}"; }
36
+
37
+ _git_dir(){ git rev-parse --git-common-dir 2>/dev/null; }
38
+
39
+ # How a message names a board action. /crew-board is a USER-ONLY skill (disable-model-invocation), so a message that
40
+ # Claude reads must give it the command it can run itself — this script — and name the slash form for the user.
41
+ # The plugin edition's copy lives outside the project, so it names its own absolute path. Only called when a message
42
+ # is printed, never on the hot path.
43
+ _selfcmd(){ local d; d="$(cd "${BASH_SOURCE%/*}" 2>/dev/null && pwd)"
44
+ if [ -f "$d/../.claude-plugin/plugin.json" ]; then printf 'bash "%s/board.sh"' "$d"; else printf 'bash .claude/hooks/board.sh'; fi; }
45
+ _act(){ printf '%s %s (or the user can type /crew-board %s)' "$(_selfcmd)" "$1" "${2:-$1}"; }
46
+
47
+ # Ref namespace. Default is a custom namespace so the board never shows up in `git branch` and never enters the
48
+ # code history. Some servers refuse refs outside refs/heads|refs/tags; `probe` detects that and records the
49
+ # fallback here, per clone.
50
+ #
51
+ # The board's settings are read ONCE per run, in one process: `crew.board`, `crew.boardRef`, `crew.boardRemote`,
52
+ # and — for the 3.x line only, removed in 4.0 — their 2.x names under `csk.`. A `crew.` key always wins. Reading
53
+ # them one `git config --get` at a time cost a process per call, and _ref alone is called a dozen times a run.
54
+ _load_cfg(){
55
+ local line k v
56
+ _C_BOARD=""; _C_REF=""; _C_REMOTE=""; _L_BOARD=""; _L_REF=""; _L_REMOTE=""
57
+ while IFS= read -r line; do
58
+ k="${line%% *}"; v="${line#* }"; [ "$k" = "$line" ] && v=""
59
+ case "$k" in
60
+ crew.board) _C_BOARD="$v" ;; crew.boardref) _C_REF="$v" ;; crew.boardremote) _C_REMOTE="$v" ;;
61
+ csk.board) _L_BOARD="$v" ;; csk.boardref) _L_REF="$v" ;; csk.boardremote) _L_REMOTE="$v" ;;
62
+ esac
63
+ done <<EOF
64
+ $(git config --get-regexp '^(crew|csk)\.board(ref|remote)?$' 2>/dev/null)
65
+ EOF
66
+ # The ref, and where its fetched copy lands (see _fetch), resolved here so no caller forks for them.
67
+ _REF="${_C_REF:-$_L_REF}"
68
+ case "$_REF" in refs/csk/board|'') _REF='refs/crew/board' ;; refs/heads/csk-board) _REF='refs/heads/crew-board' ;; esac
69
+ case "$_REF" in refs/heads/*) _RN='refs/crew/remote/branch' ;; *) _RN='refs/crew/remote/board' ;; esac
70
+ }
71
+ _load_cfg
72
+ _ref(){ printf '%s\n' "$_REF"; }
73
+ _remote(){ printf '%s\n' "${_C_REMOTE:-${_L_REMOTE:-origin}}"; }
74
+
75
+ # ---- network git: never block, never prompt ------------------------------------------------------------
76
+ # Every git call that reaches a REMOTE goes through here, because a remote is where a hook stops being slow
77
+ # and starts being infinite. Three ways that happens, all silent:
78
+ # * git asks for a username on the terminal. A hook has no terminal, so it waits — forever.
79
+ # * Git Credential Manager, the DEFAULT helper on Windows, opens a GUI dialog. In a hook nobody is looking
80
+ # at that window, and it waits forever too. This is the one that hurts: it looks like a frozen commit.
81
+ # * the remote accepts the connection and then stalls, which no prompt setting covers.
82
+ # GIT_TERMINAL_PROMPT=0 turns the first into an immediate failure, GCM_INTERACTIVE=never disables the second,
83
+ # and `timeout` bounds the third. Where timeout is absent the first two still apply.
84
+ #
85
+ # Bounding these is safe precisely because failure is ALREADY the graceful path: a non-zero _fetch means
86
+ # "remote unreachable — showing the last known state", and a non-zero _commit_push means "kept locally, the
87
+ # team cannot see it yet". So this converts a hang into a sentence the user can act on, and never into data
88
+ # loss. CREW_NET_TIMEOUT overrides the bound for a slow link.
89
+ # `timeout` is coreutils and macOS does not ship it; Homebrew's coreutils installs it as `gtimeout`. Looking for
90
+ # both is not the same as ASSUMING one — an assumed binary would fail silently, a searched one either bounds the
91
+ # call or is honestly absent. When neither exists the prompt suppression above still applies and the call is
92
+ # NOT time-bounded: measured against a black-holed remote, 15s with the bound and 21s without, where the 21s is
93
+ # git giving up on its own. So the unbounded case is slower, not infinite — the infinite case is the credential
94
+ # prompt, and that one is closed on every platform.
95
+ _TMO="$(command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null || true)"
96
+ _gitnet(){
97
+ if [ -n "$_TMO" ]; then
98
+ GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never "$_TMO" "${CREW_NET_TIMEOUT:-15}" git "$@"
99
+ else
100
+ GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never git "$@"
101
+ fi
102
+ }
103
+
104
+ # Identity. The email is the claim owner; it is already configured in every clone that can commit.
105
+ _me(){
106
+ local m; m="$(git config --get user.email 2>/dev/null)"
107
+ [ -n "$m" ] || m="$(git config --get user.name 2>/dev/null)"
108
+ [ -n "$m" ] || _die "no git identity: set user.email before claiming"
109
+ printf '%s' "$m"
110
+ }
111
+
112
+ # ---- the 2.x board, kept in step for the whole 3.x line (removed in 4.0) ---------------------------------
113
+ # A 2.x teammate reads and writes only refs/csk/board (or the csk-board branch) and will never read the crew
114
+ # name. So while that ref exists on the remote, it stays the team's lock: every 3.x write is built on whichever of
115
+ # the two refs is ahead and pushed to BOTH in one `git push --atomic` — both move or neither does, so a claim can
116
+ # never be granted on one ref and refused on the other. A 2.x claim is then refused against a 3.x claim and the
117
+ # other way round, exactly as within one version. When the team deletes the old ref, 3.x stops writing it.
118
+ # Histories that split before this (two boards made independently) are joined by a three-way merge built with
119
+ # read-tree, which every git has — no merge-tree, which needs 2.38. On the one file both sides append to,
120
+ # refusals.log, the lines are united; any other file changed on both sides keeps the 3.x side.
121
+ _rev(){ git rev-parse -q --verify "$1^{commit}" 2>/dev/null; }
122
+ _join(){ # _join <3.x commit> <2.x commit> -> a commit that has both as parents
123
+ local n="$1" l="$2" base bt idx p b t
124
+ base="$(git merge-base "$n" "$l" 2>/dev/null)"
125
+ if [ -n "$base" ]; then bt="$base^{tree}"; else bt="$(git hash-object -w -t tree /dev/null)"; fi
126
+ idx="$(_git_dir)/crew-index.$$"; rm -f "$idx"
127
+ GIT_INDEX_FILE="$idx" git read-tree -m -i --aggressive "$bt" "$n^{tree}" "$l^{tree}" 2>/dev/null || { rm -f "$idx"; return 1; }
128
+ while IFS= read -r p; do
129
+ [ -n "$p" ] || continue
130
+ if [ "$p" = refusals.log ]; then
131
+ b="$( { git cat-file -p "$n:$p" 2>/dev/null; git cat-file -p "$l:$p" 2>/dev/null; } | awk '!seen[$0]++' | _blob)"
132
+ else
133
+ b="$(git rev-parse -q --verify "$n:$p" 2>/dev/null || git rev-parse -q --verify "$l:$p" 2>/dev/null)"
134
+ fi
135
+ GIT_INDEX_FILE="$idx" git update-index --force-remove "$p" 2>/dev/null
136
+ [ -n "$b" ] && GIT_INDEX_FILE="$idx" git update-index --add --cacheinfo "100644,$b,$p" 2>/dev/null
137
+ done <<EOF
138
+ $(GIT_INDEX_FILE="$idx" git ls-files -u 2>/dev/null | cut -f2 | sort -u)
139
+ EOF
140
+ t="$(GIT_INDEX_FILE="$idx" git write-tree 2>/dev/null)"; rm -f "$idx"
141
+ [ -n "$t" ] || return 1
142
+ git commit-tree "$t" -p "$n" -p "$l" -m 'board: join the 2.x and 3.x boards'
143
+ }
144
+ # The 2.x ref this clone last saw on the remote, as "<local copy> <remote name>"; empty when there is none.
145
+ # The copy that matches this clone's mode wins; otherwise the other form (a 2.x team on the other mode).
146
+ _legacy(){
147
+ local found; found="$(git for-each-ref --format='%(refname)' refs/crew/legacy/ns/board refs/crew/legacy/branch 2>/dev/null)"
148
+ case "$_REF" in
149
+ refs/heads/*) case "$found" in *refs/crew/legacy/branch*) echo 'refs/crew/legacy/branch refs/heads/csk-board' ;; *refs/crew/legacy/ns/board*) echo 'refs/crew/legacy/ns/board refs/csk/board' ;; esac ;;
150
+ *) case "$found" in *refs/crew/legacy/ns/board*) echo 'refs/crew/legacy/ns/board refs/csk/board' ;; *refs/crew/legacy/branch*) echo 'refs/crew/legacy/branch refs/heads/csk-board' ;; esac ;;
151
+ esac
152
+ }
153
+
154
+ # One for-each-ref answers both "is there a board" and "is there a 2.x one here to start from" — the commit gate
155
+ # runs this on every commit, so a second process here would be paid by every repo, board or not. A local 2.x
156
+ # board (one that was never pushed, or a clone updated without the updater) becomes the crew ref.
157
+ _have_board(){
158
+ local found l; found="$(git for-each-ref --format='%(refname)' "$_REF" refs/csk/board refs/heads/csk-board 2>/dev/null)"
159
+ [ -n "$found" ] || return 1
160
+ case "$found" in *"$_REF"*) return 0 ;; esac
161
+ for l in refs/csk/board refs/heads/csk-board; do
162
+ case "$found" in *"$l"*) git update-ref "$_REF" "$(_rev "$l")" 2>/dev/null && return 0 ;; esac
163
+ done
164
+ return 1
165
+ }
166
+
167
+ # The board is OFF until somebody creates one — a repo with no board ref has no gates at all, which is what
168
+ # every solo project and every pre-existing install stays. This is the separate question of switching it off
169
+ # in a repo that HAS one: someone working alone on a shared repo for an afternoon, or a team pausing it.
170
+ # git config crew.board off this clone
171
+ # git config --global crew.board off every repo you touch
172
+ # CREW_NO_BOARD=1 this session only
173
+ # Honoured by every entry point, including the commit gate — a switch that turns off two of three gates is a
174
+ # trap, not a switch.
175
+ _enabled(){
176
+ [ -n "${CREW_NO_BOARD:-}" ] && return 1
177
+ case "${_C_BOARD:-$_L_BOARD}" in off|false|0|no) return 1 ;; esac
178
+ return 0
179
+ }
180
+
181
+ # Fetch the board. The remote is authoritative: local board state is never worth keeping over it — every mutation
182
+ # re-derives itself from the fetched state (see _mutate). ONE fetch, whatever the team runs: the crew ref in both
183
+ # modes and the 2.x ref in both modes, all as globs (a glob never fails for want of a match) into this clone's own
184
+ # refs/crew/remote/* and refs/crew/legacy/* — never into refs/heads, where a glob would overwrite a user's branch
185
+ # that merely starts with the same name. --prune drops a copy whose remote ref is gone, which is how 3.x learns the
186
+ # team deleted the 2.x ref. Returns 0 board here · 1 remote unreachable · 4 reachable, no board anywhere.
187
+ _fetch(){
188
+ _gitnet fetch -q --prune "$(_remote)" '+refs/crew/board*:refs/crew/remote/board*' '+refs/heads/crew-board*:refs/crew/remote/branch*' \
189
+ '+refs/csk/*:refs/crew/legacy/ns/*' '+refs/heads/csk-board*:refs/crew/legacy/branch*' >/dev/null 2>&1 || return 1
190
+ local rn lg lc b
191
+ # Only the teammate who ran `init` runs `probe`, so only that clone learns the team fell back to the orphan
192
+ # branch. Everyone else resolves it here from what the remote has, and records the answer once.
193
+ if [ -z "${_C_REF:-$_L_REF}" ] && [ -z "$(_rev refs/crew/remote/board)" ]; then
194
+ if [ -n "$(_rev refs/crew/remote/branch)" ] || { [ -z "$(_rev refs/crew/legacy/ns/board)" ] && [ -n "$(_rev refs/crew/legacy/branch)" ]; }; then
195
+ git config crew.boardRef 'refs/heads/crew-board'; _load_cfg
196
+ fi
197
+ fi
198
+ rn="$(_rev "$_RN")"; [ -n "$rn" ] || rn="$(_rev "$_REF")" # nothing on the remote yet: a local board stands
199
+ lg="$(_legacy)"; lc="$(_rev "${lg%% *}")"
200
+ if [ -z "$lc" ]; then b="$rn"
201
+ elif [ -z "$rn" ] || git merge-base --is-ancestor "$rn" "$lc" 2>/dev/null; then b="$lc"
202
+ elif git merge-base --is-ancestor "$lc" "$rn" 2>/dev/null; then b="$rn"
203
+ else b="$(_join "$rn" "$lc")" || { b="$rn"; _JOIN_ERR=1
204
+ printf 'the 2.x board could not be joined in — its entries are not shown; run %s again\n' "$(_act sync)" > "$(_git_dir)/crew-board-lasterror"; }
205
+ fi
206
+ [ -n "$b" ] || return 4
207
+ git update-ref "$_REF" "$b"
208
+ }
209
+ _cat(){ git cat-file -p "$(_ref):$1" 2>/dev/null; }
210
+ _item_paths(){ git ls-tree --name-only "$(_ref)" items/ 2>/dev/null; }
211
+
212
+ _now_iso(){ date -u +%Y-%m-%dT%H:%M:%SZ; }
213
+ _now_epoch(){ date -u +%s; }
214
+
215
+ _age(){ # _age <epoch> -> "3h" / "12m" / "2d" / "-" (how long a claim has been held)
216
+ local e="${1:-0}" now d
217
+ case "$e" in ''|*[!0-9]*) printf '%s' '-'; return 0 ;; esac
218
+ [ "$e" -gt 0 ] || { printf '%s' '-'; return 0; }
219
+ now="$(_now_epoch)"; d=$(( now - e )); [ "$d" -lt 0 ] && d=0
220
+ if [ "$d" -lt 3600 ]; then printf '%dm' $(( d / 60 ))
221
+ elif [ "$d" -lt 172800 ];then printf '%dh' $(( d / 3600 ))
222
+ else printf '%dd' $(( d / 86400 )); fi
223
+ }
224
+
225
+ # ---------------------------------------------------------------- item format
226
+ #
227
+ # id: 001
228
+ # title: Login API endpoint
229
+ # status: todo | in_progress | blocked | done
230
+ # deps: 001,002 (or "-")
231
+ # external: PROJ-142 (or "-") — link only; the board never depends on a tracker
232
+ # owner: ali@example.com (or "-")
233
+ # since: <iso> (or "-")
234
+ # since_epoch: <int> (or 0)
235
+ # beat_epoch: <int> (or 0)
236
+ # --
237
+ # ## Handover
238
+ # <free text: exactly where it was left, and why>
239
+ # ## Completion
240
+ # <free text: what shipped, commit/PR>
241
+
242
+ _field(){ # _field <content> <key>
243
+ printf '%s\n' "$1" | sed -n '1,/^--$/p' | grep -m1 "^$2: " | cut -d' ' -f2-
244
+ }
245
+ _body(){ printf '%s\n' "$1" | sed -n '/^--$/,$p' | sed '1d'; }
246
+
247
+ _section(){ # _section <content> <SectionName> -> text under "## Name" up to the next "## "
248
+ _body "$1" | awk -v s="## $2" 'BEGIN{p=0} $0==s{p=1;next} /^## /{p=0} p{print}'
249
+ }
250
+
251
+ _path_for(){ # _path_for <id> -> items/<id>-<slug>.md (resolved from the tree; ids are unique)
252
+ _item_paths | grep -m1 "^items/$1-" || _item_paths | grep -m1 "^items/$1\.md$"
253
+ }
254
+
255
+ _load(){ # _load <id> -> item content on stdout, 1 if absent
256
+ local p; p="$(_path_for "$1")"; [ -n "$p" ] || return 1; _cat "$p"
257
+ }
258
+
259
+ _render_item(){ # _render_item <id> <title> <status> <deps> <external> <owner> <since> <since_epoch> <beat> <handover> <completion>
260
+ printf 'id: %s\ntitle: %s\nstatus: %s\ndeps: %s\nexternal: %s\nowner: %s\nsince: %s\nsince_epoch: %s\nbeat_epoch: %s\n--\n## Handover\n%s\n## Completion\n%s\n' \
261
+ "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" "$9" "${10}" "${11}"
262
+ }
263
+
264
+ # ---------------------------------------------------------------- config (team-wide, stored IN the ref)
265
+
266
+ _conf(){ # _conf <key> <default>
267
+ local v; v="$(_cat config 2>/dev/null | grep -m1 "^$1: " | cut -d' ' -f2-)"
268
+ [ -n "$v" ] && printf '%s' "$v" || printf '%s' "$2"
269
+ }
270
+
271
+ # ---------------------------------------------------------------- tree building (never touches the worktree)
272
+
273
+ _blob(){ git hash-object -w --stdin; }
274
+
275
+ _tree_with(){ # _tree_with <path> <blob> [<path2> <blob2> ...] -> tree sha, based on the current board tree
276
+ local idx; idx="$(_git_dir)/crew-index.$$"
277
+ rm -f "$idx"
278
+ local rc=0
279
+ if _have_board; then
280
+ GIT_INDEX_FILE="$idx" git read-tree "$(_ref)^{tree}" 2>/dev/null || rc=1
281
+ fi
282
+ while [ $# -ge 2 ]; do
283
+ GIT_INDEX_FILE="$idx" git update-index --add --cacheinfo "100644,$2,$1" 2>/dev/null || rc=1
284
+ shift 2
285
+ done
286
+ local t; t="$(GIT_INDEX_FILE="$idx" git write-tree 2>/dev/null)" || rc=1
287
+ rm -f "$idx"
288
+ [ "$rc" = 0 ] || return 1
289
+ printf '%s' "$t"
290
+ }
291
+
292
+ _commit_push(){ # _commit_push <tree> <message> -> 0 pushed · 2 rejected (retry) · 3 unshared (offline/denied)
293
+ local tree="$1" msg="$2" sha lg err rc
294
+ if _have_board; then sha="$(git commit-tree "$tree" -p "$(_ref)" -m "$msg")"
295
+ else sha="$(git commit-tree "$tree" -m "$msg")"; fi
296
+ [ -n "$sha" ] || return 1
297
+ lg="$(_legacy)"
298
+ if [ -n "$lg" ]; then
299
+ # Both refs in one atomic push: the 2.x ref is still the team's lock (see _join's header).
300
+ err="$(LC_ALL=C _gitnet push --atomic --quiet "$(_remote)" "$sha:$(_ref)" "$sha:${lg#* }" 2>&1)"; rc=$?
301
+ case "$err" in *atomic*support*|*support*atomic*)
302
+ # A server without atomic push: the 2.x ref first, since it is the lock; the crew ref follows it and a
303
+ # failure there heals on the next write, which is built on whichever ref is ahead.
304
+ err="$(LC_ALL=C _gitnet push --quiet "$(_remote)" "$sha:${lg#* }" 2>&1)"; rc=$?
305
+ [ $rc -eq 0 ] && _gitnet push --quiet "$(_remote)" "$sha:$(_ref)" >/dev/null 2>&1 ;;
306
+ esac
307
+ [ $rc -eq 0 ] && git update-ref "${lg%% *}" "$sha"
308
+ else
309
+ err="$(LC_ALL=C _gitnet push --quiet "$(_remote)" "$sha:$(_ref)" 2>&1)"; rc=$?
310
+ fi
311
+ if [ $rc -eq 0 ]; then
312
+ git update-ref "$(_ref)" "$sha"
313
+ return 0
314
+ fi
315
+ # Non-fast-forward means somebody else claimed in between: that is the lock working, retry on fresh state.
316
+ case "$err" in
317
+ *non-fast-forward*|*fetch\ first*|*rejected*) return 2 ;;
318
+ esac
319
+ # Anything else (offline, no push rights, server refuses the namespace) is not a race. Keep the change
320
+ # locally so work is not lost, and say plainly that the team cannot see it.
321
+ git update-ref "$(_ref)" "$sha"
322
+ printf '%s\n' "$err" > "$(_git_dir)/crew-board-lasterror"
323
+ return 3
324
+ }
325
+
326
+ # ---------------------------------------------------------------- mutation with CAS retry
327
+ #
328
+ # The loop re-reads the board from the remote on every attempt and re-checks the precondition there, so a
329
+ # claim can never be granted against a stale view. Three attempts is not a timeout: each rejection means a
330
+ # real competing push landed, and re-evaluating usually turns the second attempt into a clean refusal
331
+ # ("someone else owns it") rather than a retry.
332
+
333
+ _mutate(){ # _mutate <action> <id> [note]
334
+ local action="$1" id="$2" note="${3:-}" attempt=1 rc
335
+ while [ "$attempt" -le 3 ]; do
336
+ _fetch || true # offline: fall through on the local ref, _commit_push reports unshared
337
+ _apply_once "$action" "$id" "$note"; rc=$?
338
+ case $rc in
339
+ 0) return 0 ;;
340
+ 1) return 1 ;; # precondition refused — not a race, do not retry
341
+ 3) return 3 ;; # unshared
342
+ 2) attempt=$((attempt+1)) ;; # lost the race — re-read and re-decide
343
+ esac
344
+ done
345
+ printf 'board: the board changed under three consecutive attempts; run %s and retry\n' "$(_act status)" >&2
346
+ return 1
347
+ }
348
+
349
+ _apply_once(){ # -> 0 ok · 1 refused · 2 race · 3 unshared
350
+ local action="$1" id="$2" note="${3:-}" me c path
351
+ me="$(_me)"
352
+ c="$(_load "$id")" || { printf 'board: no item #%s (list them: %s)\n' "$id" "$(_act status)" >&2; return 1; }
353
+ path="$(_path_for "$id")"
354
+
355
+ local title status deps external owner since since_ep beat hand comp
356
+ title="$(_field "$c" title)"; status="$(_field "$c" status)"; deps="$(_field "$c" deps)"
357
+ external="$(_field "$c" external)"; owner="$(_field "$c" owner)"
358
+ since="$(_field "$c" since)"; since_ep="$(_field "$c" since_epoch)"; beat="$(_field "$c" beat_epoch)"
359
+ hand="$(_section "$c" Handover)"; comp="$(_section "$c" Completion)"
360
+
361
+ case "$action" in
362
+ claim)
363
+ if [ "$status" = done ]; then printf 'board: #%s is already done\n' "$id" >&2; return 1; fi
364
+ if [ -n "$owner" ] && [ "$owner" != "-" ] && [ "$owner" != "$me" ]; then
365
+ printf 'board: #%s belongs to %s since %s. Free: %s\n' "$id" "$owner" "$since" "$(_free_ids)" >&2
366
+ _log_refusal "$id" held "$owner"
367
+ return 1
368
+ fi
369
+ local blocked; blocked="$(_unmet_deps "$deps")"
370
+ if [ -n "$blocked" ]; then
371
+ printf 'board: #%s is blocked by #%s (not done). Free: %s\n' "$id" "$blocked" "$(_free_ids)" >&2
372
+ _log_refusal "$id" blocked "#$blocked"
373
+ return 1
374
+ fi
375
+ if [ "$owner" = "$me" ] && [ "$status" = in_progress ]; then
376
+ # Re-claiming what you already hold must NOT restart the clock. It used to rewrite `since`, so an item
377
+ # held since morning read as freshly started — which breaks the two things that age is for: telling a
378
+ # teammate how long it has been held, and letting a claim go stale when its owner has walked away.
379
+ # Only the heartbeat moves: re-claiming is evidence you are still on it, not that you just began.
380
+ beat="$(_now_epoch)"
381
+ else
382
+ status=in_progress; owner="$me"; since="$(_now_iso)"; since_ep="$(_now_epoch)"; beat="$since_ep"
383
+ fi
384
+ ;;
385
+ done)
386
+ if [ "$owner" != "$me" ]; then printf 'board: #%s is not yours (%s)\n' "$id" "${owner:--}" >&2; return 1; fi
387
+ status=done; owner='-'; beat="$(_now_epoch)"
388
+ [ -n "$note" ] && comp="$note"
389
+ ;;
390
+ drop)
391
+ if [ "$owner" != "$me" ]; then printf 'board: #%s is not yours (%s)\n' "$id" "${owner:--}" >&2; return 1; fi
392
+ # A drop without a handover note is the exact failure this board exists to prevent: the next person
393
+ # inherits the item with none of the context that made it hard.
394
+ [ -n "$note" ] || { printf 'board: drop needs a handover note (where it stands, and why)\n' >&2; return 1; }
395
+ status=todo; owner='-'; since='-'; since_ep=0; beat="$(_now_epoch)"; hand="$note"
396
+ ;;
397
+ beat)
398
+ [ "$owner" = "$me" ] || return 1
399
+ beat="$(_now_epoch)"
400
+ ;;
401
+ note)
402
+ if [ "$owner" != "$me" ]; then printf 'board: #%s is not yours (%s)\n' "$id" "${owner:--}" >&2; return 1; fi
403
+ hand="$note"; beat="$(_now_epoch)"
404
+ ;;
405
+ *) _die "unknown action '$action'" ;;
406
+ esac
407
+
408
+ local blob tree
409
+ blob="$(_render_item "$id" "$title" "$status" "$deps" "$external" "$owner" "$since" "$since_ep" "$beat" "$hand" "$comp" | _blob)"
410
+ tree="$(_tree_with "$path" "$blob")" || return 1
411
+ _commit_push "$tree" "board: $action #$id by $me"
412
+ }
413
+
414
+ _unmet_deps(){ # -> comma list of dependency ids that are not done
415
+ local deps="$1" out="" d dc
416
+ [ -n "$deps" ] && [ "$deps" != "-" ] || return 0
417
+ for d in $(printf '%s' "$deps" | tr ',' ' '); do
418
+ dc="$(_load "$d")" || continue
419
+ [ "$(_field "$dc" status)" = done ] || out="${out:+$out,}$d"
420
+ done
421
+ printf '%s' "$out"
422
+ }
423
+
424
+ _free_ids(){ # claimable right now: unowned, not done, no unmet dependency
425
+ local p c out="" id
426
+ for p in $(_item_paths); do
427
+ c="$(_cat "$p")"; id="$(_field "$c" id)"
428
+ [ "$(_field "$c" status)" = done ] && continue
429
+ local o; o="$(_field "$c" owner)"; [ -n "$o" ] && [ "$o" != "-" ] && continue
430
+ [ -n "$(_unmet_deps "$(_field "$c" deps)")" ] && continue
431
+ out="${out:+$out }#$id"
432
+ done
433
+ printf '%s' "${out:-none}"
434
+ }
435
+
436
+ # ---------------------------------------------------------------- commands
437
+
438
+ cmd_add(){ # add <id> <title> [deps] [external]
439
+ local id="$1" title="$2" deps="${3:--}" ext="${4:--}" attempt=1 rc
440
+ case "$id" in ''|*[!0-9]*) _die "id must be numeric (e.g. 003)";; esac
441
+ local slug; slug="$(printf '%s' "$title" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-//;s/-$//' | cut -c1-40)"
442
+ while [ "$attempt" -le 3 ]; do
443
+ _fetch || true
444
+ if _load "$id" >/dev/null 2>&1; then _die "#$id already exists"; fi
445
+ local blob tree
446
+ blob="$(_render_item "$id" "$title" "$([ "$deps" != "-" ] && echo blocked || echo todo)" "$deps" "$ext" '-' '-' 0 0 '' '' | _blob)"
447
+ tree="$(_tree_with "items/$id-$slug.md" "$blob")" || _die "could not build the board tree"
448
+ _commit_push "$tree" "board: add #$id"; rc=$?
449
+ case $rc in 0) printf '+ #%s %s\n' "$id" "$title"; return 0;; 3) printf '+ #%s %s [UNSHARED — remote unreachable]\n' "$id" "$title"; return 0;; esac
450
+ attempt=$((attempt+1))
451
+ done
452
+ _die "the board kept changing; retry"
453
+ }
454
+
455
+ _related(){ # _related <id> — the work this item is connected to, in the words of the people who did it.
456
+ # Storing a handover note is not the same as delivering it. Whoever picks up #3 has no reason to go and read
457
+ # #1, so the moment they take #3 is when what #1 actually delivered has to arrive on its own.
458
+ local id="$1" c deps d dc out="" body who
459
+ c="$(_load "$id")" || return 0
460
+ deps="$(_field "$c" deps)"
461
+ if [ -n "$deps" ] && [ "$deps" != "-" ]; then
462
+ for d in $(printf '%s' "$deps" | tr ',' ' '); do
463
+ dc="$(_load "$d")" || continue
464
+ body="$(_section "$dc" Completion | head -6)"; [ -n "$body" ] || body="$(_section "$dc" Handover | head -6)"
465
+ who="$(_field "$dc" owner)"; [ "$who" = "-" ] && who="$(_field "$dc" status)"
466
+ out="$out
467
+ #$d \"$(_field "$dc" title)\" [$(_field "$dc" status)] — depends-on
468
+ ${body:+$(printf '%s' "$body" | sed 's/^/ /')}"
469
+ done
470
+ fi
471
+ # Who is waiting on YOU: work that will be affected by what you are about to do, so it can be told.
472
+ local p pc pd
473
+ for p in $(_item_paths); do
474
+ pc="$(_cat "$p")"; pd="$(_field "$pc" deps)"
475
+ case ",$pd," in *",$id,"*)
476
+ out="$out
477
+ #$(_field "$pc" id) \"$(_field "$pc" title)\" [$(_field "$pc" status)$([ "$(_field "$pc" owner)" != "-" ] && printf ', %s' "$(_field "$pc" owner)")] — waits on this one" ;;
478
+ esac
479
+ done
480
+ # What everyone ELSE is mid-flight on, with whatever they last said about it. The dependency graph only knows
481
+ # the edges somebody declared; two items can collide without one. "What is Ali doing right now" is a question
482
+ # people ask out loud, so it is answered here without being asked, at the one moment it changes what you do.
483
+ local me ow st note
484
+ me="$(_me)"
485
+ for p in $(_item_paths); do
486
+ pc="$(_cat "$p")"; ow="$(_field "$pc" owner)"; st="$(_field "$pc" status)"
487
+ [ "$st" = in_progress ] && [ -n "$ow" ] && [ "$ow" != "-" ] && [ "$ow" != "$me" ] || continue
488
+ [ "$(_field "$pc" id)" = "$id" ] && continue
489
+ note="$(_section "$pc" Handover | head -2)"
490
+ out="$out
491
+ #$(_field "$pc" id) \"$(_field "$pc" title)\" — $ow is on this now ($(_age "$(_field "$pc" since_epoch)"))${note:+
492
+ $(printf '%s' "$note" | sed 's/^/ /')}"
493
+ done
494
+
495
+ # Decisions this user has not read. Announcing them only at session start meant an item claimed later in the
496
+ # same session could be started against a constraint the team had already settled — which is the failure the
497
+ # decision was recorded to prevent. Starting work is the last honest moment to say it.
498
+ local dtotal dseen dnew
499
+ dtotal="$(_decision_paths | wc -l | tr -d ' ')"; dseen="$(_seen_count)"
500
+ dnew=$(( dtotal - dseen )); [ "$dnew" -lt 0 ] && dnew=0
501
+ if [ "$dnew" -gt 0 ]; then
502
+ for p in $(_decision_paths | tail -"$dnew"); do
503
+ c="$(_cat "$p")"
504
+ out="$out
505
+ DECISION $(_field "$c" id) \"$(_field "$c" title)\" — $(_field "$c" author), unread by you
506
+ $(_body "$c" | head -4 | sed 's/^/ /')"
507
+ done
508
+ fi
509
+
510
+ [ -n "$out" ] && printf 'Connected work — read this before you start, and update it when you finish:%s\n' "$out"
511
+ return 0
512
+ }
513
+
514
+ # A refusal is the only proof the lock ever did anything, and it was the one event leaving no trace: the message
515
+ # went to stderr and nowhere else. Nothing on the board, nothing in the history — so "how often did this actually
516
+ # stop a collision?" was unanswerable, and would still be unanswerable after a trial, because the data would
517
+ # never have existed. Measurement has to be installed BEFORE the thing it measures.
518
+ #
519
+ # Appended to the board so the count is the TEAM's, not one clone's. Refusals are rare by construction — they
520
+ # happen only on real contention — so a push per refusal costs little, and it rides the same CAS retry as every
521
+ # other write. Failure here is swallowed: a refusal that cannot be logged is still a refusal, and turning it
522
+ # into an error would punish the user for the bookkeeping.
523
+ _log_refusal(){ # _log_refusal <id> <reason> <holder>
524
+ _enabled || return 0
525
+ _have_board || return 0
526
+ local id="$1" reason="$2" holder="$3" attempt=1 prev line blob tree
527
+ line="$(_now_iso)|$id|$reason|$(_me)|${holder:--}"
528
+ while [ "$attempt" -le 3 ]; do
529
+ prev="$(_cat refusals.log 2>/dev/null)"
530
+ blob="$(printf '%s%s\n' "${prev:+$prev$NLR}" "$line" | _blob)"
531
+ tree="$(_tree_with refusals.log "$blob")" || return 0
532
+ _commit_push "$tree" "board: refusal #$id ($reason)" && return 0
533
+ _fetch >/dev/null 2>&1 || return 0
534
+ attempt=$((attempt+1))
535
+ done
536
+ return 0
537
+ }
538
+ NLR='
539
+ '
540
+
541
+ cmd_claim(){
542
+ _mutate claim "$1"; local rc=$?
543
+ case $rc in
544
+ 0) printf '#%s is yours (%s)\n' "$1" "$(_me)"; _related "$1" ;;
545
+ 3) # "Unshared" has two very different causes and one alarming message served both. A repo with no remote
546
+ # is a deliberate local board, not a failure, and telling that user their team cannot see it is noise
547
+ # about a team that does not exist.
548
+ if git remote get-url "$(_remote)" >/dev/null 2>&1; then
549
+ printf '#%s claimed LOCALLY ONLY — the remote is unreachable, your team cannot see it.\n Run %s before you rely on it.\n' "$1" "$(_act sync)"
550
+ else
551
+ printf '#%s is yours (%s) — local board, no remote configured.\n' "$1" "$(_me)"
552
+ _related "$1"
553
+ fi ;;
554
+ *) return $rc ;;
555
+ esac
556
+ cmd_cache >/dev/null
557
+ }
558
+
559
+ cmd_done(){ _mutate done "$1" "${2:-}" || return $?; printf '#%s done. Now claimable: %s\n' "$1" "$(_free_ids)"; cmd_cache >/dev/null; }
560
+ cmd_drop(){ _mutate drop "$1" "${2:-}" || return $?; printf '#%s released with a handover note.\n' "$1"; cmd_cache >/dev/null; }
561
+ cmd_note(){ _mutate note "$1" "${2:-}" || return $?; printf '#%s handover note updated.\n' "$1"; }
562
+
563
+ cmd_status(){
564
+ # This view fetches before it prints, and that is a deliberate reversal. It used to fetch only when there was
565
+ # no board at all, to keep the network off the foreground — and the result was a board that lied: caught in a
566
+ # real session, it showed an item as blocked while its dependency had already landed, and only the claim that
567
+ # followed corrected it. The "no network in the foreground" rule is about hooks that run on EVERY turn, not
568
+ # about a view a person asked for by name. A stale answer to "who has what" is worse than a slow one, because
569
+ # the next thing the reader does is decide what to work on.
570
+ #
571
+ # Fails open: an unreachable remote falls through to whatever is local, so the view still appears offline.
572
+ _fetch >/dev/null 2>&1 || true
573
+ _have_board || { echo "No board in this repo yet (and none on '$(_remote)'). Create one: $(_act init)"; return 0; }
574
+ local me p c id st ow ti dep bl now stale_h
575
+ me="$(_me)"; now="$(_now_epoch)"; stale_h="$(_conf stale_hours 8)"
576
+ # HELD is not decoration: "who holds it" without "for how long" is the question a teammate actually asks, and
577
+ # without it the only age signal was the STALE marker — i.e. nothing at all until 8 hours had passed.
578
+ echo "ITEM STATUS OWNER HELD TITLE"
579
+ for p in $(_item_paths); do
580
+ c="$(_cat "$p")"; id="$(_field "$c" id)"; st="$(_field "$c" status)"; ow="$(_field "$c" owner)"; ti="$(_field "$c" title)"
581
+ # Blockedness is DERIVED from the dependency graph, never read from the stored field: an item added with a
582
+ # dependency is stored as `blocked` and nothing rewrites that field when the dependency lands, so trusting
583
+ # it printed "blocked" for items the same view was simultaneously listing as claimable.
584
+ dep="$(_unmet_deps "$(_field "$c" deps)")"
585
+ if [ "$st" != done ]; then
586
+ if [ -n "$dep" ]; then st="blocked(#$dep)"
587
+ elif [ -n "$ow" ] && [ "$ow" != "-" ]; then st="in_progress"
588
+ else st="todo"; fi
589
+ fi
590
+ bl=""
591
+ if [ "$ow" = "$me" ]; then bl=" <- you"; fi
592
+ if [ -n "$ow" ] && [ "$ow" != "-" ]; then
593
+ local b; b="$(_field "$c" beat_epoch)"; case "$b" in ''|*[!0-9]*) b=0;; esac
594
+ [ "$b" -gt 0 ] && [ $(( (now - b) / 3600 )) -ge "$stale_h" ] && bl="$bl [STALE $(( (now-b)/3600 ))h]"
595
+ fi
596
+ local age='-'
597
+ { [ -n "$ow" ] && [ "$ow" != "-" ]; } && age="$(_age "$(_field "$c" since_epoch)")"
598
+ printf '#%-4s %-14s %-21s %-5s %s%s\n' "$id" "$st" "${ow:--}" "$age" "$ti" "$bl"
599
+ done
600
+ echo
601
+ echo "Claimable now: $(_free_ids)"
602
+ [ -f "$(_git_dir)/crew-board-lasterror" ] && echo "Last sync error: $(cat "$(_git_dir)/crew-board-lasterror")"
603
+ return 0
604
+ }
605
+
606
+ cmd_show(){ # show <id> — the shared memory for one item
607
+ local c; c="$(_load "$1")" || _die "no item #$1"
608
+ printf '%s\n' "$c"
609
+ }
610
+
611
+ # ---------------------------------------------------------------- cache (what the session-start hook reads)
612
+
613
+ cmd_cache(){ # rebuild the local cache; NEVER called on the foreground path with the network
614
+ # Switched off -> leave nothing behind for the other two gates to read. The write guard tests for the flag
615
+ # file and the session hook prints the cache file; removing both is what makes "off" cost exactly zero
616
+ # instead of merely suppressing the output of work still being done.
617
+ # The timestamp is written on EVERY path, including the two that produce no cache. It is what the session-start
618
+ # hook throttles on, and without it a repo that has no board (every solo project, and every install that
619
+ # upgraded into this feature) re-spawned a background fetch at every single session opening — looking, forever,
620
+ # for a ref nobody is ever going to create.
621
+ if ! _enabled; then rm -f "$(_git_dir)/crew-board-guard" "$(_git_dir)/crew-board-cache"; _now_epoch > "$(_git_dir)/crew-board-cache.at" 2>/dev/null; return 0; fi
622
+ _have_board || { _now_epoch > "$(_git_dir)/crew-board-cache.at" 2>/dev/null; return 0; }
623
+ local out me p c id st ow ti now stale_h b
624
+ me="$(_me)"; now="$(_now_epoch)"; stale_h="$(_conf stale_hours 8)"
625
+ local mine="" others="" free="" stale=""
626
+ for p in $(_item_paths); do
627
+ c="$(_cat "$p")"; id="$(_field "$c" id)"; st="$(_field "$c" status)"; ow="$(_field "$c" owner)"; ti="$(_field "$c" title)"
628
+ [ "$st" = done ] && continue
629
+ if [ "$ow" = "$me" ]; then mine="${mine:+$mine; }#$id $ti"
630
+ elif [ -n "$ow" ] && [ "$ow" != "-" ]; then
631
+ others="${others:+$others; }#$id $ow ($(_age "$(_field "$c" since_epoch)"))"
632
+ b="$(_field "$c" beat_epoch)"; case "$b" in ''|*[!0-9]*) b=0;; esac
633
+ [ "$b" -gt 0 ] && [ $(( (now - b) / 3600 )) -ge "$stale_h" ] && stale="${stale:+$stale, }#$id ($ow, $(( (now-b)/3600 ))h)"
634
+ fi
635
+ done
636
+ free="$(_free_ids)"
637
+ out="Team board — held by others: ${others:-none} · yours: ${mine:-none} · claimable: ${free}"
638
+
639
+ # Decisions the user has not looked at yet. A decision nobody reads is the same as one nobody wrote, and
640
+ # session start is the only moment it can arrive before the work it would have changed.
641
+ local dtotal dseen dnew p c
642
+ dtotal="$(_decision_paths | wc -l | tr -d ' ')"; dseen="$(_seen_count)"
643
+ dnew=$(( dtotal - dseen )); [ "$dnew" -lt 0 ] && dnew=0
644
+ if [ "$dnew" -gt 0 ]; then
645
+ out="$out
646
+ $dnew team decision(s) recorded since you last looked — read them before planning: $(_act decisions)"
647
+ for p in $(_decision_paths | tail -"$dnew"); do
648
+ c="$(_cat "$p")"
649
+ out="$out
650
+ · $(_field "$c" id) $(_field "$c" title) — $(_field "$c" author)"
651
+ done
652
+ fi
653
+
654
+ # An open claim you have stopped feeding. Not a block — the damage from a forgotten claim is that the team
655
+ # reads the item as actively held, so it has to be said out loud rather than enforced.
656
+ local om ob
657
+ for p in $(_item_paths); do
658
+ c="$(_cat "$p")"; [ "$(_field "$c" owner)" = "$me" ] || continue
659
+ [ "$(_field "$c" status)" = in_progress ] || continue
660
+ ob="$(_field "$c" since_epoch)"; case "$ob" in ''|*[!0-9]*) ob=0;; esac
661
+ [ "$ob" -gt 0 ] && [ $(( (now - ob) / 3600 )) -ge 8 ] || continue
662
+ om="$(_section "$c" Handover)"
663
+ [ -n "$om" ] && continue
664
+ out="$out
665
+ You have held #$(_field "$c" id) for $(_age "$ob") with an empty handover note. If you are still on it, say where it stands: $(_act "note $(_field "$c" id) \"<where it stands>\"" "note $(_field "$c" id)"); if not, release it: $(_act "drop $(_field "$c" id) \"<handover>\"" "drop $(_field "$c" id)")."
666
+ done
667
+ [ -n "$stale" ] && out="$out
668
+ Stale claims (no activity for ${stale_h}h+): $stale — ask the owner before taking one over; never steal silently."
669
+ out="$out
670
+ Claim before you start: $(_act "claim <id>"). Commits are gated on a live claim."
671
+ printf '%s\n' "$out" > "$(_git_dir)/crew-board-cache"
672
+ _now_epoch > "$(_git_dir)/crew-board-cache.at"
673
+
674
+ # One-bit flag for the PreToolUse write guard. The guard runs before EVERY file edit, so it must not shell out
675
+ # to this script — on Windows a process costs 62-135 ms and a hot-path fork loop is a freeze. It tests for this
676
+ # file and nothing else: present means "a board exists, it requires a claim, and this user holds none", which
677
+ # is the only state that blocks. Recomputed here, i.e. at session start and after every board command.
678
+ if [ -z "$mine" ] && [ "$(_conf require_item all)" = all ]; then
679
+ : > "$(_git_dir)/crew-board-guard"
680
+ else
681
+ rm -f "$(_git_dir)/crew-board-guard"
682
+ fi
683
+ printf '%s\n' "$out"
684
+ }
685
+
686
+ cmd_sync(){
687
+ _JOIN_ERR=""
688
+ if _fetch; then [ -n "$_JOIN_ERR" ] || rm -f "$(_git_dir)/crew-board-lasterror"
689
+ else echo "board: remote unreachable — showing the last known state"; fi
690
+ cmd_cache; }
691
+
692
+ # ---------------------------------------------------------------- decisions
693
+ #
694
+ # The board carried per-item memory and nothing else, so a decision that shaped the whole project — "refresh
695
+ # tokens travel in a header, mobile drops cookies" — reached the person who made it and nobody else. The `adr`
696
+ # skill wrote to docs/adr/, and installs gitignore docs/, so those records never left the machine that made
697
+ # them. That is the exact failure this board exists to close, one level up from an item.
698
+ #
699
+ # They live on the same ref as the items, so they travel with the board — including when the board is a
700
+ # separate repository. A decision is append-only: superseding one writes a new record that names it.
701
+
702
+ _decision_paths(){ git ls-tree --name-only "$(_ref)" decisions/ 2>/dev/null; }
703
+
704
+ _next_decision_id(){
705
+ local last; last="$(_decision_paths | sed -n 's#decisions/\([0-9][0-9]*\)-.*#\1#p' | sort -n | tail -1)"
706
+ printf '%04d' $(( 10#${last:-0} + 1 ))
707
+ }
708
+
709
+ cmd_decide(){ # decide <title> [body] [items]
710
+ local title="${1:-}" body="${2:-}" items="${3:--}" attempt=1 rc
711
+ [ -n "$title" ] || _die 'decide needs a title: board.sh decide "<what was decided>" "<why + what it means for other work>"'
712
+ _enabled || _die "the board is off in this repo ($(_act on))"
713
+ while [ "$attempt" -le 3 ]; do
714
+ _fetch || true
715
+ _have_board || _die "no board here yet ($(_act init))"
716
+ local id slug blob tree
717
+ id="$(_next_decision_id)"
718
+ slug="$(printf '%s' "$title" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-//;s/-$//' | cut -c1-48)"
719
+ blob="$(printf 'id: %s\ntitle: %s\nauthor: %s\ndate: %s\nepoch: %s\nitems: %s\n--\n%s\n' \
720
+ "$id" "$title" "$(_me)" "$(_now_iso)" "$(_now_epoch)" "$items" "$body" | _blob)"
721
+ tree="$(_tree_with "decisions/$id-$slug.md" "$blob")" || _die "could not build the board tree"
722
+ _commit_push "$tree" "board: decision $id by $(_me)"; rc=$?
723
+ case $rc in
724
+ 0) printf 'Decision %s recorded and shared: %s\n' "$id" "$title"; cmd_cache >/dev/null; return 0 ;;
725
+ 3) printf 'Decision %s recorded LOCALLY ONLY (remote unreachable) — the team cannot see it yet.\n' "$id"; return 0 ;;
726
+ esac
727
+ attempt=$((attempt+1)) # somebody else recorded one first: re-read so the ids do not collide
728
+ done
729
+ _die "the board kept changing; retry"
730
+ }
731
+
732
+ cmd_decisions(){ # decisions [id]
733
+ _have_board || { echo "No board here ($(_act init))."; return 0; }
734
+ local p c
735
+ if [ -n "${1:-}" ]; then
736
+ p="$(_decision_paths | grep -m1 "^decisions/$1-")" || true
737
+ [ -n "$p" ] || _die "no decision $1"
738
+ _cat "$p"; _seen_now; return 0
739
+ fi
740
+ local n=0
741
+ for p in $(_decision_paths); do
742
+ c="$(_cat "$p")"
743
+ printf '%s %s\n %s · %s\n' "$(_field "$c" id)" "$(_field "$c" title)" "$(_field "$c" author)" "$(_field "$c" date)"
744
+ n=$((n+1))
745
+ done
746
+ [ "$n" = 0 ] && echo "No decisions recorded yet. Record one: $(_act 'decide "<what was decided>"' 'decide "<what was decided>"')"
747
+ _seen_now
748
+ return 0
749
+ }
750
+
751
+ # "New" means "recorded since you last looked at the board" — the marker moves when the user actually views
752
+ # them, not when the cache is rebuilt, or a decision would be announced once to a session nobody was reading.
753
+ _seen_now(){ local d; d="$(_git_dir)"; [ -n "$d" ] || return 0
754
+ _decision_paths | wc -l | tr -d ' ' > "$d/crew-board-seen" 2>/dev/null || true; }
755
+ # Tested with [ -f ] rather than a redirect plus 2>/dev/null: bash applies redirections left to right, so an
756
+ # input redirect from a missing file fails BEFORE stderr is silenced and the complaint reaches the terminal —
757
+ # which is how a first-run read leaked an error line into a session-start hook.
758
+ _seen_count(){ local d s=""; d="$(_git_dir)"
759
+ [ -n "$d" ] && [ -f "$d/crew-board-seen" ] && s="$(tr -cd '0-9' < "$d/crew-board-seen" 2>/dev/null)"
760
+ printf '%s' "${s:-0}"; }
761
+
762
+ cmd_off(){ # off [--global]
763
+ git config ${1:+--global} crew.board off; _load_cfg
764
+ cmd_cache >/dev/null
765
+ echo "Board OFF${1:+ (global: every repo)}. No claim needed, no commit gate, no edit gate; the board itself is untouched."
766
+ echo "Back on: $(_act "on${1:+ --global}")"
767
+ }
768
+ cmd_on(){
769
+ git config ${1:+--global} --unset crew.board 2>/dev/null || true
770
+ git config ${1:+--global} --unset csk.board 2>/dev/null || true # the 2.x key would otherwise keep it off
771
+ # A --global off would otherwise keep overriding a repo that just turned itself back on, and the user would
772
+ # see "on" printed while nothing changed.
773
+ [ -z "${1:-}" ] && case "$(git config --global --get crew.board 2>/dev/null || git config --global --get csk.board 2>/dev/null)" in
774
+ off|false|0|no) git config crew.board on ;;
775
+ esac
776
+ _load_cfg
777
+ cmd_cache >/dev/null
778
+ _enabled && echo "Board ON. Claim before you start: $(_act "claim <id>")" \
779
+ || echo "Still off — CREW_NO_BOARD is set in this session's environment; unset it."
780
+ }
781
+
782
+ cmd_beat(){
783
+ # "Still on it." A claim with no heartbeat eventually reads as abandoned, and the team is told to ask before
784
+ # taking it over — so an active owner has to publish liveness. Publishing costs a push, so this only ever runs
785
+ # from the DETACHED half of the session-start hook, and only once the mark is half-way to stale: without that
786
+ # guard every session start would add a commit per claim to a ref nobody reads.
787
+ _have_board || return 0
788
+ local me now half p c id ow b
789
+ me="$(_me)"; now="$(_now_epoch)"; half=$(( $(_conf stale_hours 8) * 3600 / 2 ))
790
+ for p in $(_item_paths); do
791
+ c="$(_cat "$p")"; ow="$(_field "$c" owner)"; [ "$ow" = "$me" ] || continue
792
+ [ "$(_field "$c" status)" = in_progress ] || continue
793
+ b="$(_field "$c" beat_epoch)"; case "$b" in ''|*[!0-9]*) b=0;; esac
794
+ [ $(( now - b )) -ge "$half" ] || continue
795
+ id="$(_field "$c" id)"
796
+ _mutate beat "$id" >/dev/null 2>&1 || true
797
+ done
798
+ }
799
+
800
+ # ---------------------------------------------------------------- capability probe + init
801
+
802
+ cmd_probe(){
803
+ # Some servers refuse refs outside refs/heads|refs/tags (they reserve their own internal namespaces and may
804
+ # reject unknown ones). Rather than assume what a given GitHub/GitLab/Bitbucket install allows, push a tiny
805
+ # throwaway ref and read the answer from the server. On refusal, fall back to an orphan branch, which every
806
+ # server accepts and which enforces the same fast-forward rule the lock depends on.
807
+ #
808
+ # Measured 2026-08-09 against github.com: a custom refs/<name>/* namespace ACCEPTED, and the probe ref deleted cleanly afterwards
809
+ # (0 refs left). That is one server on one day, not a guarantee for every host and every org policy — which
810
+ # is why this stays a probe rather than becoming a hard-coded assumption.
811
+ local remote empty probe
812
+ remote="$(_remote)"
813
+ empty="$(git hash-object -w -t tree /dev/null)"
814
+ probe="$(git commit-tree "$empty" -m 'board: capability probe')"
815
+ if _gitnet push --quiet "$remote" "$probe:refs/crew/probe" 2>/dev/null; then
816
+ _gitnet push --quiet "$remote" ":refs/crew/probe" 2>/dev/null
817
+ git config crew.boardRef 'refs/crew/board'; _load_cfg
818
+ echo "probe: custom ref namespace accepted -> refs/crew/board (invisible to git branch)"
819
+ else
820
+ git config crew.boardRef 'refs/heads/crew-board'; _load_cfg
821
+ echo "probe: server refused refs/crew/* -> falling back to the orphan branch refs/heads/crew-board"
822
+ echo " (same fast-forward lock; it will appear in the branch list — do not merge it)"
823
+ fi
824
+ }
825
+
826
+ cmd_init(){ # init [--remote <name|url>] [all|referenced]
827
+ # The board defaults to the code repo's own `origin`, which is the case that needs no decision at all: the
828
+ # team already shares it and already has push rights. `--remote` covers the two real reasons to separate them
829
+ # — a board shared across several repos, and members who may read the code repo but not push to it — without
830
+ # anybody having to know that the setting is a git config key.
831
+ if [ "${1:-}" = "--remote" ]; then
832
+ local target="${2:-}"; shift 2 2>/dev/null || true
833
+ [ -n "$target" ] || _die "init --remote needs a remote name or a URL"
834
+ # An existing remote NAME wins; anything else is treated as a location. Deciding by shape alone was wrong:
835
+ # `https://…` and `git@host:org/x.git` were recognised but a filesystem path (a self-hosted mirror, a share,
836
+ # a test fixture) was not, and it failed with "no remote named /srv/board.git" — which reads as a bug.
837
+ if git remote get-url "$target" >/dev/null 2>&1; then
838
+ git config crew.boardRemote "$target"; _load_cfg
839
+ echo "board remote: $target"
840
+ else
841
+ if git remote get-url crew-board >/dev/null 2>&1; then git remote set-url crew-board "$target"
842
+ else git remote add crew-board "$target"; fi
843
+ git config crew.boardRemote crew-board; _load_cfg
844
+ echo "board remote: crew-board -> $target"
845
+ fi
846
+ fi
847
+ if _have_board; then echo "board already initialised at $(_ref)"; return 0; fi
848
+ # No remote at all is a legitimate solo setup, not an error — a local board still gives you the item list,
849
+ # the dependency order and the commit gate. Say which one you are getting instead of failing at push time
850
+ # with "the remote refused", which reads as a broken tool.
851
+ if ! git remote get-url "$(_remote)" >/dev/null 2>&1; then
852
+ printf 'No remote named "%s" in this repo — creating a LOCAL board.\n' "$(_remote)"
853
+ printf 'It gives you the item list, dependency order and the commit gate, but nothing is shared.\n'
854
+ printf 'To share it later: add a remote, then %s. To point elsewhere now: %s.\n' "$(_act sync)" "$(_act 'init --remote <url>')"
855
+ local lblob ltree
856
+ lblob="$(printf 'require_item: %s\nstale_hours: 8\n' "${1:-all}" | _blob)"
857
+ ltree="$(_tree_with config "$lblob")" || _die "could not build the board tree"
858
+ git update-ref "$(_ref)" "$(git commit-tree "$ltree" -m 'board: initialise (local)')"
859
+ cmd_cache >/dev/null
860
+ echo "local board created at $(_ref)"
861
+ return 0
862
+ fi
863
+ local frc; _fetch; frc=$?
864
+ [ "$frc" = 0 ] && { echo "board already exists on the remote; fetched it"; cmd_cache >/dev/null; return 0; }
865
+ # Probe only a server that answered. An unreachable one refuses the probe push too, and recording that as
866
+ # "custom refs refused" pinned this clone to the branch form for good — away from where its team's board lives.
867
+ [ "$frc" = 4 ] && cmd_probe
868
+ local blob tree
869
+ blob="$(printf 'require_item: %s\nstale_hours: 8\n' "${1:-all}" | _blob)"
870
+ tree="$(_tree_with config "$blob")" || _die "could not build the board tree"
871
+ _commit_push "$tree" "board: initialise"
872
+ case $? in
873
+ 0) echo "board initialised at $(_ref) on $(_remote)" ;;
874
+ 3) echo "board created LOCALLY ONLY — the remote refused or is unreachable:"; cat "$(_git_dir)/crew-board-lasterror" ;;
875
+ esac
876
+ }
877
+
878
+ # ---------------------------------------------------------------- commit gate (called from commit-msg)
879
+
880
+ cmd_gate(){ # gate <commit-msg-file>
881
+ local f="${1:-}"; [ -n "$f" ] && [ -f "$f" ] || return 0
882
+ # No board in this repo -> this gate does not exist. Solo users and repos that never ran init are untouched.
883
+ _enabled || return 0
884
+ _have_board || return 0
885
+ # Strip comments git adds to the message template before matching.
886
+ local msg; msg="$(grep -v '^#' "$f" 2>/dev/null)"
887
+ local id; id="$(printf '%s' "$msg" | grep -oE '\[#[0-9]+\]' | head -1 | tr -dc '0-9')"
888
+ if [ -z "$id" ]; then
889
+ printf '%s' "$msg" | grep -qE '\[chore\]' && return 0
890
+ [ "$(_conf require_item all)" = referenced ] && return 0
891
+ cat >&2 <<MSG
892
+ BOARD GATE: this commit names no board item.
893
+ Put [#<id>] in the message for the item you are working on, or [chore] for work that
894
+ belongs to no item (it is recorded, not blocked).
895
+ See what is claimable: $(_act status)
896
+ MSG
897
+ return 1
898
+ fi
899
+ local c owner status
900
+ c="$(_load "$id")" || { printf 'BOARD GATE: no item #%s on the board (stale board? run %s)\n' "$id" "$(_act sync)" >&2; return 1; }
901
+ owner="$(_field "$c" owner)"; status="$(_field "$c" status)"
902
+ local me; me="$(_me)"
903
+ if [ "$owner" != "$me" ]; then
904
+ printf 'BOARD GATE: #%s is held by %s, not you. Claim it or pick another: %s\n' "$id" "${owner:--}" "$(_act status)" >&2
905
+ printf ' (if you believe this is stale, run %s first)\n' "$(_act sync)" >&2
906
+ return 1
907
+ fi
908
+ [ "$status" = in_progress ] || { printf 'BOARD GATE: #%s is "%s", not in_progress. Run: %s\n' "$id" "$status" "$(_act "claim $id")" >&2; return 1; }
909
+ # Deliberately no heartbeat refresh here: publishing one costs a push, and a commit must work offline and
910
+ # must not be slowed by the network. The heartbeat is advanced by the board commands and at session start.
911
+ return 0
912
+ }
913
+
914
+ # ---------------------------------------------------------------- dispatch
915
+
916
+ usage(){ cat <<'U'
917
+ board.sh <command>
918
+ status board view: who holds what, what is claimable, what is blocked
919
+ show <id> one item with its shared handover / completion memory
920
+ claim <id> atomic claim (fetch -> verify -> push; a lost race refuses, it never overwrites)
921
+ done <id> [note] complete, record the note, unblock dependents
922
+ drop <id> <note> release; the handover note is REQUIRED
923
+ note <id> <text> update the item's handover memory without releasing it
924
+ add <id> <title> [deps] [external]
925
+ init [--remote <name|url>] [all|referenced]
926
+ create the board (probes ref support first). Defaults to `origin`; --remote points
927
+ it at a separate board repository (a URL gets its own `crew-board` remote).
928
+ probe ask the server whether it accepts the custom ref namespace
929
+ off [--global] / on switch every board gate off (or back on) for this repo, or for every repo.
930
+ A repo that never ran `init` has no gates in the first place.
931
+ decide "<what>" "<why + what it means for other work>" [items]
932
+ record a team decision on the board so it reaches everyone, not just this machine
933
+ decisions [id] list them, or read one
934
+ sync fetch the board and refresh the local cache
935
+ cache rebuild the local cache (no network)
936
+ gate <msgfile> commit-msg claim gate
937
+ U
938
+ }
939
+
940
+ git rev-parse --git-dir >/dev/null 2>&1 || _die "not a git repository"
941
+ CMD="${1:-status}"; shift 2>/dev/null || true
942
+ case "$CMD" in
943
+ status) cmd_status ;;
944
+ show) cmd_show "$@" ;;
945
+ claim) cmd_claim "$@" ;;
946
+ done) cmd_done "$@" ;;
947
+ drop) cmd_drop "$@" ;;
948
+ note) cmd_note "$@" ;;
949
+ add) cmd_add "$@" ;;
950
+ init) cmd_init "$@" ;;
951
+ probe) cmd_probe ;;
952
+ decide) cmd_decide "$@" ;;
953
+ decisions) cmd_decisions "$@" ;;
954
+ sync) cmd_sync ;;
955
+ cache) cmd_cache ;;
956
+ beat) cmd_beat ;;
957
+ off) cmd_off "$@" ;;
958
+ on) cmd_on "$@" ;;
959
+ gate) cmd_gate "$@" ;;
960
+ -h|--help|help) usage ;;
961
+ *) usage; exit 1 ;;
962
+ esac