@cloverleaf/reference-impl 0.5.5 → 0.6.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/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/VERSION +1 -1
- package/dist/cli.mjs +88 -2
- package/dist/dag-walker.mjs +93 -0
- package/dist/events.mjs +11 -6
- package/dist/ids.mjs +8 -2
- package/dist/index.mjs +2 -0
- package/dist/prep-worktree.mjs +73 -8
- package/dist/walk-state.mjs +31 -0
- package/lib/cli.ts +94 -2
- package/lib/dag-walker.ts +133 -0
- package/lib/events.ts +11 -6
- package/lib/ids.ts +8 -2
- package/lib/index.ts +2 -0
- package/lib/prep-worktree.ts +82 -8
- package/lib/walk-state.ts +39 -0
- package/package.json +2 -1
- package/prompts/qa.md +4 -1
- package/prompts/reviewer.md +4 -1
- package/prompts/ui-reviewer.md +10 -8
- package/skills/cloverleaf-merge/SKILL.md +9 -1
- package/skills/cloverleaf-run-plan/SKILL.md +228 -0
- package/dist/state.mjs +0 -97
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cloverleaf-run-plan
|
|
3
|
+
description: Autonomous DAG walker for Cloverleaf Plans. Given a PLAN-ID in status `gate-approved`, drives each task in the plan's task_dag through Delivery concurrently by spawning one claw-drive Session B per ready task. Default max_concurrent is 3. Surfaces only escalations and per-task final-gate approvals to the human. Resumable across invocations. Usage — /cloverleaf-run-plan <PLAN-ID> [--max-concurrent=N] [--reset].
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Cloverleaf — run-plan
|
|
7
|
+
|
|
8
|
+
## Steps
|
|
9
|
+
|
|
10
|
+
0. **Pre-flight.**
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
cd <repo_root>
|
|
14
|
+
current=$(git rev-parse --abbrev-ref HEAD)
|
|
15
|
+
if [ "$current" != "main" ]; then git checkout main; fi
|
|
16
|
+
git status --short
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
If `main` has uncommitted changes, stop and report — the user must clean up first.
|
|
20
|
+
|
|
21
|
+
1. Capture the `<PLAN-ID>` argument and optional flags:
|
|
22
|
+
|
|
23
|
+
- `--max-concurrent=N` — cap simultaneous sessions. Default `3`. Setting `--max-concurrent=1` yields serial behaviour.
|
|
24
|
+
- `--reset` — wipe `.cloverleaf/runs/plan/<PLAN-ID>/walk-state.json` and start fresh.
|
|
25
|
+
|
|
26
|
+
2. **Guard against cycles.**
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
cloverleaf-cli dag-detect-cycle <repo_root> <PLAN-ID>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
If non-zero exit, stop. The malformed Plan needs to be fixed first.
|
|
33
|
+
|
|
34
|
+
3. **Load or initialise walk-state.**
|
|
35
|
+
|
|
36
|
+
On `--reset`, `rm -f <repo_root>/.cloverleaf/runs/plan/<PLAN-ID>/walk-state.json`. Then:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
if ! cloverleaf-cli walk-state-read <repo_root> <PLAN-ID> > /tmp/walk-state-<PLAN-ID>.json 2>/dev/null; then
|
|
40
|
+
cat > /tmp/walk-state-<PLAN-ID>.json <<EOF
|
|
41
|
+
{
|
|
42
|
+
"plan_id": "<PLAN-ID>",
|
|
43
|
+
"started": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
|
44
|
+
"max_concurrent": <MAX_CONCURRENT>,
|
|
45
|
+
"tasks": {}
|
|
46
|
+
}
|
|
47
|
+
EOF
|
|
48
|
+
cloverleaf-cli walk-state-write <repo_root> /tmp/walk-state-<PLAN-ID>.json
|
|
49
|
+
fi
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
4. **Resumability: reconcile running sessions.**
|
|
53
|
+
|
|
54
|
+
For each task in the walk-state with `state === "running"`:
|
|
55
|
+
|
|
56
|
+
- Query `claw-drive sessions` — is the session still live?
|
|
57
|
+
- **Still running** → keep it, start the watch monitor from `last_seq`.
|
|
58
|
+
- **Stopped cleanly** → check the task's on-disk status:
|
|
59
|
+
- `merged` → update walk-state `state: "merged"`.
|
|
60
|
+
- Anything else → mark `state: "pending"` for re-scheduling.
|
|
61
|
+
- **Stopped with error** → mark `state: "pending"`.
|
|
62
|
+
|
|
63
|
+
Atomic walk-state writes: every update goes through `cloverleaf-cli walk-state-write`.
|
|
64
|
+
|
|
65
|
+
5. **Schedule loop.** Repeat until no running sessions AND no ready tasks:
|
|
66
|
+
|
|
67
|
+
a. **Compute ready tasks.**
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
cloverleaf-cli dag-ready-tasks <repo_root> <PLAN-ID> <MAX_CONCURRENT>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Returns a newline-separated list of task IDs that are `pending`, have all ancestors `merged`, and fit within free concurrency slots.
|
|
74
|
+
|
|
75
|
+
b. **For each ready task**, isolate it in its own git worktree and spawn a claw-drive Session B rooted in that worktree. A shared `cwd` across concurrent sessions is **unsafe** — each Session B's `/cloverleaf-run` mutates HEAD via `git checkout -b cloverleaf/<TASK-ID>`, and parallel Sessions on one working tree irreparably clobber each other's branches and state. Worktrees give each Session its own working directory so code + state commits on the task branch are fully isolated; the walker (in the primary repo, on `main`) handles the final merge serially.
|
|
76
|
+
|
|
77
|
+
Per ready task:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
WT="${XDG_CACHE_HOME:-$HOME/.cache}/cloverleaf/walker/<PLAN-ID>-<TASK-ID>"
|
|
81
|
+
mkdir -p "$(dirname "$WT")"
|
|
82
|
+
rm -rf "$WT" # idempotent: clean any leftover from a prior run
|
|
83
|
+
git -C <repo_root> worktree add "$WT" -b cloverleaf/<TASK-ID> main
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Then `mcp__claw-drive__start_session` with:
|
|
87
|
+
|
|
88
|
+
- `cwd`: `$WT` (NOT `<repo_root>`)
|
|
89
|
+
- `decision_timeout_seconds`: `3600`
|
|
90
|
+
- `scenario_brief`: constructed for this task (see "Session brief template" below — critically, the brief instructs Session B to stop **before** invoking `/cloverleaf-merge`; the walker merges on main in step 5e).
|
|
91
|
+
- `policy`: the v0.6 walker policy (see "Walker policy" below).
|
|
92
|
+
|
|
93
|
+
Record the returned `session_id`, `worktree_path`, and `branch_name` in walk-state with `state: "running"`, `started_at: <now>`, `last_seq: 0`. Persist via `walk-state-write`.
|
|
94
|
+
|
|
95
|
+
c. **Monitor live sessions.** Start `claw-drive watch <session_id> --since <last_seq>` for each running session. Merge the streams into a single notification feed (e.g., the Monitor tool, or `claw-drive watch` run per-session in the background with a filter).
|
|
96
|
+
|
|
97
|
+
d. **Handle events.**
|
|
98
|
+
|
|
99
|
+
- **tool_decision_required** → let the walker policy decide (auto-approve per rules, defer to user for anything not covered).
|
|
100
|
+
- **turn_completed with final-gate prompt text** → push onto the final-gate queue.
|
|
101
|
+
- **Escalation detected** (assistant text contains `escalated` / Reviewer/QA/UI-Reviewer bounce cap / git merge abort) → **surface to user immediately** with:
|
|
102
|
+
> ⚠️ `<TASK-ID>` escalated at `<agent>` (reason: `<detail>`). Session `<session_id>`. Descendants in this Plan are now blocked until you unstick it.
|
|
103
|
+
> To unstick: read feedback at `.cloverleaf/feedback/<TASK-ID>-*.json`, fix the issue, and run `/cloverleaf-run <TASK-ID>` manually. The walker will re-check on its next tick — when the task reaches `merged`, it'll pick up descendants automatically.
|
|
104
|
+
Mark the task `state: "escalated"` in walk-state; do NOT queue it behind final-gate approvals; continue other branches.
|
|
105
|
+
- **session_stopped** → reconcile as in step 4.
|
|
106
|
+
- **Per-session idle > 30 min** → surface to user for inspection; do NOT auto-kill.
|
|
107
|
+
|
|
108
|
+
e. **Drain the final-gate queue serially and merge on main.** Session B does NOT invoke `/cloverleaf-merge` — it stops at automated-gates (fast lane) or final-gate (full pipeline) and reports. The walker performs the merge on main in the primary repo. For each queued task:
|
|
109
|
+
|
|
110
|
+
1. Print a full summary to the driver:
|
|
111
|
+
```
|
|
112
|
+
⏵ <TASK-ID> ready to merge (<fast lane | full pipeline>)
|
|
113
|
+
Reviewer: <summary>
|
|
114
|
+
UI Reviewer: <summary or "skipped">
|
|
115
|
+
QA: <summary or "n/a for fast lane">
|
|
116
|
+
Session <session_id>, worktree <worktree_path>
|
|
117
|
+
|
|
118
|
+
Confirm merge? (y/N, or ask a question)
|
|
119
|
+
```
|
|
120
|
+
2. Read the user's response.
|
|
121
|
+
3. If it matches `^y(es)?$|^Y(ES)?$` → perform the merge in the primary repo:
|
|
122
|
+
|
|
123
|
+
First, **guard against conflict markers** — scan every file changed on the task branch for unresolved conflict markers before attempting the merge:
|
|
124
|
+
```bash
|
|
125
|
+
cd <repo_root>
|
|
126
|
+
git checkout main
|
|
127
|
+
CHANGED_FILES=$(git diff --name-only main..cloverleaf/<TASK-ID>)
|
|
128
|
+
if [ -n "$CHANGED_FILES" ] && echo "$CHANGED_FILES" | xargs grep -l -E '^(<{7}|={7}|>{7})' 2>/dev/null | grep -q .; then
|
|
129
|
+
echo "ERROR: conflict markers found in changed files — aborting merge for <TASK-ID>"
|
|
130
|
+
echo "$CHANGED_FILES" | xargs grep -l -E '^(<{7}|={7}|>{7})' 2>/dev/null
|
|
131
|
+
# Do NOT proceed; mark task escalated and surface to user
|
|
132
|
+
else
|
|
133
|
+
git merge --no-ff cloverleaf/<TASK-ID> -m "cloverleaf: <TASK-ID> merged (<fast_lane | full_pipeline>)"
|
|
134
|
+
fi
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
If conflict markers are found, abort the merge: mark task `state: "escalated"` in walk-state, surface to the user with the list of affected files, and do NOT advance state. Continue with the next queued task.
|
|
138
|
+
|
|
139
|
+
After a **successful** `git merge --no-ff`, advance state and commit:
|
|
140
|
+
```bash
|
|
141
|
+
# Fast lane:
|
|
142
|
+
cloverleaf-cli emit-gate-decision <repo_root> <TASK-ID> human_merge approve human
|
|
143
|
+
cloverleaf-cli advance-status <repo_root> <TASK-ID> merged human human_merge fast_lane
|
|
144
|
+
# Full pipeline (task is already at final-gate):
|
|
145
|
+
cloverleaf-cli emit-gate-decision <repo_root> <TASK-ID> final_approval_gate approve human
|
|
146
|
+
cloverleaf-cli advance-status <repo_root> <TASK-ID> merged human final_approval_gate full_pipeline
|
|
147
|
+
```
|
|
148
|
+
```bash
|
|
149
|
+
git add .cloverleaf/ && git commit -m "cloverleaf: <TASK-ID> merged"
|
|
150
|
+
```
|
|
151
|
+
Capture the merge commit SHA:
|
|
152
|
+
```bash
|
|
153
|
+
MERGE_COMMIT=$(git rev-parse HEAD)
|
|
154
|
+
```
|
|
155
|
+
Immediately update walk-state to record the successful merge (bug #7 fix — walk-state must reflect `merged` state):
|
|
156
|
+
```bash
|
|
157
|
+
# Write a temporary walk-state JSON with state: "merged" and merge_commit, then persist atomically
|
|
158
|
+
# (build the updated walk-state object in-memory and call walk-state-write)
|
|
159
|
+
cloverleaf-cli walk-state-write <repo_root> <updated-walk-state-json-path>
|
|
160
|
+
# The updated walk-state sets tasks["<TASK-ID>"].state = "merged"
|
|
161
|
+
# and tasks["<TASK-ID>"].merge_commit = "$MERGE_COMMIT"
|
|
162
|
+
```
|
|
163
|
+
Send `y` (informational) back to Session B so it can record the outcome and exit, but the walker is the authoritative merge-performer.
|
|
164
|
+
**Tear down the worktree**: `git -C <repo_root> worktree remove --force <worktree_path>`. Delete the branch is optional (keep if useful for post-hoc inspection).
|
|
165
|
+
4. If it matches `^n(o)?$|^N(O)?$` → mark task `state: "awaiting_final_gate"`. Send `n` to Session B. **Keep the worktree** so the user can re-run `/cloverleaf-merge <TASK-ID>` manually pointing at it, or fix and retry. Continue with the next queued task.
|
|
166
|
+
5. Otherwise → forward the user's text as a user turn to Session B via `mcp__claw-drive__send_turn` (it's a question). Wait for the session's next `turn_completed`. Print the answer. **Re-surface the same y/N prompt** (with the Q&A appended to shown context). Loop until step 3 or 4 fires.
|
|
167
|
+
|
|
168
|
+
Final-gate drain is strictly serial across tasks — one prompt, one decision, then the next. The merge itself is sequential on main for the same reason: two concurrent `git merge --no-ff` on main would race, even if the feature branches are independent.
|
|
169
|
+
|
|
170
|
+
f. **Exit check.** If no running sessions AND `dag-ready-tasks` returned empty AND the final-gate queue is empty, break the loop.
|
|
171
|
+
|
|
172
|
+
6. **Report.**
|
|
173
|
+
|
|
174
|
+
- `merged: [ ... ]` — with merge-commit SHAs.
|
|
175
|
+
- `escalated: [ ... ]` — with reason per task.
|
|
176
|
+
- `awaiting_final_gate: [ ... ]` — user said `n`; re-invoke `/cloverleaf-merge <TASK-ID>` to retry.
|
|
177
|
+
- `unreachable: [ ... ]` — descendants of escalated tasks.
|
|
178
|
+
|
|
179
|
+
If every task in the plan's `task_dag.nodes` has `state: "merged"`, print: "✓ Plan `<PLAN-ID>` complete."
|
|
180
|
+
|
|
181
|
+
## Session brief template
|
|
182
|
+
|
|
183
|
+
The walker constructs a per-task `scenario_brief` roughly like:
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
You are driving <TASK-ID> Delivery via /cloverleaf-run inside a dedicated
|
|
187
|
+
git worktree at <worktree_path>. The worktree is checked out to branch
|
|
188
|
+
cloverleaf/<TASK-ID> (already created from main). Task risk_class: <class>.
|
|
189
|
+
|
|
190
|
+
Plan: invoke `/cloverleaf-run <TASK-ID>`.
|
|
191
|
+
|
|
192
|
+
**DO NOT invoke `/cloverleaf-merge`**. Fast lane stops after `/cloverleaf-review`
|
|
193
|
+
lands the task at `automated-gates`. Full pipeline stops after QA/UI-Review
|
|
194
|
+
lands the task at `final-gate`. Report status + summaries at that point and
|
|
195
|
+
exit cleanly. The walker runs in the primary repo on `main` and performs the
|
|
196
|
+
real `git merge --no-ff` itself after human approval — the worktree's main
|
|
197
|
+
branch can't be checked out concurrently, which is why the walker owns the
|
|
198
|
+
merge. If `/cloverleaf-run` would normally invoke `/cloverleaf-merge`
|
|
199
|
+
internally (fast-lane orchestrator), interrupt before that step and exit.
|
|
200
|
+
|
|
201
|
+
All four v0.5.2+v0.5.3+v0.5.4+v0.5.5 dogfood fixes are in place:
|
|
202
|
+
- /cloverleaf-merge actor: human final_approval_gate full_pipeline.
|
|
203
|
+
- cloverleaf-cli prep-worktree is idempotent.
|
|
204
|
+
- Documenter runs `git status --porcelain` and stages every modified doc.
|
|
205
|
+
- cloverleaf-ui-review uses /cloverleaf-approve-baselines (fully-qualified).
|
|
206
|
+
|
|
207
|
+
Expected: zero interventions until you reach automated-gates / final-gate,
|
|
208
|
+
then exit.
|
|
209
|
+
|
|
210
|
+
Do not push. Do not publish. Report merge + state commit SHAs on completion.
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Walker policy
|
|
214
|
+
|
|
215
|
+
The walker spawns each Session B with a conservative auto-approve policy (Read/Glob/Grep, git-read, cloverleaf-cli, npm/npx/node, common compound scripts, prep-worktree, mkdir -p, etc.) and an auto-reject list covering sudo, `rm -rf /`, git push, npm publish, destructive disk ops. Anything else escalates to the walker for human-in-the-loop handling.
|
|
216
|
+
|
|
217
|
+
The concrete policy JSON is the same one used during the CLV-16..CLV-20 dogfood runs; see `.cloverleaf/claw-drive-policy.json` in the repo for the starting template.
|
|
218
|
+
|
|
219
|
+
## Rules
|
|
220
|
+
|
|
221
|
+
- Never push. Never publish.
|
|
222
|
+
- Always persist walk-state via `cloverleaf-cli walk-state-write` (atomic). Never write the file directly.
|
|
223
|
+
- Always treat the on-disk `.cloverleaf/tasks/<id>.json` status as the source of truth AFTER a task's branch has been merged; before that, the task's state lives on `cloverleaf/<TASK-ID>` in its worktree (walk-state is authoritative for the walker's scheduling decisions during the walk).
|
|
224
|
+
- **Every ready task runs in its own git worktree.** Sharing `cwd` across concurrent sessions is unsafe — parallel `git checkout` / `commit` races corrupt branches and state. The walker creates a worktree per task, passes it to Session B as `cwd`, and owns the final merge serially on main.
|
|
225
|
+
- Session B must NOT invoke `/cloverleaf-merge`. The walker performs the merge in the primary repo, on main, as the authoritative merge-performer.
|
|
226
|
+
- Escalations surface immediately; they do NOT queue behind the final-gate drain.
|
|
227
|
+
- Final-gate drain is serial across tasks — one prompt, one decision.
|
|
228
|
+
- The walker exits after the loop reports the final status; it does not auto-retry escalated tasks.
|
package/dist/state.mjs
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { createRequire } from 'node:module';
|
|
4
|
-
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { tasksDir, projectsDir } from './paths.mjs';
|
|
6
|
-
import { emitStatusTransition, formatReason } from './events.mjs';
|
|
7
|
-
// Import validator from @cloverleaf/standard.
|
|
8
|
-
// The standard package ships TypeScript source only with no exports map.
|
|
9
|
-
// Vitest (via vite-node) resolves .js → .ts for workspace symlinked packages,
|
|
10
|
-
// so the .js convention works here. If it ever fails with "module not found",
|
|
11
|
-
// switch the specifier to '@cloverleaf/standard/validators/index.ts'.
|
|
12
|
-
import { validateStatusTransitionLegality } from '@cloverleaf/standard/validators/index.js';
|
|
13
|
-
import { validateOrThrow } from './validate.mjs';
|
|
14
|
-
const req = createRequire(import.meta.url);
|
|
15
|
-
export function loadTask(repoRoot, taskId) {
|
|
16
|
-
const path = join(tasksDir(repoRoot), `${taskId}.json`);
|
|
17
|
-
if (!existsSync(path))
|
|
18
|
-
throw new Error(`Task ${taskId} not found at ${path}`);
|
|
19
|
-
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
20
|
-
}
|
|
21
|
-
export function saveTask(repoRoot, task) {
|
|
22
|
-
validateOrThrow('https://cloverleaf.example/schemas/task.schema.json', task);
|
|
23
|
-
const path = join(tasksDir(repoRoot), `${task.id}.json`);
|
|
24
|
-
writeFileSync(path, JSON.stringify(task, null, 2) + '\n');
|
|
25
|
-
}
|
|
26
|
-
export function loadProject(repoRoot, projectId) {
|
|
27
|
-
const path = join(projectsDir(repoRoot), `${projectId}.json`);
|
|
28
|
-
if (!existsSync(path))
|
|
29
|
-
throw new Error(`Project ${projectId} not found at ${path}`);
|
|
30
|
-
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
31
|
-
}
|
|
32
|
-
function loadTaskStateMachine() {
|
|
33
|
-
// state-machines/task.json is a static JSON asset. Navigate from standard's
|
|
34
|
-
// package.json — no exports map support needed.
|
|
35
|
-
const pkgPath = req.resolve('@cloverleaf/standard/package.json');
|
|
36
|
-
const pkgDir = pkgPath.replace(/\/package\.json$/, '');
|
|
37
|
-
return JSON.parse(readFileSync(`${pkgDir}/state-machines/task.json`, 'utf-8'));
|
|
38
|
-
}
|
|
39
|
-
export function advanceStatus(repoRoot, taskId, toStatus, actor, options = {}) {
|
|
40
|
-
const task = loadTask(repoRoot, taskId);
|
|
41
|
-
const from = task.status;
|
|
42
|
-
const sm = loadTaskStateMachine();
|
|
43
|
-
// Read risk_class directly from the task (defaulting to 'low' if absent).
|
|
44
|
-
// The validator derives itemPath from workItem.risk_class: low → fast_lane, else full_pipeline.
|
|
45
|
-
// If caller passed options.path, translate it back to risk_class for the validator.
|
|
46
|
-
const riskClass = options.path === 'fast_lane' ? 'low'
|
|
47
|
-
: options.path === 'full_pipeline' ? 'high'
|
|
48
|
-
: (task.risk_class ?? 'low');
|
|
49
|
-
// Build a minimal Task-shaped object so the validator can resolve path-tagged transitions.
|
|
50
|
-
const workItemForValidator = {
|
|
51
|
-
type: 'task',
|
|
52
|
-
id: task.id,
|
|
53
|
-
project: task.project,
|
|
54
|
-
status: task.status,
|
|
55
|
-
risk_class: riskClass,
|
|
56
|
-
context: { rfc: { project: task.project, id: task.id } },
|
|
57
|
-
definition_of_done: task.definition_of_done,
|
|
58
|
-
acceptance_criteria: task.acceptance_criteria,
|
|
59
|
-
};
|
|
60
|
-
const reason = formatReason({ gate: options.gate, path: options.path });
|
|
61
|
-
const event = {
|
|
62
|
-
event_id: randomUUID(),
|
|
63
|
-
event_type: 'status_transition',
|
|
64
|
-
occurred_at: new Date().toISOString(),
|
|
65
|
-
work_item_id: { project: task.project, id: task.id },
|
|
66
|
-
work_item_type: 'task',
|
|
67
|
-
from_status: from,
|
|
68
|
-
to_status: toStatus,
|
|
69
|
-
actor: { kind: actor, id: actor },
|
|
70
|
-
...(reason ? { reason } : {}),
|
|
71
|
-
};
|
|
72
|
-
const result = validateStatusTransitionLegality(event, sm, workItemForValidator);
|
|
73
|
-
if (!result.ok) {
|
|
74
|
-
const msgs = result.violations.map((v) => v.message).join('; ');
|
|
75
|
-
throw new Error(`Illegal transition ${from} → ${toStatus}: ${msgs}`);
|
|
76
|
-
}
|
|
77
|
-
// NEW: emit first, save second. validateStatusTransitionLegality stays above.
|
|
78
|
-
const emittedPath = emitStatusTransition(repoRoot, {
|
|
79
|
-
project: task.project,
|
|
80
|
-
workItemType: 'task',
|
|
81
|
-
workItemId: task.id,
|
|
82
|
-
from,
|
|
83
|
-
to: toStatus,
|
|
84
|
-
actor,
|
|
85
|
-
gate: options.gate,
|
|
86
|
-
path: options.path,
|
|
87
|
-
});
|
|
88
|
-
const proposed = { ...task, status: toStatus };
|
|
89
|
-
try {
|
|
90
|
-
saveTask(repoRoot, proposed);
|
|
91
|
-
}
|
|
92
|
-
catch (err) {
|
|
93
|
-
const inner = err instanceof Error ? err.message : String(err);
|
|
94
|
-
throw new Error(`orphan event written to ${emittedPath} but task save failed: ${inner}`);
|
|
95
|
-
}
|
|
96
|
-
return proposed;
|
|
97
|
-
}
|