forge-orkes 0.71.0 → 0.73.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/bin/create-forge.js +6 -4
- package/experimental/m10/source/mcp-server/README.md +2 -2
- package/experimental/m10/source/mcp-server/index.js +2 -2
- package/experimental/m10/source/skills/orchestrating/SKILL.md +7 -3
- package/package.json +1 -1
- package/template/.claude/hooks/forge-model-outcome.sh +5 -3
- package/template/.claude/hooks/forge-reserve.sh +419 -106
- package/template/.claude/hooks/forge-slice-notify.sh +12 -3
- package/template/.claude/hooks/forge-slice-runner.sh +40 -19
- package/template/.claude/hooks/tests/forge-reserve.test.sh +169 -0
- package/template/.claude/skills/chief-of-staff/SKILL.md +1 -0
- package/template/.claude/skills/forge/SKILL.md +8 -6
- package/template/.claude/skills/notion-integration/SKILL.md +1 -0
- package/template/.claude/skills/planning/SKILL.md +3 -3
- package/template/.forge/FORGE.md +5 -5
- package/template/.forge/checks/forge-board-render.sh +21 -18
- package/template/.forge/checks/forge-check-lint.sh +48 -5
- package/template/.forge/checks/forge-hold-check.sh +123 -2
- package/template/.forge/checks/forge-jarvis-awareness.sh +62 -11
- package/template/.forge/checks/forge-jarvis-dispatch.sh +88 -8
- package/template/.forge/checks/forge-jarvis-relay.sh +15 -6
- package/template/.forge/checks/forge-jarvis.sh +28 -0
- package/template/.forge/checks/forge-roadmap-check.sh +106 -22
- package/template/.forge/checks/tests/forge-check-lint.test.sh +126 -4
- package/template/.forge/checks/tests/forge-hold-check.test.sh +166 -1
- package/template/.forge/checks/tests/forge-jarvis-awareness.test.sh +53 -0
- package/template/.forge/checks/tests/forge-jarvis-dispatch.test.sh +114 -0
- package/template/.forge/checks/tests/forge-jarvis-relay.test.sh +30 -0
- package/template/.forge/checks/tests/forge-jarvis.test.sh +78 -4
- package/template/.forge/checks/tests/forge-reserve.test.sh +235 -0
- package/template/.forge/checks/tests/forge-roadmap-check.test.sh +487 -0
- package/template/.forge/templates/hold-config.yml +15 -0
- package/template/.forge/templates/project.yml +10 -0
- package/template/.forge/templates/state/desire-path.yml +15 -45
- package/template/.forge/templates/waive-hold.yml +63 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Fixture suite for .forge/checks/forge-roadmap-check.sh (m-39, plan-01, phase 71).
|
|
3
|
+
#
|
|
4
|
+
# Contract under test (locked interface — do not re-derive):
|
|
5
|
+
# forge-roadmap-check.sh [<roadmap-file>] # default: <git-root>/.forge/roadmap.yml
|
|
6
|
+
# stdout: ok=clean # no findings
|
|
7
|
+
# finding=<rule>:<detail> # one line per problem
|
|
8
|
+
# rule ∈ missing-field | duplicate-phase-id | dangling-phase-ref | orphan-phase
|
|
9
|
+
# | bad-milestone-dir | dangling-wave-ref
|
|
10
|
+
# exit: 0 = ok=clean; 1 = findings; 2 = bad invocation (missing/unreadable file)
|
|
11
|
+
#
|
|
12
|
+
# Required fields — milestone entry: id, name, phases (non-empty: an empty
|
|
13
|
+
# list — `phases: []` — raises missing-field:milestone:<id>:phases).
|
|
14
|
+
# phase entry: id, name, goal, requirements, dependencies,
|
|
15
|
+
# success_criteria, estimated_hours, status.
|
|
16
|
+
# milestone_dir is DELIBERATELY not required (17/65 live phases predate it) —
|
|
17
|
+
# validated only when present (bad-milestone-dir rule); absence never a finding.
|
|
18
|
+
#
|
|
19
|
+
# Unlike forge-hold-check.sh/forge-check-lint.sh, this check reads ONE YAML file,
|
|
20
|
+
# not a diff — fixtures are plain files under mktemp -d (one throwaway git repo
|
|
21
|
+
# for the no-arg default-path case only).
|
|
22
|
+
#
|
|
23
|
+
# Usage:
|
|
24
|
+
# ./.forge/checks/tests/forge-roadmap-check.test.sh # all cases
|
|
25
|
+
# ./.forge/checks/tests/forge-roadmap-check.test.sh duplicate-id # one tag
|
|
26
|
+
# Filter tags: clean missing-field duplicate-id refs milestone-dir waves malformed invocation
|
|
27
|
+
#
|
|
28
|
+
# RED-phase friendly: missing helper → every selected case FAILS, suite exits
|
|
29
|
+
# non-zero, no abort. Exits 0 iff every selected case passes AND at least one
|
|
30
|
+
# case block was selected (a typo'd tag must not exit green). Self-cleans.
|
|
31
|
+
set -u
|
|
32
|
+
|
|
33
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
34
|
+
HELPER="$SCRIPT_DIR/../forge-roadmap-check.sh"
|
|
35
|
+
FILTER="${1:-}"
|
|
36
|
+
|
|
37
|
+
[ -x "$HELPER" ] || printf 'NOTE: %s missing or not executable — RED phase, every selected case below should FAIL\n' "$HELPER" >&2
|
|
38
|
+
|
|
39
|
+
ROOT="$(mktemp -d)"
|
|
40
|
+
trap 'rm -rf "$ROOT"' EXIT INT TERM
|
|
41
|
+
|
|
42
|
+
PASSED=0
|
|
43
|
+
FAILED=0
|
|
44
|
+
SELECTED=0
|
|
45
|
+
|
|
46
|
+
want_tag() {
|
|
47
|
+
if [ -z "$FILTER" ] || [ "$FILTER" = "$1" ]; then
|
|
48
|
+
SELECTED=$((SELECTED + 1))
|
|
49
|
+
return 0
|
|
50
|
+
fi
|
|
51
|
+
return 1
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
pass() { PASSED=$((PASSED + 1)); printf 'PASS: %s\n' "$1"; }
|
|
55
|
+
fail() { FAILED=$((FAILED + 1)); printf 'FAIL: %s\n' "$1" >&2; }
|
|
56
|
+
|
|
57
|
+
# run_check <fixture-path> -> sets OUT (stdout) and RC (exit status)
|
|
58
|
+
run_check() {
|
|
59
|
+
OUT="$("$HELPER" "$1" 2>"$ROOT/stderr")"
|
|
60
|
+
RC=$?
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
assert_exit() {
|
|
64
|
+
# $1=case-label $2=expected-code
|
|
65
|
+
if [ "$RC" = "$2" ]; then pass "$1 (exit $2)"; else fail "$1 (exit expected $2, got $RC)"; fi
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
assert_finding() {
|
|
69
|
+
# $1=case-label $2=exact-expected-line
|
|
70
|
+
if printf '%s\n' "$OUT" | grep -qF "$2"; then
|
|
71
|
+
pass "$1 (found: $2)"
|
|
72
|
+
else
|
|
73
|
+
fail "$1 (missing expected line: $2 -- got: $OUT)"
|
|
74
|
+
fi
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
assert_no_finding_matching() {
|
|
78
|
+
# $1=case-label $2=grep-pattern that must NOT appear
|
|
79
|
+
if printf '%s\n' "$OUT" | grep -qE "$2"; then
|
|
80
|
+
fail "$1 (unexpected finding matching: $2 -- got: $OUT)"
|
|
81
|
+
else
|
|
82
|
+
pass "$1 (no finding matching: $2)"
|
|
83
|
+
fi
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
assert_clean() {
|
|
87
|
+
# $1=case-label
|
|
88
|
+
if [ "$OUT" = "ok=clean" ]; then pass "$1 (ok=clean)"; else fail "$1 (expected ok=clean, got: $OUT)"; fi
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# Fixture bodies
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
# A minimal, fully well-formed roadmap. Phase 2 deliberately carries NO
|
|
96
|
+
# milestone_dir -- doubles as the milestone-dir negative control (absence of
|
|
97
|
+
# the optional field must never raise a finding).
|
|
98
|
+
write_clean() {
|
|
99
|
+
cat >"$1" <<'EOF'
|
|
100
|
+
roadmap:
|
|
101
|
+
milestones:
|
|
102
|
+
- id: 1
|
|
103
|
+
name: "Alpha"
|
|
104
|
+
phases: [1, 2]
|
|
105
|
+
- id: 2
|
|
106
|
+
name: "Beta"
|
|
107
|
+
phases: [3]
|
|
108
|
+
|
|
109
|
+
phases:
|
|
110
|
+
- id: 1
|
|
111
|
+
name: "alpha-one"
|
|
112
|
+
milestone_dir: "milestone-1/1-alpha-one"
|
|
113
|
+
goal: "User can alpha"
|
|
114
|
+
requirements: [FR-001]
|
|
115
|
+
dependencies: []
|
|
116
|
+
integration_checkpoint: false
|
|
117
|
+
success_criteria:
|
|
118
|
+
- "does the thing"
|
|
119
|
+
estimated_hours: 1
|
|
120
|
+
status: pending
|
|
121
|
+
- id: 2
|
|
122
|
+
name: "alpha-two"
|
|
123
|
+
goal: "User can alpha two"
|
|
124
|
+
requirements: [FR-002]
|
|
125
|
+
dependencies: [1]
|
|
126
|
+
success_criteria:
|
|
127
|
+
- "does thing two"
|
|
128
|
+
estimated_hours: 1
|
|
129
|
+
status: pending
|
|
130
|
+
- id: 3
|
|
131
|
+
name: "beta-one"
|
|
132
|
+
milestone_dir: "milestone-2/3-beta-one"
|
|
133
|
+
goal: "User can beta"
|
|
134
|
+
requirements: [FR-003]
|
|
135
|
+
dependencies: []
|
|
136
|
+
success_criteria:
|
|
137
|
+
- "does beta"
|
|
138
|
+
estimated_hours: 2
|
|
139
|
+
status: pending
|
|
140
|
+
|
|
141
|
+
waves:
|
|
142
|
+
1: [1, 3]
|
|
143
|
+
2: [2]
|
|
144
|
+
EOF
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# One well-formed phase entry (id 1, under milestone 1), field-complete --
|
|
148
|
+
# the SINGLE source fixture every single-mutation case derives from via sed
|
|
149
|
+
# (a required-field change edits this one body, not N copies).
|
|
150
|
+
write_phase_template() {
|
|
151
|
+
cat >"$1" <<'EOF'
|
|
152
|
+
roadmap:
|
|
153
|
+
milestones:
|
|
154
|
+
- id: 1
|
|
155
|
+
name: "Alpha"
|
|
156
|
+
phases: [1]
|
|
157
|
+
|
|
158
|
+
phases:
|
|
159
|
+
- id: 1
|
|
160
|
+
name: "alpha-one"
|
|
161
|
+
milestone_dir: "milestone-1/1-alpha-one"
|
|
162
|
+
goal: "User can alpha"
|
|
163
|
+
requirements: [FR-001]
|
|
164
|
+
dependencies: []
|
|
165
|
+
integration_checkpoint: false
|
|
166
|
+
success_criteria:
|
|
167
|
+
- "does the thing"
|
|
168
|
+
estimated_hours: 1
|
|
169
|
+
status: pending
|
|
170
|
+
|
|
171
|
+
waves:
|
|
172
|
+
1: [1]
|
|
173
|
+
EOF
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# clean
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
if want_tag clean; then
|
|
180
|
+
f="$ROOT/clean.yml"; write_clean "$f"
|
|
181
|
+
run_check "$f"
|
|
182
|
+
assert_exit "clean: well-formed roadmap" 0
|
|
183
|
+
assert_clean "clean: well-formed roadmap"
|
|
184
|
+
assert_no_finding_matching "clean: phase-2 has no milestone_dir" "bad-milestone-dir:2:"
|
|
185
|
+
|
|
186
|
+
# Quoted m-33-style milestone id + matching milestone_dir — 8 live milestones
|
|
187
|
+
# use this shape; an unquote/bare_num regression must not mass-false-positive.
|
|
188
|
+
f="$ROOT/clean-quoted-id.yml"
|
|
189
|
+
cat >"$f" <<'EOF'
|
|
190
|
+
roadmap:
|
|
191
|
+
milestones:
|
|
192
|
+
- id: "m-33"
|
|
193
|
+
name: "Quoted id"
|
|
194
|
+
phases: [4]
|
|
195
|
+
|
|
196
|
+
phases:
|
|
197
|
+
- id: 4
|
|
198
|
+
name: "quoted-one"
|
|
199
|
+
milestone_dir: "milestone-33/4-quoted-one"
|
|
200
|
+
goal: "Quoted milestone ids resolve"
|
|
201
|
+
requirements: [FR-001]
|
|
202
|
+
dependencies: []
|
|
203
|
+
success_criteria:
|
|
204
|
+
- "stays clean"
|
|
205
|
+
estimated_hours: 1
|
|
206
|
+
status: pending
|
|
207
|
+
|
|
208
|
+
waves:
|
|
209
|
+
1: [4]
|
|
210
|
+
EOF
|
|
211
|
+
run_check "$f"
|
|
212
|
+
assert_exit "clean: quoted m-33-style milestone id" 0
|
|
213
|
+
assert_clean "clean: quoted m-33-style milestone id"
|
|
214
|
+
|
|
215
|
+
# Trailing "# comment" on id lines (both milestone and phase) must not corrupt
|
|
216
|
+
# the captured id — pre-fix this false-fired bad-milestone-dir for every phase
|
|
217
|
+
# of the commented milestone (reproduced at m-39 review).
|
|
218
|
+
f="$ROOT/clean-id-comment.yml"
|
|
219
|
+
write_phase_template "$f"
|
|
220
|
+
sed -i.bak 's/^ - id: 1$/ - id: 1 # renumbered at rebase/' "$f"
|
|
221
|
+
run_check "$f"
|
|
222
|
+
assert_exit "clean: trailing comment on id lines" 0
|
|
223
|
+
assert_clean "clean: trailing comment on id lines"
|
|
224
|
+
|
|
225
|
+
# Block-style and wrapped flow phases: lists are real claims — pre-fix both
|
|
226
|
+
# parsed as empty and false-fired orphan-phase per phase.
|
|
227
|
+
f="$ROOT/clean-block-phases.yml"
|
|
228
|
+
cat >"$f" <<'EOF'
|
|
229
|
+
roadmap:
|
|
230
|
+
milestones:
|
|
231
|
+
- id: 1
|
|
232
|
+
name: "Alpha"
|
|
233
|
+
phases:
|
|
234
|
+
- 1
|
|
235
|
+
- 2
|
|
236
|
+
- id: 2
|
|
237
|
+
name: "Beta"
|
|
238
|
+
phases: [2,
|
|
239
|
+
3]
|
|
240
|
+
|
|
241
|
+
phases:
|
|
242
|
+
- id: 1
|
|
243
|
+
name: "alpha-one"
|
|
244
|
+
goal: "User can alpha"
|
|
245
|
+
requirements: [FR-001]
|
|
246
|
+
dependencies: []
|
|
247
|
+
success_criteria:
|
|
248
|
+
- "does the thing"
|
|
249
|
+
estimated_hours: 1
|
|
250
|
+
status: pending
|
|
251
|
+
- id: 2
|
|
252
|
+
name: "alpha-two"
|
|
253
|
+
goal: "User can alpha two"
|
|
254
|
+
requirements: [FR-002]
|
|
255
|
+
dependencies: [1]
|
|
256
|
+
success_criteria:
|
|
257
|
+
- "does thing two"
|
|
258
|
+
estimated_hours: 1
|
|
259
|
+
status: pending
|
|
260
|
+
- id: 3
|
|
261
|
+
name: "beta-one"
|
|
262
|
+
goal: "User can beta"
|
|
263
|
+
requirements: [FR-003]
|
|
264
|
+
dependencies: []
|
|
265
|
+
success_criteria:
|
|
266
|
+
- "does beta"
|
|
267
|
+
estimated_hours: 2
|
|
268
|
+
status: pending
|
|
269
|
+
|
|
270
|
+
waves:
|
|
271
|
+
1: [1, 2, 3]
|
|
272
|
+
EOF
|
|
273
|
+
run_check "$f"
|
|
274
|
+
assert_exit "clean: block-style + wrapped flow phase lists" 0
|
|
275
|
+
assert_clean "clean: block-style + wrapped flow phase lists"
|
|
276
|
+
fi
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
# missing-field (phase side): delete one required field line at a time
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
if want_tag missing-field; then
|
|
282
|
+
for field in name goal requirements dependencies estimated_hours status; do
|
|
283
|
+
f="$ROOT/missing-phase-$field.yml"
|
|
284
|
+
write_phase_template "$f"
|
|
285
|
+
sed -i.bak "/^ $field:/d" "$f"
|
|
286
|
+
run_check "$f"
|
|
287
|
+
assert_exit "missing-field phase:$field" 1
|
|
288
|
+
assert_finding "missing-field phase:$field" "finding=missing-field:phase:1:$field"
|
|
289
|
+
done
|
|
290
|
+
|
|
291
|
+
# success_criteria is a two-line block field (key + one list item)
|
|
292
|
+
f="$ROOT/missing-phase-success_criteria.yml"
|
|
293
|
+
write_phase_template "$f"
|
|
294
|
+
sed -i.bak '/^ success_criteria:/,/^ - /d' "$f"
|
|
295
|
+
run_check "$f"
|
|
296
|
+
assert_exit "missing-field phase:success_criteria" 1
|
|
297
|
+
assert_finding "missing-field phase:success_criteria" "finding=missing-field:phase:1:success_criteria"
|
|
298
|
+
|
|
299
|
+
# milestone side: name, phases
|
|
300
|
+
f="$ROOT/missing-milestone-name.yml"
|
|
301
|
+
write_phase_template "$f"
|
|
302
|
+
sed -i.bak '/^ name: "Alpha"/d' "$f"
|
|
303
|
+
run_check "$f"
|
|
304
|
+
assert_exit "missing-field milestone:name" 1
|
|
305
|
+
assert_finding "missing-field milestone:name" "finding=missing-field:milestone:1:name"
|
|
306
|
+
|
|
307
|
+
f="$ROOT/missing-milestone-phases.yml"
|
|
308
|
+
write_phase_template "$f"
|
|
309
|
+
sed -i.bak '/^ phases: \[1\]/d' "$f"
|
|
310
|
+
run_check "$f"
|
|
311
|
+
assert_exit "missing-field milestone:phases" 1
|
|
312
|
+
assert_finding "missing-field milestone:phases" "finding=missing-field:milestone:1:phases"
|
|
313
|
+
|
|
314
|
+
# phases present but EMPTY — the "(non-empty)" half of the requirement:
|
|
315
|
+
# phases: [] must raise the same missing-field finding, not pass silently.
|
|
316
|
+
f="$ROOT/empty-milestone-phases.yml"
|
|
317
|
+
write_phase_template "$f"
|
|
318
|
+
sed -i.bak 's/^ phases: \[1\]$/ phases: []/' "$f"
|
|
319
|
+
run_check "$f"
|
|
320
|
+
assert_exit "missing-field milestone:phases empty list" 1
|
|
321
|
+
assert_finding "missing-field milestone:phases empty list" "finding=missing-field:milestone:1:phases"
|
|
322
|
+
fi
|
|
323
|
+
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
# duplicate-id: the REAL live m-34/m-36 shape (id 63 claimed by two phases).
|
|
326
|
+
# Kept literal — this fixture IS the incident, not a derived mutation.
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
if want_tag duplicate-id; then
|
|
329
|
+
f="$ROOT/duplicate-id.yml"
|
|
330
|
+
cat >"$f" <<'EOF'
|
|
331
|
+
roadmap:
|
|
332
|
+
milestones:
|
|
333
|
+
- id: 36
|
|
334
|
+
name: "FORGE.md context diet"
|
|
335
|
+
phases: [63]
|
|
336
|
+
- id: 34
|
|
337
|
+
name: "Origin-first upgrading"
|
|
338
|
+
phases: [63]
|
|
339
|
+
|
|
340
|
+
phases:
|
|
341
|
+
- id: 63
|
|
342
|
+
name: "context-diet"
|
|
343
|
+
milestone_dir: "milestone-36/63-context-diet"
|
|
344
|
+
goal: "FORGE.md slimmed"
|
|
345
|
+
requirements: [FR-191]
|
|
346
|
+
dependencies: []
|
|
347
|
+
success_criteria:
|
|
348
|
+
- "wc -c <= 35840"
|
|
349
|
+
estimated_hours: 3
|
|
350
|
+
status: pending
|
|
351
|
+
- id: 63
|
|
352
|
+
name: "origin-upgrade-bin"
|
|
353
|
+
milestone_dir: "milestone-34/63-origin-upgrade-bin"
|
|
354
|
+
goal: "Non-interactive upgrade bin"
|
|
355
|
+
requirements: [FR-184]
|
|
356
|
+
dependencies: []
|
|
357
|
+
success_criteria:
|
|
358
|
+
- "upgrade --yes --json exits 0"
|
|
359
|
+
estimated_hours: 4
|
|
360
|
+
status: pending
|
|
361
|
+
|
|
362
|
+
waves:
|
|
363
|
+
1: [63]
|
|
364
|
+
EOF
|
|
365
|
+
run_check "$f"
|
|
366
|
+
assert_exit "duplicate-id: real m-34/m-36 id-63 shape" 1
|
|
367
|
+
assert_finding "duplicate-id: real m-34/m-36 id-63 shape" "finding=duplicate-phase-id:63:milestone-36/63-context-diet,milestone-34/63-origin-upgrade-bin"
|
|
368
|
+
fi
|
|
369
|
+
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
# refs: dangling-phase-ref (milestone claims a nonexistent phase) + orphan-phase
|
|
372
|
+
# — both derived from a source fixture by single-line sed mutation
|
|
373
|
+
# ---------------------------------------------------------------------------
|
|
374
|
+
if want_tag refs; then
|
|
375
|
+
f="$ROOT/dangling-ref.yml"
|
|
376
|
+
write_phase_template "$f"
|
|
377
|
+
sed -i.bak 's/^ phases: \[1\]$/ phases: [1, 99]/' "$f"
|
|
378
|
+
run_check "$f"
|
|
379
|
+
assert_exit "refs: dangling-phase-ref" 1
|
|
380
|
+
assert_finding "refs: dangling-phase-ref" "finding=dangling-phase-ref:milestone:1:phase:99"
|
|
381
|
+
|
|
382
|
+
# orphan: drop phase 2 from the clean fixture's claims — phase 2 still exists,
|
|
383
|
+
# no milestone claims it. Phase 2 carries no milestone_dir, so this also
|
|
384
|
+
# covers the "(none)" detail branch of the orphan finding.
|
|
385
|
+
f="$ROOT/orphan-phase.yml"
|
|
386
|
+
write_clean "$f"
|
|
387
|
+
sed -i.bak 's/^ phases: \[1, 2\]$/ phases: [1]/' "$f"
|
|
388
|
+
run_check "$f"
|
|
389
|
+
assert_exit "refs: orphan-phase" 1
|
|
390
|
+
assert_finding "refs: orphan-phase" "finding=orphan-phase:2:(none)"
|
|
391
|
+
fi
|
|
392
|
+
|
|
393
|
+
# ---------------------------------------------------------------------------
|
|
394
|
+
# milestone-dir: bad prefix fires; absent field never fires (negative control)
|
|
395
|
+
# — both derived from the template by single-line sed mutation
|
|
396
|
+
# ---------------------------------------------------------------------------
|
|
397
|
+
if want_tag milestone-dir; then
|
|
398
|
+
f="$ROOT/bad-milestone-dir.yml"
|
|
399
|
+
write_phase_template "$f"
|
|
400
|
+
sed -i.bak 's|milestone-1/1-alpha-one|milestone-99/1-alpha-one|' "$f"
|
|
401
|
+
run_check "$f"
|
|
402
|
+
assert_exit "milestone-dir: bad prefix" 1
|
|
403
|
+
assert_finding "milestone-dir: bad prefix" "finding=bad-milestone-dir:1:milestone-99/1-alpha-one"
|
|
404
|
+
|
|
405
|
+
f="$ROOT/no-milestone-dir.yml"
|
|
406
|
+
write_phase_template "$f"
|
|
407
|
+
sed -i.bak '/^ milestone_dir:/d' "$f"
|
|
408
|
+
run_check "$f"
|
|
409
|
+
assert_exit "milestone-dir: absent field stays clean" 0
|
|
410
|
+
assert_clean "milestone-dir: absent field stays clean"
|
|
411
|
+
fi
|
|
412
|
+
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
# waves: a wave referencing a phase id absent from phases:
|
|
415
|
+
# — derived from the template by single-line sed mutation
|
|
416
|
+
# ---------------------------------------------------------------------------
|
|
417
|
+
if want_tag waves; then
|
|
418
|
+
f="$ROOT/dangling-wave.yml"
|
|
419
|
+
write_phase_template "$f"
|
|
420
|
+
sed -i.bak 's/^ 1: \[1\]$/ 1: [1, 99]/' "$f"
|
|
421
|
+
run_check "$f"
|
|
422
|
+
assert_exit "waves: dangling-wave-ref" 1
|
|
423
|
+
assert_finding "waves: dangling-wave-ref" "finding=dangling-wave-ref:1:99"
|
|
424
|
+
fi
|
|
425
|
+
|
|
426
|
+
# ---------------------------------------------------------------------------
|
|
427
|
+
# malformed: a field mis-indented one level shallow -- proves the parser is
|
|
428
|
+
# structural (indentation-keyed), not a flat "key: value anywhere" grep. A
|
|
429
|
+
# flat grep would still find "success_criteria:" text in the file and wrongly
|
|
430
|
+
# pass; the structural walk must NOT associate it with phase 1 at the wrong depth.
|
|
431
|
+
# ---------------------------------------------------------------------------
|
|
432
|
+
if want_tag malformed; then
|
|
433
|
+
f="$ROOT/malformed-indent.yml"
|
|
434
|
+
cat >"$f" <<'EOF'
|
|
435
|
+
roadmap:
|
|
436
|
+
milestones:
|
|
437
|
+
- id: 1
|
|
438
|
+
name: "Alpha"
|
|
439
|
+
phases: [1]
|
|
440
|
+
|
|
441
|
+
phases:
|
|
442
|
+
- id: 1
|
|
443
|
+
name: "alpha-one"
|
|
444
|
+
goal: "User can alpha"
|
|
445
|
+
requirements: [FR-001]
|
|
446
|
+
dependencies: []
|
|
447
|
+
success_criteria:
|
|
448
|
+
- "does the thing"
|
|
449
|
+
estimated_hours: 1
|
|
450
|
+
status: pending
|
|
451
|
+
|
|
452
|
+
waves:
|
|
453
|
+
1: [1]
|
|
454
|
+
EOF
|
|
455
|
+
run_check "$f"
|
|
456
|
+
assert_exit "malformed: mis-indented success_criteria" 1
|
|
457
|
+
assert_finding "malformed: mis-indented success_criteria" "finding=missing-field:phase:1:success_criteria"
|
|
458
|
+
fi
|
|
459
|
+
|
|
460
|
+
# ---------------------------------------------------------------------------
|
|
461
|
+
# invocation: missing/unreadable file -> exit 2, nothing on stdout;
|
|
462
|
+
# no-arg default resolves <git-root>/.forge/roadmap.yml
|
|
463
|
+
# ---------------------------------------------------------------------------
|
|
464
|
+
if want_tag invocation; then
|
|
465
|
+
run_check "$ROOT/does-not-exist.yml"
|
|
466
|
+
assert_exit "invocation: nonexistent file" 2
|
|
467
|
+
if [ -z "$OUT" ]; then pass "invocation: empty stdout on bad invocation"; else fail "invocation: expected empty stdout, got: $OUT"; fi
|
|
468
|
+
if [ -s "$ROOT/stderr" ]; then pass "invocation: diagnostic on stderr"; else fail "invocation: expected a diagnostic on stderr"; fi
|
|
469
|
+
|
|
470
|
+
# default no-arg path inside a throwaway git repo (the only git use in this suite)
|
|
471
|
+
if command -v git >/dev/null 2>&1; then
|
|
472
|
+
d="$ROOT/defrepo"
|
|
473
|
+
mkdir -p "$d/.forge"
|
|
474
|
+
git init -q "$d"
|
|
475
|
+
write_clean "$d/.forge/roadmap.yml"
|
|
476
|
+
OUT="$(cd "$d" && "$HELPER" 2>"$ROOT/stderr")"
|
|
477
|
+
RC=$?
|
|
478
|
+
assert_exit "invocation: no-arg resolves git-root roadmap" 0
|
|
479
|
+
assert_clean "invocation: no-arg resolves git-root roadmap"
|
|
480
|
+
else
|
|
481
|
+
printf 'NOTE: git unavailable — skipping no-arg default-path case\n' >&2
|
|
482
|
+
fi
|
|
483
|
+
fi
|
|
484
|
+
|
|
485
|
+
# ---------------------------------------------------------------------------
|
|
486
|
+
printf '\n%d selected case block(s): %d passed, %d failed\n' "$SELECTED" "$PASSED" "$FAILED"
|
|
487
|
+
[ "$FAILED" -eq 0 ] && [ "$SELECTED" -gt 0 ]
|
|
@@ -32,3 +32,18 @@ deploy_paths: []
|
|
|
32
32
|
# deleted-assertion scope when set — default there is EVERY test file in the diff).
|
|
33
33
|
gating_tests: []
|
|
34
34
|
# - "tests/e2e/*"
|
|
35
|
+
|
|
36
|
+
# OPTIONAL run-waiver binding — the Environment-gated hold waiver (default OFF).
|
|
37
|
+
# When set and a hold has fired (and no operator PR approval waives it), the
|
|
38
|
+
# check queries the host for a SUCCESSFUL run of this workflow on the repo's
|
|
39
|
+
# DEFAULT branch whose run title carries the PR head SHA (the workflow's
|
|
40
|
+
# run-name templates the sha input in — activate .forge/templates/waive-hold.yml
|
|
41
|
+
# as .github/workflows/waive-hold.yml, gated by a `hold-waiver` Environment
|
|
42
|
+
# whose required reviewer is the operator). Match → the hold passes, annotated
|
|
43
|
+
# with the run URL. FAIL-CLOSED on every leg: unconfigured, API unreachable,
|
|
44
|
+
# no matching run, run/config ref != default branch, stale/superseded SHA → the
|
|
45
|
+
# hold stands. Applies to the HOLD CHECK ONLY — never lint or CI test checks.
|
|
46
|
+
# CI needs `actions: read` on the token; see docs/merge-floor-checks.md §5.
|
|
47
|
+
# waiver:
|
|
48
|
+
# workflow: waive-hold.yml # workflow file under .github/workflows/
|
|
49
|
+
# ref: main # MUST equal the repo's default branch
|
|
@@ -155,6 +155,16 @@ models:
|
|
|
155
155
|
# # phone-spawned sessions act without prompts — opt in only where the
|
|
156
156
|
# # phone lane carries the same trust as an attended terminal.
|
|
157
157
|
|
|
158
|
+
# attribution: # OPT-IN Driven-by attribution (hold-waiver patch) — default OFF.
|
|
159
|
+
# driven_by: true # When true, the merge-floor check-quality lint
|
|
160
|
+
# # (.forge/checks/forge-check-lint.sh) requires every non-merge PR
|
|
161
|
+
# # commit to carry a `Driven-by: <email>` trailer (optional trailing
|
|
162
|
+
# # session ref allowed) — the durable per-commit record of the driving
|
|
163
|
+
# # human, whatever identity authors the commit. Read from the BASE side
|
|
164
|
+
# # of the PR diff (a PR cannot disable it for itself; this file is on
|
|
165
|
+
# # the hold check's protected paths, so flipping it holds for operator
|
|
166
|
+
# # review). Absent/false → rule fully off, zero behavior change.
|
|
167
|
+
|
|
158
168
|
risks: # What could go wrong?
|
|
159
169
|
- risk: ""
|
|
160
170
|
mitigation: ""
|
|
@@ -1,46 +1,16 @@
|
|
|
1
|
-
# Forge Desire-Path Observation —
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
# that is what used to make state/index.yml conflict across worktrees.
|
|
6
|
-
#
|
|
7
|
-
# Filename: state/desire-paths/{YYYY-MM-DD}-{type}-{milestone}-{slug}.yml
|
|
8
|
-
# e.g. state/desire-paths/2026-06-10-recurring_friction-m7-uncommitted-state.yml
|
|
9
|
-
# The date+type+milestone+slug make the name unique, so concurrent agents in
|
|
10
|
-
# different worktrees only ever ADD files — git never has to merge content.
|
|
11
|
-
#
|
|
12
|
-
# Occurrence counts are DERIVED: the `forge` skill globs this directory and
|
|
13
|
-
# groups by (type + normalized note). A pattern appearing in 3+ files is a
|
|
14
|
-
# candidate for framework evolution, surfaced at `forge` boot — the single
|
|
15
|
-
# review owner (see ADR-012).
|
|
16
|
-
#
|
|
17
|
-
# SCOPE decides where a 3+ pattern's fix lands:
|
|
18
|
-
# project = fix targets user-owned files (constitution.md, design-system.md,
|
|
19
|
-
# context.md). Stays in-project.
|
|
20
|
-
# framework = fix targets Forge's own skills/templates. Eligible for UPSTREAM
|
|
21
|
-
# transport — forge review writes a portable block to
|
|
22
|
-
# state/desire-paths/upstream/ and (when gh + forge.upstream_repo
|
|
23
|
-
# are available) offers a GH issue against the Forge repo.
|
|
24
|
-
# Lazy migration: a file with no `scope` is read as `project`.
|
|
1
|
+
# Forge Desire-Path Observation — one file per observation
|
|
2
|
+
# Copy to .forge/state/desire-paths/{YYYY-MM-DD}-{type}-{milestone}-{slug}.yml
|
|
3
|
+
# Append-only: never edit an existing observation file; recurrence is derived
|
|
4
|
+
# by globbing (type + normalized note), not a counter. See FORGE.md § state/desire-paths/.
|
|
25
5
|
|
|
26
|
-
type: ""
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
note: "" # one-line description — the grouping key for occurrence counts
|
|
38
|
-
|
|
39
|
-
# Optional type-specific detail (fill what applies to `type`):
|
|
40
|
-
detail:
|
|
41
|
-
detected: "" # tier_override: what Forge detected
|
|
42
|
-
overridden_to: "" # tier_override: what the user chose
|
|
43
|
-
rule: null # deviation_pattern: 1 | 2 | 3
|
|
44
|
-
correction: "" # user_correction: the repeated correction
|
|
45
|
-
failure_pattern: "" # agent_struggle: how the task fails
|
|
46
|
-
step: "" # skipped_step: the step skipped
|
|
6
|
+
type: "" # recurring_friction | agent_struggle | user_correction (etc. — free-form, grouped by exact string)
|
|
7
|
+
date: "" # ISO 8601 date the observation was made, e.g. "2026-07-23"
|
|
8
|
+
milestone: "" # milestone id, or a short label for non-milestone work (e.g. "quick-task (...)")
|
|
9
|
+
scope: project # project | framework — absent ⇒ project (this repo only; framework ⇒ upstream-eligible per ADR-012)
|
|
10
|
+
suggestion_target: "" # the file/skill this observation would change if acted on
|
|
11
|
+
note: >
|
|
12
|
+
What was observed — concrete enough that a future review can judge recurrence
|
|
13
|
+
(same type + normalized note) without re-reading the original session.
|
|
14
|
+
suggestion: >
|
|
15
|
+
What to do about it, if a fix is proposed. Omit this key when the observation
|
|
16
|
+
has no concrete suggestion yet.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# waive-hold — Environment-gated hold waiver (TEMPLATE — hold-waiver patch,
|
|
2
|
+
# operator-ruled 2026-07-22; see docs/merge-floor-checks.md).
|
|
3
|
+
#
|
|
4
|
+
# This file is a TEMPLATE shipped in .forge/templates/ — it is NOT an active
|
|
5
|
+
# workflow here. To activate on a consuming repo:
|
|
6
|
+
# 1. Create the `hold-waiver` Environment (repo Settings → Environments) with
|
|
7
|
+
# the operator as required reviewer. No secrets, no branch restriction.
|
|
8
|
+
# 2. Copy this file to .github/workflows/waive-hold.yml on the DEFAULT branch.
|
|
9
|
+
# 3. Bind it in .forge/hold-config.yml:
|
|
10
|
+
# waiver:
|
|
11
|
+
# workflow: waive-hold.yml
|
|
12
|
+
# ref: <default-branch>
|
|
13
|
+
#
|
|
14
|
+
# How it waives: the operator dispatches this workflow (phone-friendly) with the
|
|
15
|
+
# held PR's full 40-char head SHA. The `environment: hold-waiver` gate parks the
|
|
16
|
+
# run until the operator approves it — the approval IS the recorded human act
|
|
17
|
+
# (D-9: lowering a hold is an explicit recorded act; Environment approvals have
|
|
18
|
+
# no self-approval ban). On approval the run executes and succeeds; run-name
|
|
19
|
+
# templates the SHA into the run's display title, which is exactly what
|
|
20
|
+
# forge-hold-check.sh's run-waiver channel matches on (workflow path + default-
|
|
21
|
+
# branch ref + success conclusion + head SHA in the title). A new push to the PR
|
|
22
|
+
# changes its head SHA, so an old waiver run stops matching — waive per SHA.
|
|
23
|
+
#
|
|
24
|
+
# The job's body IS the record: it validates the input and asserts the SHA is
|
|
25
|
+
# the head of an open PR (a run for a random SHA waives nothing anyway — the
|
|
26
|
+
# assert just keeps the record clean). It deploys NOTHING and needs NO secrets —
|
|
27
|
+
# only the default GITHUB_TOKEN, read-only.
|
|
28
|
+
name: waive-hold
|
|
29
|
+
run-name: "waive-hold sha=${{ inputs.sha }}"
|
|
30
|
+
|
|
31
|
+
on:
|
|
32
|
+
workflow_dispatch:
|
|
33
|
+
inputs:
|
|
34
|
+
sha:
|
|
35
|
+
description: "Full 40-char head SHA of the held PR this waiver approves"
|
|
36
|
+
required: true
|
|
37
|
+
type: string
|
|
38
|
+
|
|
39
|
+
permissions:
|
|
40
|
+
contents: read
|
|
41
|
+
pull-requests: read
|
|
42
|
+
|
|
43
|
+
jobs:
|
|
44
|
+
record-waiver:
|
|
45
|
+
runs-on: ubuntu-latest
|
|
46
|
+
environment: hold-waiver # the gate: required reviewer = the operator
|
|
47
|
+
env:
|
|
48
|
+
SHA: ${{ inputs.sha }} # env-var indirection — the input never lands in script text
|
|
49
|
+
GH_TOKEN: ${{ github.token }}
|
|
50
|
+
steps:
|
|
51
|
+
- name: Validate SHA format (full 40-char hex, nothing else)
|
|
52
|
+
run: |
|
|
53
|
+
printf '%s\n' "$SHA" | grep -qE '^[0-9a-f]{40}$' \
|
|
54
|
+
|| { printf 'waive-hold: sha input must be a full 40-char lowercase hex SHA, got: %s\n' "$SHA"; exit 1; }
|
|
55
|
+
- name: Assert the SHA is the head of an open PR
|
|
56
|
+
run: |
|
|
57
|
+
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/commits/$SHA/pulls" \
|
|
58
|
+
--jq "[.[] | select(.state == \"open\" and .head.sha == \"$SHA\")][0].number")"
|
|
59
|
+
if [ -z "$pr_number" ] || [ "$pr_number" = "null" ]; then
|
|
60
|
+
printf 'waive-hold: %s is not the head SHA of any OPEN pull request — refusing to record a waiver\n' "$SHA"
|
|
61
|
+
exit 1
|
|
62
|
+
fi
|
|
63
|
+
printf 'waive-hold: recorded — approved waiver for open PR #%s at head %s\n' "$pr_number" "$SHA"
|