diff --git a/CHANGELOG.md b/CHANGELOG.md
index 43fa3277..ea25f688 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m
## Unreleased
+- fix(build-plan-status): **every State the build-plan skill lists (built, partial, not built, blocked) and every spelling the phase card already counts (complete, verified, in progress, not needed and the like) is now read the same way by the plan header and the phase badge, so card, header and badge give one count; blocked counts as not built and is named in the header, genuinely unreadable values are still reported as unreadable; the later-stage placeholder FAIL is relabelled PENDING only while that stage is pending in PROJECT.md, with tests that a real FAIL stays FAIL and that FAIL stays when the current stage is unknown** — MendixMau
- fix(release-review): **the v1.0 release review: the multilingual skill loses its em and en dashes (wording only, content unchanged; its routing row keeps the trigger separator the agent stubs cut on), `doctor.sh` warns when a hook copy in `~/.claude/hooks` calls a toolkit script that no longer exists (it names the script and says to re-run `bin/install-claude-hooks.sh --full`; a warning, never a failure), and the `.gitignore` line for `.claude/progress-report-sessions.txt` now also reaches a project checked out as a git worktree (`.git` is a file there) in `doctor.sh` and `sync-project.sh`; `check-ignore --no-index` stops the line being appended again when the file is already tracked, and a `git rm --cached` hint is printed.** Tests: new `test-doctor-stale-hooks.sh`; worktree and tracked-file cases added to `test-progress-report-gitignore.sh`. CHANGELOG tidy (a stray blank line, one credit). — MendixMau
- fix(progress-report): **round 6 of the review: the session cache is machine-local and tied to its project, the first cached run is not slower than a cold one, and the build ledger decides the Stage 4 to 5 switch.** (1) `.claude/progress-report-sessions.txt` holds absolute session-log paths, so `init-project.sh`, `doctor.sh` and `sync-project.sh` now put `/.claude/progress-report-sessions.txt` in the project's `.gitignore`, the way they do for the doctor receipt and `toolkit.env`. (2) The cache header records the project's real path and the attribution rules (25% of tool calls, 20 calls); a copied or moved project, or a changed threshold, reads it as cold, so a copy never lists the original's sessions. (3) Files skipped by the 300-file cap are recorded as `no`, so a cached run never reads more files than a cold one. (4) With rows in `metrics-ledger.tsv` (else BUILD-LOG) the switch comes from the ledger; an earlier exec.sh call or build skill in a session log is ignored, the timed sign-off rule stays, and the footer says how the switch is chosen. Tests: F15 to F17 in `test-progress-report-field-fixes.sh`, new `test-progress-report-gitignore.sh` — MendixMau
- fix(progress-report): **polish on a fresh and an updated demo project: a "done " plan row counts as built in the card, the plan header and the phase badge alike; a placeholder gate failure on a stage after the current one reads PENDING, not FAIL; the no-session-log notice is one calm line with the regenerate hint inside "How this was measured"; the test-moments table says unit tests run for the flows chosen at CAC-5, not "when switched on".** — field review of the #434 report
diff --git a/project-bin/build-plan-status.sh b/project-bin/build-plan-status.sh
index 2846d628..b9530361 100755
--- a/project-bin/build-plan-status.sh
+++ b/project-bin/build-plan-status.sh
@@ -300,18 +300,27 @@ def column_map(headers):
m[key] = i
return m if "number" in m and "state" in m else None
+# Same prefixes as classify_state in progress-report.py, so the phase card, the plan header and
+# the phase badge give one count. brd-to-build-plan.md lists `built` / `partial` / `not built` /
+# `blocked`; the rest are the spellings the card already counts. `blocked` counts as not built.
+# A "not needed" style row is out of the count and out of the rollup. None = cannot be read.
def classify(state):
- s = state.lower()
- if s.startswith("not built"): return "not built"
- if s.startswith(("built", "done")): return "built"
+ s = state.lower().replace("**", "").strip()
if s.startswith("pending a person"): return "pending a person"
+ if s.startswith(("blocked",)): return "blocked"
+ if s.startswith(("not built", "not done", "not started", "planned", "to do", "todo", "open", "pending", "failed")): return "not built"
+ if s.startswith(("not needed", "n/a", "dropped", "skipped", "waived", "deferred")): return "skipped"
+ if s.startswith(("built", "done", "complete", "verified", "proven", "passed")): return "built"
+ if s.startswith(("partial", "in progress", "started", "wip")): return "partial"
return None
def rollup(steps):
kinds = [classify(s["state"]) for s in steps]
- if not kinds or None in kinds: return "unknown"
+ if None in kinds: return "unknown"
+ kinds = [k for k in kinds if k != "skipped"]
+ if not kinds: return "unknown"
if all(k == "built" for k in kinds): return "built"
- if all(k == "not built" for k in kinds): return "not built"
+ if all(k in ("not built", "blocked") for k in kinds): return "not built"
if all(k in ("built", "pending a person") for k in kinds): return "pending a person"
return "in progress"
@@ -556,19 +565,23 @@ def on_disk(pat):
if any(fnmatch.fnmatchcase(f, pat) for f in files): return "written"
return "missing"
-def classify(state): # the parser's rollup reading, per row
- s = (state or "").lower()
- if s.startswith("not built"): return "not built"
- if s.startswith(("built", "done")): return "built"
+def classify(state): # the parser's rollup reading, per row (keep in step with classify in parse_plan)
+ s = (state or "").lower().replace("**", "").strip()
if s.startswith("pending a person"): return "pending a person"
+ if s.startswith("blocked"): return "blocked"
+ if s.startswith(("not built", "not done", "not started", "planned", "to do", "todo", "open", "pending", "failed")): return "not built"
+ if s.startswith(("not needed", "n/a", "dropped", "skipped", "waived", "deferred")): return "skipped"
+ if s.startswith(("built", "done", "complete", "verified", "proven", "passed")): return "built"
+ if s.startswith(("partial", "in progress", "started", "wip")): return "partial"
return None
CLS = {"built": "st-built", "not built": "st-todo", "pending a person": "st-person",
+ "partial": "st-progress", "blocked": "st-todo", "skipped": "st-todo",
"in progress": "st-progress", "unknown": "st-unknown", None: "st-unknown"}
phases = d.get("phases", [])
all_steps = [s for p in phases for s in p["steps"]]
-tally = {"built": 0, "not built": 0, "pending a person": 0, None: 0}
+tally = {"built": 0, "not built": 0, "pending a person": 0, "partial": 0, "blocked": 0, "skipped": 0, None: 0}
for s in all_steps:
tally[classify(s["state"])] += 1
named_total = named_done = drift_rows = 0
@@ -607,9 +620,12 @@ for p in phases:
plural = lambda n, w: "%d %s%s" % (n, w, "" if n == 1 else "s")
print("The plan
")
print("From architecture/build-plan.md: %s, %s: "
- "%d built, %d not built, %d pending a person%s.
" % (
+ "%d built, %d partial, %d not built%s, %d pending a person%s%s.
" % (
plural(len(phases), "phase"), plural(len(all_steps), "row"),
- tally["built"], tally["not built"], tally["pending a person"],
+ tally["built"], tally["partial"], tally["not built"] + tally["blocked"],
+ (" (%d of them blocked)" % tally["blocked"]) if tally["blocked"] else "",
+ tally["pending a person"],
+ (", %d not needed" % tally["skipped"]) if tally["skipped"] else "",
(", %d with a State cell this page cannot read" % tally[None]) if tally[None] else ""))
if named_total:
print("Scripts named in the plan: %d, of which %d are renamed done- under mdlsource/.%s
" % (
diff --git a/project-bin/progress-report.py b/project-bin/progress-report.py
index 3051b750..f8235a8d 100755
--- a/project-bin/progress-report.py
+++ b/project-bin/progress-report.py
@@ -1475,7 +1475,7 @@ def money(c): return "n/a" if unp else "$%.0f" % c
elif row:
# a placeholder FAIL on a stage after the current one is just "not started yet": show it as pending, not as a failure
rs = row["status"]
- if rs == "FAIL" and "placeholder" in row["detail"].lower() and info["current"] in STAGES and STAGES.index(s) > STAGES.index(info["current"]): rs = "PENDING"
+ if rs == "FAIL" and states[s] == "pending" and "placeholder" in row["detail"].lower() and info["current"] in STAGES and STAGES.index(s) > STAGES.index(info["current"]): rs = "PENDING"
w("Gate-check page: %s %s
" % (esc(row["detail"]), esc(rs), esc(row["detail"][:110] + ("..." if len(row["detail"]) > 110 else ""))))
files = list(arts[s]["files"].items()); prim = [(f, i) for f, i in files if link_kind(f) == "html"]; rest = [(f, i) for f, i in files if link_kind(f) != "html"]
def chip(f, cls=""):
diff --git a/tests/wave2/test-progress-report-polish.sh b/tests/wave2/test-progress-report-polish.sh
index e33de53c..a64b9b66 100644
--- a/tests/wave2/test-progress-report-polish.sh
+++ b/tests/wave2/test-progress-report-polish.sh
@@ -6,6 +6,11 @@
# nothing is reported as a State cell the page cannot read. "not built" is still checked first.
# P2 a gate-check placeholder failure on a stage AFTER the current one reads PENDING, not FAIL; the same failure
# on the current stage stays FAIL.
+# P2b only a placeholder FAIL is relabelled: a real FAIL on a later stage stays FAIL, and every FAIL stays FAIL
+# when the current stage cannot be read from PROJECT.md.
+# P5 every State the build-plan skill lists (built / partial / not built / blocked) and the spellings the card
+# counts (complete, verified, in progress, ...) are read the same way by the card, the plan header and the
+# phase badge; a genuinely unreadable value is still reported as unreadable.
# P3 no session log: one calm line (not the orange box), the regenerate hint inside "How this was measured";
# with --no-log the page never claims it looked in folders or logs.
#
@@ -53,7 +58,7 @@ CLAUDE_CONFIG_DIR="$EMPTY" bash "$SCRIPT" "$P1" --html >/dev/null 2>&1
R="$P1/architecture/progress-report.html"
[ -f "$R" ] && ok "report written" || bad "report written"
has "$R" "2 of 3 rows built" "Stage 5 card counts the done row as built (2 of 3)"
-has "$R" "2 built, 1 not built" "plan header agrees: 2 built, 1 not built"
+has "$R" "2 built, 0 partial, 1 not built" "plan header agrees: 2 built, 1 not built"
hasnt "$R" "cannot read" "no State cell is called unreadable"
has "$R" "1 of 2 rows built" "Phase 2 shows 1 of 2 built"
hasnt "$R" 'st-unknown">unknown' "no phase is labelled unknown"
@@ -78,6 +83,75 @@ assert line("0") == "PENDING", line("0")
assert line("P") == "FAIL", line("P")
PY
+echo "P2b only placeholder FAILs relabel; unknown current stage keeps FAIL"
+P2B="$WORK/p2b"; mkproj "$P2B" "**Stage P - Kickoff**, in progress - gates passed: none"
+cat > "$P2B/index.html" <<'HTML'
+
+| P | Kickoff | FAIL | 'Confirmed by:' still holds the shipped placeholder: "[user] on [date]" |
+| 0 | Triage | FAIL | decision table has no rows |
+| 1 | Intake | FAIL | 'Confirmed by:' still holds the shipped placeholder: "[user] on [date]" |
+
+HTML
+CLAUDE_CONFIG_DIR="$EMPTY" bash "$SCRIPT" "$P2B" --html >/dev/null 2>&1
+stage_line() { # report stage -> status word shown on the gate-check line
+ "$PY" - "$1" "$2" <<'PY'
+import re, sys
+h = open(sys.argv[1], encoding="utf-8").read()
+i = h.index("id='stage-%s'" % sys.argv[2]); j = h.find("class='card stage", i + 10)
+print(re.search(r"Gate-check page: (\w+)", h[i:j if j > 0 else len(h)]).group(1))
+PY
+}
+R2B="$P2B/architecture/progress-report.html"
+[ "$(stage_line "$R2B" 0)" = FAIL ] && ok "non-placeholder FAIL on a later stage stays FAIL" || bad "non-placeholder FAIL on a later stage stays FAIL"
+[ "$(stage_line "$R2B" 1)" = PENDING ] && ok "placeholder FAIL on a later stage still reads PENDING" || bad "placeholder FAIL on a later stage still reads PENDING"
+P2C="$WORK/p2c"; mkdir -p "$P2C/architecture"; printf '# PROJECT.md\n\nno stage line here\n' > "$P2C/PROJECT.md"
+cp "$P2/index.html" "$P2C/index.html"
+CLAUDE_CONFIG_DIR="$EMPTY" bash "$SCRIPT" "$P2C" --html >/dev/null 2>&1
+R2C="$P2C/architecture/progress-report.html"
+[ "$(stage_line "$R2C" 0)" = FAIL ] && ok "unknown current stage: placeholder FAIL stays FAIL" || bad "unknown current stage: placeholder FAIL stays FAIL"
+
+echo "P5 every listed State is read the same by card, plan header and phase badge"
+P5="$WORK/p5"; mkproj "$P5" "**Stage 5 - Build**, in progress - gates passed: P, 0, 1, 2, 3, 4"
+cat > "$P5/architecture/build-plan.md" <<'MD'
+# Build plan
+
+### Phase 1 - Alpha
+
+| # | Kind | Step | Produces | Depends on | State |
+|---|---|---|---|---|---|
+| 1 | BUILD | one | 01-a.mdl | - | built |
+| 2 | BUILD | two | 02-a.mdl | 1 | complete |
+| 3 | BUILD | three | 03-a.mdl | 2 | verified |
+
+### Phase 2 - Beta
+
+| # | Kind | Step | Produces | Depends on | State |
+|---|---|---|---|---|---|
+| 4 | BUILD | four | 04-b.mdl | - | partial |
+| 5 | BUILD | five | 05-b.mdl | 4 | blocked |
+| 6 | BUILD | six | 06-b.mdl | 4 | not built |
+
+### Phase 3 - Gamma
+
+| # | Kind | Step | Produces | Depends on | State |
+|---|---|---|---|---|---|
+| 7 | BUILD | seven | 07-c.mdl | - | blocked (waiting on an answer) |
+| 8 | BUILD | eight | 08-c.mdl | - | not built |
+MD
+CLAUDE_CONFIG_DIR="$EMPTY" bash "$SCRIPT" "$P5" --html >/dev/null 2>&1
+R5="$P5/architecture/progress-report.html"
+has "$R5" "3 of 8 rows built, 1 partial" "card: 3 of 8 built, 1 partial"
+has "$R5" "3 built, 1 partial, 4 not built (2 of them blocked)" "plan header: 3 built, 1 partial, 4 not built of which 2 blocked"
+hasnt "$R5" "cannot read" "no listed State is called unreadable"
+hasnt "$R5" 'st-unknown">unknown' "no phase is labelled unknown"
+has "$R5" 'st-built">built' "Phase 1 badge: built"
+has "$R5" 'st-progress">in progress' "Phase 2 badge: in progress"
+has "$R5" 'st-todo">not built' "Phase 3 badge: not built (blocked counts as not built)"
+has "$R5" "3 of 3 rows built" "Phase 1 shows 3 of 3 built"
+sed 's/| not built |$/| whenever it suits |/' "$P5/architecture/build-plan.md" > "$P5/architecture/bp2.md" && mv "$P5/architecture/bp2.md" "$P5/architecture/build-plan.md"
+CLAUDE_CONFIG_DIR="$EMPTY" bash "$SCRIPT" "$P5" --html >/dev/null 2>&1
+has "$R5" "2 with a State cell this page cannot read" "a genuinely unreadable State is still reported as unreadable"
+
echo "P3 no-log line is calm and honest"
has "$R" "class=nolog" "one calm line"
hasnt "$R" "class='card note-card'" "no big banner box"