muse-crew 0.3.0 → 0.4.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/API.md +22 -2
- package/docs/guide.md +1 -1
- package/lib/AGENTS.md +1 -0
- package/lib/orphan-sweep.sh +98 -25
- package/lib/test-orphan-sweep.sh +109 -0
- package/lib/worktree-lifecycle.sh +0 -1
- package/package.json +2 -2
- package/workflows/bugfix.js +96 -17
- package/workflows/chore.js +89 -17
- package/workflows/crew-dispatch.js +5 -3
- package/workflows/crew-init.js +39 -4
- package/workflows/standard.js +96 -17
package/API.md
CHANGED
|
@@ -16,7 +16,7 @@ Create a new task on the board.
|
|
|
16
16
|
|-------|------|----------|-------|
|
|
17
17
|
| `title` | string (1–160 chars) | yes | |
|
|
18
18
|
| `description` | string (≤ 5000) | no | Defaults to `""` |
|
|
19
|
-
| `project` | slug | no |
|
|
19
|
+
| `project` | slug | no | Implicit project resolution: an explicit `project` wins; otherwise the service resolves `config.default_project` (set by `setdefaultproject`, `crew-init`, or automatically for the first registered project); as a last resort it falls back to the legacy `"orchestra-dashboard"` project id. Agents filing tasks normally omit `project` and inherit the dashboard's default. |
|
|
20
20
|
| `workflow` | slug or null | no | Routing label (e.g. `standard`, `bugfix`, `chore`, `docs`) |
|
|
21
21
|
| `priority` | `high` · `normal` · `low` | no | Defaults to `normal` |
|
|
22
22
|
| `state` | `todo` · `in_progress` · `parked` · `done` | no | Defaults to `todo` |
|
|
@@ -143,19 +143,31 @@ Register a new project.
|
|
|
143
143
|
| `simultaneity` | integer (1–100) | no | Max concurrent tasks; defaults to 2 |
|
|
144
144
|
| `quiesced` | boolean | no | Start paused; defaults to false |
|
|
145
145
|
|
|
146
|
+
The first registered project automatically becomes the `default_project` (see State) when no default is set yet.
|
|
147
|
+
|
|
146
148
|
### `updateproject`
|
|
147
149
|
|
|
148
150
|
Update project fields. Only `id` is required; all others are optional patch fields. Same fields as `createproject`, except `simultaneity` minimum is 0 (for kill switch state).
|
|
149
151
|
|
|
150
152
|
**Context-change guard:** changing `repo_path`, `deploy_type`, or `deploy_slug` is blocked while any task in the project has an active run (an agent session with status `running` that started within the last hour): the call throws, because live runs keep the old project config and the change would split the project context mid-run. Wait for the runs to finish, or recover/park those tasks first, then retry.
|
|
151
153
|
|
|
154
|
+
### `setdefaultproject`
|
|
155
|
+
|
|
156
|
+
Set the dashboard's default project — the project the CLI and agents use implicitly when no explicit project is given. The dispatcher resolves a task's project as its explicit label first, then this default, then the first registered project as a last resort.
|
|
157
|
+
|
|
158
|
+
| Field | Type | Required | Notes |
|
|
159
|
+
|-------|------|----------|-------|
|
|
160
|
+
| `project_id` | slug | yes | Must be a registered project id, else the call throws `"Project not found."` |
|
|
161
|
+
|
|
162
|
+
Returns `{ "ok": true, "project_id": "<slug>" }`. Idempotent: sets the `default_project` config key via upsert.
|
|
163
|
+
|
|
152
164
|
### `deleteproject`
|
|
153
165
|
|
|
154
166
|
Remove a project registration.
|
|
155
167
|
|
|
156
168
|
| Field | Type | Required | Notes |
|
|
157
169
|
|-------|------|----------|-------|
|
|
158
|
-
| `id` | slug | yes | |
|
|
170
|
+
| `id` | slug | yes | If this project was the `default_project`, the default pointer is cleared so it never aims at a missing project. |
|
|
159
171
|
|
|
160
172
|
### `listprojects`
|
|
161
173
|
|
|
@@ -203,6 +215,14 @@ Read the full dashboard state: tasks, sessions, events, and projects. Used by wo
|
|
|
203
215
|
|
|
204
216
|
Read global configuration. Takes no arguments.
|
|
205
217
|
|
|
218
|
+
**Known keys:**
|
|
219
|
+
|
|
220
|
+
| Key | Value | Set by |
|
|
221
|
+
|-----|-------|--------|
|
|
222
|
+
| `default_project` | project slug | `setdefaultproject` (explicit upsert), `createproject` (first project only), `crew-init` (the dashboard's own project) |
|
|
223
|
+
|
|
224
|
+
`default_project` is the dashboard's configured default project: agents and the CLI inherit it when no explicit project is given. Deleting the project it points at clears it. No migration is needed — the key simply appears once set, and the dispatcher reads it from `getdispatchstate`'s `config` object.
|
|
225
|
+
|
|
206
226
|
### `updateconfig`
|
|
207
227
|
|
|
208
228
|
Set a global configuration value.
|
package/docs/guide.md
CHANGED
|
@@ -18,7 +18,7 @@ This is the full setup and operations reference. If you're new, start with the [
|
|
|
18
18
|
- An **`.orchestration/` directory** in the crew home with identities, personas, workflow docs, and feedback conventions.
|
|
19
19
|
- A **project registration** — the task service registered as its own first project.
|
|
20
20
|
- A **polling loop** — checks for work every 3 minutes via Muse's scheduling, even when nobody's in the conversation.
|
|
21
|
-
- An **orphan sweep** — runs every 30 minutes to clean merged worktrees and break stale merge locks.
|
|
21
|
+
- An **orphan sweep** — runs every 30 minutes to clean merged worktrees and break stale merge locks. It never touches worktrees belonging to tasks with a running dashboard session.
|
|
22
22
|
|
|
23
23
|
The agent running in the main chat receives the dispatcher's claims and launches each task workflow. Workflows can't launch workflows, so this handoff is structural.
|
|
24
24
|
|
package/lib/AGENTS.md
CHANGED
|
@@ -6,3 +6,4 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
|
|
|
6
6
|
- `merge-lock.sh` — serialized merge lock for concurrent agents; records owner PID
|
|
7
7
|
- `worktree-lifecycle.sh` — git worktree create/cleanup for isolated agent work
|
|
8
8
|
- `orphan-sweep.sh` — find and clean stale worktrees and merge locks
|
|
9
|
+
- `test-orphan-sweep.sh` — regression tests for orphan-sweep.sh (active-run guard, verified removal, fail-closed)
|
package/lib/orphan-sweep.sh
CHANGED
|
@@ -3,9 +3,24 @@
|
|
|
3
3
|
#
|
|
4
4
|
# Usage:
|
|
5
5
|
# orphan-sweep.sh report — list orphans (read-only for worktrees);
|
|
6
|
-
#
|
|
6
|
+
# stale locks with dead PIDs are released
|
|
7
7
|
# orphan-sweep.sh clean — remove safe-to-clean orphans (merged branches only)
|
|
8
|
-
# and
|
|
8
|
+
# and release stale merge locks
|
|
9
|
+
#
|
|
10
|
+
# Active-run knowledge is injected by the caller, never fetched here:
|
|
11
|
+
# CREW_ACTIVE_TASKS — space-separated task IDs with a running session
|
|
12
|
+
# CREW_ACTIVE_TASKS_FILE — path to a file with one task ID per line
|
|
13
|
+
# (blank lines and '#' comments are ignored)
|
|
14
|
+
# Both inputs feed one active set, matched exactly against worktree dir names.
|
|
15
|
+
# A task in the active set is never touched: its worktree and any merge lock
|
|
16
|
+
# it holds are skipped regardless of merge status, lock age, or PID liveness.
|
|
17
|
+
#
|
|
18
|
+
# Fail closed: clean mode without either input refuses to remove anything
|
|
19
|
+
# and exits 2. Report mode without either input still runs read-only but
|
|
20
|
+
# prints a banner noting the ACTIVE checks were skipped.
|
|
21
|
+
#
|
|
22
|
+
# Callers (the sweep cron body; worktree-lifecycle.sh cmd_sweep is a
|
|
23
|
+
# passthrough) must export CREW_ACTIVE_TASKS.
|
|
9
24
|
#
|
|
10
25
|
# "Safe to clean" means the task branch is fully merged into main.
|
|
11
26
|
# Dirty or unmerged worktrees are always preserved and reported.
|
|
@@ -19,33 +34,74 @@ STALE_LOCK_MIN=30
|
|
|
19
34
|
|
|
20
35
|
cmd="${1:-report}"
|
|
21
36
|
found=0
|
|
37
|
+
any_failed=0
|
|
38
|
+
|
|
39
|
+
# --- Active-run set (union of both inputs) ---
|
|
40
|
+
have_active_data=0
|
|
41
|
+
ACTIVE_TASKS=""
|
|
42
|
+
if [ -n "${CREW_ACTIVE_TASKS+set}" ]; then
|
|
43
|
+
have_active_data=1
|
|
44
|
+
ACTIVE_TASKS="${ACTIVE_TASKS} ${CREW_ACTIVE_TASKS}"
|
|
45
|
+
fi
|
|
46
|
+
if [ -n "${CREW_ACTIVE_TASKS_FILE+set}" ]; then
|
|
47
|
+
if [ -r "${CREW_ACTIVE_TASKS_FILE}" ]; then
|
|
48
|
+
have_active_data=1
|
|
49
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
50
|
+
case "$line" in ''|\#*) continue ;; esac
|
|
51
|
+
ACTIVE_TASKS="${ACTIVE_TASKS} ${line}"
|
|
52
|
+
done < "${CREW_ACTIVE_TASKS_FILE}"
|
|
53
|
+
fi
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
task_is_active() {
|
|
57
|
+
[ -n "${1:-}" ] || return 1
|
|
58
|
+
case " ${ACTIVE_TASKS} " in
|
|
59
|
+
*" $1 "*) return 0 ;;
|
|
60
|
+
*) return 1 ;;
|
|
61
|
+
esac
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if [ "$cmd" = "clean" ] && [ "$have_active_data" -eq 0 ]; then
|
|
65
|
+
echo "BLOCKED: clean mode requires the active-run list (set CREW_ACTIVE_TASKS or CREW_ACTIVE_TASKS_FILE) — refusing to remove anything"
|
|
66
|
+
exit 2
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
if [ "$cmd" != "clean" ] && [ "$have_active_data" -eq 0 ]; then
|
|
70
|
+
echo "NOTE: no active-run data provided; ACTIVE checks skipped"
|
|
71
|
+
fi
|
|
22
72
|
|
|
23
73
|
# --- Stale merge lock ---
|
|
24
74
|
if [ -f "$LOCK_FILE" ]; then
|
|
25
75
|
lock_holder=$(cut -d' ' -f1 "$LOCK_FILE" 2>/dev/null || echo "unknown")
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if [
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
76
|
+
if task_is_active "$lock_holder"; then
|
|
77
|
+
echo "ACTIVE_LOCK: held by $lock_holder (active run on dashboard) — skipping"
|
|
78
|
+
found=1
|
|
79
|
+
else
|
|
80
|
+
lock_time=$(cut -d' ' -f2 "$LOCK_FILE" 2>/dev/null || echo "")
|
|
81
|
+
lock_pid=$(awk '{print $3}' "$LOCK_FILE" 2>/dev/null || echo "")
|
|
82
|
+
if [ -n "$lock_time" ]; then
|
|
83
|
+
lock_epoch=$(date -d "$lock_time" +%s 2>/dev/null || echo 0)
|
|
84
|
+
now_epoch=$(date -u +%s)
|
|
85
|
+
age_min=$(( (now_epoch - lock_epoch) / 60 ))
|
|
86
|
+
if [ "$age_min" -gt "$STALE_LOCK_MIN" ]; then
|
|
87
|
+
# Check if owner process is still alive
|
|
88
|
+
pid_alive=0
|
|
89
|
+
if [ -n "$lock_pid" ] && [ "$lock_pid" != "-" ] && kill -0 "$lock_pid" 2>/dev/null; then
|
|
90
|
+
pid_alive=1
|
|
91
|
+
fi
|
|
38
92
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
else
|
|
42
|
-
echo "STALE_LOCK: held ${age_min}m by $lock_holder (pid ${lock_pid:-none} dead) — threshold ${STALE_LOCK_MIN}m"
|
|
43
|
-
found=1
|
|
44
|
-
rm -f "$LOCK_FILE"
|
|
45
|
-
if [ ! -f "$LOCK_FILE" ]; then
|
|
46
|
-
echo " → released (dead PID)"
|
|
93
|
+
if [ "$pid_alive" -eq 1 ]; then
|
|
94
|
+
echo "ACTIVE_LOCK: held ${age_min}m by $lock_holder (pid $lock_pid alive) — skipping"
|
|
47
95
|
else
|
|
48
|
-
echo "
|
|
96
|
+
echo "STALE_LOCK: held ${age_min}m by $lock_holder (pid ${lock_pid:-none} dead) — threshold ${STALE_LOCK_MIN}m"
|
|
97
|
+
found=1
|
|
98
|
+
rm -f "$LOCK_FILE"
|
|
99
|
+
if [ ! -f "$LOCK_FILE" ]; then
|
|
100
|
+
echo " → released (dead PID)"
|
|
101
|
+
else
|
|
102
|
+
echo " → FAILED: lock file still present after rm"
|
|
103
|
+
any_failed=1
|
|
104
|
+
fi
|
|
49
105
|
fi
|
|
50
106
|
fi
|
|
51
107
|
fi
|
|
@@ -67,19 +123,31 @@ if [ -d "$WORKTREE_DIR" ]; then
|
|
|
67
123
|
|
|
68
124
|
branch="task/$task_id"
|
|
69
125
|
|
|
126
|
+
found=1
|
|
127
|
+
|
|
128
|
+
# Active runs are never touched, regardless of merge status.
|
|
129
|
+
if task_is_active "$task_id"; then
|
|
130
|
+
echo "ACTIVE: $task_id — dashboard shows a running session, skipped"
|
|
131
|
+
continue
|
|
132
|
+
fi
|
|
133
|
+
|
|
70
134
|
# Is the branch merged into main?
|
|
71
135
|
merged=$(git branch --merged main 2>/dev/null | sed 's/^[* +]*//' | grep -Fx "$branch" || true)
|
|
72
136
|
|
|
73
137
|
# Is the worktree dirty?
|
|
74
138
|
dirty=$(cd "$wt" && git status --porcelain 2>/dev/null | wc -l)
|
|
75
139
|
|
|
76
|
-
found=1
|
|
77
140
|
if [ -n "$merged" ]; then
|
|
78
141
|
echo "MERGED: $task_id — branch merged into main, safe to remove"
|
|
79
142
|
if [ "$cmd" = "clean" ]; then
|
|
80
143
|
git worktree remove "$wt" 2>/dev/null || true
|
|
81
144
|
git branch -d "$branch" 2>/dev/null || true
|
|
82
|
-
|
|
145
|
+
if [ ! -d "$wt" ] && ! git show-ref --verify --quiet "refs/heads/$branch"; then
|
|
146
|
+
echo " → removed"
|
|
147
|
+
else
|
|
148
|
+
echo " → FAILED: removal attempted but worktree/branch still present, left in place"
|
|
149
|
+
any_failed=1
|
|
150
|
+
fi
|
|
83
151
|
fi
|
|
84
152
|
elif [ "$dirty" -gt 0 ]; then
|
|
85
153
|
echo "DIRTY: $task_id — $dirty uncommitted changes, preserved"
|
|
@@ -92,3 +160,8 @@ fi
|
|
|
92
160
|
if [ "$found" -eq 0 ]; then
|
|
93
161
|
echo "CLEAN: no orphans, no stale locks"
|
|
94
162
|
fi
|
|
163
|
+
|
|
164
|
+
if [ "$any_failed" -eq 1 ]; then
|
|
165
|
+
exit 1
|
|
166
|
+
fi
|
|
167
|
+
exit 0
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test-orphan-sweep.sh — regression tests for orphan-sweep.sh
|
|
3
|
+
#
|
|
4
|
+
# Self-contained: builds throwaway git fixtures in a temp dir and drives
|
|
5
|
+
# lib/orphan-sweep.sh through the CREW_ACTIVE_TASKS / CREW_ACTIVE_TASKS_FILE
|
|
6
|
+
# seam (no network, no dashboard). Exits 0 only if every case passes.
|
|
7
|
+
|
|
8
|
+
set -uo pipefail
|
|
9
|
+
|
|
10
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
11
|
+
SWEEP="$SCRIPT_DIR/orphan-sweep.sh"
|
|
12
|
+
|
|
13
|
+
pass=0
|
|
14
|
+
fail=0
|
|
15
|
+
ok() { echo "PASS: $1"; pass=$((pass + 1)); }
|
|
16
|
+
no() { echo "FAIL: $1"; fail=$((fail + 1)); }
|
|
17
|
+
|
|
18
|
+
TMPBASE="$(mktemp -d)"
|
|
19
|
+
trap 'rm -rf "$TMPBASE"' EXIT
|
|
20
|
+
|
|
21
|
+
# new_fixture <name> — fresh git repo, one commit on main; exports CREW_REPO.
|
|
22
|
+
new_fixture() {
|
|
23
|
+
local dir="$TMPBASE/$1"
|
|
24
|
+
mkdir -p "$dir"
|
|
25
|
+
git init -q -b main "$dir" >/dev/null
|
|
26
|
+
git -C "$dir" config user.email "test@example.com"
|
|
27
|
+
git -C "$dir" config user.name "test"
|
|
28
|
+
echo base > "$dir/seed.txt"
|
|
29
|
+
git -C "$dir" add seed.txt
|
|
30
|
+
git -C "$dir" commit -qm "initial"
|
|
31
|
+
export CREW_REPO="$dir"
|
|
32
|
+
cd "$dir" || exit 1
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# merged_worktree <task_id> — worktree whose branch gets a real merge commit.
|
|
36
|
+
merged_worktree() {
|
|
37
|
+
local t="$1"
|
|
38
|
+
git worktree add -q ".worktrees/$t" -b "task/$t" >/dev/null
|
|
39
|
+
echo "$t" > ".worktrees/$t/work.txt"
|
|
40
|
+
git -C ".worktrees/$t" add work.txt
|
|
41
|
+
git -C ".worktrees/$t" commit -qm "work for $t"
|
|
42
|
+
git merge -q --no-ff "task/$t" -m "merge $t" >/dev/null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# --- Case 1: live run protected (the incident's exact case) ---
|
|
46
|
+
# T1's branch tip equals main (no commits — "merged" by equality), clean tree.
|
|
47
|
+
new_fixture case1
|
|
48
|
+
git worktree add -q .worktrees/T1 -b task/T1 >/dev/null
|
|
49
|
+
out=$(CREW_ACTIVE_TASKS="T1" "$SWEEP" clean 2>&1); code=$?
|
|
50
|
+
[ "$code" -eq 0 ] && ok "1: clean exits 0 with only an active run present" || no "1: exit $code, want 0"
|
|
51
|
+
[ -d .worktrees/T1 ] && ok "1: active worktree dir preserved" || no "1: active worktree dir removed"
|
|
52
|
+
git show-ref --verify --quiet refs/heads/task/T1 && ok "1: active branch preserved" || no "1: active branch deleted"
|
|
53
|
+
echo "$out" | grep -q "ACTIVE: T1" && ok "1: ACTIVE line printed" || no "1: no ACTIVE line in output"
|
|
54
|
+
|
|
55
|
+
# --- Case 2: dead run cleaned ---
|
|
56
|
+
new_fixture case2
|
|
57
|
+
merged_worktree T2
|
|
58
|
+
out=$(CREW_ACTIVE_TASKS="" "$SWEEP" clean 2>&1); code=$?
|
|
59
|
+
[ "$code" -eq 0 ] && ok "2: clean exits 0" || no "2: exit $code, want 0"
|
|
60
|
+
[ ! -d .worktrees/T2 ] && ok "2: merged worktree dir removed" || no "2: merged worktree dir still present"
|
|
61
|
+
git show-ref --verify --quiet refs/heads/task/T2 && no "2: merged branch still present" || ok "2: merged branch deleted"
|
|
62
|
+
echo "$out" | grep -q "→ removed" && ok "2: removal reported" || no "2: no removal line in output"
|
|
63
|
+
|
|
64
|
+
# --- Case 3: fail closed ---
|
|
65
|
+
new_fixture case3
|
|
66
|
+
merged_worktree T3
|
|
67
|
+
out=$(env -u CREW_ACTIVE_TASKS -u CREW_ACTIVE_TASKS_FILE "$SWEEP" clean 2>&1); code=$?
|
|
68
|
+
[ "$code" -eq 2 ] && ok "3: clean without active data exits 2" || no "3: exit $code, want 2"
|
|
69
|
+
echo "$out" | grep -q "BLOCKED" && ok "3: BLOCKED line printed" || no "3: no BLOCKED line in output"
|
|
70
|
+
[ -d .worktrees/T3 ] && ok "3: worktree left alone (dir)" || no "3: worktree removed despite BLOCKED"
|
|
71
|
+
git show-ref --verify --quiet refs/heads/task/T3 && ok "3: worktree left alone (branch)" || no "3: branch deleted despite BLOCKED"
|
|
72
|
+
|
|
73
|
+
# --- Case 4: false report impossible (forced removal failure via worktree lock) ---
|
|
74
|
+
new_fixture case4
|
|
75
|
+
merged_worktree T4
|
|
76
|
+
git worktree lock .worktrees/T4
|
|
77
|
+
out=$(CREW_ACTIVE_TASKS="" "$SWEEP" clean 2>&1); code=$?
|
|
78
|
+
[ "$code" -eq 1 ] && ok "4: clean with failed removal exits 1" || no "4: exit $code, want 1"
|
|
79
|
+
echo "$out" | grep -q "→ FAILED" && ok "4: FAILED line printed (no false 'removed')" || no "4: no FAILED line — false report!"
|
|
80
|
+
[ -d .worktrees/T4 ] && ok "4: failed worktree left in place" || no "4: worktree gone despite failed removal"
|
|
81
|
+
git worktree unlock .worktrees/T4 2>/dev/null || true
|
|
82
|
+
|
|
83
|
+
# --- Case 5: lock path ---
|
|
84
|
+
new_fixture case5
|
|
85
|
+
mkdir -p .worktrees
|
|
86
|
+
printf 'T5 2020-01-01T00:00:00Z 999999\n' > .worktrees/.merge-lock
|
|
87
|
+
out=$(CREW_ACTIVE_TASKS="" "$SWEEP" clean 2>&1); code=$?
|
|
88
|
+
[ ! -f .worktrees/.merge-lock ] && ok "5a: stale lock released" || no "5a: stale lock file still present"
|
|
89
|
+
echo "$out" | grep -q "released (dead PID)" && ok "5a: release reported" || no "5a: no release line in output"
|
|
90
|
+
[ "$code" -eq 0 ] && ok "5a: exit 0" || no "5a: exit $code, want 0"
|
|
91
|
+
|
|
92
|
+
printf 'T6 2020-01-01T00:00:00Z 999999\n' > .worktrees/.merge-lock
|
|
93
|
+
out=$(CREW_ACTIVE_TASKS="T6" "$SWEEP" clean 2>&1); code=$?
|
|
94
|
+
[ -f .worktrees/.merge-lock ] && ok "5b: active holder's lock not released" || no "5b: active holder's lock was released"
|
|
95
|
+
echo "$out" | grep -q "ACTIVE_LOCK" && ok "5b: ACTIVE_LOCK line printed" || no "5b: no ACTIVE_LOCK line in output"
|
|
96
|
+
[ "$code" -eq 0 ] && ok "5b: exit 0" || no "5b: exit $code, want 0"
|
|
97
|
+
|
|
98
|
+
# --- Case 6: report mode without active data ---
|
|
99
|
+
new_fixture case6
|
|
100
|
+
merged_worktree T7
|
|
101
|
+
out=$(env -u CREW_ACTIVE_TASKS -u CREW_ACTIVE_TASKS_FILE "$SWEEP" report 2>&1); code=$?
|
|
102
|
+
[ "$code" -eq 0 ] && ok "6: report exits 0" || no "6: exit $code, want 0"
|
|
103
|
+
echo "$out" | grep -q "NOTE: no active-run data provided; ACTIVE checks skipped" && ok "6: NOTE banner printed" || no "6: no NOTE banner in output"
|
|
104
|
+
[ -d .worktrees/T7 ] && ok "6: report mode removes nothing" || no "6: report mode removed a worktree"
|
|
105
|
+
echo "$out" | grep -q "MERGED: T7" && ok "6: MERGED line still listed" || no "6: MERGED line missing from report"
|
|
106
|
+
|
|
107
|
+
echo ""
|
|
108
|
+
echo "== $pass passed, $fail failed =="
|
|
109
|
+
[ "$fail" -eq 0 ]
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muse-crew",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Opinionated orchestration for Muse
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "Opinionated orchestration for Muse \u2014 workflows, identities, and tooling for autonomous software development.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
7
7
|
"repository": {
|
package/workflows/bugfix.js
CHANGED
|
@@ -78,6 +78,24 @@ const MAX_TOTAL_REWORK = 2;
|
|
|
78
78
|
let totalReworkCount = 0;
|
|
79
79
|
let rejectionNotes = inputs.rejection_notes || "";
|
|
80
80
|
let mapperSpec = "";
|
|
81
|
+
// Merge-time versioning: the release decision is extracted deterministically
|
|
82
|
+
// from the accepted Build summary (extractReleaseDecision) so the Publish
|
|
83
|
+
// agent never decides whether to publish. Two consecutive Publish runs
|
|
84
|
+
// rationalized a skip against explicit instruction text — text alone did not
|
|
85
|
+
// hold, so the decision now lives in workflow code, not agent judgment.
|
|
86
|
+
let releaseDecision = null; // { release: "yes"|"no", version_bump: "patch"|"minor"|"major"|null }
|
|
87
|
+
function extractReleaseDecision(text) {
|
|
88
|
+
const t = text || "";
|
|
89
|
+
const r = /^release:\s*(yes|no)\s*$/im.exec(t);
|
|
90
|
+
if (!r) return null;
|
|
91
|
+
const b = /^version_bump:\s*(patch|minor|major)\s*$/im.exec(t);
|
|
92
|
+
if (r[1].toLowerCase() === "yes" && !b) return null;
|
|
93
|
+
return { release: r[1].toLowerCase(), version_bump: b ? b[1].toLowerCase() : null };
|
|
94
|
+
}
|
|
95
|
+
function releaseDecisionText() {
|
|
96
|
+
if (!releaseDecision) return "no parseable release:/version_bump: decision";
|
|
97
|
+
return "release: " + releaseDecision.release + (releaseDecision.version_bump ? ", version_bump: " + releaseDecision.version_bump : " (no version_bump line)");
|
|
98
|
+
}
|
|
81
99
|
let i = startStepIndex;
|
|
82
100
|
|
|
83
101
|
// Pin lifecycle scripts
|
|
@@ -150,6 +168,28 @@ while (i < STEPS.length) {
|
|
|
150
168
|
return { status: "blocked", task_id: taskId, reason: "Unknown publish target '" + PUBLISH_TYPE + "' — expected 'npm', 'artifact', 'vercel', or empty (skip publish)." };
|
|
151
169
|
}
|
|
152
170
|
|
|
171
|
+
// Merge-time versioning, decided deterministically: the release decision was
|
|
172
|
+
// extracted from the accepted Build summary above. release: no never reaches
|
|
173
|
+
// the Publish agent (the workflow skips it here, exactly like the no-target
|
|
174
|
+
// case); an unparseable decision blocks fail-closed. The Publish agent below
|
|
175
|
+
// therefore has no decision point to rationalize into a skip.
|
|
176
|
+
if (step.name === "Publish" && PUBLISH_TYPE === "npm") {
|
|
177
|
+
if (!releaseDecision) {
|
|
178
|
+
return { status: "blocked", task_id: taskId, reason: "Build summary has no parseable release:/version_bump: lines — cannot assign version at publish time." };
|
|
179
|
+
}
|
|
180
|
+
if (releaseDecision.release === "no") {
|
|
181
|
+
log("Publish skipped for task " + taskId + " — accepted Build summary declared release: no");
|
|
182
|
+
await agent(
|
|
183
|
+
"Release the merge lock and clean up without publishing.\n" +
|
|
184
|
+
"Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
|
|
185
|
+
"If the output contains DEPLOYED, the lock is released and the worktree is cleaned up.",
|
|
186
|
+
{ key: "publish-skip-release-no", label: "Skipping Publish (release: no)", schema: { type: "object" } }
|
|
187
|
+
);
|
|
188
|
+
i++;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
153
193
|
// Claim session
|
|
154
194
|
let activeSessionId;
|
|
155
195
|
if (isFirstClaim && firstSessionId) {
|
|
@@ -204,7 +244,10 @@ while (i < STEPS.length) {
|
|
|
204
244
|
"Edit the TypeScript source files directly. Do NOT use artifact_edit — that happens in the Publish phase.\n" +
|
|
205
245
|
"Do not add unrequested features. Build exactly what the spec calls for.\n" +
|
|
206
246
|
"PUBLIC DOCS: If your change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), update the public docs in the same commit — API.md for API changes. Documentation and implementation ship together.\n\n" +
|
|
207
|
-
(PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry
|
|
247
|
+
(PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry. Versions are assigned at PUBLISH time — never in your branch. Do NOT touch the `version` field in package.json (or package-lock). Instead, end your summary with exactly these two lines:\n" +
|
|
248
|
+
"release: yes|no — 'yes' if this change warrants a published release (anything a consumer can observe: workflow behavior, phase lists, identities, published docs, API); 'no' if internal-only.\n" +
|
|
249
|
+
"version_bump: patch|minor|major — patch for fixes (default), minor for new behavior, major for breaking changes. Omit this line only when release is no.\n" +
|
|
250
|
+
"Example: release: yes\\nversion_bump: minor\n\n" : "") +
|
|
208
251
|
"STEP 3: Commit your changes.\n" +
|
|
209
252
|
"cd " + REPO_PATH + "/.worktrees/" + taskId + "\n" +
|
|
210
253
|
"git add -A\n" +
|
|
@@ -224,7 +267,12 @@ while (i < STEPS.length) {
|
|
|
224
267
|
"Check quality, correctness, and spec compliance.\n" +
|
|
225
268
|
"Check that public-affecting changes have matching public doc updates (API.md or the published API contract). If the docs are missing or inaccurate, reject with notes on what is stale.\n" +
|
|
226
269
|
"If the branch has no commits ahead of main (inspect shows an empty commit log), approve ONLY if the Build summary declares `repo_diff: none` with a plausible runtime-state deliverable (e.g. a cron created via the cron tool). Otherwise reject: 'no commits ahead of main and no repo_diff: none declaration — the builder likely forgot to commit'.\n" +
|
|
227
|
-
(PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry
|
|
270
|
+
(PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry, and versions are assigned at publish time — never in branches. Two checks:\n" +
|
|
271
|
+
"(a) The task branch must NOT have changed package.json's `version` field. Check: cd " + REPO_PATH + " && git diff main...task/" + taskId + " -- package.json. If the branch touched `version` in any way, REJECT with notes: 'versions are assigned at publish time, never in branches — remove the version change'.\n" +
|
|
272
|
+
"(b) The accepted Build summary declares: " + releaseDecisionText() + ". " +
|
|
273
|
+
(releaseDecision
|
|
274
|
+
? "Validate this decision against the change: release must be 'yes' when the change is consumer-observable and 'no' when internal-only; the version_bump scope must fit the change (patch for fixes, minor for new behavior, major for breaking changes). If the decision is wrong or mis-scoped, reject with notes."
|
|
275
|
+
: "The decision is missing or malformed — REJECT with notes: 'Build summary must end with release: yes|no and (when release is yes) version_bump: patch|minor|major lines'.") + "\n" : "") +
|
|
228
276
|
"If the work passes review, your final response MUST be valid JSON and nothing else: { \"passed\": true, \"summary\": \"approval notes\" }.\n" +
|
|
229
277
|
"If the work fails review, your final response MUST be valid JSON and nothing else: { \"passed\": false, \"summary\": \"rejection notes explaining what needs to change\" }.\n" +
|
|
230
278
|
"No prose, no markdown, just the JSON object.";
|
|
@@ -236,7 +284,15 @@ while (i < STEPS.length) {
|
|
|
236
284
|
"- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
|
|
237
285
|
"- If it contains MERGED_EMPTY, the branch had no commits ahead of main (a runtime-state deliverable, declared by Build as repo_diff: none). Integration succeeded vacuously: the merge lock was NOT taken and there is no new commit. Set passed to true with summary 'merged empty: no repo changes — deliverable was runtime state'. SKIP STEP 2 (push): there is no new commit to push.\n" +
|
|
238
286
|
"- If it contains LOCK_HELD, another task holds the merge lock (mid Integrate/Publish). Set passed to false.\n" +
|
|
239
|
-
"- If it contains CONFLICT,
|
|
287
|
+
"- If it contains CONFLICT, the plain merge failed — the merge was aborted, main is clean, and your task still holds the merge lock. Do NOT fail yet. Resolve it:\n" +
|
|
288
|
+
"RESOLUTION:\n" +
|
|
289
|
+
"R1. Refresh the merge lock FIRST (a long resolution must not silently lose the lock to the orphan sweep): CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + ". Create a scratch worktree WITH A NEW BRANCH (main is already checked out in the repo checkout, so git forbids checking it out a second time): cd " + REPO_PATH + " && git worktree add -b resolve/" + taskId + " /tmp/crew-resolve-" + taskId + " main. Reproduce the conflict in the scratch worktree: cd /tmp/crew-resolve-" + taskId + " && git merge task/" + taskId + ". This reproduces the exact conflict (main has not moved — the lock was held throughout). The task branch task/" + taskId + " is never modified.\n" +
|
|
290
|
+
"R2. For each conflicted file, read the three sides: git show :1:<file> (base), git show :2:<file> (ours = main), git show :3:<file> (theirs = task branch). Resolve each hunk by keeping both sides' changes when they do not semantically overlap. Version-only hunks resolve to the higher semver (safety net). Never invent new behavior. Leave no markers.\n" +
|
|
291
|
+
"R3. Verify in the scratch worktree, in this order: (a) git diff --check is clean; (b) git grep -n '^<<<<<<<' -- . returns nothing; (c) node --check every changed .js file (conflicted files plus everything listed by git diff --name-only). If any check fails, refresh the merge lock (CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "), then retry the resolution using the failure output as context — max 3 attempts total.\n" +
|
|
292
|
+
"R4. Commit the resolution on resolve/" + taskId + ": git add -A && git commit -m \"resolve conflicts: " + taskId + "\".\n" +
|
|
293
|
+
"R5. Back in " + REPO_PATH + ": git checkout main && git merge --ff-only resolve/" + taskId + ". This fast-forwards — main has not moved while the lock was held. Then STEP 2 applies: git push origin main. Report the merged commit hash.\n" +
|
|
294
|
+
"R6. Clean up: cd " + REPO_PATH + " && git worktree remove --force /tmp/crew-resolve-" + taskId + " && git branch -D resolve/" + taskId + ".\n" +
|
|
295
|
+
"ESCALATE — return {\"passed\": false, \"summary\": \"conflict needs human resolution\"} — when: 3 attempts are exhausted; the conflict touches generated files, migrations, or public API contracts; or 'looks right + checks pass' is not sufficient for any other reason. On escalation, RELEASE THE LOCK so the task can be reworked later: run CREW_REPO=" + REPO_PATH + " " + MERGE_LOCK + " release " + taskId + " (release is keyed on task id; no PID needed). Do NOT run post-deploy on escalation — it would delete the untouched task branch the human still needs.\n" +
|
|
240
296
|
"- If it contains ERROR, something else failed. Set passed to false.\n\n" +
|
|
241
297
|
"\n" +
|
|
242
298
|
"STEP 2: Push the merged main to the remote repository.\n" +
|
|
@@ -250,28 +306,34 @@ while (i < STEPS.length) {
|
|
|
250
306
|
} else if (step.name === "Publish") {
|
|
251
307
|
if (PUBLISH_TYPE === "npm") {
|
|
252
308
|
// npm packages: immutable release + pack + publish to the registry (push is universal in Integrate)
|
|
309
|
+
// The release decision arrived deterministically from the workflow (release: yes) —
|
|
310
|
+
// these steps are unconditional. There is no decision to make and no skip path.
|
|
253
311
|
instructions = "Publish the npm package to the registry.\n\n" +
|
|
254
|
-
"The
|
|
255
|
-
"
|
|
312
|
+
"The release decision is already made and recorded — it is not yours to make: the accepted Build summary (validated by Review) declares " + releaseDecisionText() + ". Execute every step below in order.\n\n" +
|
|
313
|
+
"The repo push already happened in Integrate — do NOT push to git in this phase except STEP 7, and NEVER force-push.\n\n" +
|
|
256
314
|
"STEP 0: Refresh the merge lock to prevent stale-lock breaking during publish.\n" +
|
|
257
315
|
"Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "\n\n" +
|
|
258
316
|
"STEP 1: Install and activate the immutable release.\n" +
|
|
259
317
|
"Run: " + RELEASE_SCRIPT + " deploy " + REPO_PATH + "\n" +
|
|
260
318
|
"Verify the output contains INSTALLED and ACTIVATED (or EXISTS and ACTIVATED if unchanged).\n\n" +
|
|
261
|
-
"STEP 2:
|
|
262
|
-
"
|
|
263
|
-
"
|
|
264
|
-
"
|
|
265
|
-
"STEP
|
|
319
|
+
"STEP 2: Read the registry base version.\n" +
|
|
320
|
+
"Run: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
|
|
321
|
+
"If NOT_FOUND, use the local package.json version as the base instead.\n\n" +
|
|
322
|
+
"STEP 3: Apply the version_bump scope (" + releaseDecision.version_bump + ") to the base version: patch increments the last segment; minor increments the middle and resets the last to 0; major increments the first and resets the rest to 0. Example: base 1.2.3 + minor → 1.3.0. Call the result <new-version>.\n" +
|
|
323
|
+
"STEP 4: Write <new-version> into package.json (only the `version` field), then commit it under the still-held merge lock: cd " + REPO_PATH + " && git add package.json && git commit -m \"release: muse-crew@<new-version>\". The lock serializes Publish per repo, so two tasks can never pick the same version.\n" +
|
|
324
|
+
"STEP 5: Pack and publish.\n" +
|
|
266
325
|
"Run: cd " + REPO_PATH + " && npm pack\n" +
|
|
267
|
-
"Then publish: python3 ~/workspace/skills/npm/bin/npm-publish.py " + REPO_PATH + "/muse-crew
|
|
268
|
-
"If publish fails with 'You cannot publish over the previously published versions',
|
|
269
|
-
"STEP
|
|
326
|
+
"Then publish: python3 ~/workspace/skills/npm/bin/npm-publish.py " + REPO_PATH + "/muse-crew-<new-version>.tgz\n" +
|
|
327
|
+
"If publish fails with 'You cannot publish over the previously published versions', <new-version> is already on the registry (a retried Publish — the merge lock guarantees no other task picked this version): continue to STEP 6 verification. Any other publish failure: set passed to false with the failure details.\n\n" +
|
|
328
|
+
"STEP 6: Verify.\n" +
|
|
270
329
|
"Run: npm view muse-crew version\n" +
|
|
271
|
-
"
|
|
272
|
-
"STEP
|
|
330
|
+
"It must equal <new-version>. If not, set passed to false with the mismatch details.\n\n" +
|
|
331
|
+
"STEP 7: Push the version-bump commit: cd " + REPO_PATH + " && git push origin main. Never use --force.\n\n" +
|
|
332
|
+
"STEP 8: Finalize.\n" +
|
|
273
333
|
"Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
|
|
274
334
|
"If the output contains DEPLOYED, finalization is complete.\n\n" +
|
|
335
|
+
"Do NOT compare the local package.json version to the registry version: with versions assigned at publish time, local==registry is the normal steady state before assignment — not a signal to skip. Execute every step above.\n\n" +
|
|
336
|
+
"End your summary with exactly this line: published: muse-crew@<new-version>\n\n" +
|
|
275
337
|
"Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true }.\n" +
|
|
276
338
|
"No prose, no markdown, just the JSON object.";
|
|
277
339
|
} else if (PUBLISH_TYPE === "artifact") {
|
|
@@ -308,6 +370,12 @@ while (i < STEPS.length) {
|
|
|
308
370
|
"No prose, no markdown, just the JSON object.";
|
|
309
371
|
}
|
|
310
372
|
} else if (step.name === "QA") {
|
|
373
|
+
// Backstop for merge-time versioning: when the accepted Build summary
|
|
374
|
+
// declared release: yes, QA verifies the registry actually moved. A silent
|
|
375
|
+
// publish skip becomes a loud QA failure with evidence, not a pass.
|
|
376
|
+
var npmPublishCheck = (PUBLISH_TYPE === "npm" && releaseDecision && releaseDecision.release === "yes")
|
|
377
|
+
? "NPM PUBLISH CHECK: the accepted Build summary declared release: yes, so this run's Publish phase must have published. Find this task's recorded Publish result: call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getstate\", args: { \"events_limit\": 1 }, then find the session for this task_id with step \"Publish\" (status completed) in the returned sessions array and extract its `published: muse-crew@<version>` line from the session notes (the Publish agent ends its summary with exactly that line — event history does NOT carry it). Then run: npm view muse-crew version. The registry version MUST equal the published version. If there is no completed Publish session with a published: line, or the registry version does not match, FAIL with { \"passed\": false, \"summary\": \"npm publish verification failed: [details]\" }.\n"
|
|
378
|
+
: "";
|
|
311
379
|
instructions = "Final QA testing. You are CODE-BLIND — do NOT read source code.\n" +
|
|
312
380
|
"Public docs (API.md, README, published action schemas) are NOT source code — read them freely, exactly as a user would.\n" +
|
|
313
381
|
"DOCS GATE: If the fix is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), verify the public docs describe it. If public docs are missing or stale, FAIL with { \"passed\": false, \"summary\": \"public docs missing/stale for [the change]\" }. QA always fails when public-affecting changes lack public docs. Guide/tutorial gaps are lower priority — file a follow-up task for those instead of failing.\n" +
|
|
@@ -316,6 +384,7 @@ while (i < STEPS.length) {
|
|
|
316
384
|
"You can also check specific data with the getevents action.\n" +
|
|
317
385
|
"Do NOT use artifact_inspect — it is async and will not return results inline.\n" +
|
|
318
386
|
"File follow-up tasks via artifact_invoke_action createtask on slug \"" + DASHBOARD_SLUG + "\" for related issues.\n" +
|
|
387
|
+
npmPublishCheck +
|
|
319
388
|
"If testing passes, your final response MUST be valid JSON and nothing else: { \"passed\": true, \"summary\": \"test results\" }.\n" +
|
|
320
389
|
"If testing fails, your final response MUST be valid JSON and nothing else: { \"passed\": false, \"summary\": \"failure details\" }.\n" +
|
|
321
390
|
"No prose, no markdown, just the JSON object.";
|
|
@@ -330,9 +399,12 @@ while (i < STEPS.length) {
|
|
|
330
399
|
}
|
|
331
400
|
}
|
|
332
401
|
|
|
333
|
-
// Task event history —
|
|
402
|
+
// Task event history — Review and Publish are excluded. Review is cold by
|
|
403
|
+
// design; Publish receives its release decision deterministically from the
|
|
404
|
+
// workflow, and prior rounds' history contains the exact skip deviation being
|
|
405
|
+
// eliminated — agents imitate recorded precedent.
|
|
334
406
|
var eventPreamble = "";
|
|
335
|
-
if (step.name !== "Review") {
|
|
407
|
+
if (step.name !== "Review" && step.name !== "Publish") {
|
|
336
408
|
eventPreamble = "CONTEXT: First, fetch this task's event history for background.\n" +
|
|
337
409
|
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getevents\", args: { \"task_id\": \"" + taskId + "\" }.\n" +
|
|
338
410
|
"The returned events are filtered to this task. They contain notes and decisions from prior phases.\n\n";
|
|
@@ -393,6 +465,13 @@ while (i < STEPS.length) {
|
|
|
393
465
|
mapperSpec = summary;
|
|
394
466
|
}
|
|
395
467
|
|
|
468
|
+
// Capture the accepted Build summary's machine-readable release decision.
|
|
469
|
+
// Parsed from the RAW summary (before the 2000-char slice above) — the
|
|
470
|
+
// release:/version_bump: lines sit at the very end and must survive truncation.
|
|
471
|
+
if (step.name === "Build" && passed) {
|
|
472
|
+
releaseDecision = extractReleaseDecision(stepResult.summary || "");
|
|
473
|
+
}
|
|
474
|
+
|
|
396
475
|
await agent(
|
|
397
476
|
"Update the session and log the event.\n" +
|
|
398
477
|
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
|
package/workflows/chore.js
CHANGED
|
@@ -72,6 +72,24 @@ const MAX_REWORK = 2;
|
|
|
72
72
|
let reworkCount = 0;
|
|
73
73
|
let rejectionNotes = inputs.rejection_notes || "";
|
|
74
74
|
let mapperSpec = "";
|
|
75
|
+
// Merge-time versioning: the release decision is extracted deterministically
|
|
76
|
+
// from the accepted Build summary (extractReleaseDecision) so the Publish
|
|
77
|
+
// agent never decides whether to publish. Two consecutive Publish runs
|
|
78
|
+
// rationalized a skip against explicit instruction text — text alone did not
|
|
79
|
+
// hold, so the decision now lives in workflow code, not agent judgment.
|
|
80
|
+
let releaseDecision = null; // { release: "yes"|"no", version_bump: "patch"|"minor"|"major"|null }
|
|
81
|
+
function extractReleaseDecision(text) {
|
|
82
|
+
const t = text || "";
|
|
83
|
+
const r = /^release:\s*(yes|no)\s*$/im.exec(t);
|
|
84
|
+
if (!r) return null;
|
|
85
|
+
const b = /^version_bump:\s*(patch|minor|major)\s*$/im.exec(t);
|
|
86
|
+
if (r[1].toLowerCase() === "yes" && !b) return null;
|
|
87
|
+
return { release: r[1].toLowerCase(), version_bump: b ? b[1].toLowerCase() : null };
|
|
88
|
+
}
|
|
89
|
+
function releaseDecisionText() {
|
|
90
|
+
if (!releaseDecision) return "no parseable release:/version_bump: decision";
|
|
91
|
+
return "release: " + releaseDecision.release + (releaseDecision.version_bump ? ", version_bump: " + releaseDecision.version_bump : " (no version_bump line)");
|
|
92
|
+
}
|
|
75
93
|
let i = startStepIndex;
|
|
76
94
|
|
|
77
95
|
// Pin lifecycle scripts
|
|
@@ -144,6 +162,28 @@ while (i < STEPS.length) {
|
|
|
144
162
|
return { status: "blocked", task_id: taskId, reason: "Unknown publish target '" + PUBLISH_TYPE + "' — expected 'npm', 'artifact', 'vercel', or empty (skip publish)." };
|
|
145
163
|
}
|
|
146
164
|
|
|
165
|
+
// Merge-time versioning, decided deterministically: the release decision was
|
|
166
|
+
// extracted from the accepted Build summary above. release: no never reaches
|
|
167
|
+
// the Publish agent (the workflow skips it here, exactly like the no-target
|
|
168
|
+
// case); an unparseable decision blocks fail-closed. The Publish agent below
|
|
169
|
+
// therefore has no decision point to rationalize into a skip.
|
|
170
|
+
if (step.name === "Publish" && PUBLISH_TYPE === "npm") {
|
|
171
|
+
if (!releaseDecision) {
|
|
172
|
+
return { status: "blocked", task_id: taskId, reason: "Build summary has no parseable release:/version_bump: lines — cannot assign version at publish time." };
|
|
173
|
+
}
|
|
174
|
+
if (releaseDecision.release === "no") {
|
|
175
|
+
log("Publish skipped for task " + taskId + " — accepted Build summary declared release: no");
|
|
176
|
+
await agent(
|
|
177
|
+
"Release the merge lock and clean up without publishing.\n" +
|
|
178
|
+
"Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
|
|
179
|
+
"If the output contains DEPLOYED, the lock is released and the worktree is cleaned up.",
|
|
180
|
+
{ key: "publish-skip-release-no", label: "Skipping Publish (release: no)", schema: { type: "object" } }
|
|
181
|
+
);
|
|
182
|
+
i++;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
147
187
|
let activeSessionId;
|
|
148
188
|
if (isFirstClaim && firstSessionId) {
|
|
149
189
|
activeSessionId = firstSessionId;
|
|
@@ -186,7 +226,10 @@ while (i < STEPS.length) {
|
|
|
186
226
|
"Edit the TypeScript source files directly. Do NOT use artifact_edit — that happens in the Publish phase.\n" +
|
|
187
227
|
"Do not add unrequested features.\n" +
|
|
188
228
|
"PUBLIC DOCS: If your change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), update the public docs in the same commit — API.md for API changes. Documentation and implementation ship together.\n\n" +
|
|
189
|
-
(PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry
|
|
229
|
+
(PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry. Versions are assigned at PUBLISH time — never in your branch. Do NOT touch the `version` field in package.json (or package-lock). Instead, end your summary with exactly these two lines:\n" +
|
|
230
|
+
"release: yes|no — 'yes' if this change warrants a published release (anything a consumer can observe: workflow behavior, phase lists, identities, published docs, API); 'no' if internal-only.\n" +
|
|
231
|
+
"version_bump: patch|minor|major — patch for fixes (default), minor for new behavior, major for breaking changes. Omit this line only when release is no.\n" +
|
|
232
|
+
"Example: release: yes\\nversion_bump: minor\n\n" : "") +
|
|
190
233
|
"STEP 3: Commit your changes.\n" +
|
|
191
234
|
"cd " + REPO_PATH + "/.worktrees/" + taskId + "\n" +
|
|
192
235
|
"git add -A\n" +
|
|
@@ -206,7 +249,12 @@ while (i < STEPS.length) {
|
|
|
206
249
|
"Check quality, correctness, spec compliance.\n" +
|
|
207
250
|
"Check that public-affecting changes have matching public doc updates (API.md or the published API contract). If the docs are missing or inaccurate, reject with notes on what is stale.\n" +
|
|
208
251
|
"If the branch has no commits ahead of main (inspect shows an empty commit log), approve ONLY if the Build summary declares `repo_diff: none` with a plausible runtime-state deliverable (e.g. a cron created via the cron tool). Otherwise reject: 'no commits ahead of main and no repo_diff: none declaration — the builder likely forgot to commit'.\n" +
|
|
209
|
-
(PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry
|
|
252
|
+
(PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry, and versions are assigned at publish time — never in branches. Two checks:\n" +
|
|
253
|
+
"(a) The task branch must NOT have changed package.json's `version` field. Check: cd " + REPO_PATH + " && git diff main...task/" + taskId + " -- package.json. If the branch touched `version` in any way, REJECT with notes: 'versions are assigned at publish time, never in branches — remove the version change'.\n" +
|
|
254
|
+
"(b) The accepted Build summary declares: " + releaseDecisionText() + ". " +
|
|
255
|
+
(releaseDecision
|
|
256
|
+
? "Validate this decision against the change: release must be 'yes' when the change is consumer-observable and 'no' when internal-only; the version_bump scope must fit the change (patch for fixes, minor for new behavior, major for breaking changes). If the decision is wrong or mis-scoped, reject with notes."
|
|
257
|
+
: "The decision is missing or malformed — REJECT with notes: 'Build summary must end with release: yes|no and (when release is yes) version_bump: patch|minor|major lines'.") + "\n" : "") +
|
|
210
258
|
"If it passes, your final response MUST be valid JSON and nothing else: { \"passed\": true, \"summary\": \"approval notes\" }.\n" +
|
|
211
259
|
"If it fails, your final response MUST be valid JSON and nothing else: { \"passed\": false, \"summary\": \"rejection notes\" }.\n" +
|
|
212
260
|
"No prose, no markdown, just the JSON object.";
|
|
@@ -218,7 +266,15 @@ while (i < STEPS.length) {
|
|
|
218
266
|
"- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
|
|
219
267
|
"- If it contains MERGED_EMPTY, the branch had no commits ahead of main (a runtime-state deliverable, declared by Build as repo_diff: none). Integration succeeded vacuously: the merge lock was NOT taken and there is no new commit. Set passed to true with summary 'merged empty: no repo changes — deliverable was runtime state'. SKIP STEP 2 (push): there is no new commit to push.\n" +
|
|
220
268
|
"- If it contains LOCK_HELD, another task holds the merge lock (mid Integrate/Publish). Set passed to false.\n" +
|
|
221
|
-
"- If it contains CONFLICT,
|
|
269
|
+
"- If it contains CONFLICT, the plain merge failed — the merge was aborted, main is clean, and your task still holds the merge lock. Do NOT fail yet. Resolve it:\n" +
|
|
270
|
+
"RESOLUTION:\n" +
|
|
271
|
+
"R1. Refresh the merge lock FIRST (a long resolution must not silently lose the lock to the orphan sweep): CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + ". Create a scratch worktree WITH A NEW BRANCH (main is already checked out in the repo checkout, so git forbids checking it out a second time): cd " + REPO_PATH + " && git worktree add -b resolve/" + taskId + " /tmp/crew-resolve-" + taskId + " main. Reproduce the conflict in the scratch worktree: cd /tmp/crew-resolve-" + taskId + " && git merge task/" + taskId + ". This reproduces the exact conflict (main has not moved — the lock was held throughout). The task branch task/" + taskId + " is never modified.\n" +
|
|
272
|
+
"R2. For each conflicted file, read the three sides: git show :1:<file> (base), git show :2:<file> (ours = main), git show :3:<file> (theirs = task branch). Resolve each hunk by keeping both sides' changes when they do not semantically overlap. Version-only hunks resolve to the higher semver (safety net). Never invent new behavior. Leave no markers.\n" +
|
|
273
|
+
"R3. Verify in the scratch worktree, in this order: (a) git diff --check is clean; (b) git grep -n '^<<<<<<<' -- . returns nothing; (c) node --check every changed .js file (conflicted files plus everything listed by git diff --name-only). If any check fails, refresh the merge lock (CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "), then retry the resolution using the failure output as context — max 3 attempts total.\n" +
|
|
274
|
+
"R4. Commit the resolution on resolve/" + taskId + ": git add -A && git commit -m \"resolve conflicts: " + taskId + "\".\n" +
|
|
275
|
+
"R5. Back in " + REPO_PATH + ": git checkout main && git merge --ff-only resolve/" + taskId + ". This fast-forwards — main has not moved while the lock was held. Then STEP 2 applies: git push origin main. Report the merged commit hash.\n" +
|
|
276
|
+
"R6. Clean up: cd " + REPO_PATH + " && git worktree remove --force /tmp/crew-resolve-" + taskId + " && git branch -D resolve/" + taskId + ".\n" +
|
|
277
|
+
"ESCALATE — return {\"passed\": false, \"summary\": \"conflict needs human resolution\"} — when: 3 attempts are exhausted; the conflict touches generated files, migrations, or public API contracts; or 'looks right + checks pass' is not sufficient for any other reason. On escalation, RELEASE THE LOCK so the task can be reworked later: run CREW_REPO=" + REPO_PATH + " " + MERGE_LOCK + " release " + taskId + " (release is keyed on task id; no PID needed). Do NOT run post-deploy on escalation — it would delete the untouched task branch the human still needs.\n" +
|
|
222
278
|
"- If it contains ERROR, something else failed. Set passed to false.\n\n" +
|
|
223
279
|
"\n" +
|
|
224
280
|
"STEP 2: Push the merged main to the remote repository.\n" +
|
|
@@ -232,28 +288,34 @@ while (i < STEPS.length) {
|
|
|
232
288
|
} else if (step.name === "Publish") {
|
|
233
289
|
if (PUBLISH_TYPE === "npm") {
|
|
234
290
|
// npm packages: immutable release + pack + publish to the registry (push is universal in Integrate)
|
|
291
|
+
// The release decision arrived deterministically from the workflow (release: yes) —
|
|
292
|
+
// these steps are unconditional. There is no decision to make and no skip path.
|
|
235
293
|
instructions = "Publish the npm package to the registry.\n\n" +
|
|
236
|
-
"The
|
|
237
|
-
"
|
|
294
|
+
"The release decision is already made and recorded — it is not yours to make: the accepted Build summary (validated by Review) declares " + releaseDecisionText() + ". Execute every step below in order.\n\n" +
|
|
295
|
+
"The repo push already happened in Integrate — do NOT push to git in this phase except STEP 7, and NEVER force-push.\n\n" +
|
|
238
296
|
"STEP 0: Refresh the merge lock to prevent stale-lock breaking during publish.\n" +
|
|
239
297
|
"Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "\n\n" +
|
|
240
298
|
"STEP 1: Install and activate the immutable release.\n" +
|
|
241
299
|
"Run: " + RELEASE_SCRIPT + " deploy " + REPO_PATH + "\n" +
|
|
242
300
|
"Verify the output contains INSTALLED and ACTIVATED (or EXISTS and ACTIVATED if unchanged).\n\n" +
|
|
243
|
-
"STEP 2:
|
|
244
|
-
"
|
|
245
|
-
"
|
|
246
|
-
"
|
|
247
|
-
"STEP
|
|
301
|
+
"STEP 2: Read the registry base version.\n" +
|
|
302
|
+
"Run: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
|
|
303
|
+
"If NOT_FOUND, use the local package.json version as the base instead.\n\n" +
|
|
304
|
+
"STEP 3: Apply the version_bump scope (" + releaseDecision.version_bump + ") to the base version: patch increments the last segment; minor increments the middle and resets the last to 0; major increments the first and resets the rest to 0. Example: base 1.2.3 + minor → 1.3.0. Call the result <new-version>.\n" +
|
|
305
|
+
"STEP 4: Write <new-version> into package.json (only the `version` field), then commit it under the still-held merge lock: cd " + REPO_PATH + " && git add package.json && git commit -m \"release: muse-crew@<new-version>\". The lock serializes Publish per repo, so two tasks can never pick the same version.\n" +
|
|
306
|
+
"STEP 5: Pack and publish.\n" +
|
|
248
307
|
"Run: cd " + REPO_PATH + " && npm pack\n" +
|
|
249
|
-
"Then publish: python3 ~/workspace/skills/npm/bin/npm-publish.py " + REPO_PATH + "/muse-crew
|
|
250
|
-
"If publish fails with 'You cannot publish over the previously published versions',
|
|
251
|
-
"STEP
|
|
308
|
+
"Then publish: python3 ~/workspace/skills/npm/bin/npm-publish.py " + REPO_PATH + "/muse-crew-<new-version>.tgz\n" +
|
|
309
|
+
"If publish fails with 'You cannot publish over the previously published versions', <new-version> is already on the registry (a retried Publish — the merge lock guarantees no other task picked this version): continue to STEP 6 verification. Any other publish failure: set passed to false with the failure details.\n\n" +
|
|
310
|
+
"STEP 6: Verify.\n" +
|
|
252
311
|
"Run: npm view muse-crew version\n" +
|
|
253
|
-
"
|
|
254
|
-
"STEP
|
|
312
|
+
"It must equal <new-version>. If not, set passed to false with the mismatch details.\n\n" +
|
|
313
|
+
"STEP 7: Push the version-bump commit: cd " + REPO_PATH + " && git push origin main. Never use --force.\n\n" +
|
|
314
|
+
"STEP 8: Finalize.\n" +
|
|
255
315
|
"Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
|
|
256
316
|
"If the output contains DEPLOYED, finalization is complete.\n\n" +
|
|
317
|
+
"Do NOT compare the local package.json version to the registry version: with versions assigned at publish time, local==registry is the normal steady state before assignment — not a signal to skip. Execute every step above.\n\n" +
|
|
318
|
+
"End your summary with exactly this line: published: muse-crew@<new-version>\n\n" +
|
|
257
319
|
"Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true }.\n" +
|
|
258
320
|
"No prose, no markdown, just the JSON object.";
|
|
259
321
|
} else if (PUBLISH_TYPE === "artifact") {
|
|
@@ -291,9 +353,12 @@ while (i < STEPS.length) {
|
|
|
291
353
|
}
|
|
292
354
|
}
|
|
293
355
|
|
|
294
|
-
// Task event history —
|
|
356
|
+
// Task event history — Review and Publish are excluded. Review is cold by
|
|
357
|
+
// design; Publish receives its release decision deterministically from the
|
|
358
|
+
// workflow, and prior rounds' history contains the exact skip deviation being
|
|
359
|
+
// eliminated — agents imitate recorded precedent.
|
|
295
360
|
var eventPreamble = "";
|
|
296
|
-
if (step.name !== "Review") {
|
|
361
|
+
if (step.name !== "Review" && step.name !== "Publish") {
|
|
297
362
|
eventPreamble = "CONTEXT: First, fetch this task's event history for background.\n" +
|
|
298
363
|
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getevents\", args: { \"task_id\": \"" + taskId + "\" }.\n" +
|
|
299
364
|
"The returned events are filtered to this task. They contain notes and decisions from prior phases.\n\n";
|
|
@@ -349,6 +414,13 @@ while (i < STEPS.length) {
|
|
|
349
414
|
mapperSpec = summary;
|
|
350
415
|
}
|
|
351
416
|
|
|
417
|
+
// Capture the accepted Build summary's machine-readable release decision.
|
|
418
|
+
// Parsed from the RAW summary (before the 2000-char slice above) — the
|
|
419
|
+
// release:/version_bump: lines sit at the very end and must survive truncation.
|
|
420
|
+
if (step.name === "Build" && passed) {
|
|
421
|
+
releaseDecision = extractReleaseDecision(stepResult.summary || "");
|
|
422
|
+
}
|
|
423
|
+
|
|
352
424
|
await agent(
|
|
353
425
|
"Update session and log event.\n" +
|
|
354
426
|
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"upsertagentsession\", args:\n" +
|
|
@@ -83,8 +83,10 @@ const allTasks = boardResult.ready_tasks || [];
|
|
|
83
83
|
const config = boardResult.config || {};
|
|
84
84
|
const projects = boardResult.projects || [];
|
|
85
85
|
|
|
86
|
-
// Default project: explicit arg,
|
|
87
|
-
|
|
86
|
+
// Default project: explicit arg wins, then the dashboard's configured default
|
|
87
|
+
// project (config.default_project — set by crew-init or the dashboard UI),
|
|
88
|
+
// then the first registered project as a last resort.
|
|
89
|
+
const DEFAULT_PROJECT = inputs.defaultProject || config.default_project || (projects.length > 0 ? projects[0].id : "");
|
|
88
90
|
|
|
89
91
|
// Per-project quiesce: build a set of quiesced project IDs
|
|
90
92
|
const quiescedProjects = {};
|
|
@@ -290,7 +292,7 @@ for (var p = 0; p < toProcess.length; p++) {
|
|
|
290
292
|
crewHome: crewHome
|
|
291
293
|
};
|
|
292
294
|
|
|
293
|
-
log("Claimed " + iworkflow + " for \"" + itask.title + "\" at step " + nextStepName);
|
|
295
|
+
log("Claimed " + iworkflow + " for \"" + itask.title + "\" [" + taskProject + "] at step " + nextStepName);
|
|
294
296
|
results.push({ task_id: itask.id, workflow: iworkflow, step: nextStepName, action: "claimed", scriptPath: scriptPath, args: launchArgs });
|
|
295
297
|
}
|
|
296
298
|
|
package/workflows/crew-init.js
CHANGED
|
@@ -145,6 +145,39 @@ try {
|
|
|
145
145
|
}
|
|
146
146
|
log("Project: " + (projectResult.registered ? "registered" : "failed"));
|
|
147
147
|
|
|
148
|
+
// ── Phase 3b: Set the dashboard's project as the default ─────────────
|
|
149
|
+
// The CLI takes the project implicitly from the dashboard's configured
|
|
150
|
+
// default (config.default_project). Idempotent: setdefaultproject upserts.
|
|
151
|
+
phase("default-project");
|
|
152
|
+
var defaultProjectResult;
|
|
153
|
+
try {
|
|
154
|
+
defaultProjectResult = await agent(
|
|
155
|
+
"Set the dashboard's default project to the dashboard itself.\\n\\n" +
|
|
156
|
+
"Dashboard slug: " + dashboardSlug + "\\n\\n" +
|
|
157
|
+
"Steps:\\n" +
|
|
158
|
+
"1. Call artifact_invoke_action with:\\n" +
|
|
159
|
+
" slug: '" + dashboardSlug + "'\\n" +
|
|
160
|
+
" action_name: 'setdefaultproject'\\n" +
|
|
161
|
+
" args: { project_id: '" + dashboardSlug + "' }\\n" +
|
|
162
|
+
"2. Return { defaulted: true } on success.\\n\\n" +
|
|
163
|
+
"Return JSON with defaulted (boolean).",
|
|
164
|
+
{
|
|
165
|
+
key: "project-2",
|
|
166
|
+
label: "Set default project",
|
|
167
|
+
schema: {
|
|
168
|
+
type: "object",
|
|
169
|
+
properties: {
|
|
170
|
+
defaulted: { type: "boolean" }
|
|
171
|
+
},
|
|
172
|
+
required: ["defaulted"]
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
return { __hatchWorkflowControl: "blocked", result: { blocked_reason: "Default project could not be set", message: String(e.message || e) } };
|
|
178
|
+
}
|
|
179
|
+
log("Default project: " + (defaultProjectResult.defaulted ? "set" : "failed"));
|
|
180
|
+
|
|
148
181
|
// ── Phase 4: Polling cron ─────────────────────────────────────────────
|
|
149
182
|
// The cron body template lives in the repo at seed/cron-body-template.md.
|
|
150
183
|
// Placeholders: {crewHome}, {dashboardSlug} are replaced with actual values.
|
|
@@ -218,10 +251,12 @@ try {
|
|
|
218
251
|
"Body text for the cron:\n" +
|
|
219
252
|
"---\n" +
|
|
220
253
|
"## Muse Crew Orphan Sweep\n\n" +
|
|
221
|
-
"
|
|
222
|
-
"
|
|
223
|
-
|
|
224
|
-
"
|
|
254
|
+
"1. Call artifact_invoke_action on slug \"" + dashboardSlug + "\", action \"getdispatchstate\", args {}.\n" +
|
|
255
|
+
"2. Collect the task IDs of every entry in ready_tasks whose latest_session.status is \"running\".\n" +
|
|
256
|
+
" (Sessions older than 1h are already reclassified as timed_out by the dashboard — no extra math.)\n" +
|
|
257
|
+
"3. Run: CREW_ACTIVE_TASKS=\"<space-separated ids>\" " + crewHome + "/lib/orphan-sweep.sh clean\n" +
|
|
258
|
+
" If the list is empty, pass an empty string — do NOT omit the variable (the sweep fails closed without it).\n" +
|
|
259
|
+
"4. Report the output. If it says CLEAN, no action was needed.\n" +
|
|
225
260
|
"---\n\n" +
|
|
226
261
|
"Return JSON with existed (boolean).",
|
|
227
262
|
{
|
package/workflows/standard.js
CHANGED
|
@@ -77,6 +77,24 @@ const MAX_TOTAL_REWORK = 2;
|
|
|
77
77
|
let totalReworkCount = 0;
|
|
78
78
|
let rejectionNotes = inputs.rejection_notes || "";
|
|
79
79
|
let mapperSpec = "";
|
|
80
|
+
// Merge-time versioning: the release decision is extracted deterministically
|
|
81
|
+
// from the accepted Build summary (extractReleaseDecision) so the Publish
|
|
82
|
+
// agent never decides whether to publish. Two consecutive Publish runs
|
|
83
|
+
// rationalized a skip against explicit instruction text — text alone did not
|
|
84
|
+
// hold, so the decision now lives in workflow code, not agent judgment.
|
|
85
|
+
let releaseDecision = null; // { release: "yes"|"no", version_bump: "patch"|"minor"|"major"|null }
|
|
86
|
+
function extractReleaseDecision(text) {
|
|
87
|
+
const t = text || "";
|
|
88
|
+
const r = /^release:\s*(yes|no)\s*$/im.exec(t);
|
|
89
|
+
if (!r) return null;
|
|
90
|
+
const b = /^version_bump:\s*(patch|minor|major)\s*$/im.exec(t);
|
|
91
|
+
if (r[1].toLowerCase() === "yes" && !b) return null;
|
|
92
|
+
return { release: r[1].toLowerCase(), version_bump: b ? b[1].toLowerCase() : null };
|
|
93
|
+
}
|
|
94
|
+
function releaseDecisionText() {
|
|
95
|
+
if (!releaseDecision) return "no parseable release:/version_bump: decision";
|
|
96
|
+
return "release: " + releaseDecision.release + (releaseDecision.version_bump ? ", version_bump: " + releaseDecision.version_bump : " (no version_bump line)");
|
|
97
|
+
}
|
|
80
98
|
let i = startStepIndex;
|
|
81
99
|
|
|
82
100
|
// ── Pin lifecycle scripts ────────────────────────────────────────────
|
|
@@ -158,6 +176,28 @@ while (i < STEPS.length) {
|
|
|
158
176
|
return { status: "blocked", task_id: taskId, reason: "Unknown publish target '" + PUBLISH_TYPE + "' — expected 'npm', 'artifact', 'vercel', or empty (skip publish)." };
|
|
159
177
|
}
|
|
160
178
|
|
|
179
|
+
// Merge-time versioning, decided deterministically: the release decision was
|
|
180
|
+
// extracted from the accepted Build summary above. release: no never reaches
|
|
181
|
+
// the Publish agent (the workflow skips it here, exactly like the no-target
|
|
182
|
+
// case); an unparseable decision blocks fail-closed. The Publish agent below
|
|
183
|
+
// therefore has no decision point to rationalize into a skip.
|
|
184
|
+
if (step.name === "Publish" && PUBLISH_TYPE === "npm") {
|
|
185
|
+
if (!releaseDecision) {
|
|
186
|
+
return { status: "blocked", task_id: taskId, reason: "Build summary has no parseable release:/version_bump: lines — cannot assign version at publish time." };
|
|
187
|
+
}
|
|
188
|
+
if (releaseDecision.release === "no") {
|
|
189
|
+
log("Publish skipped for task " + taskId + " — accepted Build summary declared release: no");
|
|
190
|
+
await agent(
|
|
191
|
+
"Release the merge lock and clean up without publishing.\n" +
|
|
192
|
+
"Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
|
|
193
|
+
"If the output contains DEPLOYED, the lock is released and the worktree is cleaned up.",
|
|
194
|
+
{ key: "publish-skip-release-no", label: "Skipping Publish (release: no)", schema: { type: "object" } }
|
|
195
|
+
);
|
|
196
|
+
i++;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
161
201
|
// Claim session — reuse dispatcher's session for the very first step
|
|
162
202
|
let activeSessionId;
|
|
163
203
|
if (isFirstClaim && firstSessionId) {
|
|
@@ -202,7 +242,10 @@ while (i < STEPS.length) {
|
|
|
202
242
|
"Edit source files directly. Do NOT use artifact_edit — that happens in the Publish phase.\n" +
|
|
203
243
|
"Do not add unrequested features. Build exactly what the spec calls for.\n" +
|
|
204
244
|
"PUBLIC DOCS: If your change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), update the public docs in the same commit — API.md for API changes. Documentation and implementation ship together.\n\n" +
|
|
205
|
-
(PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry
|
|
245
|
+
(PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry. Versions are assigned at PUBLISH time — never in your branch. Do NOT touch the `version` field in package.json (or package-lock). Instead, end your summary with exactly these two lines:\n" +
|
|
246
|
+
"release: yes|no — 'yes' if this change warrants a published release (anything a consumer can observe: workflow behavior, phase lists, identities, published docs, API); 'no' if internal-only.\n" +
|
|
247
|
+
"version_bump: patch|minor|major — patch for fixes (default), minor for new behavior, major for breaking changes. Omit this line only when release is no.\n" +
|
|
248
|
+
"Example: release: yes\\nversion_bump: minor\n\n" : "") +
|
|
206
249
|
"STEP 3: Commit your changes.\n" +
|
|
207
250
|
"cd " + REPO_PATH + "/.worktrees/" + taskId + "\n" +
|
|
208
251
|
"git add -A\n" +
|
|
@@ -222,7 +265,12 @@ while (i < STEPS.length) {
|
|
|
222
265
|
"Check quality, correctness, and spec compliance.\n" +
|
|
223
266
|
"Check that public-affecting changes have matching public doc updates (API.md or the published API contract). If the docs are missing or inaccurate, reject with notes on what is stale.\n" +
|
|
224
267
|
"If the branch has no commits ahead of main (inspect shows an empty commit log), approve ONLY if the Build summary declares `repo_diff: none` with a plausible runtime-state deliverable (e.g. a cron created via the cron tool). Otherwise reject: 'no commits ahead of main and no repo_diff: none declaration — the builder likely forgot to commit'.\n" +
|
|
225
|
-
(PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry
|
|
268
|
+
(PUBLISH_TYPE === "npm" ? "PACKAGE VERSION: this project publishes to the npm registry, and versions are assigned at publish time — never in branches. Two checks:\n" +
|
|
269
|
+
"(a) The task branch must NOT have changed package.json's `version` field. Check: cd " + REPO_PATH + " && git diff main...task/" + taskId + " -- package.json. If the branch touched `version` in any way, REJECT with notes: 'versions are assigned at publish time, never in branches — remove the version change'.\n" +
|
|
270
|
+
"(b) The accepted Build summary declares: " + releaseDecisionText() + ". " +
|
|
271
|
+
(releaseDecision
|
|
272
|
+
? "Validate this decision against the change: release must be 'yes' when the change is consumer-observable and 'no' when internal-only; the version_bump scope must fit the change (patch for fixes, minor for new behavior, major for breaking changes). If the decision is wrong or mis-scoped, reject with notes."
|
|
273
|
+
: "The decision is missing or malformed — REJECT with notes: 'Build summary must end with release: yes|no and (when release is yes) version_bump: patch|minor|major lines'.") + "\n" : "") +
|
|
226
274
|
"If the work passes review, your final response MUST be valid JSON and nothing else: { \"passed\": true, \"summary\": \"approval notes\" }.\n" +
|
|
227
275
|
"If the work fails review, your final response MUST be valid JSON and nothing else: { \"passed\": false, \"summary\": \"rejection notes explaining what needs to change\" }.\n" +
|
|
228
276
|
"No prose, no markdown, just the JSON object.";
|
|
@@ -234,7 +282,15 @@ while (i < STEPS.length) {
|
|
|
234
282
|
"- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
|
|
235
283
|
"- If it contains MERGED_EMPTY, the branch had no commits ahead of main (a runtime-state deliverable, declared by Build as repo_diff: none). Integration succeeded vacuously: the merge lock was NOT taken and there is no new commit. Set passed to true with summary 'merged empty: no repo changes — deliverable was runtime state'. SKIP STEP 2 (push): there is no new commit to push.\n" +
|
|
236
284
|
"- If it contains LOCK_HELD, another task holds the merge lock (mid Integrate/Publish). Set passed to false with summary 'merge lock held'.\n" +
|
|
237
|
-
"- If it contains CONFLICT,
|
|
285
|
+
"- If it contains CONFLICT, the plain merge failed — the merge was aborted, main is clean, and your task still holds the merge lock. Do NOT fail yet. Resolve it:\n" +
|
|
286
|
+
"RESOLUTION:\n" +
|
|
287
|
+
"R1. Refresh the merge lock FIRST (a long resolution must not silently lose the lock to the orphan sweep): CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + ". Create a scratch worktree WITH A NEW BRANCH (main is already checked out in the repo checkout, so git forbids checking it out a second time): cd " + REPO_PATH + " && git worktree add -b resolve/" + taskId + " /tmp/crew-resolve-" + taskId + " main. Reproduce the conflict in the scratch worktree: cd /tmp/crew-resolve-" + taskId + " && git merge task/" + taskId + ". This reproduces the exact conflict (main has not moved — the lock was held throughout). The task branch task/" + taskId + " is never modified.\n" +
|
|
288
|
+
"R2. For each conflicted file, read the three sides: git show :1:<file> (base), git show :2:<file> (ours = main), git show :3:<file> (theirs = task branch). Resolve each hunk by keeping both sides' changes when they do not semantically overlap. Version-only hunks resolve to the higher semver (safety net). Never invent new behavior. Leave no markers.\n" +
|
|
289
|
+
"R3. Verify in the scratch worktree, in this order: (a) git diff --check is clean; (b) git grep -n '^<<<<<<<' -- . returns nothing; (c) node --check every changed .js file (conflicted files plus everything listed by git diff --name-only). If any check fails, refresh the merge lock (CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "), then retry the resolution using the failure output as context — max 3 attempts total.\n" +
|
|
290
|
+
"R4. Commit the resolution on resolve/" + taskId + ": git add -A && git commit -m \"resolve conflicts: " + taskId + "\".\n" +
|
|
291
|
+
"R5. Back in " + REPO_PATH + ": git checkout main && git merge --ff-only resolve/" + taskId + ". This fast-forwards — main has not moved while the lock was held. Then STEP 2 applies: git push origin main. Report the merged commit hash.\n" +
|
|
292
|
+
"R6. Clean up: cd " + REPO_PATH + " && git worktree remove --force /tmp/crew-resolve-" + taskId + " && git branch -D resolve/" + taskId + ".\n" +
|
|
293
|
+
"ESCALATE — return {\"passed\": false, \"summary\": \"conflict needs human resolution\"} — when: 3 attempts are exhausted; the conflict touches generated files, migrations, or public API contracts; or 'looks right + checks pass' is not sufficient for any other reason. On escalation, RELEASE THE LOCK so the task can be reworked later: run CREW_REPO=" + REPO_PATH + " " + MERGE_LOCK + " release " + taskId + " (release is keyed on task id; no PID needed). Do NOT run post-deploy on escalation — it would delete the untouched task branch the human still needs.\n" +
|
|
238
294
|
"- If it contains ERROR, something else failed. Set passed to false with the error.\n\n" +
|
|
239
295
|
"\n" +
|
|
240
296
|
"STEP 2: Push the merged main to the remote repository.\n" +
|
|
@@ -248,28 +304,34 @@ while (i < STEPS.length) {
|
|
|
248
304
|
} else if (step.name === "Publish") {
|
|
249
305
|
if (PUBLISH_TYPE === "npm") {
|
|
250
306
|
// npm packages: immutable release + pack + publish to the registry (push is universal in Integrate)
|
|
307
|
+
// The release decision arrived deterministically from the workflow (release: yes) —
|
|
308
|
+
// these steps are unconditional. There is no decision to make and no skip path.
|
|
251
309
|
instructions = "Publish the npm package to the registry.\n\n" +
|
|
252
|
-
"The
|
|
253
|
-
"
|
|
310
|
+
"The release decision is already made and recorded — it is not yours to make: the accepted Build summary (validated by Review) declares " + releaseDecisionText() + ". Execute every step below in order.\n\n" +
|
|
311
|
+
"The repo push already happened in Integrate — do NOT push to git in this phase except STEP 7, and NEVER force-push.\n\n" +
|
|
254
312
|
"STEP 0: Refresh the merge lock to prevent stale-lock breaking during publish.\n" +
|
|
255
313
|
"Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " refresh-lock " + taskId + "\n\n" +
|
|
256
314
|
"STEP 1: Install and activate the immutable release.\n" +
|
|
257
315
|
"Run: " + RELEASE_SCRIPT + " deploy " + REPO_PATH + "\n" +
|
|
258
316
|
"Verify the output contains INSTALLED and ACTIVATED (or EXISTS and ACTIVATED if unchanged).\n\n" +
|
|
259
|
-
"STEP 2:
|
|
260
|
-
"
|
|
261
|
-
"
|
|
262
|
-
"
|
|
263
|
-
"STEP
|
|
317
|
+
"STEP 2: Read the registry base version.\n" +
|
|
318
|
+
"Run: npm view muse-crew version 2>/dev/null || echo NOT_FOUND\n" +
|
|
319
|
+
"If NOT_FOUND, use the local package.json version as the base instead.\n\n" +
|
|
320
|
+
"STEP 3: Apply the version_bump scope (" + releaseDecision.version_bump + ") to the base version: patch increments the last segment; minor increments the middle and resets the last to 0; major increments the first and resets the rest to 0. Example: base 1.2.3 + minor → 1.3.0. Call the result <new-version>.\n" +
|
|
321
|
+
"STEP 4: Write <new-version> into package.json (only the `version` field), then commit it under the still-held merge lock: cd " + REPO_PATH + " && git add package.json && git commit -m \"release: muse-crew@<new-version>\". The lock serializes Publish per repo, so two tasks can never pick the same version.\n" +
|
|
322
|
+
"STEP 5: Pack and publish.\n" +
|
|
264
323
|
"Run: cd " + REPO_PATH + " && npm pack\n" +
|
|
265
|
-
"Then publish: python3 ~/workspace/skills/npm/bin/npm-publish.py " + REPO_PATH + "/muse-crew
|
|
266
|
-
"If publish fails with 'You cannot publish over the previously published versions',
|
|
267
|
-
"STEP
|
|
324
|
+
"Then publish: python3 ~/workspace/skills/npm/bin/npm-publish.py " + REPO_PATH + "/muse-crew-<new-version>.tgz\n" +
|
|
325
|
+
"If publish fails with 'You cannot publish over the previously published versions', <new-version> is already on the registry (a retried Publish — the merge lock guarantees no other task picked this version): continue to STEP 6 verification. Any other publish failure: set passed to false with the failure details.\n\n" +
|
|
326
|
+
"STEP 6: Verify.\n" +
|
|
268
327
|
"Run: npm view muse-crew version\n" +
|
|
269
|
-
"
|
|
270
|
-
"STEP
|
|
328
|
+
"It must equal <new-version>. If not, set passed to false with the mismatch details.\n\n" +
|
|
329
|
+
"STEP 7: Push the version-bump commit: cd " + REPO_PATH + " && git push origin main. Never use --force.\n\n" +
|
|
330
|
+
"STEP 8: Finalize.\n" +
|
|
271
331
|
"Run: CREW_REPO=" + REPO_PATH + " " + LIFECYCLE + " post-deploy " + taskId + "\n" +
|
|
272
332
|
"If the output contains DEPLOYED, finalization is complete.\n\n" +
|
|
333
|
+
"Do NOT compare the local package.json version to the registry version: with versions assigned at publish time, local==registry is the normal steady state before assignment — not a signal to skip. Execute every step above.\n\n" +
|
|
334
|
+
"End your summary with exactly this line: published: muse-crew@<new-version>\n\n" +
|
|
273
335
|
"Your final response MUST be valid JSON and nothing else: { \"summary\": \"result\", \"passed\": true }.\n" +
|
|
274
336
|
"No prose, no markdown, just the JSON object.";
|
|
275
337
|
} else if (PUBLISH_TYPE === "artifact") {
|
|
@@ -306,6 +368,12 @@ while (i < STEPS.length) {
|
|
|
306
368
|
"No prose, no markdown, just the JSON object.";
|
|
307
369
|
}
|
|
308
370
|
} else if (step.name === "QA") {
|
|
371
|
+
// Backstop for merge-time versioning: when the accepted Build summary
|
|
372
|
+
// declared release: yes, QA verifies the registry actually moved. A silent
|
|
373
|
+
// publish skip becomes a loud QA failure with evidence, not a pass.
|
|
374
|
+
var npmPublishCheck = (PUBLISH_TYPE === "npm" && releaseDecision && releaseDecision.release === "yes")
|
|
375
|
+
? "NPM PUBLISH CHECK: the accepted Build summary declared release: yes, so this run's Publish phase must have published. Find this task's recorded Publish result: call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getstate\", args: { \"events_limit\": 1 }, then find the session for this task_id with step \"Publish\" (status completed) in the returned sessions array and extract its `published: muse-crew@<version>` line from the session notes (the Publish agent ends its summary with exactly that line — event history does NOT carry it). Then run: npm view muse-crew version. The registry version MUST equal the published version. If there is no completed Publish session with a published: line, or the registry version does not match, FAIL with { \"passed\": false, \"summary\": \"npm publish verification failed: [details]\" }.\n"
|
|
376
|
+
: "";
|
|
309
377
|
if (PUBLISH_TYPE === "artifact") {
|
|
310
378
|
var safeDesc = taskDescription.replace(/"/g, "'").replace(/\\/g, "\\\\").slice(0, 500);
|
|
311
379
|
instructions = "You are code-blind QA. You NEVER read source files.\n" +
|
|
@@ -335,14 +403,18 @@ while (i < STEPS.length) {
|
|
|
335
403
|
"Verify the change is working as described in the task.\n" +
|
|
336
404
|
"DOCS GATE: If the change is public-affecting (it alters anything a user or consumer can observe: API actions, parameters, behavior, or errors), verify the public docs describe it. If public docs are missing or stale, FAIL with { \"passed\": false, \"summary\": \"public docs missing/stale for [the change]\" }. QA always fails when public-affecting changes lack public docs. Guide/tutorial gaps are lower priority — file a follow-up task for those instead of failing.\n" +
|
|
337
405
|
"File follow-up tasks via artifact_invoke_action createtask on slug \"" + DASHBOARD_SLUG + "\" for related issues found.\n\n" +
|
|
406
|
+
npmPublishCheck +
|
|
338
407
|
"Your final response MUST be valid JSON and nothing else: { \"passed\": true/false, \"summary\": \"what you tested and found\" }.\n" +
|
|
339
408
|
"No prose, no markdown, just the JSON object.";
|
|
340
409
|
}
|
|
341
410
|
}
|
|
342
411
|
|
|
343
|
-
// Task event history —
|
|
412
|
+
// Task event history — Review and Publish are excluded. Review is cold by
|
|
413
|
+
// design; Publish receives its release decision deterministically from the
|
|
414
|
+
// workflow, and prior rounds' history contains the exact skip deviation being
|
|
415
|
+
// eliminated — agents imitate recorded precedent.
|
|
344
416
|
var eventPreamble = "";
|
|
345
|
-
if (step.name !== "Review") {
|
|
417
|
+
if (step.name !== "Review" && step.name !== "Publish") {
|
|
346
418
|
eventPreamble = "CONTEXT: First, fetch this task's event history for background.\n" +
|
|
347
419
|
"Call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getevents\", args: { \"task_id\": \"" + taskId + "\" }.\n" +
|
|
348
420
|
"The returned events are filtered to this task. They contain notes and decisions from prior phases.\n\n";
|
|
@@ -404,6 +476,13 @@ while (i < STEPS.length) {
|
|
|
404
476
|
mapperSpec = summary;
|
|
405
477
|
}
|
|
406
478
|
|
|
479
|
+
// Capture the accepted Build summary's machine-readable release decision.
|
|
480
|
+
// Parsed from the RAW summary (before the 2000-char slice above) — the
|
|
481
|
+
// release:/version_bump: lines sit at the very end and must survive truncation.
|
|
482
|
+
if (step.name === "Build" && passed) {
|
|
483
|
+
releaseDecision = extractReleaseDecision(stepResult.summary || "");
|
|
484
|
+
}
|
|
485
|
+
|
|
407
486
|
// Record session result
|
|
408
487
|
await agent(
|
|
409
488
|
"Update the session and log the event.\n" +
|