forge-orkes 0.68.1 → 0.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/.claude/hooks/forge-slice-runner.sh +8 -0
- package/template/.claude/skills/jarvis/SKILL.md +2 -0
- package/template/.claude/skills/planning/SKILL.md +2 -0
- package/template/.forge/checks/forge-jarvis-dispatch.sh +180 -12
- package/template/.forge/checks/forge-roadmap-check.sh +246 -0
- package/template/.forge/checks/tests/forge-jarvis-dispatch.test.sh +147 -0
package/package.json
CHANGED
|
@@ -571,7 +571,15 @@ for phase in $PHASES; do
|
|
|
571
571
|
|
|
572
572
|
# (4) REQUIRE the on-disk phase-report.yml. Absent = crash → halt now (do NOT
|
|
573
573
|
# spend the retry on a crash; the resume path re-runs it from files).
|
|
574
|
+
# This is a slice END, so it MUST ping like the other two ends (checks-failed
|
|
575
|
+
# halt below, done) — a silent phone is never success. Live gap: canvaz
|
|
576
|
+
# 2026-07-23, m-EVT01 hit error_max_turns and the slice died dark. Key is
|
|
577
|
+
# per-phase ("$phase-crash"), NOT the shared "slice" key: a crash marker on
|
|
578
|
+
# the shared key would suppress a LATER legitimate checks-failed halt after
|
|
579
|
+
# resume; a re-crash of the same phase stays deduped (append-once semantics).
|
|
574
580
|
if [ ! -f "$report" ]; then
|
|
581
|
+
slice_notify halt "$phase-crash" \
|
|
582
|
+
"slice HALTED at phase $phase ($idx/$total) — launcher crash, no phase-report.yml written (attempt $attempt); see $wdir/result.json + $wdir/launch.err"
|
|
575
583
|
printf '[runner] phase %s: no phase-report.yml written at %s — halting\n' "$phase" "$report" >&2
|
|
576
584
|
printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
|
|
577
585
|
exit 1
|
|
@@ -56,6 +56,8 @@ The helper (see its header for the full contract) owns the caller-side environme
|
|
|
56
56
|
- **`FORGE_SLICE_NOTIFY` points at a log-only channel** (`forge-jarvis-notify-logonly.sh`) so the default channel's per-ping headless `claude -p` micro-call stays quiet — the B4 relay is the single delivery path; a live default channel would risk a double-buzz (B1 probe x). This is the swappable channel seam used exactly as designed: the runner is untouched, the caller owns the env.
|
|
57
57
|
- **The model map is forwarded verbatim.** The helper carries no model-selection logic (see Boundaries — routing is step 5).
|
|
58
58
|
- **The work order is materialized at dispatch** (FR-197): the runner's contract is `<worktree>/slice.yml` + `phases/<name>/plan.md`; when `slice.yml` is absent the helper builds both from the milestone's locked `.forge/phases/milestone-{id}/` plans (an existing `slice.yml` is honored untouched). When it cannot, it **refuses synchronously, before the detach** — relay that refusal to the operator immediately. And shortly after any real dispatch, read `<worktree>/.forge/runner-work/dispatch.out` once: a runner refusal lands there and must be relayed — **a silent phone is never success** (walk run 4's failure mode: the runner declined instantly and nothing ever buzzed).
|
|
59
|
+
- **Mid-milestone re-dispatch is `--plan`** (FR-234): a later "go" through the same worktree (plan-01 landed, plan-02 next) adds `--plan <phase>/plan-02` (or `--plan <phase>` for a whole phase; repeatable) so only the named work is bundled — never landed plans. A typo'd spec refuses loudly; a hand-authored `slice.yml` always wins (`--plan` refuses to replace it — only a helper-materialized one is archived and rebuilt). No more hand-authoring a scoped `slice.yml` per re-dispatch.
|
|
60
|
+
- **A new work order starts pristine** (FR-235): materializing a new `slice.yml` automatically rotates stale `<worktree>/.forge/runner-work/` into `.forge/runner-work-archive/<ts>/` — old ping markers and phase reports would otherwise silence the new run's done ping and skip its phases. A resume (crash, or park→answer→resume) keeps the existing `slice.yml` and is never rotated. Nothing is deleted; the archive keeps the prior run's evidence.
|
|
59
61
|
- **Runner artifacts live at knowable paths — never walk the filesystem for them** (a walk rooted at `/` or the bare home is refused by the Bash guard): work dir `<worktree>/.forge/runner-work/`, per-phase `phase-<N>/result.json` (with `phase-report.yml` and `answer.md`), ping log `notify.log`, dispatch/refusal output `dispatch.out` — state these paths to any session you spawn about a slice.
|
|
60
62
|
|
|
61
63
|
After dispatch, the slice runs headless in its worktree, writing every ping to its notify log. That log is the event bus the callback relay watches (B4).
|
|
@@ -139,6 +139,8 @@ If a sibling milestone (e.g. m50) has state + requirements but is missing from `
|
|
|
139
139
|
|
|
140
140
|
**Promoted-from-backlog milestone (`milestone.origin` set).** If `state/milestone-{N}.yml` carries `milestone.origin: {R-id}` (set by `forge` Step 1.2 when promoting a Standard-tier refactor item), no extra bookkeeping is needed here — the originating backlog item is flipped to `done` automatically when the milestone completes (see `reviewing` → **Promoted Milestone Completion Hook**). Just plan it as a normal milestone.
|
|
141
141
|
|
|
142
|
+
**Roadmap integrity check (advisory, m-39).** After writing or updating `roadmap.yml` in any of the three cases above, run `.forge/checks/forge-roadmap-check.sh`. If it exits non-zero, surface every `finding=...` line to the operator as one advisory note (e.g. *"roadmap-check found {N} issue(s): {lines}"*). This NEVER blocks — Step 9 Present and the handoff proceed regardless of findings; it is a signal, not a gate (same advisory posture as `verification.commands` in FORGE.md → Verification Gates). Script absent (pre-m-39 install) → skip silently.
|
|
143
|
+
|
|
142
144
|
## Step 6: Decompose Tasks
|
|
143
145
|
|
|
144
146
|
### Step 6.1: Cross-Layer Contract Detection
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env sh
|
|
2
|
-
# forge-jarvis-dispatch.sh --slice <worktree> [runner args...] [--dry-run]
|
|
3
|
-
# (m-37, Brief-R2 step 4 B3; contract handoff-step4-jarvis.md REV2)
|
|
2
|
+
# forge-jarvis-dispatch.sh --slice <worktree> [runner args...] [--dry-run] [--plan <spec>]...
|
|
3
|
+
# (m-37, Brief-R2 step 4 B3; contract handoff-step4-jarvis.md REV2; m-45 re-dispatch hygiene)
|
|
4
4
|
#
|
|
5
5
|
# On the operator's "go", Jarvis dispatches the slice runner DETACHED so the build
|
|
6
6
|
# survives Jarvis's death (B1 probe viii: nohup-&). This helper owns ONLY the caller-side
|
|
@@ -25,12 +25,40 @@
|
|
|
25
25
|
# this dispatch ever starts choosing models, STOP — routing is step 5.
|
|
26
26
|
#
|
|
27
27
|
# All non-flag args are forwarded to the runner unchanged (--slice <worktree>, and optionally
|
|
28
|
-
# --work-dir / --config / --schema / --report-template — whatever the runner accepts). The
|
|
29
|
-
#
|
|
28
|
+
# --work-dir / --config / --schema / --report-template — whatever the runner accepts). The args
|
|
29
|
+
# this helper consumes for itself are --dry-run, --materialize-only, and --plan (the runner has
|
|
30
|
+
# no --plan; forwarding it would be a routing-adjacent contract change — FR-198 boundary class).
|
|
30
31
|
#
|
|
31
32
|
# --dry-run print the exact `nohup env ... runner ...` command + env on stdout and exit 0;
|
|
32
33
|
# never dispatches. Use it to show the operator what "go" will run.
|
|
33
34
|
#
|
|
35
|
+
# --plan <spec> (repeatable; m-45, canvaz desire path 2026-07-22 defect 2) scope the
|
|
36
|
+
# materialized work order instead of bundling EVERY phase dir + plan-NN.md:
|
|
37
|
+
# --plan 72-bbb → the whole phase dir 72-bbb (all its plan-NN.md)
|
|
38
|
+
# --plan 72-bbb/plan-02 → exactly that plan file (.md optional)
|
|
39
|
+
# The mid-milestone re-dispatch tool: plan-01 landed, plan-02 next → dispatch
|
|
40
|
+
# --plan <phase>/plan-02 and only the next plan is bundled — never landed work.
|
|
41
|
+
# A spec that resolves to nothing REFUSES (exit 2, before the detach) — a typo
|
|
42
|
+
# must not dispatch the wrong work order. With an existing slice.yml: one this
|
|
43
|
+
# helper materialized (header line) is archived + re-materialized; a hand-
|
|
44
|
+
# authored one REFUSES — hand-authored work orders win, delete it yourself.
|
|
45
|
+
# Selected phases keep numeric order regardless of flag order.
|
|
46
|
+
#
|
|
47
|
+
# STALE-STATE ROTATION (m-45, canvaz desire path 2026-07-22 defect 3). Runner-work state is
|
|
48
|
+
# per-WORK-ORDER: pings/{key}-{kind}.marker dedups pings across re-runs of the SAME order, and
|
|
49
|
+
# index-keyed phase-{idx}/phase-report.yml verdicts derive its resume point. Dispatching a NEW
|
|
50
|
+
# work order through the same worktree with that state in place is poison — the old done marker
|
|
51
|
+
# silences the new run's done ping, and a stale verdict short-circuits the new phase as already
|
|
52
|
+
# complete (observed live: silent phone, last_ping=none). So whenever this helper materializes a
|
|
53
|
+
# NEW slice.yml it first rotates a non-empty .forge/runner-work WHOLE into
|
|
54
|
+
# .forge/runner-work-archive/<UTC-ts>/ — whole because relay-markers are keyed by notify.log
|
|
55
|
+
# LINE NUMBER (sibling dir); a partial reset would misalign relay offsets. Archive, never
|
|
56
|
+
# delete: git commits are the durable evidence, runner-work is runtime. The resume paths are
|
|
57
|
+
# untouched by construction — crash re-dispatch and park→answer→resume arrive with slice.yml
|
|
58
|
+
# present and take the honored-as-is early return, so rotation never fires there. An explicitly
|
|
59
|
+
# forwarded --work-dir is the caller's own state management and is NOT rotated; dispatching a
|
|
60
|
+
# new order while the prior runner still lives in the worktree is operator error, unchanged.
|
|
61
|
+
#
|
|
34
62
|
# WORK-ORDER MATERIALIZATION (FR-197; walk run 4, 2026-07-22). The runner's contract
|
|
35
63
|
# (consumed, never edited) is <worktree>/slice.yml listing phase names, each resolving to
|
|
36
64
|
# <worktree>/phases/<name>/plan.md — but the Forge intake act writes plans under
|
|
@@ -55,6 +83,8 @@ CHANNEL="$SELF_DIR/forge-jarvis-notify-logonly.sh" # the log-only cha
|
|
|
55
83
|
DRY_RUN=0
|
|
56
84
|
MAT_ONLY=0
|
|
57
85
|
WORKTREE=""
|
|
86
|
+
PLAN_SPECS="" # newline-separated --plan specs (may be empty)
|
|
87
|
+
REPLACE_MATERIALIZED=0 # set when --plan replaces a helper-materialized slice.yml
|
|
58
88
|
argc=$#
|
|
59
89
|
while [ "$argc" -gt 0 ]; do
|
|
60
90
|
arg="$1"; shift; argc=$((argc - 1))
|
|
@@ -63,6 +93,11 @@ while [ "$argc" -gt 0 ]; do
|
|
|
63
93
|
DRY_RUN=1 ;; # ours — not forwarded
|
|
64
94
|
--materialize-only)
|
|
65
95
|
MAT_ONLY=1 ;; # ours — not forwarded
|
|
96
|
+
--plan) # ours — not forwarded (runner has no --plan)
|
|
97
|
+
_spec="${1:-}"; shift; argc=$((argc - 1))
|
|
98
|
+
[ -n "$_spec" ] || { printf 'forge-jarvis-dispatch: --plan requires a <phase>[/plan-NN] spec\n' >&2; exit 2; }
|
|
99
|
+
PLAN_SPECS="${PLAN_SPECS}${_spec}
|
|
100
|
+
" ;;
|
|
66
101
|
--slice)
|
|
67
102
|
WORKTREE="${1:-}"; shift; argc=$((argc - 1))
|
|
68
103
|
set -- "$@" --slice "$WORKTREE" ;; # forward it too (runner needs it)
|
|
@@ -78,15 +113,68 @@ WORKTREE="$(cd "$WORKTREE" && pwd)" # absolutize — the log pa
|
|
|
78
113
|
[ -f "$RUNNER" ] || { printf 'forge-jarvis-dispatch: runner not found: %s\n' "$RUNNER" >&2; exit 2; }
|
|
79
114
|
[ -f "$CHANNEL" ] || { printf 'forge-jarvis-dispatch: log-only channel not found: %s\n' "$CHANNEL" >&2; exit 2; }
|
|
80
115
|
|
|
116
|
+
# --- rotate stale runner state when a NEW work order lands (FR-235, m-45) --------------------
|
|
117
|
+
# Called at exactly ONE point: right before materialize_work_order commits a new slice.yml —
|
|
118
|
+
# every refusal path exits earlier, and the resume paths (crash re-dispatch, park→answer→
|
|
119
|
+
# resume) take the honored-as-is early return and never reach it. The dir moves WHOLE (see
|
|
120
|
+
# header: relay-markers are line-number-keyed siblings of notify.log). Archive, never delete.
|
|
121
|
+
rotate_runner_work() {
|
|
122
|
+
_rw="$WORKTREE/.forge/runner-work"
|
|
123
|
+
_stale=0
|
|
124
|
+
if [ -d "$_rw" ] && [ -n "$(ls -A "$_rw" 2>/dev/null)" ]; then _stale=1; fi
|
|
125
|
+
[ "$_stale" = 1 ] || [ "$REPLACE_MATERIALIZED" = 1 ] || return 0
|
|
126
|
+
_parent="$WORKTREE/.forge/runner-work-archive"
|
|
127
|
+
_base="$_parent/$(date -u +%Y%m%dT%H%M%SZ)"
|
|
128
|
+
_dest="$_base"; _n=2
|
|
129
|
+
while [ -e "$_dest" ] || [ -L "$_dest" ]; do _dest="$_base-$_n"; _n=$((_n + 1)); done
|
|
130
|
+
mkdir -p "$_parent" || {
|
|
131
|
+
printf 'forge-jarvis-dispatch: REFUSED — cannot create %s; stale runner-work would poison the new work order. RELAY this refusal to the operator.\n' "$_parent" >&2
|
|
132
|
+
return 2
|
|
133
|
+
}
|
|
134
|
+
if [ "$_stale" = 1 ]; then
|
|
135
|
+
mv "$_rw" "$_dest" || {
|
|
136
|
+
printf 'forge-jarvis-dispatch: REFUSED — cannot rotate %s → %s; stale runner-work would poison the new work order. RELAY this refusal to the operator.\n' "$_rw" "$_dest" >&2
|
|
137
|
+
return 2
|
|
138
|
+
}
|
|
139
|
+
else
|
|
140
|
+
mkdir -p "$_dest" || {
|
|
141
|
+
printf 'forge-jarvis-dispatch: REFUSED — cannot create %s. RELAY this refusal to the operator.\n' "$_dest" >&2
|
|
142
|
+
return 2
|
|
143
|
+
}
|
|
144
|
+
fi
|
|
145
|
+
# A replaced helper-materialized slice.yml is part of the prior run — archive it alongside.
|
|
146
|
+
if [ "$REPLACE_MATERIALIZED" = 1 ] && [ -f "$WORKTREE/slice.yml" ]; then
|
|
147
|
+
mv "$WORKTREE/slice.yml" "$_dest/slice.yml" || {
|
|
148
|
+
printf 'forge-jarvis-dispatch: REFUSED — cannot archive the prior slice.yml into %s. RELAY this refusal to the operator.\n' "$_dest" >&2
|
|
149
|
+
return 2
|
|
150
|
+
}
|
|
151
|
+
fi
|
|
152
|
+
printf 'forge-jarvis-dispatch: rotated stale runner state -> %s\n' "$_dest" >&2
|
|
153
|
+
return 0
|
|
154
|
+
}
|
|
155
|
+
|
|
81
156
|
# --- materialize the work order from the milestone's locked plans (FR-197) -------------------
|
|
82
|
-
# slice.yml present → honored untouched
|
|
83
|
-
# .forge/phases/milestone-*/ dir: one
|
|
84
|
-
# concatenated in order), slice.yml listing
|
|
85
|
-
#
|
|
157
|
+
# slice.yml present → honored untouched (unless --plan targets a helper-materialized one —
|
|
158
|
+
# header doc). Absent → build it from the single .forge/phases/milestone-*/ dir: one
|
|
159
|
+
# phases/<name>/plan.md per phase subdir (plan-NN.md concatenated in order), slice.yml listing
|
|
160
|
+
# the names numerically; --plan scopes both. Cannot → REFUSE, exit 2, BEFORE the detach — the
|
|
161
|
+
# caller sees it synchronously and must relay it to the operator.
|
|
86
162
|
materialize_work_order() {
|
|
87
163
|
if [ -f "$WORKTREE/slice.yml" ]; then
|
|
88
|
-
|
|
89
|
-
|
|
164
|
+
if [ -z "$PLAN_SPECS" ]; then
|
|
165
|
+
printf 'forge-jarvis-dispatch: slice.yml present — using it as-is\n' >&2
|
|
166
|
+
return 0
|
|
167
|
+
fi
|
|
168
|
+
# --plan asks for a NEW work order. Only a slice.yml this helper itself wrote may be
|
|
169
|
+
# replaced (and it is archived, not lost — rotate_runner_work); a hand-authored work
|
|
170
|
+
# order wins, always.
|
|
171
|
+
case "$(head -n 1 "$WORKTREE/slice.yml" 2>/dev/null)" in
|
|
172
|
+
"# Materialized by forge-jarvis-dispatch"*)
|
|
173
|
+
REPLACE_MATERIALIZED=1 ;;
|
|
174
|
+
*)
|
|
175
|
+
printf 'forge-jarvis-dispatch: REFUSED — slice.yml present and not materialized by this helper; hand-authored work orders win. Delete it yourself to re-materialize with --plan. RELAY this refusal to the operator.\n' >&2
|
|
176
|
+
return 2 ;;
|
|
177
|
+
esac
|
|
90
178
|
fi
|
|
91
179
|
# Prefer the worktree's OWN milestone — .forge/phases/ carries the repo's whole milestone
|
|
92
180
|
# history (32 dirs on the forge repo), so "the only milestone dir" is a fixture-world
|
|
@@ -128,6 +216,54 @@ materialize_work_order() {
|
|
|
128
216
|
return 2
|
|
129
217
|
fi
|
|
130
218
|
fi
|
|
219
|
+
# --plan validation (FR-234): every spec must resolve inside the picked milestone dir —
|
|
220
|
+
# an unknown spec is a refusal, never a silent skip (a typo must not dispatch the wrong
|
|
221
|
+
# work order). Whole-phase specs must also hold at least one plan-NN.md, or the phase
|
|
222
|
+
# would silently vanish from the order below.
|
|
223
|
+
if [ -n "$PLAN_SPECS" ]; then
|
|
224
|
+
while IFS= read -r _s; do
|
|
225
|
+
[ -n "$_s" ] || continue
|
|
226
|
+
_sp="${_s%%/*}"
|
|
227
|
+
_pp=""; case "$_s" in */*) _pp="${_s#*/}" ;; esac
|
|
228
|
+
# Path hygiene (specs are semi-trusted operator input): the phase part must be a
|
|
229
|
+
# real dir NAME and the plan part a file NAME — a separator or dot-dir would let a
|
|
230
|
+
# crafted spec reach outside the milestone dir (case * matches / in sh patterns).
|
|
231
|
+
case "$_sp" in .|..)
|
|
232
|
+
printf 'forge-jarvis-dispatch: REFUSED — --plan %s: phase part must be a phase dir name. RELAY this refusal to the operator.\n' "$_s" >&2
|
|
233
|
+
return 2 ;;
|
|
234
|
+
esac
|
|
235
|
+
case "$_pp" in */*)
|
|
236
|
+
printf 'forge-jarvis-dispatch: REFUSED — --plan %s: the plan part must be a bare plan-NN name (no path separators). RELAY this refusal to the operator.\n' "$_s" >&2
|
|
237
|
+
return 2 ;;
|
|
238
|
+
esac
|
|
239
|
+
if [ ! -d "$_mdir/$_sp" ]; then
|
|
240
|
+
printf 'forge-jarvis-dispatch: REFUSED — --plan %s: no phase dir %s under %s. RELAY this refusal to the operator.\n' "$_s" "$_sp" "$_mdir" >&2
|
|
241
|
+
return 2
|
|
242
|
+
fi
|
|
243
|
+
if [ -n "$_pp" ]; then
|
|
244
|
+
case "$_pp" in *.md) : ;; *) _pp="$_pp.md" ;; esac
|
|
245
|
+
case "$_pp" in
|
|
246
|
+
plan-*.md) : ;;
|
|
247
|
+
*)
|
|
248
|
+
printf 'forge-jarvis-dispatch: REFUSED — --plan %s: the plan part must name a plan-NN file. RELAY this refusal to the operator.\n' "$_s" >&2
|
|
249
|
+
return 2 ;;
|
|
250
|
+
esac
|
|
251
|
+
if [ ! -f "$_mdir/$_sp/$_pp" ]; then
|
|
252
|
+
printf 'forge-jarvis-dispatch: REFUSED — --plan %s: no %s in %s. RELAY this refusal to the operator.\n' "$_s" "$_pp" "$_mdir/$_sp" >&2
|
|
253
|
+
return 2
|
|
254
|
+
fi
|
|
255
|
+
else
|
|
256
|
+
_has=0
|
|
257
|
+
for _f in "$_mdir/$_sp"/plan-*.md; do [ -f "$_f" ] && { _has=1; break; }; done
|
|
258
|
+
if [ "$_has" != 1 ]; then
|
|
259
|
+
printf 'forge-jarvis-dispatch: REFUSED — --plan %s: phase %s has no plan-*.md. RELAY this refusal to the operator.\n' "$_s" "$_sp" >&2
|
|
260
|
+
return 2
|
|
261
|
+
fi
|
|
262
|
+
fi
|
|
263
|
+
done <<PLAN_VALIDATE_EOF
|
|
264
|
+
$PLAN_SPECS
|
|
265
|
+
PLAN_VALIDATE_EOF
|
|
266
|
+
fi
|
|
131
267
|
_tmp="$WORKTREE/slice.yml.tmp"
|
|
132
268
|
# Write-target hygiene: refuse a pre-existing tmp (incl. a pre-committed symlink —
|
|
133
269
|
# a redirect would follow it and truncate whatever it points at).
|
|
@@ -143,6 +279,26 @@ materialize_work_order() {
|
|
|
143
279
|
_names=$(for _d in "$_mdir"/*/; do [ -d "$_d" ] || continue; basename "$_d"; done | sort -n)
|
|
144
280
|
while IFS= read -r _p; do
|
|
145
281
|
[ -n "$_p" ] || continue
|
|
282
|
+
# --plan scoping (FR-234): with specs, only selected phases materialize; a whole-phase
|
|
283
|
+
# spec takes all its plans, plan-part specs take exactly those files. Numeric order
|
|
284
|
+
# comes from the sort -n pipeline above, regardless of --plan flag order.
|
|
285
|
+
_whole=1; _plansel=""
|
|
286
|
+
if [ -n "$PLAN_SPECS" ]; then
|
|
287
|
+
_sel=0; _whole=0
|
|
288
|
+
while IFS= read -r _s; do
|
|
289
|
+
[ -n "$_s" ] || continue
|
|
290
|
+
case "$_s" in
|
|
291
|
+
"$_p") _sel=1; _whole=1 ;;
|
|
292
|
+
"$_p"/*)
|
|
293
|
+
_sel=1
|
|
294
|
+
_f="${_s#*/}"; case "$_f" in *.md) : ;; *) _f="$_f.md" ;; esac
|
|
295
|
+
_plansel="$_plansel $_f" ;;
|
|
296
|
+
esac
|
|
297
|
+
done <<PLAN_SELECT_EOF
|
|
298
|
+
$PLAN_SPECS
|
|
299
|
+
PLAN_SELECT_EOF
|
|
300
|
+
[ "$_sel" = 1 ] || continue
|
|
301
|
+
fi
|
|
146
302
|
_have=0
|
|
147
303
|
for _f in "$_mdir/$_p"/plan-*.md; do [ -f "$_f" ] && { _have=1; break; }; done
|
|
148
304
|
[ "$_have" = 1 ] || continue
|
|
@@ -156,6 +312,12 @@ materialize_work_order() {
|
|
|
156
312
|
: > "$_pl"
|
|
157
313
|
for _f in "$_mdir/$_p"/plan-*.md; do
|
|
158
314
|
[ -f "$_f" ] || continue
|
|
315
|
+
if [ "$_whole" != 1 ]; then
|
|
316
|
+
case " $_plansel " in
|
|
317
|
+
*" $(basename "$_f") "*) : ;;
|
|
318
|
+
*) continue ;;
|
|
319
|
+
esac
|
|
320
|
+
fi
|
|
159
321
|
cat "$_f" >> "$_pl"
|
|
160
322
|
done
|
|
161
323
|
printf ' - %s\n' "$_p" >> "$_tmp"
|
|
@@ -168,6 +330,9 @@ MATERIALIZE_EOF
|
|
|
168
330
|
printf 'forge-jarvis-dispatch: REFUSED — %s has no phase plans (plan-*.md); nothing to dispatch. RELAY this refusal to the operator.\n' "$_mdir" >&2
|
|
169
331
|
return 2
|
|
170
332
|
fi
|
|
333
|
+
# Commit point: the new work order definitely lands. Rotate stale runner state first
|
|
334
|
+
# (FR-235) — this is the ONLY call site; every refusal above exits before it.
|
|
335
|
+
rotate_runner_work || { rm -f "$_tmp"; return 2; }
|
|
171
336
|
mv "$_tmp" "$WORKTREE/slice.yml"
|
|
172
337
|
printf 'forge-jarvis-dispatch: materialized slice.yml (%s phase(s)) from %s\n' "$_count" "${_mdir#"$WORKTREE"/}" >&2
|
|
173
338
|
return 0
|
|
@@ -178,10 +343,13 @@ LOG="$WORKTREE/.forge/runner-work/notify.log" # INTO the slice worktree (
|
|
|
178
343
|
|
|
179
344
|
if [ "$DRY_RUN" = "1" ]; then
|
|
180
345
|
# Side-effect-free: report work-order state to stderr, print the command to stdout.
|
|
181
|
-
|
|
346
|
+
# (Materialization AND rotation both live behind the real dispatch — dry-run touches nothing.)
|
|
347
|
+
if [ -n "$PLAN_SPECS" ]; then
|
|
348
|
+
printf 'forge-jarvis-dispatch: (dry-run) --plan specs given — a real dispatch materializes a SCOPED work order (replacing a helper-materialized slice.yml, refusing a hand-authored one) and rotates stale runner-work\n' >&2
|
|
349
|
+
elif [ -f "$WORKTREE/slice.yml" ]; then
|
|
182
350
|
printf 'forge-jarvis-dispatch: (dry-run) slice.yml present — will be used as-is\n' >&2
|
|
183
351
|
else
|
|
184
|
-
printf 'forge-jarvis-dispatch: (dry-run) no slice.yml — a real dispatch materializes it from .forge/phases/milestone
|
|
352
|
+
printf 'forge-jarvis-dispatch: (dry-run) no slice.yml — a real dispatch materializes it from .forge/phases/milestone-*/ and rotates stale runner-work\n' >&2
|
|
185
353
|
fi
|
|
186
354
|
# Print exactly what would run — greppable: worktree-scoped log, log-only channel, nohup, forwarded args.
|
|
187
355
|
printf 'nohup env FORGE_SLICE_NOTIFY_LOG=%s FORGE_SLICE_NOTIFY=%s %s' "$LOG" "$CHANNEL" "$RUNNER"
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# forge-roadmap-check.sh — structural integrity check for .forge/roadmap.yml (m-39).
|
|
3
|
+
#
|
|
4
|
+
# Validates the roadmap against the drift class that caused the m-34/m-36 phase-id
|
|
5
|
+
# collision: two independent renumbering passes both picked phase id 63. A flat
|
|
6
|
+
# grep over "id: N" cannot tell a milestone's id from a phase's id from an
|
|
7
|
+
# unrelated numeral (estimated_hours: 63), and cannot catch a field that drifted
|
|
8
|
+
# to the wrong indentation depth. This check instead walks the file as a small
|
|
9
|
+
# indentation-keyed state machine over roadmap.yml's three fixed second-level
|
|
10
|
+
# blocks (milestones:/phases:/waves:) — HONEST LIMIT: it understands exactly this
|
|
11
|
+
# one fixed shape, not arbitrary YAML.
|
|
12
|
+
#
|
|
13
|
+
# forge-roadmap-check.sh [<roadmap-file>] # default: <git-root>/.forge/roadmap.yml
|
|
14
|
+
#
|
|
15
|
+
# stdout (machine-parseable, fold style):
|
|
16
|
+
# ok=clean # no findings
|
|
17
|
+
# finding=<rule>:<detail> # one line per problem
|
|
18
|
+
# rule ∈ missing-field | duplicate-phase-id | dangling-phase-ref | orphan-phase
|
|
19
|
+
# | bad-milestone-dir | dangling-wave-ref
|
|
20
|
+
# exit: 0 = ok=clean; 1 = one or more findings; 2 = bad invocation (missing/unreadable file)
|
|
21
|
+
#
|
|
22
|
+
# Required fields (confirmed against the live file — zero false positives today):
|
|
23
|
+
# milestone entry: id, name, phases (non-empty)
|
|
24
|
+
# phase entry: id, name, goal, requirements, dependencies, success_criteria,
|
|
25
|
+
# estimated_hours, status
|
|
26
|
+
# milestone_dir is DELIBERATELY not required — 17/65 live phase entries predate
|
|
27
|
+
# the nested-layout convention. Validated only when present (bad-milestone-dir).
|
|
28
|
+
#
|
|
29
|
+
# Pure POSIX sh + awk. No git-repo dependency (reads the file directly, unlike the
|
|
30
|
+
# diff-scoped forge-hold-check.sh/forge-check-lint.sh). Collects every finding in
|
|
31
|
+
# one pass before deciding exit code — a single run surfaces everything wrong.
|
|
32
|
+
set -u
|
|
33
|
+
|
|
34
|
+
FILE="${1:-}"
|
|
35
|
+
if [ -z "$FILE" ]; then
|
|
36
|
+
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
|
37
|
+
FILE="$ROOT/.forge/roadmap.yml"
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
if [ ! -f "$FILE" ] || [ ! -r "$FILE" ]; then
|
|
41
|
+
echo "roadmap-check: $FILE: not found or unreadable" >&2
|
|
42
|
+
exit 2
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
awk '
|
|
46
|
+
function trim(s) {
|
|
47
|
+
gsub(/^[ \t]+|[ \t]+$/, "", s)
|
|
48
|
+
return s
|
|
49
|
+
}
|
|
50
|
+
function unquote(s) {
|
|
51
|
+
gsub(/"/, "", s)
|
|
52
|
+
return s
|
|
53
|
+
}
|
|
54
|
+
function bare_num(s, n) {
|
|
55
|
+
n = unquote(trim(s))
|
|
56
|
+
if (n ~ /^m-/) n = substr(n, 3)
|
|
57
|
+
return n
|
|
58
|
+
}
|
|
59
|
+
function finish_entry() {
|
|
60
|
+
if (!entry_open) return
|
|
61
|
+
if (entry_type == "milestone") {
|
|
62
|
+
ms_count++
|
|
63
|
+
ms_id[ms_count] = cur_id
|
|
64
|
+
ms_fields[ms_count] = cur_fields
|
|
65
|
+
ms_phaselist[ms_count] = cur_phaselist
|
|
66
|
+
} else if (entry_type == "phase") {
|
|
67
|
+
ph_count++
|
|
68
|
+
ph_id[ph_count] = cur_id + 0
|
|
69
|
+
ph_fields[ph_count] = cur_fields
|
|
70
|
+
ph_mdir[ph_count] = cur_mdir
|
|
71
|
+
}
|
|
72
|
+
entry_open = 0
|
|
73
|
+
cur_id = ""; cur_fields = ""; cur_mdir = ""; cur_phaselist = ""
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# 2-space block header
|
|
77
|
+
$0 ~ /^ (milestones|phases|waves):[ \t]*$/ {
|
|
78
|
+
finish_entry()
|
|
79
|
+
block = trim($0)
|
|
80
|
+
gsub(/:$/, "", block)
|
|
81
|
+
next
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# waves block: " N: [a, b, c]"
|
|
85
|
+
block == "waves" && $0 ~ /^ [0-9]+:/ {
|
|
86
|
+
line = $0
|
|
87
|
+
sub(/^ /, "", line)
|
|
88
|
+
split(line, kv, ":")
|
|
89
|
+
wnum = trim(kv[1])
|
|
90
|
+
rest = line
|
|
91
|
+
sub(/^[^:]*:/, "", rest)
|
|
92
|
+
if (match(rest, /\[[^]]*\]/)) {
|
|
93
|
+
ids = substr(rest, RSTART + 1, RLENGTH - 2)
|
|
94
|
+
} else {
|
|
95
|
+
ids = ""
|
|
96
|
+
}
|
|
97
|
+
wave_count++
|
|
98
|
+
wave_num[wave_count] = wnum
|
|
99
|
+
wave_ids[wave_count] = ids
|
|
100
|
+
next
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# entry open: " - id: 36" / " - id: \"m-33\""
|
|
104
|
+
(block == "milestones" || block == "phases") && $0 ~ /^ - id:/ {
|
|
105
|
+
finish_entry()
|
|
106
|
+
entry_open = 1
|
|
107
|
+
entry_type = (block == "milestones" ? "milestone" : "phase")
|
|
108
|
+
val = $0
|
|
109
|
+
sub(/^ - id:[ \t]*/, "", val)
|
|
110
|
+
cur_id = unquote(trim(val))
|
|
111
|
+
cur_fields = " id "
|
|
112
|
+
next
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# 6-space field line inside an open entry
|
|
116
|
+
(block == "milestones" || block == "phases") && entry_open && $0 ~ /^ [A-Za-z_]+:/ {
|
|
117
|
+
line = $0
|
|
118
|
+
sub(/^ /, "", line)
|
|
119
|
+
match(line, /^[A-Za-z_]+/)
|
|
120
|
+
field = substr(line, RSTART, RLENGTH)
|
|
121
|
+
cur_fields = cur_fields field " "
|
|
122
|
+
if (field == "milestone_dir") {
|
|
123
|
+
val = line
|
|
124
|
+
sub(/^milestone_dir:[ \t]*/, "", val)
|
|
125
|
+
cur_mdir = unquote(trim(val))
|
|
126
|
+
}
|
|
127
|
+
if (field == "phases" && entry_type == "milestone") {
|
|
128
|
+
rest = line
|
|
129
|
+
if (match(rest, /\[[^]]*\]/)) {
|
|
130
|
+
cur_phaselist = substr(rest, RSTART + 1, RLENGTH - 2)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
next
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# any other 4-space line inside milestones:/phases: (not "- id:") -> malformed
|
|
137
|
+
# dedent: closes the current entry without opening a new one.
|
|
138
|
+
(block == "milestones" || block == "phases") && $0 ~ /^ [A-Za-z_-]/ {
|
|
139
|
+
finish_entry()
|
|
140
|
+
next
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
END {
|
|
144
|
+
finish_entry()
|
|
145
|
+
|
|
146
|
+
required_ms = "name phases"
|
|
147
|
+
required_ph = "name goal requirements dependencies success_criteria estimated_hours status"
|
|
148
|
+
|
|
149
|
+
# 1. required fields
|
|
150
|
+
for (i = 1; i <= ms_count; i++) {
|
|
151
|
+
n = split(required_ms, reqs, " ")
|
|
152
|
+
for (j = 1; j <= n; j++) {
|
|
153
|
+
if (index(ms_fields[i], " " reqs[j] " ") == 0) {
|
|
154
|
+
print "finding=missing-field:milestone:" ms_id[i] ":" reqs[j]
|
|
155
|
+
found++
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (i = 1; i <= ph_count; i++) {
|
|
160
|
+
n = split(required_ph, reqs, " ")
|
|
161
|
+
for (j = 1; j <= n; j++) {
|
|
162
|
+
if (index(ph_fields[i], " " reqs[j] " ") == 0) {
|
|
163
|
+
print "finding=missing-field:phase:" ph_id[i] ":" reqs[j]
|
|
164
|
+
found++
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# phase-id set + first-seen order tracking for duplicate detection
|
|
170
|
+
for (i = 1; i <= ph_count; i++) {
|
|
171
|
+
id = ph_id[i]
|
|
172
|
+
phase_seen[id] = phase_seen[id] "" (phase_seen[id] == "" ? "" : ",")
|
|
173
|
+
dup_list[id] = (dup_list[id] == "" ? "" : dup_list[id] ",") (ph_mdir[i] == "" ? "(none)" : ph_mdir[i])
|
|
174
|
+
dup_count[id]++
|
|
175
|
+
phase_exists[id] = 1
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
# 2. duplicate phase ids
|
|
179
|
+
for (id in dup_count) {
|
|
180
|
+
if (dup_count[id] > 1) {
|
|
181
|
+
print "finding=duplicate-phase-id:" id ":" dup_list[id]
|
|
182
|
+
found++
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
# 3. dangling-phase-ref (milestone claims a phase id with no phase entry)
|
|
187
|
+
for (i = 1; i <= ms_count; i++) {
|
|
188
|
+
m = split(ms_phaselist[i], claimed, /[ ,]+/)
|
|
189
|
+
for (j = 1; j <= m; j++) {
|
|
190
|
+
pid = trim(claimed[j])
|
|
191
|
+
if (pid == "") continue
|
|
192
|
+
claimed_by[pid] = 1
|
|
193
|
+
if (!(pid in phase_exists)) {
|
|
194
|
+
print "finding=dangling-phase-ref:milestone:" ms_id[i] ":phase:" pid
|
|
195
|
+
found++
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
# orphan-phase (phase entry claimed by zero milestones)
|
|
201
|
+
for (i = 1; i <= ph_count; i++) {
|
|
202
|
+
id = ph_id[i]
|
|
203
|
+
if (!(id in claimed_by)) {
|
|
204
|
+
print "finding=orphan-phase:" id ":" (ph_mdir[i] == "" ? "(none)" : ph_mdir[i])
|
|
205
|
+
found++
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
# 4. bad-milestone-dir (milestone_dir prefix must resolve to a real milestone id)
|
|
210
|
+
for (i = 1; i <= ms_count; i++) {
|
|
211
|
+
ms_numeral[bare_num(ms_id[i])] = 1
|
|
212
|
+
}
|
|
213
|
+
for (i = 1; i <= ph_count; i++) {
|
|
214
|
+
if (ph_mdir[i] == "") continue
|
|
215
|
+
if (match(ph_mdir[i], /^milestone-[^\/]+/)) {
|
|
216
|
+
prefix = substr(ph_mdir[i], RSTART, RLENGTH)
|
|
217
|
+
numeral = prefix
|
|
218
|
+
sub(/^milestone-/, "", numeral)
|
|
219
|
+
if (!(numeral in ms_numeral)) {
|
|
220
|
+
print "finding=bad-milestone-dir:" ph_id[i] ":" ph_mdir[i]
|
|
221
|
+
found++
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# 5. dangling-wave-ref
|
|
227
|
+
for (i = 1; i <= wave_count; i++) {
|
|
228
|
+
m = split(wave_ids[i], wids, /[ ,]+/)
|
|
229
|
+
for (j = 1; j <= m; j++) {
|
|
230
|
+
pid = trim(wids[j])
|
|
231
|
+
if (pid == "") continue
|
|
232
|
+
if (!(pid in phase_exists)) {
|
|
233
|
+
print "finding=dangling-wave-ref:" wave_num[i] ":" pid
|
|
234
|
+
found++
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (found + 0 == 0) {
|
|
240
|
+
print "ok=clean"
|
|
241
|
+
exit 0
|
|
242
|
+
}
|
|
243
|
+
exit 1
|
|
244
|
+
}
|
|
245
|
+
' "$FILE"
|
|
246
|
+
exit $?
|
|
@@ -192,6 +192,153 @@ else
|
|
|
192
192
|
bad "numeric-id branch pick" "rc=$rc err=$err14"
|
|
193
193
|
fi
|
|
194
194
|
|
|
195
|
+
# --- 15-18. --plan WORK-ORDER SCOPING (FR-234; canvaz desire path 2026-07-22 defect 2:
|
|
196
|
+
# materialization bundled ALL phase dirs + ALL plan-NN.md, so a mid-milestone re-dispatch
|
|
197
|
+
# re-bundled landed work). --plan <phase> takes a whole phase, <phase>/plan-NN one plan
|
|
198
|
+
# file; unknown specs and hand-authored slice.yml refuse loudly. mktemp fixtures — a
|
|
199
|
+
# single milestone dir exercises the (branch-free) fallback pick.
|
|
200
|
+
|
|
201
|
+
# 15. single-plan spec: only the named plan file lands in the phase's plan.md
|
|
202
|
+
PSLICE="$ROOT/plan-one-wt"
|
|
203
|
+
mkdir -p "$PSLICE/.forge/phases/milestone-77/1-aaa"
|
|
204
|
+
printf 'LANDED plan one\n' > "$PSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
|
|
205
|
+
printf 'NEXT plan two\n' > "$PSLICE/.forge/phases/milestone-77/1-aaa/plan-02.md"
|
|
206
|
+
err15="$(sh "$DISPATCH" --slice "$PSLICE" --materialize-only --plan 1-aaa/plan-02 2>&1)"; rc=$?
|
|
207
|
+
if [ "$rc" = 0 ] && grep -q '^ - 1-aaa$' "$PSLICE/slice.yml" 2>/dev/null \
|
|
208
|
+
&& grep -q 'NEXT plan two' "$PSLICE/phases/1-aaa/plan.md" 2>/dev/null \
|
|
209
|
+
&& ! grep -q 'LANDED plan one' "$PSLICE/phases/1-aaa/plan.md" 2>/dev/null; then
|
|
210
|
+
ok "--plan <phase>/plan-NN scopes to exactly that plan (landed sibling NOT re-bundled)"
|
|
211
|
+
else
|
|
212
|
+
bad "--plan single-plan scoping" "rc=$rc err=$err15 plan.md=$(cat "$PSLICE/phases/1-aaa/plan.md" 2>/dev/null)"
|
|
213
|
+
fi
|
|
214
|
+
|
|
215
|
+
# 16. whole-phase specs + ordering: numeric slice order regardless of flag order; unselected
|
|
216
|
+
# phases do NOT materialize
|
|
217
|
+
OSLICE="$ROOT/plan-order-wt"
|
|
218
|
+
mkdir -p "$OSLICE/.forge/phases/milestone-77/71-aaa" "$OSLICE/.forge/phases/milestone-77/72-bbb" "$OSLICE/.forge/phases/milestone-77/73-ccc"
|
|
219
|
+
printf 'plan A\n' > "$OSLICE/.forge/phases/milestone-77/71-aaa/plan-01.md"
|
|
220
|
+
printf 'plan B\n' > "$OSLICE/.forge/phases/milestone-77/72-bbb/plan-01.md"
|
|
221
|
+
printf 'decoy\n' > "$OSLICE/.forge/phases/milestone-77/73-ccc/plan-01.md"
|
|
222
|
+
err16="$(sh "$DISPATCH" --slice "$OSLICE" --materialize-only --plan 72-bbb --plan 71-aaa 2>&1)"; rc=$?
|
|
223
|
+
if [ "$rc" = 0 ] \
|
|
224
|
+
&& [ "$(grep -E '^ - ' "$OSLICE/slice.yml" 2>/dev/null | tr -d ' -')" = "71aaa
|
|
225
|
+
72bbb" ] \
|
|
226
|
+
&& grep -q 'plan A' "$OSLICE/phases/71-aaa/plan.md" 2>/dev/null \
|
|
227
|
+
&& grep -q 'plan B' "$OSLICE/phases/72-bbb/plan.md" 2>/dev/null \
|
|
228
|
+
&& [ ! -d "$OSLICE/phases/73-ccc" ]; then
|
|
229
|
+
ok "--plan whole-phase specs keep numeric order (flag order ignored); unselected phase skipped"
|
|
230
|
+
else
|
|
231
|
+
bad "--plan ordering" "rc=$rc err=$err16 slice.yml=$(cat "$OSLICE/slice.yml" 2>/dev/null)"
|
|
232
|
+
fi
|
|
233
|
+
|
|
234
|
+
# 17. NEG: unknown spec → loud refusal, no slice.yml (a typo must not dispatch the wrong order)
|
|
235
|
+
USLICE="$ROOT/plan-unknown-wt"
|
|
236
|
+
mkdir -p "$USLICE/.forge/phases/milestone-77/1-aaa"
|
|
237
|
+
printf 'x\n' > "$USLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
|
|
238
|
+
err17="$(sh "$DISPATCH" --slice "$USLICE" --materialize-only --plan 2-nope 2>&1)"; rc=$?
|
|
239
|
+
if [ "$rc" != 0 ] && [ ! -f "$USLICE/slice.yml" ] && printf '%s' "$err17" | grep -q 'REFUSED'; then
|
|
240
|
+
ok "neg-control: unknown --plan spec → loud refusal, no slice.yml"
|
|
241
|
+
else
|
|
242
|
+
bad "unknown --plan spec" "rc=$rc err=$err17"
|
|
243
|
+
fi
|
|
244
|
+
|
|
245
|
+
# 18. NEG: --plan vs a HAND-AUTHORED slice.yml (no materializer header) → refusal; slice.yml
|
|
246
|
+
# byte-unchanged, seeded runner-work untouched (hand-authored work orders win, always)
|
|
247
|
+
HPSLICE="$ROOT/plan-hand-wt"
|
|
248
|
+
mkdir -p "$HPSLICE/.forge/phases/milestone-77/1-aaa" "$HPSLICE/.forge/runner-work/pings"
|
|
249
|
+
printf 'x\n' > "$HPSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
|
|
250
|
+
printf 'phases:\n - hand-rolled\n' > "$HPSLICE/slice.yml"
|
|
251
|
+
touch "$HPSLICE/.forge/runner-work/pings/k-done.marker"
|
|
252
|
+
before18="$(cat "$HPSLICE/slice.yml")"
|
|
253
|
+
err18="$(sh "$DISPATCH" --slice "$HPSLICE" --materialize-only --plan 1-aaa 2>&1)"; rc=$?
|
|
254
|
+
if [ "$rc" != 0 ] && printf '%s' "$err18" | grep -q 'REFUSED' \
|
|
255
|
+
&& [ "$(cat "$HPSLICE/slice.yml")" = "$before18" ] \
|
|
256
|
+
&& [ -f "$HPSLICE/.forge/runner-work/pings/k-done.marker" ]; then
|
|
257
|
+
ok "neg-control: --plan refuses a hand-authored slice.yml; nothing touched"
|
|
258
|
+
else
|
|
259
|
+
bad "--plan vs hand-authored" "rc=$rc err=$err18"
|
|
260
|
+
fi
|
|
261
|
+
|
|
262
|
+
# 18b. NEG (path hygiene): a plan part carrying a separator (traversal shape) refuses —
|
|
263
|
+
# sh case patterns let * match /, so plan-../x.md would pass the plan-*.md form check
|
|
264
|
+
# and reach outside the milestone dir without this guard.
|
|
265
|
+
TSLICE="$ROOT/plan-traverse-wt"
|
|
266
|
+
mkdir -p "$TSLICE/.forge/phases/milestone-77/1-aaa"
|
|
267
|
+
printf 'x\n' > "$TSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
|
|
268
|
+
err18b="$(sh "$DISPATCH" --slice "$TSLICE" --materialize-only --plan '1-aaa/plan-../x' 2>&1)"; rc=$?
|
|
269
|
+
if [ "$rc" != 0 ] && [ ! -f "$TSLICE/slice.yml" ] && printf '%s' "$err18b" | grep -q 'REFUSED'; then
|
|
270
|
+
ok "neg-control: --plan file part with a path separator → loud refusal (no traversal)"
|
|
271
|
+
else
|
|
272
|
+
bad "--plan traversal guard" "rc=$rc err=$err18b"
|
|
273
|
+
fi
|
|
274
|
+
|
|
275
|
+
# --- 19-21. RUNNER-WORK ROTATION (FR-235; canvaz desire path 2026-07-22 defect 3: stale
|
|
276
|
+
# pings/{key}-{kind}.marker silenced the new run's done ping and index-keyed
|
|
277
|
+
# phase-{idx}/phase-report.yml verdict:done short-circuited the new phase). A NEW work
|
|
278
|
+
# order rotates runner-work WHOLE (relay-markers are line-number-keyed siblings of
|
|
279
|
+
# notify.log — a partial reset would misalign offsets); resume paths keep slice.yml and
|
|
280
|
+
# are never rotated; archive, never delete.
|
|
281
|
+
|
|
282
|
+
# 19. materializing a NEW order rotates seeded state into runner-work-archive; a replaced
|
|
283
|
+
# helper-materialized slice.yml is archived alongside
|
|
284
|
+
RSLICE="$ROOT/rotate-wt"
|
|
285
|
+
mkdir -p "$RSLICE/.forge/phases/milestone-77/1-aaa"
|
|
286
|
+
printf 'LANDED\n' > "$RSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
|
|
287
|
+
printf 'NEXT\n' > "$RSLICE/.forge/phases/milestone-77/1-aaa/plan-02.md"
|
|
288
|
+
sh "$DISPATCH" --slice "$RSLICE" --materialize-only >/dev/null 2>&1 # first order (all plans) — seeds a materialized slice.yml
|
|
289
|
+
# Seed the "run happened" state AFTER the first materialization (a dispatch with stale
|
|
290
|
+
# runner-work already present would rotate it — exactly the behavior under test in 19).
|
|
291
|
+
mkdir -p "$RSLICE/.forge/runner-work/pings" "$RSLICE/.forge/runner-work/phase-1" "$RSLICE/.forge/runner-work/relay-markers"
|
|
292
|
+
touch "$RSLICE/.forge/runner-work/pings/k-done.marker"
|
|
293
|
+
printf 'verdict: done\n' > "$RSLICE/.forge/runner-work/phase-1/phase-report.yml"
|
|
294
|
+
touch "$RSLICE/.forge/runner-work/relay-markers/1.relayed"
|
|
295
|
+
err19="$(sh "$DISPATCH" --slice "$RSLICE" --materialize-only --plan 1-aaa/plan-02 2>&1)"; rc=$?
|
|
296
|
+
if [ "$rc" = 0 ] \
|
|
297
|
+
&& [ ! -e "$RSLICE/.forge/runner-work/pings/k-done.marker" ] \
|
|
298
|
+
&& [ ! -e "$RSLICE/.forge/runner-work/phase-1/phase-report.yml" ] \
|
|
299
|
+
&& ls "$RSLICE/.forge/runner-work-archive/"*/pings/k-done.marker >/dev/null 2>&1 \
|
|
300
|
+
&& ls "$RSLICE/.forge/runner-work-archive/"*/phase-1/phase-report.yml >/dev/null 2>&1 \
|
|
301
|
+
&& ls "$RSLICE/.forge/runner-work-archive/"*/relay-markers/1.relayed >/dev/null 2>&1 \
|
|
302
|
+
&& ls "$RSLICE/.forge/runner-work-archive/"*/slice.yml >/dev/null 2>&1 \
|
|
303
|
+
&& grep -q 'NEXT' "$RSLICE/phases/1-aaa/plan.md" 2>/dev/null \
|
|
304
|
+
&& ! grep -q 'LANDED' "$RSLICE/phases/1-aaa/plan.md" 2>/dev/null; then
|
|
305
|
+
ok "new work order rotates runner-work WHOLE (pings+report+relay-markers) + archives replaced slice.yml"
|
|
306
|
+
else
|
|
307
|
+
bad "rotation on new order" "rc=$rc err=$err19"
|
|
308
|
+
fi
|
|
309
|
+
|
|
310
|
+
# 20. NEG (resume preserved): existing materialized slice.yml, NO --plan → honored as-is,
|
|
311
|
+
# runner-work untouched, no archive (crash-resume / park→answer→resume path)
|
|
312
|
+
KSLICE="$ROOT/keep-wt"
|
|
313
|
+
mkdir -p "$KSLICE/.forge/phases/milestone-77/1-aaa"
|
|
314
|
+
printf 'x\n' > "$KSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
|
|
315
|
+
sh "$DISPATCH" --slice "$KSLICE" --materialize-only >/dev/null 2>&1 # materialized header slice.yml
|
|
316
|
+
mkdir -p "$KSLICE/.forge/runner-work/pings" # seed AFTER — see case 19 note
|
|
317
|
+
touch "$KSLICE/.forge/runner-work/pings/k-done.marker"
|
|
318
|
+
before20="$(cat "$KSLICE/slice.yml")"
|
|
319
|
+
err20="$(sh "$DISPATCH" --slice "$KSLICE" --materialize-only 2>&1)"; rc=$?
|
|
320
|
+
if [ "$rc" = 0 ] && [ "$(cat "$KSLICE/slice.yml")" = "$before20" ] \
|
|
321
|
+
&& [ -f "$KSLICE/.forge/runner-work/pings/k-done.marker" ] \
|
|
322
|
+
&& [ ! -d "$KSLICE/.forge/runner-work-archive" ]; then
|
|
323
|
+
ok "neg-control: existing slice.yml honored → NO rotation (resume state survives)"
|
|
324
|
+
else
|
|
325
|
+
bad "resume preserved" "rc=$rc err=$err20"
|
|
326
|
+
fi
|
|
327
|
+
|
|
328
|
+
# 21. NEG: --dry-run stays fully side-effect-free — no slice.yml, no rotation, even with
|
|
329
|
+
# stale runner-work present (extends case 12)
|
|
330
|
+
YSLICE="$ROOT/dry-rotate-wt"
|
|
331
|
+
mkdir -p "$YSLICE/.forge/phases/milestone-77/1-aaa" "$YSLICE/.forge/runner-work/pings"
|
|
332
|
+
printf 'x\n' > "$YSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
|
|
333
|
+
touch "$YSLICE/.forge/runner-work/pings/k-done.marker"
|
|
334
|
+
sh "$DISPATCH" --slice "$YSLICE" --dry-run >/dev/null 2>&1
|
|
335
|
+
if [ ! -f "$YSLICE/slice.yml" ] && [ ! -d "$YSLICE/.forge/runner-work-archive" ] \
|
|
336
|
+
&& [ -f "$YSLICE/.forge/runner-work/pings/k-done.marker" ]; then
|
|
337
|
+
ok "neg-control: --dry-run rotates nothing, writes nothing"
|
|
338
|
+
else
|
|
339
|
+
bad "dry-run rotation" "dry-run had side effects"
|
|
340
|
+
fi
|
|
341
|
+
|
|
195
342
|
# --- summary -----------------------------------------------------------------
|
|
196
343
|
printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$PASSED" "$FAILED"
|
|
197
344
|
[ "$FAILED" = 0 ]
|