forge-orkes 0.67.0 → 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/bin/create-forge.js +49 -27
- package/package.json +1 -1
- package/template/.claude/hooks/forge-slice-runner.sh +8 -0
- package/template/.claude/settings.json +6 -1
- package/template/.claude/skills/planning/SKILL.md +2 -0
- package/template/.claude/skills/upgrading/SKILL.md +1 -1
- package/template/.forge/checks/forge-jarvis-dispatch.sh +16 -8
- package/template/.forge/checks/forge-roadmap-check.sh +246 -0
- package/template/.forge/checks/tests/forge-jarvis-dispatch.test.sh +35 -0
package/bin/create-forge.js
CHANGED
|
@@ -52,31 +52,22 @@ const FORGE_END = '<!-- forge:end -->';
|
|
|
52
52
|
|
|
53
53
|
// --- File classification for upgrades ---
|
|
54
54
|
|
|
55
|
-
// Framework-owned: Forge controls these entirely
|
|
55
|
+
// Framework-owned: Forge controls these entirely. Auto-clean removes files that
|
|
56
|
+
// dropped out of the template — but ownership is decided per top-level UNIT
|
|
57
|
+
// (a skill DIR under .claude/skills/, an agent FILE under .claude/agents/), and
|
|
58
|
+
// only the template defines which units Forge shipped. A unit the base template
|
|
59
|
+
// never shipped — user-authored, vendored-parity, or an opt-in experimental
|
|
60
|
+
// skill installed separately (e.g. m10's orchestrating, the convention packs) —
|
|
61
|
+
// is NOT framework content and is preserved, never swept, even though it lives
|
|
62
|
+
// under a framework-owned dir. Deleting it would destroy work Forge never owned,
|
|
63
|
+
// and .claude/ is frequently gitignored, so the loss is unrecoverable. Files
|
|
64
|
+
// removed from WITHIN a still-shipped unit (e.g. a renamed helper inside a
|
|
65
|
+
// template skill dir) stay cleanable — their unit is still present in the
|
|
66
|
+
// template. See upgradeDir(). Experimental skills are additionally diffed
|
|
67
|
+
// against their shipped sources by detectExperimentalStaleness() (report-only;
|
|
68
|
+
// re-sync consent lives in the /upgrading skill layer).
|
|
56
69
|
const FRAMEWORK_OWNED_DIRS = ['.claude/agents', '.claude/skills'];
|
|
57
70
|
|
|
58
|
-
// Experimental / opt-in skills installed separately (e.g. from experimental/m10).
|
|
59
|
-
// They are NOT in the shipped base template, so the framework-owned auto-clean
|
|
60
|
-
// would otherwise delete them on upgrade. Preserve them instead.
|
|
61
|
-
//
|
|
62
|
-
// The npm tarball ships experimental/ alongside bin/ + template/, so this route
|
|
63
|
-
// CAN diff an installed experimental skill against its same-tarball source:
|
|
64
|
-
// detectExperimentalStaleness() reports unchanged|stale with the differing files.
|
|
65
|
-
// (Blind preservation is what silently froze these pre-0.33.0 — issue #5: a base
|
|
66
|
-
// migration that changed experimental-skill behavior became a no-op.) Detection
|
|
67
|
-
// only — consent for a re-sync lives in the /upgrading skill layer, and
|
|
68
|
-
// load-bearing conventions are still promoted to base (FORGE.md) rather than
|
|
69
|
-
// left to a frozen skill. This preserve-list gates auto-clean only; staleness
|
|
70
|
-
// discovery is by directory scan of the shipped experimental/ tree.
|
|
71
|
-
const EXPERIMENTAL_SKILL_PATHS = ['.claude/skills/orchestrating'];
|
|
72
|
-
|
|
73
|
-
function isExperimentalSkillPath(displayPath) {
|
|
74
|
-
const norm = displayPath.split(path.sep).join('/');
|
|
75
|
-
return EXPERIMENTAL_SKILL_PATHS.some(
|
|
76
|
-
(p) => norm === p || norm.startsWith(p + '/')
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
71
|
// Compare dotted numeric versions (e.g. "0.19.1"). Returns 1 if a>b, -1 if a<b, 0 if equal.
|
|
81
72
|
function compareVersions(a, b) {
|
|
82
73
|
const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
|
|
@@ -259,9 +250,15 @@ function upgradeDir(relDir, { autoClean = false } = {}) {
|
|
|
259
250
|
const srcPath = path.join(srcDir, rel);
|
|
260
251
|
if (!fs.existsSync(srcPath)) {
|
|
261
252
|
const displayPath = path.join(relDir, rel);
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
253
|
+
// Ownership is per top-level unit: the first path segment of `rel` is the
|
|
254
|
+
// skill dir (or, for flat agents, the file itself). If the template no
|
|
255
|
+
// longer ships that unit at all, the file is user-authored, vendored, or an
|
|
256
|
+
// opt-in experimental skill — not Forge's to delete. Preserve it. Only
|
|
257
|
+
// files whose unit the template STILL ships (a stale file inside a shipped
|
|
258
|
+
// skill dir) remain removal candidates.
|
|
259
|
+
const topUnit = rel.split(path.sep)[0];
|
|
260
|
+
if (autoClean && !fs.existsSync(path.join(srcDir, topUnit))) {
|
|
261
|
+
result.preserved.push(`${displayPath} (kept — not shipped by the base template)`);
|
|
265
262
|
continue;
|
|
266
263
|
}
|
|
267
264
|
const destPath = path.join(destDir, rel);
|
|
@@ -374,6 +371,30 @@ function mergeManagedDeny(destSettings, srcSettings) {
|
|
|
374
371
|
}
|
|
375
372
|
}
|
|
376
373
|
|
|
374
|
+
/**
|
|
375
|
+
* Union the template's managed allow rules into the project's permissions.allow.
|
|
376
|
+
* Additive — a user's own allow/deny entries are untouched (mirrors
|
|
377
|
+
* mergeManagedDeny). Forge automates a worktree-heavy workflow (streams, the
|
|
378
|
+
* slice runner, orchestration/human-verified teardown), but the harness auto-mode
|
|
379
|
+
* classifier soft-denies `git worktree remove` for a worktree the session didn't
|
|
380
|
+
* create in-session, and a Remote-Control / detached session cannot enter
|
|
381
|
+
* bypassPermissions after launch. A NARROW allow rule survives auto mode and runs
|
|
382
|
+
* before the classifier, so the automating session can clean up its own worktrees.
|
|
383
|
+
* These are read/teardown of Forge-managed worktrees only — `remove` is git-guarded
|
|
384
|
+
* (refuses on a dirty/untracked tree without --force) and, in the Forge flow, only
|
|
385
|
+
* ever runs on a merged, human-verified worktree. It never touches commits or
|
|
386
|
+
* branches — worktree removal drops a checkout, not history.
|
|
387
|
+
*/
|
|
388
|
+
function mergeManagedAllow(destSettings, srcSettings) {
|
|
389
|
+
const srcAllow = (srcSettings.permissions && srcSettings.permissions.allow) || [];
|
|
390
|
+
if (!srcAllow.length) return;
|
|
391
|
+
destSettings.permissions = destSettings.permissions || {};
|
|
392
|
+
const destAllow = destSettings.permissions.allow || (destSettings.permissions.allow = []);
|
|
393
|
+
for (const rule of srcAllow) {
|
|
394
|
+
if (!destAllow.includes(rule)) destAllow.push(rule);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
377
398
|
/**
|
|
378
399
|
* Strip the legacy *inline* active-skill guard from PreToolUse (forge#12). It was
|
|
379
400
|
* an inline `if [ ! -f .../.forge/.active-skill ]; then ... exit 2; fi` one-liner;
|
|
@@ -437,6 +458,7 @@ function upgradeSettings() {
|
|
|
437
458
|
// Additively install framework-managed safety guardrails (ADR-017)
|
|
438
459
|
mergeManagedHooks(destSettings, srcSettings);
|
|
439
460
|
mergeManagedDeny(destSettings, srcSettings);
|
|
461
|
+
mergeManagedAllow(destSettings, srcSettings);
|
|
440
462
|
|
|
441
463
|
const after = JSON.stringify(destSettings);
|
|
442
464
|
if (before === after) return 'unchanged';
|
|
@@ -949,7 +971,7 @@ async function upgrade() {
|
|
|
949
971
|
if (results.preserved.length > 0) {
|
|
950
972
|
say(` Preserved (${results.preserved.length}):`);
|
|
951
973
|
for (const f of results.preserved) {
|
|
952
|
-
say(` ${f}
|
|
974
|
+
say(` ${f}`);
|
|
953
975
|
}
|
|
954
976
|
for (const e of experimental) {
|
|
955
977
|
say(
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"forge": {
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.68.0",
|
|
4
4
|
"default_tier": "standard",
|
|
5
5
|
"beads_integration": false,
|
|
6
6
|
"context_gates": {
|
|
@@ -48,6 +48,11 @@
|
|
|
48
48
|
"Edit(**/.secrets/**)",
|
|
49
49
|
"Edit(**/*.pem)",
|
|
50
50
|
"Edit(**/*.key)"
|
|
51
|
+
],
|
|
52
|
+
"allow": [
|
|
53
|
+
"Bash(git worktree remove *)",
|
|
54
|
+
"Bash(git worktree prune)",
|
|
55
|
+
"Bash(git worktree prune *)"
|
|
51
56
|
]
|
|
52
57
|
},
|
|
53
58
|
"hooks": {
|
|
@@ -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
|
|
@@ -139,7 +139,7 @@ Source version (`{source}/packages/create-forge/package.json`) **<** installed (
|
|
|
139
139
|
|
|
140
140
|
Dev mode syncs from a checkout, so it keeps file-classification semantics — **the npm bin's sync behavior is the spec**; do not re-derive it here. Categories:
|
|
141
141
|
|
|
142
|
-
- **Framework-owned** (`.claude/agents/*.md`, `.claude/skills/*/SKILL.md`, `.forge/FORGE.md`) → overwrite when different
|
|
142
|
+
- **Framework-owned** (`.claude/agents/*.md`, `.claude/skills/*/SKILL.md`, `.forge/FORGE.md`) → overwrite when different. Ownership is per top-level **unit** — a skill DIR under `.claude/skills/`, a flat agent FILE under `.claude/agents/`: a unit the template doesn't ship (user-authored, vendored-parity, or an opt-in experimental skill) is **preserved, never deleted**; only stale files *inside* a still-shipped skill dir are cleaned. Experimental skills additionally get the staleness pass below.
|
|
143
143
|
- **Template-only** (`.forge/templates/**`, `.forge/migrations/**`, `.forge/adapters/**`, `.forge/gitignore` → `.forge/.gitignore`) → overwrite; `.forge/FORGE.md` lands BEFORE the CLAUDE.md pass.
|
|
144
144
|
- **Additive** (`.claude/hooks/**` incl. `tests/`, `.claude/rules/**`, `.forge/checks/**`) → add + overwrite template-shipped files, never delete local extras, `chmod +x` synced `*.sh`.
|
|
145
145
|
- **Import-managed** `CLAUDE.md` → the bin's `ensureClaudeMdImport()` cases verbatim; user content outside a forge section is never modified.
|
|
@@ -91,17 +91,25 @@ materialize_work_order() {
|
|
|
91
91
|
# Prefer the worktree's OWN milestone — .forge/phases/ carries the repo's whole milestone
|
|
92
92
|
# history (32 dirs on the forge repo), so "the only milestone dir" is a fixture-world
|
|
93
93
|
# assumption. The Worktree Convention names the branch forge/{anchor} with anchor =
|
|
94
|
-
# {milestone-id}[-{session}]; derive the id and pick that milestone's dir.
|
|
94
|
+
# {milestone-id}[-{session}]; derive the id and pick that milestone's dir. The id's own
|
|
95
|
+
# m- may be a strippable prefix (m-40 → milestone-40) or PART of a labeled id kept
|
|
96
|
+
# verbatim (m-EVT01 → milestone-m-EVT01) — canvaz first run 2026-07-22: stripping it
|
|
97
|
+
# unconditionally missed milestone-m-EVT01 and fell through to a 38-dir refusal. Try the
|
|
98
|
+
# anchor verbatim before the m--stripped form, then both again minus one trailing
|
|
99
|
+
# -{session} token; first existing dir wins (exact/longer forms first).
|
|
95
100
|
_mdir=""
|
|
96
101
|
_branch="$(git -C "$WORKTREE" branch --show-current 2>/dev/null)"
|
|
97
102
|
case "$_branch" in
|
|
98
|
-
forge
|
|
99
|
-
_anchor="${_branch#forge/
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
forge/*)
|
|
104
|
+
_anchor="${_branch#forge/}" # m-40 | m-40-a1b2c3d4 | m-EVT01-e6063597
|
|
105
|
+
_short="${_anchor%-*}" # minus one trailing -token (session suffix)
|
|
106
|
+
for _cand in "$_anchor" "${_anchor#m-}" "$_short" "${_short#m-}"; do
|
|
107
|
+
[ -n "$_cand" ] || continue
|
|
108
|
+
if [ -d "$WORKTREE/.forge/phases/milestone-$_cand" ]; then
|
|
109
|
+
_mdir="$WORKTREE/.forge/phases/milestone-$_cand"
|
|
110
|
+
break
|
|
111
|
+
fi
|
|
112
|
+
done ;;
|
|
105
113
|
esac
|
|
106
114
|
# Fallback (non-git fixture dirs / unconventional branches): a SINGLE milestone dir is
|
|
107
115
|
# unambiguous; several without a branch-derived pick is a refusal, never a guess.
|
|
@@ -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 $?
|
|
@@ -157,6 +157,41 @@ else
|
|
|
157
157
|
bad "dry-run side effects" "dry-run wrote work-order files"
|
|
158
158
|
fi
|
|
159
159
|
|
|
160
|
+
# --- 13-14. BRANCH-DERIVED MILESTONE PICK (canvaz first run 2026-07-22): with several
|
|
161
|
+
# milestone dirs, the forge/{anchor} branch names the one to build. The id's m- may be
|
|
162
|
+
# strippable (m-40 → milestone-40) or part of a labeled id (m-EVT01 → milestone-m-EVT01);
|
|
163
|
+
# unconditional stripping refused a clean labeled-id dispatch. These fixtures are real
|
|
164
|
+
# git repos — cases 9-12 (mktemp dirs) never reach the branch path.
|
|
165
|
+
mkbranch() { git -C "$1" init -q -b "$2" 2>/dev/null || { git -C "$1" init -q && git -C "$1" checkout -q -b "$2"; }; }
|
|
166
|
+
|
|
167
|
+
# 13. labeled id: forge/m-EVT01-<session> resolves milestone-m-EVT01 (m- is part of the id)
|
|
168
|
+
LSLICE="$ROOT/labeled-wt"
|
|
169
|
+
mkdir -p "$LSLICE/.forge/phases/milestone-m-EVT01/1-evt" "$LSLICE/.forge/phases/milestone-5/1-decoy"
|
|
170
|
+
printf 'evt plan\n' > "$LSLICE/.forge/phases/milestone-m-EVT01/1-evt/plan-01.md"
|
|
171
|
+
printf 'decoy\n' > "$LSLICE/.forge/phases/milestone-5/1-decoy/plan-01.md"
|
|
172
|
+
mkbranch "$LSLICE" forge/m-EVT01-e6063597
|
|
173
|
+
err13="$(sh "$DISPATCH" --slice "$LSLICE" --materialize-only 2>&1)"; rc=$?
|
|
174
|
+
if [ "$rc" = 0 ] && grep -q '^ - 1-evt$' "$LSLICE/slice.yml" 2>/dev/null \
|
|
175
|
+
&& grep -q 'evt plan' "$LSLICE/phases/1-evt/plan.md" 2>/dev/null && [ ! -d "$LSLICE/phases/1-decoy" ]; then
|
|
176
|
+
ok "labeled milestone id: branch picks milestone-m-EVT01 among several dirs (m- kept)"
|
|
177
|
+
else
|
|
178
|
+
bad "labeled-id branch pick" "rc=$rc err=$err13"
|
|
179
|
+
fi
|
|
180
|
+
|
|
181
|
+
# 14. numeric id + session suffix: forge/m-40-<session> still resolves milestone-40 (m- stripped)
|
|
182
|
+
QSLICE="$ROOT/numeric-wt"
|
|
183
|
+
mkdir -p "$QSLICE/.forge/phases/milestone-40/1-num" "$QSLICE/.forge/phases/milestone-39/1-decoy"
|
|
184
|
+
printf 'num plan\n' > "$QSLICE/.forge/phases/milestone-40/1-num/plan-01.md"
|
|
185
|
+
printf 'decoy\n' > "$QSLICE/.forge/phases/milestone-39/1-decoy/plan-01.md"
|
|
186
|
+
mkbranch "$QSLICE" forge/m-40-a1b2c3d4
|
|
187
|
+
err14="$(sh "$DISPATCH" --slice "$QSLICE" --materialize-only 2>&1)"; rc=$?
|
|
188
|
+
if [ "$rc" = 0 ] && grep -q '^ - 1-num$' "$QSLICE/slice.yml" 2>/dev/null \
|
|
189
|
+
&& grep -q 'num plan' "$QSLICE/phases/1-num/plan.md" 2>/dev/null && [ ! -d "$QSLICE/phases/1-decoy" ]; then
|
|
190
|
+
ok "numeric milestone id: branch picks milestone-40 among several dirs (m- stripped)"
|
|
191
|
+
else
|
|
192
|
+
bad "numeric-id branch pick" "rc=$rc err=$err14"
|
|
193
|
+
fi
|
|
194
|
+
|
|
160
195
|
# --- summary -----------------------------------------------------------------
|
|
161
196
|
printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$PASSED" "$FAILED"
|
|
162
197
|
[ "$FAILED" = 0 ]
|