Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m

## Unreleased

- fix(progress-report): **polish on a fresh and an updated demo project: a "done <date>" 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
- 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` — review of PR #434
- fix(progress-report): **round 5 of the field test: "building now" marks the phase of the last successful build step (its mdlsource folder, else the script name matched to the plan, else the highest active phase; blocked steps and earlier amber rows do not move it); discovery of sessions from other folders counts only tool calls that operate on the project (at least 25% of a session's calls and at least 20, gaps over 30 minutes cut, sessions that run progress-report.py excluded, text mentions never count) and lists the sessions used in a collapsed element; a Stage 4 sign-off row with a date but no time puts the Stage 5 switch at the first build activity on or after that date (marked inferred) instead of midnight; the exec log is now the primary source for build activity: `.claude/loop/metrics-ledger.tsv` (written by exec.sh, UTC shown as local time) gives the Stage 5 build facts with or without a session log (runs, applied, failed, blocked, restored, exec time, wall span, runs per phase, latest model score, share of exec time lost to failed or restored runs), the last build step, the building-now phase and the Stage 4 to 5 switch, with BUILD-LOG and then session activity as fallbacks; docs/progress/TIME-LEDGER.md is an optional hand-written extra and its reader maps columns by each table's header.** Tests F10 (existing-project fixture) and F11 (older toolkit, then sync-project.sh) added. — MendixMau
- fix(progress-report): **field-test round on two real projects: one source of truth for stage state, a plan read from build-plan.md when the JSON is stale, wider log discovery with a ledger fallback, and a Build progress section.** (1) PROJECT.md (the stage line and the Decisions sign-off rows) beats a gate-check page that is older than it: the stage line's `· manual: 5` tail no longer swallows the last passed stage, the counter always equals the list it sits beside ("6 of 8" for P,0,1,2,3,4, plus "N waived"), and a passed stage the page does not list shows an amber "Gate-check page is N days old, re-run gate-check". (2) The report never lists itself as a stage artifact, nor any file outside the project folder. (3) `build-plan.json` older than `build-plan.md` or than the newest `docs/BUILD-LOG.md` row is no longer trusted: row state is read from the md State column by prefix (`not built` first; `built (...)` and `done ...` are built; `partial` and `in progress` are partial; `not needed` rows are not counted) and the page says "plan JSON was stale, counted from build-plan.md". (4) With no log in the project's own folder, discovery also scans other `~/.claude/projects/*` folders (30 days, newest 300 files): a log counts when its cwd is the project or when the project path appears in it at least 5 times, in which case only the stretch from the first to the last mention (padded 30 min) is counted, and the page says so; with no log at all, `docs/progress/TIME-LEDGER.md`, `docs/BUILD-LOG.md` and `.claude/loop/metrics-ledger.tsv` feed a labelled ledger card, and the banner says how to pass `--session-log`. (5) The Stage 4 to 5 switch for time and tokens is the earliest evidenced of the Stage 4 sign-off row in PROJECT.md, the first `exec.sh` call and the build skill, so a stale gate page no longer puts all build work in Stage 4. (6) The build detail section is now "Build progress", linked from the Stage 5 card: stacked built / partial / not built bars per phase, the headline "N of M rows built, K partial", the phase being built now marked, and the last build step with how long ago it ran. Test: `tests/wave2/test-progress-report-field-fixes.sh` (39 checks). — MendixMau
Expand Down
4 changes: 2 additions & 2 deletions project-bin/build-plan-status.sh
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ def column_map(headers):
def classify(state):
s = state.lower()
if s.startswith("not built"): return "not built"
if s.startswith("built"): return "built"
if s.startswith(("built", "done")): return "built"
if s.startswith("pending a person"): return "pending a person"
return None

Expand Down Expand Up @@ -559,7 +559,7 @@ def on_disk(pat):
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"): return "built"
if s.startswith(("built", "done")): return "built"
if s.startswith("pending a person"): return "pending a person"
return None

Expand Down
18 changes: 12 additions & 6 deletions project-bin/progress-report.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ def context_window(msgs, compacts=(), flag_1m=False):
if nxt is None: return None, "a context of %s exceeds every known window" % fmt_tok(obs)
return nxt, "inferred: the log shows %s and no 1M marker or compaction" % fmt_tok(obs)
TEST_MOMENTS = [("Every change", "Gate check plus only the tests that touch the change"),
("Module done", "Module journeys, plus unit tests when the project has them switched on"),
("Module done", "Module journeys, plus the per-flow unit tests chosen at CAC-5 (listed in the module brief)"),
("Build complete", "Everything, once"),
("Before every later deploy", "Everything"),
("User asks", "Everything")]
Expand Down Expand Up @@ -1294,7 +1294,7 @@ def rule(ratio, kind, title, seen, matters, fix, why, lost_s=0.0, lost_tok=0.0,
.advi{border-left:4px solid var(--warn)}.advi.hi{border-left-color:var(--bad)}.advi .rk{font-size:12px;color:var(--mut)}.advi h3{margin:2px 0 6px}.advi p{margin:4px 0;font-size:13px}.advi .why{color:var(--mut);font-size:12px}
details.ph>summary{list-style:none;cursor:pointer;color:var(--ink)}details.ph>summary::-webkit-details-marker{display:none}
details.ph[open]{grid-column:1/-1}.steps td{font-size:12px;padding:3px 6px}
.note-card{margin-top:14px;border-left:4px solid var(--warn)}.live{font-size:12px;color:var(--muted)}.live a{color:inherit}details.how{margin:10px 0;font-size:13px;color:var(--muted)}details.how summary{cursor:pointer}
.nolog{margin:10px 0;font-size:13px}.note-card{margin-top:14px;border-left:4px solid var(--warn)}.live{font-size:12px;color:var(--muted)}.live a{color:inherit}details.how{margin:10px 0;font-size:13px;color:var(--muted)}details.how summary{cursor:pointer}
.frag section.phase{margin-top:14px}.frag h2{font-size:16px;margin:24px 0 4px}.frag h3{margin:14px 0 2px}.frag .scroll{overflow-x:auto}
.frag .lede{font-size:14px}.frag .empty{color:var(--mut)}.frag code{font-size:12px}
.frag .st-built,.frag .done,.frag .CLEAN{color:var(--ok)}.frag .st-progress,.frag .in-progress,.frag .INCOMPLETE{color:var(--warn)}
Expand Down Expand Up @@ -1380,13 +1380,15 @@ def money(c): return "n/a" if unp else "$%.0f" % c
if SWITCH.get("inferred"):
how_parts.append("<p><b>Stage switch inferred.</b> The Stage 4 sign-off row gives a date (%s) without a time, so the switch to Stage 5 is %s, the earliest build activity on or after it (source: %s). Stage 4 time includes that day's planning.</p>" % (datetime.datetime.fromtimestamp(SWITCH["day"]).strftime("%Y-%m-%d"), local_hm(SWITCH["t"]), esc(SWITCH.get("src", "the build activity"))))
if facts: how_parts.append("<p><b>Build runs</b> come from %s (UTC in the file, shown in local time); a run counts as passed when the script was applied.</p>" % esc(facts["src"]))
if how_parts: w("<details class=how id=how><summary>How this was measured</summary>%s</details>" % "".join(how_parts))
if not L:
dsc = DISCOVERY
looked = (" Looked in this project's own log folder and %d other project folders (%d logs from the last %d days)." % (dsc.get("other_dirs", 0), dsc.get("other_files", 0), SCAN_DAYS)) if dsc.get("searched") else ""
looked = (" Looked in this project's own log folder and %d other project folders (%d logs from the last %d days)." % (dsc.get("other_dirs", 0), dsc.get("other_files", 0), SCAN_DAYS)) if dsc.get("searched") and not args.no_log else ""
howto = (" To use a log from another place pass it with <code>--session-log &lt;path&gt;</code> (a .jsonl file, or a folder of them, for example <code>~/.claude/projects/&lt;slug&gt;/</code>), then regenerate: <code>project-bin/build-plan-status.sh &lt;project&gt; --html --session-log &lt;path&gt;</code>.") if not args.no_log else ""
ledgers = (" Build runs below come from the exec log (%s); tokens and cost need a session log." % ", ".join("<code>%s</code>" % esc(x) for x in args.ledgers["sources"])) if args.ledgers else ""
w("<div class='card note-card'><b>Time, token and cost data from a session log are unavailable.</b> %s%s%s%s Everything else (stages, gates, artifacts, decisions, the plan) is shown as usual.</div>" % (esc(args.log_note), looked, howto, ledgers))
how_parts.append("<p><b>No session log.</b> %s%s%s%s</p>" % (esc(args.log_note), looked, howto, ledgers))
if how_parts: w("<details class=how id=how><summary>How this was measured</summary>%s</details>" % "".join(how_parts))
if not L:
w("<p class=nolog>%s Time, token and cost figures are not shown; everything else is (details in How this was measured).</p>" % esc(args.log_note))
if args.ledgers and args.ledgers["phases"]: render_ledgers(w, args.ledgers)
w("<div class='card' style='margin-top:14px'><div class=hd><h3><a href='#stage-%s'>%s</a></h3><span class=sub>%d of 8 gates passed%s</span></div>" % (info["current"] or "P", esc(info["current_line"] or "Current stage unknown"), npass, (", %d waived" % sum(1 for s_ in STAGES if states[s_] == "waived")) if "waived" in states.values() else ""))
w("<div class='bar big ok' style='margin:8px 0' title='mean of the eight stage bars'><i style='width:%.0f%%'></i></div>" % overall)
Expand Down Expand Up @@ -1470,7 +1472,11 @@ def money(c): return "n/a" if unp else "$%.0f" % c
if states[s] == "passed" and info.get("gate_stale_s") and (not row or row["status"] != "PASS"):
age = time.time() - gate["mtime"]
w("<div class='sub stale' title='PROJECT.md says this stage passed; the generated gate-check page is older than PROJECT.md and does not list it'>Gate-check page is %s old, re-run gate-check</div>" % (("%d days" % round(age / 86400)) if age >= 86400 else ("%d hours" % max(1, round(age / 3600)))))
elif row: w("<div class=sub title='%s'>Gate-check page: <b>%s</b> %s</div>" % (esc(row["detail"]), esc(row["status"]), esc(row["detail"][:110] + ("..." if len(row["detail"]) > 110 else ""))))
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"
w("<div class=sub title='%s'>Gate-check page: <b>%s</b> %s</div>" % (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=""):
return "<a class='chip %s' href='%s'>%s</a>" % (cls, href(f), esc(os.path.relpath(f, proj)))
Expand Down
14 changes: 7 additions & 7 deletions tests/wave2/test-progress-report-field-fixes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
# ("not built" is not built, "built (...)" and "done ..." are built, "partial" is partial) and the page says so.
# F4 no log in the project's own slug: found in another slug when 25% of its tool calls and at least 20 operate on the
# project path, counted only between those calls; a session that merely mentions the project is ignored. With no log at
# all the time ledger and BUILD-LOG feed the page, labelled, and the banner says how to pass --session-log.
# all the time ledger and BUILD-LOG feed the page, labelled, and the details block says how to pass --session-log.
# F5 stage boundaries use the same truth: log events after the Stage 4 sign-off count toward Stage 5 even when the
# gate-check page is stale.
# F6 Build progress: stacked phase bars, headline "N of M rows built, K partial", the phase being built now is marked,
Expand Down Expand Up @@ -185,16 +185,16 @@ P1R="$(cd "$P1" && pwd -P)"
CFG="$WORK/cfg"; mklog "$CFG" "$P1R" 25
CLAUDE_CONFIG_DIR="$CFG" "$PY" "$GEN" "$P1" --out "$WORK/out4/r.html" >/dev/null 2>&1
has "$WORK/out4/r.html" "Sessions from other folders (1)" "a log in another slug qualifies by tool calls on the project (28 of 28)"
hasnt "$WORK/out4/r.html" "Time, token and cost data from a session log are unavailable" "no unavailable banner when a log is found"
hasnt "$WORK/out4/r.html" "class=nolog" "no unavailable banner when a log is found"
hasnt "$WORK/out4/r.html" "13 Oct" "events three days after the last mention are clipped out"
CFG2="$WORK/cfg2"; mklog "$CFG2" "$P1R" 10
CLAUDE_CONFIG_DIR="$CFG2" "$PY" "$GEN" "$P1" --out "$WORK/out4/r2.html" >/dev/null 2>&1
has "$WORK/out4/r2.html" "Time, token and cost data from a session log are unavailable" "13 tool calls on the project are not enough (20 needed): no log"
has "$WORK/out4/r2.html" "class=nolog" "13 tool calls on the project are not enough (20 needed): no log"
has "$WORK/out4/r2.html" "docs/progress/TIME-LEDGER.md" "fallback names the time ledger as its source"
has "$WORK/out4/r2.html" "docs/BUILD-LOG.md" "fallback names BUILD-LOG as its source"
has "$WORK/out4/r2.html" "Time and effort from the project ledgers" "ledger card shown"
has "$WORK/out4/r2.html" "136k" "ledger phase row shown (tokens)"
has "$WORK/out4/r2.html" "session-log &lt;path&gt;" "banner says how to pass --session-log"
has "$WORK/out4/r2.html" "session-log &lt;path&gt;" "details block says how to pass --session-log"
CLAUDE_CONFIG_DIR="$CFG2" "$PY" "$GEN" "$P1" --out "$WORK/out4/r3.html" --session-log "$CFG2/projects/-tmp-elsewhere/s1.jsonl" >/dev/null 2>&1
hasnt "$WORK/out4/r3.html" "Sessions from other folders" "--session-log replaces discovery"

Expand Down Expand Up @@ -255,13 +255,13 @@ has "$WORK/out8/a.html" "<summary>How this was measured</summary>" "the sessions
has "$WORK/out8/a.html" "<code>s1</code></td><td>2026-10-10</td><td class=n>100%</td>" "list row: short id, date, share of tool calls on the project"
CFG8B="$WORK/cfg8b"; FILL=150 mklog "$CFG8B" "$P1R" 25
CLAUDE_CONFIG_DIR="$CFG8B" "$PY" "$GEN" "$P1" --out "$WORK/out8/b.html" >/dev/null 2>&1
has "$WORK/out8/b.html" "Time, token and cost data from a session log are unavailable" "28 of 178 calls (16%) is under the 25% share: not used"
has "$WORK/out8/b.html" "class=nolog" "28 of 178 calls (16%) is under the 25% share: not used"
CFG8C="$WORK/cfg8c"; TEXT=60 mklog "$CFG8C" "$P1R" 10
CLAUDE_CONFIG_DIR="$CFG8C" "$PY" "$GEN" "$P1" --out "$WORK/out8/c.html" >/dev/null 2>&1
has "$WORK/out8/c.html" "Time, token and cost data from a session log are unavailable" "60 text mentions do not attribute a session"
has "$WORK/out8/c.html" "class=nolog" "60 text mentions do not attribute a session"
CFG8D="$WORK/cfg8d"; REPORTRUN=1 mklog "$CFG8D" "$P1R" 25
CLAUDE_CONFIG_DIR="$CFG8D" "$PY" "$GEN" "$P1" --out "$WORK/out8/d.html" >/dev/null 2>&1
has "$WORK/out8/d.html" "Time, token and cost data from a session log are unavailable" "a session that runs progress-report.py is excluded"
has "$WORK/out8/d.html" "class=nolog" "a session that runs progress-report.py is excluded"
CFG8E="$WORK/cfg8e"; mklog "$CFG8E" "$P1R" 25 s1; FILL=100 mklog "$CFG8E" "$P1R" 25 s2
CLAUDE_CONFIG_DIR="$CFG8E" "$PY" "$GEN" "$P1" --out "$WORK/out8/e.html" >/dev/null 2>&1
has "$WORK/out8/e.html" "<code>s1</code>" "two sessions: the qualifying one is listed"
Expand Down
Loading
Loading