forge-orkes 0.68.1 → 0.70.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.68.1",
3
+ "version": "0.70.0",
4
4
  "description": "Set up the Forge meta-prompting framework for Claude Code in your project",
5
5
  "bin": {
6
6
  "create-forge": "./bin/create-forge.js"
@@ -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
@@ -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
@@ -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 $?