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.
- package/CHANGELOG.md +3784 -0
- package/LICENSE +21 -0
- package/README.md +37 -7
- package/VERSION +1 -0
- package/adopt.sh +1670 -0
- package/bin/cli.js +497 -0
- package/kit/AGENT_TEMPLATE.md +92 -0
- package/kit/CLAUDE.md +228 -0
- package/kit/README.md +107 -0
- package/kit/agents/crew-backend-expert.md +95 -0
- package/kit/agents/crew-commit-agent.md +96 -0
- package/kit/agents/crew-database-expert.md +84 -0
- package/kit/agents/crew-devops-expert.md +82 -0
- package/kit/agents/crew-frontend-expert.md +85 -0
- package/kit/agents/crew-performance-expert.md +78 -0
- package/kit/agents/crew-planner.md +60 -0
- package/kit/agents/crew-privacy-agent.md +71 -0
- package/kit/agents/crew-review-agent.md +95 -0
- package/kit/agents/crew-security-expert.md +73 -0
- package/kit/agents/crew-session-manager.md +77 -0
- package/kit/agents/crew-test-expert.md +64 -0
- package/kit/eval/doctor.sh +465 -0
- package/kit/eval/gate-report.sh +137 -0
- package/kit/eval/golden-holdout.txt +53 -0
- package/kit/eval/golden-routing.txt +199 -0
- package/kit/eval/lib/crew-env.sh +14 -0
- package/kit/eval/lib/settings-json.awk +154 -0
- package/kit/eval/lib/star.sh +70 -0
- package/kit/eval/parser-conformance.sh +612 -0
- package/kit/eval/preflight.sh +186 -0
- package/kit/eval/routing-eval.sh +209 -0
- package/kit/eval/scan-skill.sh +162 -0
- package/kit/eval/smoke-test.sh +6693 -0
- package/kit/eval/update-guard.sh +81 -0
- package/kit/eval/utilization.sh +130 -0
- package/kit/hooks/board-sync.sh +106 -0
- package/kit/hooks/board.sh +962 -0
- package/kit/hooks/commit-msg +49 -0
- package/kit/hooks/context-usage.sh +353 -0
- package/kit/hooks/floor-blocklist.txt +124 -0
- package/kit/hooks/guard-bash.sh +1431 -0
- package/kit/hooks/guard-commit-scan.sh +511 -0
- package/kit/hooks/guard-write.sh +513 -0
- package/kit/hooks/pre-commit +577 -0
- package/kit/hooks/route-hint.sh +185 -0
- package/kit/hooks/secret-blocklist.txt +82 -0
- package/kit/hooks/session-guard.sh +149 -0
- package/kit/hooks/session-rehydrate.sh +41 -0
- package/kit/hooks/session-stats.sh +252 -0
- package/kit/hooks/session-update-check.sh +236 -0
- package/kit/hooks/skill-trust.sh +150 -0
- package/kit/hooks/trace-blocklist.txt +53 -0
- package/kit/settings.json +132 -0
- package/kit/skills/a11y/SKILL.md +44 -0
- package/kit/skills/adr/SKILL.md +74 -0
- package/kit/skills/api-design/SKILL.md +50 -0
- package/kit/skills/automode-policy/SKILL.md +91 -0
- package/kit/skills/automode-policy/references/policy.json +13 -0
- package/kit/skills/automode-policy/scripts/apply.sh +84 -0
- package/kit/skills/automode-policy/scripts/check.sh +102 -0
- package/kit/skills/backend-architecture/SKILL.md +103 -0
- package/kit/skills/brainstorm/SKILL.md +47 -0
- package/kit/skills/ci-pipeline/SKILL.md +38 -0
- package/kit/skills/commit-message/SKILL.md +82 -0
- package/kit/skills/commit-message/references/examples.md +8 -0
- package/kit/skills/confidence-check/SKILL.md +86 -0
- package/kit/skills/crew-board/SKILL.md +55 -0
- package/kit/skills/crew-brainstorm/SKILL.md +19 -0
- package/kit/skills/crew-code-review/SKILL.md +151 -0
- package/kit/skills/crew-code-review/references/history-search.md +19 -0
- package/kit/skills/crew-code-review/references/panel-mode.md +32 -0
- package/kit/skills/crew-code-review/references/receiving.md +23 -0
- package/kit/skills/crew-doctor/SKILL.md +36 -0
- package/kit/skills/crew-gates/SKILL.md +25 -0
- package/kit/skills/crew-handoff/SKILL.md +14 -0
- package/kit/skills/crew-plan/SKILL.md +18 -0
- package/kit/skills/crew-review/SKILL.md +23 -0
- package/kit/skills/crew-ship/SKILL.md +19 -0
- package/kit/skills/crew-skill/SKILL.md +61 -0
- package/kit/skills/crew-studio/SKILL.md +129 -0
- package/kit/skills/crew-update/SKILL.md +43 -0
- package/kit/skills/db-migration/SKILL.md +120 -0
- package/kit/skills/db-migration/references/backup-restore.md +33 -0
- package/kit/skills/db-migration/references/raw-sql.md +30 -0
- package/kit/skills/db-migration/references/tool-matrix.md +35 -0
- package/kit/skills/dependency-audit/SKILL.md +61 -0
- package/kit/skills/dependency-upgrade/SKILL.md +82 -0
- package/kit/skills/deploy/SKILL.md +100 -0
- package/kit/skills/deploy/references/proxy-ssl.md +56 -0
- package/kit/skills/deploy/references/rollback.md +18 -0
- package/kit/skills/deploy/references/scripts.md +9 -0
- package/kit/skills/deploy/references/topology-a.md +128 -0
- package/kit/skills/deploy/references/topology-b.md +44 -0
- package/kit/skills/docs-writer/SKILL.md +53 -0
- package/kit/skills/docs-writer/references/coauthoring.md +34 -0
- package/kit/skills/eval-grader/SKILL.md +75 -0
- package/kit/skills/eval-grader/references/method.md +51 -0
- package/kit/skills/frontend/SKILL.md +56 -0
- package/kit/skills/frontend/references/verify-contract.md +41 -0
- package/kit/skills/frontend-design/SKILL.md +71 -0
- package/kit/skills/frontend-design/references/design-review.md +34 -0
- package/kit/skills/frontend-rn-expo/SKILL.md +25 -0
- package/kit/skills/handoff/SKILL.md +71 -0
- package/kit/skills/handoff/references/cold-reader.md +51 -0
- package/kit/skills/i18n-integrity/SKILL.md +31 -0
- package/kit/skills/incident-runbook/SKILL.md +40 -0
- package/kit/skills/incident-runbook/references/postmortem.md +16 -0
- package/kit/skills/iterate/SKILL.md +40 -0
- package/kit/skills/mcp-builder/SKILL.md +54 -0
- package/kit/skills/mcp-builder/references/building.md +71 -0
- package/kit/skills/observability/SKILL.md +50 -0
- package/kit/skills/performance/SKILL.md +38 -0
- package/kit/skills/performance/references/bottlenecks.md +10 -0
- package/kit/skills/privacy-compliance/SKILL.md +75 -0
- package/kit/skills/red-team/SKILL.md +48 -0
- package/kit/skills/reflect/SKILL.md +57 -0
- package/kit/skills/release/SKILL.md +44 -0
- package/kit/skills/security-scan/SKILL.md +99 -0
- package/kit/skills/security-scan/references/ai-agents.md +107 -0
- package/kit/skills/security-scan/references/fronts.md +99 -0
- package/kit/skills/security-scan/references/prompting.md +23 -0
- package/kit/skills/security-scan/references/reporting.md +75 -0
- package/kit/skills/security-scan/references/verify.md +131 -0
- package/kit/skills/sonarqube-check/SKILL.md +108 -0
- package/kit/skills/sonarqube-check/references/local-engines.md +32 -0
- package/kit/skills/sonarqube-check/references/stand-up-server.md +25 -0
- package/kit/skills/spec-planning/SKILL.md +92 -0
- package/kit/skills/systematic-debugging/SKILL.md +57 -0
- package/kit/skills/systematic-debugging/references/techniques.md +44 -0
- package/kit/skills/teamboard/SKILL.md +88 -0
- package/kit/skills/teamboard/references/setup.md +37 -0
- package/kit/skills/testing/SKILL.md +49 -0
- package/kit/skills/testing/references/flaky-triage.md +51 -0
- package/kit/skills/threat-model/SKILL.md +57 -0
- package/kit/skills/threat-model/references/interview.md +51 -0
- package/kit/skills/threat-model/references/schema.md +42 -0
- package/kit/skills/token-budget/SKILL.md +51 -0
- package/kit/skills/trace-scan/SKILL.md +43 -0
- package/kit/skills/worktree/SKILL.md +40 -0
- package/kit/studio/README.md +266 -0
- package/kit/studio/ensure-node.sh +471 -0
- package/kit/studio/package.json +14 -0
- package/kit/studio/server/hooks/studio-gate.sh +169 -0
- package/kit/studio/server/index.js +902 -0
- package/kit/studio/server/lib/crew-env.js +11 -0
- package/kit/studio/server/lib/fleet.js +146 -0
- package/kit/studio/server/lib/graph.js +533 -0
- package/kit/studio/server/lib/instance.js +135 -0
- package/kit/studio/server/lib/kit-telemetry.js +182 -0
- package/kit/studio/server/lib/kit.js +158 -0
- package/kit/studio/server/lib/palette.js +118 -0
- package/kit/studio/server/lib/peers.js +72 -0
- package/kit/studio/server/lib/permissions.js +150 -0
- package/kit/studio/server/lib/projects.js +294 -0
- package/kit/studio/server/lib/roster.js +191 -0
- package/kit/studio/server/lib/session.js +309 -0
- package/kit/studio/server/lib/terminal.js +91 -0
- package/kit/studio/server/lib/transcript.js +100 -0
- package/kit/studio/web/app.js +1098 -0
- package/kit/studio/web/canvas.js +1179 -0
- package/kit/studio/web/chat.js +543 -0
- package/kit/studio/web/index.html +86 -0
- package/kit/studio/web/md.js +110 -0
- package/kit/studio/web/storage-migrate.js +23 -0
- package/kit/studio/web/style.css +1453 -0
- package/package.json +46 -8
- package/start.sh +748 -0
- 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
|