diff --git a/CHANGELOG.md b/CHANGELOG.md index f472375c..4b72c2b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased +- 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 +- new(progress-report): **`architecture/progress-report.html` replaces `architecture/build-plan.html`, and adds time, token and tips views from the session log.** `build-plan-status.sh --html` now writes one self-contained page: every phase and the built versus proven views the old page had, stages P to 6 with artifact links, a progress bar and stage nav, and, when a Claude Code log for the project folder exists, time per stage, sub-agent use, context size, an estimated token cost (rates in an optional file, placeholder defaults, labelled as estimates), a Run health strip and a speed panel. Tips and tricks sit in a narrow side column (after the title, headline and stage cards on a phone), in one collapsed box per theme (Context, Tokens and cost, Speed, Health, Sub-agents), empty themes hidden. Only patterns with a concrete action in the project are shown, ranked by time or tokens lost, each with what was seen, why it matters and one fix. Severity is calm: red only for a key script (doctor, gate-check, exec, tests) failing every run with at least 3 runs, or a hang over 30 minutes; context is never red (a Context tip is amber when the window was reached or a compaction happened). Required gates and builds on the inherent list never produce a hang or slow tip and never change Run health; tips that lose under a minute are dropped; tips name real scripts or commands only. Costs that come from how the toolkit and mxcli work (build time, required gates and reviews, waiting on builds or the user, the mandated review model) are flagged inherent in the script and never shown. The context window is 1M only when the log records it (a `[1m]` model id or a context-1m beta); otherwise 200k, and a context above 200k with auto compactions caps the window at the compaction point and is labelled inferred. Never a percentage over 100. Every threshold sits in one block at the top of `project-bin/progress-report.py`, and the footer records the project folder and session log paths used. Only numbers, stage names, script names, file paths and sub-agent short descriptions are read from the log, never message text. Without a log the project sections render and the page says time and token data are unavailable. The page refreshes after every `exec.sh` slice and reloads itself every 30 s only while the build is active. PROJECT.md `Progress report: off | ask | auto` (default `ask`) controls it, and `--open` opens it. An existing `build-plan.html` is never deleted, and gate-check and status.sh still accept it. `--json` is unchanged. Skill words fell by about 100 (Step 10 and step 16 shortened). Tests: `tests/wave2/test-progress-report.sh`, `test-build-plan-json.sh`, `test-surface-column.sh`. — MendixMau - fix(process-map): migrate no longer trusts a regex for the model key: it checks the top-level `anchors` changed and otherwise re-writes the model, and says so instead of claiming "only that key was changed"; a string `targets` in an old file counts as a one-item list instead of crashing; unfilled entries already in the current anchors file are listed, not dropped silently (follow-up to #438 review) — MendixMau - new(brd-report): the Stage 2 report opens with the pass-1 process map (drawn by `process-map.js build --embed`, not a copy) and lists requirements and open questions under it, each tied to its step; a click on a step filters the list, what no step covers stays under "whole app". `open-questions.html` and `bin/questions-report.sh` are retired: the gate and the interview hook write `analysis/brd-report.html`, and an old project's `docs/open-questions.html` is never an error, only pointed away from. Stage 3: the prototype links to the full map and the map links back — a business owner could not tell where in the work a question fits (field run, 2026-10-16) — MendixMau diff --git a/README.md b/README.md index 7fc9c472..e3fcbadc 100644 --- a/README.md +++ b/README.md @@ -606,7 +606,7 @@ Every mxcli project has a `.ai-context/skills/` directory (bundled by `mxcli ini | Dispatching a build step to a helper — generate its one-file pack (the brief's Build steps row, mxcli brain brief, live DESCRIBEs, example, folder) and hand over the path instead of a reading list. Measured: 25-38% fewer tool calls and 30-41% less time, same quality; the token saving is not proven | `project-bin/context-pack.sh` | | An exec.sh guard refused a write (not a permission prompt), or building a module with mxcli — verified, iterative, coverage-checklist gated; on a refusal: commit, wait or stop, never FORCE_EXEC | `skills/iterative-build-loop.md` | | Adding a second language, a language picker or a translation script — and the moment a language picker errors, shows a blank label or changes nothing: source language, enabled languages, the user's starting language, a switch a non-admin role can use | `skills/learned-multilingual-app.md` | -| After marking a module done, or any time "how much is built vs proven" is asked — renders build-plan.html from done- prefixes and verify-module.sh/improvement-register.md, kept as two honestly separate views; --json writes architecture/build-plan.json parsed from build-plan.md's Phase headings (a plan with no Phase headings gets no file); --refresh keeps an existing build-plan.json current but never creates one | `project-bin/build-plan-status.sh` | +| After marking a module done, or any time "how much is built vs proven" is asked — renders progress-report.html from done- prefixes and verify-module.sh/improvement-register.md, kept as two honestly separate views; --json writes architecture/build-plan.json parsed from build-plan.md's Phase headings (a plan with no Phase headings gets no file); --refresh keeps an existing build-plan.json current but never creates one | `project-bin/build-plan-status.sh` | | Turning a client-derived Mendix app into a clean, shareable demo with zero client fingerprint — branding, data, custom widgets | `skills/anonymize-client-app-for-demo.md` | | Handing a headless-built model to a person — opening it in Studio Pro, a free sandbox, or a colleague's machine: the model travels, the demo data and runtime config (keys, an agent's bound model) do not, and each needs its own re-establish step | `skills/handoff-to-studio-pro.md` | | Stage 5 start, before the first module of any entry mode — one entity, flow, page, nav, demo user, journey and screenshot proven in the running app, so build/run/look/test are known to work before a module depends on them | `skills/walking-skeleton.md` | diff --git a/ROUTING.md b/ROUTING.md index 024dedee..bb872da6 100644 --- a/ROUTING.md +++ b/ROUTING.md @@ -145,7 +145,7 @@ picks the row up. That is the whole procedure — there is no second list to rem | Dispatching a build step to a helper — generate its one-file pack (the brief's Build steps row, mxcli brain brief, live DESCRIBEs, example, folder) and hand over the path instead of a reading list. Measured: 25-38% fewer tool calls and 30-41% less time, same quality; the token saving is not proven | `project-bin/context-pack.sh` | mdl | 5 | ondemand | | An exec.sh guard refused a write (not a permission prompt), or building a module with mxcli — verified, iterative, coverage-checklist gated; on a refusal: commit, wait or stop, never FORCE_EXEC | `skills/iterative-build-loop.md` | mdl,gate | 5 | ondemand | | Adding a second language, a language picker or a translation script — and the moment a language picker errors, shows a blank label or changes nothing: source language, enabled languages, the user's starting language, a switch a non-admin role can use | `skills/learned-multilingual-app.md` | architect,mdl,review | 3,5,6 | ondemand | -| After marking a module done, or any time "how much is built vs proven" is asked — renders build-plan.html from done- prefixes and verify-module.sh/improvement-register.md, kept as two honestly separate views; --json writes architecture/build-plan.json parsed from build-plan.md's Phase headings (a plan with no Phase headings gets no file); --refresh keeps an existing build-plan.json current but never creates one | `project-bin/build-plan-status.sh` | architect,gate,review | 4,5,6 | ondemand | +| After marking a module done, or any time "how much is built vs proven" is asked — renders progress-report.html from done- prefixes and verify-module.sh/improvement-register.md, kept as two honestly separate views; --json writes architecture/build-plan.json parsed from build-plan.md's Phase headings (a plan with no Phase headings gets no file); --refresh keeps an existing build-plan.json current but never creates one | `project-bin/build-plan-status.sh` | architect,gate,review | 4,5,6 | ondemand | | Turning a client-derived Mendix app into a clean, shareable demo with zero client fingerprint — branding, data, custom widgets | `skills/anonymize-client-app-for-demo.md` | mdl,review | 6 | ondemand | | Handing a headless-built model to a person — opening it in Studio Pro, a free sandbox, or a colleague's machine: the model travels, the demo data and runtime config (keys, an agent's bound model) do not, and each needs its own re-establish step | `skills/handoff-to-studio-pro.md` | test,review | 6,7 | ondemand | | Sharing the project with a colleague on Mendix Team Server while the build loop stays on GitHub — content-transplant snapshots between the two clones (status/push/pull); SUPERSEDED by platform-link.md §3 (adopt without rewriting history, field-proven) — only if that path fails; UNPROVEN against a real Team Server | `project-bin/ts-sync.sh` | mdl,gate | 6,7 | experimental | diff --git a/bin/doctor.sh b/bin/doctor.sh index 2d0c8f7c..bd888363 100755 --- a/bin/doctor.sh +++ b/bin/doctor.sh @@ -1466,6 +1466,12 @@ if [ -n "$PROJECT_DIR" ] && [ -d "$PROJECT_DIR" ]; then >> "$PROJECT_DIR/.gitignore" 2>/dev/null || true note "(added /.claude/toolkit.env to the project's .gitignore — it holds this machine's paths)" fi + # The progress report's session cache holds absolute session-log paths (user name, session ids). + if [ -d "$PROJECT_DIR/.git" ] && ! git -C "$PROJECT_DIR" check-ignore -q .claude/progress-report-sessions.txt 2>/dev/null; then + printf '\n# Machine-local progress report session cache (absolute session-log paths)\n/.claude/progress-report-sessions.txt\n' \ + >> "$PROJECT_DIR/.gitignore" 2>/dev/null || true + note "(added /.claude/progress-report-sessions.txt to the project's .gitignore — the cache is machine-local)" + fi fi if [ "$FAIL" -gt 0 ]; then diff --git a/bin/gate-check.sh b/bin/gate-check.sh index 680c911c..9ec15e29 100755 --- a/bin/gate-check.sh +++ b/bin/gate-check.sh @@ -1732,6 +1732,9 @@ stage_surface_status() { # test-report.html is answered by the same function check_stage_6 uses (#233), so the # harness report pair also discharges it. if [ "$pat" = "test-report.html" ] && test_surface_hit; then continue; fi + # progress-report.html replaced build-plan.html; a project that still has the old page keeps + # its Surface green (the file is never deleted, so it must not read as missing). + if [ "$pat" = "progress-report.html" ] && [ -e "$PROJECT_DIR/architecture/build-plan.html" ]; then continue; fi # SURFACE_BASES, not just the project root. §2's Surface cells are inconsistent about # paths: some carry one ("architecture/blueprint.html", "docs/report.json") and some name # the file bare ("design-system.html", "build-plan.html", "test-report.html"). A bare cell diff --git a/bin/init-project.sh b/bin/init-project.sh index 69cd4c81..aed1111e 100755 --- a/bin/init-project.sh +++ b/bin/init-project.sh @@ -613,6 +613,18 @@ else echo "Created/updated: .gitignore (.claude/settings.local.json ignored)" fi +# The progress report caches which session logs belong to this project: absolute paths with a +# user name and session ids. Machine-local, never tracked. +if [ -f "$GITIGNORE" ] && grep -qE '^/?\.claude/progress-report-sessions\.txt$' "$GITIGNORE"; then + echo "Skip: .claude/progress-report-sessions.txt already listed in .gitignore." +else + if [ -f "$GITIGNORE" ] && [ -s "$GITIGNORE" ] && [ -n "$(tail -c 1 "$GITIGNORE")" ]; then + printf '\n' >> "$GITIGNORE" + fi + printf '# Machine-local progress report session cache (absolute session-log paths) — never shared\n/.claude/progress-report-sessions.txt\n' >> "$GITIGNORE" + echo "Created/updated: .gitignore (.claude/progress-report-sessions.txt ignored)" +fi + # --------------------------------------------------------------------------- # Register this project's name with the toolkit's leak guard. # diff --git a/bin/lib/artifact-manifest.tsv b/bin/lib/artifact-manifest.tsv index 4a54214e..d4ab8b25 100644 --- a/bin/lib/artifact-manifest.tsv +++ b/bin/lib/artifact-manifest.tsv @@ -91,7 +91,7 @@ ds-css 3 design/ds.css skills/design-artifacts.md:75 (the physical token/compone wireframes 3 design/wireframes/*.html skills/design-artifacts.md Step 3, one per screen bin/gate-check.sh:923 (check_stage_3), bin/gate-check.sh:1129 (build-ready), skills/ui-preflight-pages.md (mdl-agent pre-flight input) migration,requirements,existing-app-change gate module-definition 3 architecture/modules/*/definition.md skills/architecture-blueprint.md Step 1 (:60 — the directory shape, one doc per module) THIS ROW IS ITS FIRST MECHANICAL CONSUMER — as of 2026-08-22 nothing executable read definition.md (improvement-plan P7 residue, the newest instance of the pattern this table exists to stop); prose consumers: skills/architecture-blueprint.md:43, skills/coverage-ledger.md:27 migration,requirements,existing-app-change report build-plan 4 architecture/build-plan.md skills/brd-to-build-plan.md bin/gate-check.sh:955 (check_stage_4), project-bin/build-plan-status.sh (progress board), project-bin/coverage-preflight.sh:238 (the claims join) migration,requirements,existing-app-change gate -build-plan-html 4 architecture/build-plan.html project-bin/build-plan-status.sh --html (skills/brd-to-build-plan.md:47 — derived view, never hand-edited; refreshed by exec.sh and full gate-check.sh runs via --refresh) conversion-runbook.md §2 Stage-4 Surface row, parsed at bin/gate-check.sh:1284 migration,requirements,existing-app-change report +build-plan-html 4 architecture/progress-report.html project-bin/build-plan-status.sh --html (skills/brd-to-build-plan.md:47 — derived view, never hand-edited; refreshed by exec.sh and full gate-check.sh runs via --refresh) conversion-runbook.md §2 Stage-4 Surface row, parsed at bin/gate-check.sh:1284 migration,requirements,existing-app-change report build-plan-json 4 architecture/build-plan.json project-bin/build-plan-status.sh --json (derived from build-plan.md's Step-5 Phase headings, row tables and claims: blocks, never hand-edited; not written when the markdown has no Phase headings; --refresh keeps an existing file current but never creates it) MXTK Studio Plan room (a view built from data, not from the HTML); nothing in the toolkit reads it and no verdict may depend on it migration,requirements,existing-app-change report module-brief 4 architecture/modules/*/module-brief.md|architecture/modules/*-brief.md skills/module-brief.md — ba-agent writes the first at Stage 4, the rest just-in-time bin/gate-check.sh:1121 (build-ready fails without one), project-bin/build-plan-status.sh:109 migration,requirements,existing-app-change gate coverage-ledger 4 architecture/modules/*/coverage-ledger.md|architecture/modules/*-coverage-ledger.md|architecture/coverage-ledger.md skills/coverage-ledger.md procedure at Stage 4 (BRD leaves × build-plan claims); the three shapes are §"Where the ledger lives", first canonical project-bin/conformance-check.sh:85-93, project-bin/coverage-preflight.sh:134-137, project-bin/verify-module.sh:299-300, project-bin/review-module.sh:289, conversion-runbook.md:487 (Stage-4 produces row) — the eleven-consumers-no-producer artifact of improvement-plan Finding 12 migration,requirements report diff --git a/bin/lib/closeout.sh b/bin/lib/closeout.sh index f16e8110..5243c2a1 100755 --- a/bin/lib/closeout.sh +++ b/bin/lib/closeout.sh @@ -166,7 +166,7 @@ _co_plain_artifact() { wireframes) echo "one wireframe per screen" ;; module-definition) echo "one definition document per module" ;; build-plan) echo "the numbered build plan" ;; - build-plan-html) echo "the build progress board" ;; + build-plan-html) echo "the progress report" ;; module-brief) echo "the first module's brief (what the builder gets)" ;; coverage-ledger) echo "the ledger proving every requirement is claimed by a build step" ;; ui-reviews) echo "the page review report" ;; diff --git a/bin/lib/install-manifest.sh b/bin/lib/install-manifest.sh index 56e46064..5eb0928f 100644 --- a/bin/lib/install-manifest.sh +++ b/bin/lib/install-manifest.sh @@ -119,7 +119,7 @@ MXTK_AGENTS_STAGE_BUILD="mdl-agent.md gate-agent.md test-agent.md review-agent.m # the generator reads the template from its own directory. The project runs it at Stage 3 # (skills/process-map.md Step 4) after every change to design/process-model.json, the # anchors, a wireframe or a BRD, and gate-check.sh check_stage_3 fails a stale render. -MXTK_PROJECT_BIN="process-map.js process-map.template.html app-restart.sh test-scratch.sh assemble-prototype.js prototype-route.js check-prototype-links.js wf-add-path-terminators.py wf-set-call-captions.py mxunit_bson.py mxpatch.py _common.sh app-facts.sh _claims.sh snapshot-mpr.sh restore-mpr.sh exec.sh affected-tests.sh look-ledger.sh save-sp.sh restart-sp.sh check-sp-health.sh verify-module.sh test-stack-up.sh fixture-manifest.sh check-root-clean.sh lint-gate.sh lint-trend.sh metrics-ledger.sh metrics-trend.sh close-task.sh conformance-check.sh coverage-preflight.sh graph-sweep.sh review-module.sh coherence-cadence.sh build-plan-status.sh done-drift-check.sh page-scope.sh render-improvement-register.sh check-design-portability.sh check-design-reaches-app.sh check-page-shell.sh page-fidelity.js model-stamp.sh verify-model.sh install-project-hooks.sh mx-metadata-note.py session-check.sh constants-audit.sh context-pack.sh check-staged-secrets.sh" +MXTK_PROJECT_BIN="process-map.js process-map.template.html app-restart.sh test-scratch.sh assemble-prototype.js prototype-route.js check-prototype-links.js wf-add-path-terminators.py wf-set-call-captions.py mxunit_bson.py mxpatch.py _common.sh app-facts.sh _claims.sh snapshot-mpr.sh restore-mpr.sh exec.sh affected-tests.sh look-ledger.sh save-sp.sh restart-sp.sh check-sp-health.sh verify-module.sh test-stack-up.sh fixture-manifest.sh check-root-clean.sh lint-gate.sh lint-trend.sh metrics-ledger.sh metrics-trend.sh close-task.sh conformance-check.sh coverage-preflight.sh graph-sweep.sh review-module.sh coherence-cadence.sh build-plan-status.sh progress-report.py done-drift-check.sh page-scope.sh render-improvement-register.sh check-design-portability.sh check-design-reaches-app.sh check-page-shell.sh page-fidelity.js model-stamp.sh verify-model.sh install-project-hooks.sh mx-metadata-note.py session-check.sh constants-audit.sh context-pack.sh check-staged-secrets.sh" # Files in project-bin/ that are deliberately NOT installed into projects. The reverse check # below flags anything named by NEITHER list, so a new file in project-bin/ has to be either diff --git a/bin/lib/skill-routing.tsv b/bin/lib/skill-routing.tsv index 81d28c6f..6f2b3096 100644 --- a/bin/lib/skill-routing.tsv +++ b/bin/lib/skill-routing.tsv @@ -189,7 +189,7 @@ tool-output-is-not-ground-truth skills/tool-output-is-not-ground-truth.md Any ti agent-permission-friction skills/agent-permission-friction.md Any refused, denied or blocked command — BEFORE rewriting a permission rule and before telling the user a tool is blocked. A rule matches the START of the command line, so an allowlisted tool prefixed with cd matches nothing all - ondemand diagnose process-coherence-pass skills/process-coherence-pass.md Checking whether the whole journey hangs together rather than each piece — finds correctly-built components nothing reaches, which per-element conformance and UI tests both miss review 5,6 ondemand verify coherence-cadence project-bin/coherence-cadence.sh After every module's CONFIRM stage — counts proven modules since the last cluster/full coherence pass and exits DUE once the threshold is reached, so the cadence isn't left to memory mdl,gate,review 5,6 ondemand verify -build-plan-status project-bin/build-plan-status.sh After marking a module done, or any time "how much is built vs proven" is asked — renders build-plan.html from done- prefixes and verify-module.sh/improvement-register.md, kept as two honestly separate views; --json writes architecture/build-plan.json parsed from build-plan.md's Phase headings (a plan with no Phase headings gets no file); --refresh keeps an existing build-plan.json current but never creates one architect,gate,review 4,5,6 ondemand build +build-plan-status project-bin/build-plan-status.sh After marking a module done, or any time "how much is built vs proven" is asked — renders progress-report.html from done- prefixes and verify-module.sh/improvement-register.md, kept as two honestly separate views; --json writes architecture/build-plan.json parsed from build-plan.md's Phase headings (a plan with no Phase headings gets no file); --refresh keeps an existing build-plan.json current but never creates one architect,gate,review 4,5,6 ondemand build e2e-evidence-report skills/e2e-evidence-report.md Turning an already-rigorous run into a narrated proof a stakeholder can trust without running anything test,review 6 ondemand verify record-demo-video skills/record-demo-video.md Recording a narrated screen-capture demo of a running app for a human to watch — opening on the app instead of a blank frame, and keeping captions synced to the pixels test 6 ondemand verify demo-recording-polish skills/demo-recording-polish.md A demo film someone will watch looks captured, not produced — 25 fps smear, scroll jumps, no visible pointer, a dense page nobody can read: 2x CDP screencast at 60 fps, drawn cursor and click ripple, eased scroll, and a zoom rendered afterwards from the click log (never a CSS transform in the page) test 6 ondemand verify diff --git a/bin/status.sh b/bin/status.sh index cd648493..1b26ceea 100755 --- a/bin/status.sh +++ b/bin/status.sh @@ -88,21 +88,22 @@ fi N_STALL="$(printf '%s' "$STALL_LIST" | grep -c .)"; N_STALL="${N_STALL:-0}" NOT_DONE_PASS="$(printf '%s\n' "$STALL_LIST" | sed '/^$/d' | head -3 | tr '\n' ' ')" # done- is gated on the coverage checklist (iterative-build-loop.md). Field case 2026-10-06: 47 -# scripts built, 0 done-, build-plan.html empty — the checklist could never pass and nothing said +# scripts built, 0 done-, progress report empty — the checklist could never pass and nothing said # so. Several gate-passes and ZERO done- is a stall, not a backlog. DONE_STALL="" [ "$N_DONE" -eq 0 ] && [ "$N_STALL" -ge 3 ] && DONE_STALL="done- stalled: $N_STALL gate-pass, 0 done-" -# build-plan.html older than the newest mdlsource/ change reads as a plan nobody is building. +# the progress report (or an older build-plan.html) older than the newest mdlsource/ change reads as a plan nobody is building. BP_STALE="" -BP_HTML="$PROJECT_DIR/architecture/build-plan.html" +BP_HTML="$PROJECT_DIR/architecture/progress-report.html" +[ -f "$BP_HTML" ] || BP_HTML="$PROJECT_DIR/architecture/build-plan.html" # pre-progress-report projects if [ -f "$BP_HTML" ] && [ -d "$MDL_DIR" ] && git -C "$PROJECT_DIR" rev-parse --git-dir >/dev/null 2>&1; then _mdl_ts="$(git -C "$PROJECT_DIR" log -1 --format=%ct -- mdlsource 2>/dev/null)" # Commit time when the page is tracked (a fresh clone resets every mtime); mtime only for an # untracked, locally generated page. - _bp_ts="$(git -C "$PROJECT_DIR" log -1 --format=%ct -- architecture/build-plan.html 2>/dev/null)" + _bp_ts="$(git -C "$PROJECT_DIR" log -1 --format=%ct -- "${BP_HTML#"$PROJECT_DIR"/}" 2>/dev/null)" [ -n "$_bp_ts" ] || _bp_ts="$(stat -c %Y "$BP_HTML" 2>/dev/null || stat -f %m "$BP_HTML" 2>/dev/null)" case "$_mdl_ts$_bp_ts" in ''|*[!0-9]*) : ;; *) - [ "$_bp_ts" -lt "$_mdl_ts" ] && BP_STALE="build-plan.html STALE (older than the newest mdlsource/ commit — bin/build-plan-status.sh --html)" ;; + [ "$_bp_ts" -lt "$_mdl_ts" ] && BP_STALE="$(basename "$BP_HTML") STALE (older than the newest mdlsource/ commit — bin/build-plan-status.sh --html)" ;; esac fi # Agent slots still unfilled (#211) — by design until each agent's stage starts, never invisible. diff --git a/bin/sync-project.sh b/bin/sync-project.sh index 8453f253..1e5b56fd 100755 --- a/bin/sync-project.sh +++ b/bin/sync-project.sh @@ -1610,6 +1610,21 @@ if [ "$WIRED" -eq 1 ] && [ -x "$SCRIPT_DIR/install-harness-permissions.sh" ]; th fi fi +# ── Progress report session cache: machine-local, keep it out of git ───────────────────── +# It holds absolute session-log paths (user name, session ids). Same shape as doctor.sh's +# receipt and toolkit.env lines; a project synced from an older toolkit has no such line. +if [ "$WIRED" -eq 1 ] && [ -d "$PROJECT_DIR/.git" ] \ + && ! git -C "$PROJECT_DIR" check-ignore -q .claude/progress-report-sessions.txt 2>/dev/null; then + if [ "$DRY_RUN" -eq 1 ]; then + echo "Would add: /.claude/progress-report-sessions.txt to .gitignore (machine-local cache)" + else + if [ -s "$PROJECT_DIR/.gitignore" ] && [ -n "$(tail -c 1 "$PROJECT_DIR/.gitignore")" ]; then printf '\n' >> "$PROJECT_DIR/.gitignore"; fi + printf '\n# Machine-local progress report session cache (absolute session-log paths)\n/.claude/progress-report-sessions.txt\n' >> "$PROJECT_DIR/.gitignore" + echo "Added: /.claude/progress-report-sessions.txt to .gitignore (machine-local cache)" + fi + CHANGES=$((CHANGES + 1)) +fi + # ── Starlark lint rules ────────────────────────────────────────────────────────────────── # Gated on WIRED for the same reason as the crash net: a mistyped path must not scatter # .star files into an unrelated directory. Unlike the crash net this is also a REPAIR path, diff --git a/commands/mobile-auto-test-prompt.md b/commands/mobile-auto-test-prompt.md index 9834910b..cb006e25 100644 --- a/commands/mobile-auto-test-prompt.md +++ b/commands/mobile-auto-test-prompt.md @@ -89,7 +89,7 @@ For each module: 8. After every module, run project-bin/coherence-cadence.sh — when it reports DUE, run the full skills/process-coherence-pass.md sweep before moving to the next module, then run coherence-cadence.sh --record. -9. Run project-bin/build-plan-status.sh --html periodically to keep architecture/build-plan.html +9. Run project-bin/build-plan-status.sh --html periodically to keep architecture/progress-report.html current — it shows built (done- prefixes) vs proven (verify-module.sh + improvement-register.md) as two separate views on purpose. diff --git a/project-bin/build-plan-status.sh b/project-bin/build-plan-status.sh index f96bc1b5..75876f9d 100755 --- a/project-bin/build-plan-status.sh +++ b/project-bin/build-plan-status.sh @@ -28,15 +28,22 @@ # --html and --json outputs and never touches the .mpr. # # Usage: -# build-plan-status.sh [project-dir] [--html] [--json] [--quiet] [--refresh] (flags and path in any order) -# --html also write architecture/build-plan.html (self-contained, no external deps): the +# build-plan-status.sh [project-dir] [--html] [--json] [--quiet] [--refresh] [--open] (flags and path in any order) +# --html also write architecture/progress-report.html (self-contained, no external deps), +# which replaced architecture/build-plan.html: stages P to 6, time and token data +# when a Claude Code session log exists (project-bin/progress-report.py), and the # plan itself (phases, rows, claims from build-plan.md, see "P." below) followed -# by views A and B +# by views A and B. An existing build-plan.html is left alone. +# --session-log PATH use this Claude Code session log (a .jsonl file or a folder of them; repeatable) instead of +# discovery, for a session started outside the project folder. Passed to progress-report.py. # --refresh the unattended form of --html, for exec.sh and gate-check.sh: quiet, writes only -# when build-plan.md exists, never over a build-plan.html this script did not -# write, and leaves the file untouched when nothing but the timestamp would change. +# when build-plan.md exists, never over a progress-report.html this script did not +# write, and leaves the file untouched when nothing but the stamp lines would change # It also keeps an EXISTING architecture/build-plan.json current (same parser as # --json, same no-rewrite-for-a-timestamp rule) and never creates one +# --open --html, then show it: on macOS reload the Chrome tab whose URL contains +# progress-report.html, else open the file once; elsewhere a no-op +# PROJECT.md `Progress report: off | ask | auto` (default ask): off = never write the page # --json also write architecture/build-plan.json, parsed from architecture/build-plan.md's # Step-5 Phase headings, row tables and claims: blocks (see "C." below) # --quiet suppress the stdout table (useful when only --html/--json output is wanted) @@ -55,9 +62,15 @@ WRITE_HTML=0 WRITE_JSON=0 QUIET=0 REFRESH=0 +OPEN=0 +SESSION_LOGS=() +_want_log=0 for a in "$@"; do + if [ "$_want_log" -eq 1 ]; then SESSION_LOGS+=("$a"); _want_log=0; continue; fi case "$a" in + --session-log) _want_log=1 ;; --html) WRITE_HTML=1 ;; + --open) WRITE_HTML=1; OPEN=1 ;; --refresh) WRITE_HTML=1; REFRESH=1; QUIET=1 ;; --json) WRITE_JSON=1 ;; --quiet) QUIET=1 ;; @@ -65,9 +78,11 @@ for a in "$@"; do *) if [ -z "$ROOT" ]; then ROOT="$a"; else echo "unexpected second path: $a (first was $ROOT)" >&2; exit 2; fi ;; esac done +[ "$_want_log" -eq 0 ] || { echo "--session-log needs a path" >&2; exit 2; } [ -n "$ROOT" ] || ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" cd "$ROOT" || exit 0 +ROOT="$(pwd)" # a relative project path must survive the cd (the page is written under $ROOT) MDLSOURCE="$ROOT/mdlsource" MODULES_DIR="$ROOT/architecture/modules" @@ -75,6 +90,24 @@ REGISTER="$ROOT/docs/improvement-register.md" BUILD_PLAN="$ROOT/architecture/build-plan.md" STAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +# PROJECT.md `Progress report: off | ask | auto` (default ask). off = this script never writes the +# page; ask and auto only change what the agent does at a stage gate or module close (the skills +# say: ask once, or open it), so the script treats them the same. +report_name() { # the page name lives in progress-report.py (REPORT_FILE); asked once, here + REPORT_FILE="$("${PY:-python3}" "$(dirname "${BASH_SOURCE[0]}")/progress-report.py" --print-name 2>/dev/null)" # portability-ok: PY is resolve_py output; python3 only if unset + [ -n "$REPORT_FILE" ] || REPORT_FILE="progress-report.html" +} +progress_report_setting() { + _v="$(grep -iE '^[[:space:]>*_-]*Progress report[[:space:]]*:' "$ROOT/PROJECT.md" 2>/dev/null | head -1 \ + | sed -E 's/^[^:]*:[[:space:]*_`]*//' | tr 'A-Z' 'a-z')" + case "$_v" in + *"|"*) echo ask ;; # the option list pasted verbatim is not a choice + off*) echo off ;; + auto*) echo auto ;; + *) echo ask ;; + esac +} + # ── A. Build-plan phase progress, from mdlsource//done- counts ─────── # Phase folders are read as they exist on disk, not matched against build-plan.md's prose Phase # headers — a fuzzy name-match would be a guess, and skills-over-scripts.md says code carries no @@ -614,51 +647,38 @@ if w: PYEOF } +# ── Progress report page ────────────────────────────────────────────────── +# architecture/progress-report.html replaced build-plan.html. The page is rendered by +# progress-report.py (stages P to 6, session time and token data when a Claude Code log exists); +# this script contributes the part only it can read: the plan tables and views A and B, written +# to a scratch fragment above and embedded as the page's build detail. An older project's +# architecture/build-plan.html is left exactly where it is: not read, not rewritten, not deleted. if [ "$WRITE_HTML" -eq 1 ]; then - OUT="$ROOT/architecture/build-plan.html" SKIP_HTML="" - if [ "$REFRESH" -eq 1 ]; then + GEN="$(dirname "${BASH_SOURCE[0]}")/progress-report.py" + report_name + OUT="$ROOT/architecture/$REPORT_FILE" + SETTING="$(progress_report_setting)" + if [ "$SETTING" = "off" ]; then + SKIP_HTML="PROJECT.md says Progress report: off" + elif [ "$REFRESH" -eq 1 ] && [ ! -f "$BUILD_PLAN" ]; then # Unattended callers (exec.sh, gate-check.sh) get gate-check.sh's index.html rules: no plan, - # no page; and never over a page this script did not write (before 2026-08-19 agents wrote - # this file by hand, and a project may still carry one). Every render from this script names - # it in the stamp line, so older renders are recognised and adopted. - if [ ! -f "$BUILD_PLAN" ]; then - SKIP_HTML="no build-plan.md" - elif [ -f "$OUT" ] && ! grep -qF 'build-plan-status.sh' "$OUT"; then - SKIP_HTML="architecture/build-plan.html was not written by this script" - fi + # no page; and never over a page this script did not write. + SKIP_HTML="no build-plan.md" + elif [ -f "$OUT" ] && ! grep -qF 'build-plan-status.sh' "$OUT"; then + SKIP_HTML="architecture/$REPORT_FILE was not written by this script" + elif [ -z "${PY:-}" ]; then + SKIP_HTML="no working Python 3 found (tried python3, python, py), which the report needs" # portability-ok: names in a diagnostic + elif [ ! -f "$GEN" ]; then + SKIP_HTML="progress-report.py is not next to this script (run bin/sync-project.sh)" fi if [ -n "$SKIP_HTML" ]; then - [ "$QUIET" -eq 0 ] && echo "" && echo " build-plan.html not written: $SKIP_HTML" + [ "$QUIET" -eq 0 ] && echo "" && echo " $REPORT_FILE not written: $SKIP_HTML" else mkdir -p "$ROOT/architecture" OUT_TMP="$OUT.tmp.$$" + FRAG_TMP="$OUT.frag.$$" { - echo '' - echo '' - echo 'Build Plan' - echo '
' - echo "

Build Plan

Generated $STAMP by project-bin/build-plan-status.sh from architecture/build-plan.md, mdlsource/ and the module review files. exec.sh and gate-check.sh regenerate it; never hand-edit.

" - render_plan_section echo "

A. Script folders: how much is built

" @@ -688,17 +708,64 @@ details{margin:.5rem 0} summary{cursor:pointer;color:var(--muted);font-size:.85r echo "" fi echo "

The plan: what build-plan.md says. A: built, from mdlsource/ done- prefixes. B: proven, from verify-module.sh + docs/improvement-register.md. A phase at 100% with no reviewed row in B is built, not proven.

" - echo '
' - } > "$OUT_TMP" - # Two renders of the same state differ only in the stamp line. A refresh that changed nothing - # leaves the file alone, or every exec.sh run would dirty the working tree. - if [ "$REFRESH" -eq 1 ] && [ -f "$OUT" ] \ - && [ "$(grep -v 'id=stamp' "$OUT")" = "$(grep -v 'id=stamp' "$OUT_TMP")" ]; then + } > "$FRAG_TMP" + GEN_ARGS=(--out "$OUT_TMP" --fragment "$FRAG_TMP" --plan-msg "${PLAN_MSG:-}") + [ -n "$PLAN_JSON" ] && GEN_ARGS+=(--plan-json "$PLAN_JSON") + for _sl in ${SESSION_LOGS[@]+"${SESSION_LOGS[@]}"}; do GEN_ARGS+=(--session-log "$_sl"); done + if "$PY" "$GEN" "$ROOT" "${GEN_ARGS[@]}" >/dev/null 2>"$OUT.err.$$"; then + # Two renders of the same state differ only in the stamp lines. A refresh that changed + # nothing leaves the file alone, or every exec.sh run would dirty the working tree. + if [ "$REFRESH" -eq 1 ] && [ -f "$OUT" ] \ + && [ "$(grep -v 'id=stamp' "$OUT")" = "$(grep -v 'id=stamp' "$OUT_TMP")" ]; then + rm -f "${OUT_TMP:?}" + else + mv -f "$OUT_TMP" "$OUT" + fi + [ "$QUIET" -eq 0 ] && echo "" && echo " wrote ${OUT#$ROOT/}" + else rm -f "${OUT_TMP:?}" + _why="$(tail -n 1 "$OUT.err.$$" 2>/dev/null | head -c 200)" + if [ "$QUIET" -eq 0 ]; then echo "" && echo " $REPORT_FILE not written: the generator failed: $(head -c 300 "$OUT.err.$$" 2>/dev/null)" + else echo "WARN: $REPORT_FILE not updated, progress-report.py failed: ${_why:-no message}" >&2; fi # unattended callers stay quiet on success, never on a crash + fi + rm -f "${FRAG_TMP:?}" "${OUT}.err.$$" + fi +fi + +# ── --open: show the page ──────────────────────────────────────────────────── +# macOS with Chrome running: reload the tab showing this project's report (its exact file:// URL), never a new +# window when a tab exists. Otherwise open the file once. Anywhere else (Linux, Windows, no GUI) +# it does nothing and says so. The setting gates the write above; this only needs the file. +if [ "$OPEN" -eq 1 ]; then report_name; fi +if [ "$OPEN" -eq 1 ] && [ -f "$ROOT/architecture/$REPORT_FILE" ] && [ "$(progress_report_setting)" != "off" ]; then + if [ "$(uname -s)" = "Darwin" ]; then + _res="none" + if pgrep -x "Google Chrome" >/dev/null 2>&1; then # portability-ok: --open is macOS-only by design (guarded by uname) + # Match this project's file URL exactly (file:// + absolute path, percent-encoded), never any tab that + # merely contains the page name: another project's report must not be reloaded in its place. + _url="file://$("${PY:-python3}" -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1]))' "$ROOT/architecture/$REPORT_FILE" 2>/dev/null)" # portability-ok: PY is resolve_py output + _as='tell application "Google Chrome" +repeat with w in windows +repeat with t in tabs of w +set u to URL of t +if u is "'"$_url"'" or u starts with "'"$_url"'#" or u starts with "'"$_url"'?" then +reload t +return "reloaded" +end if +end repeat +end repeat +end tell +return "none"' + _res="$(osascript -e "$_as" 2>/dev/null || echo none)" # portability-ok: --open is macOS-only by design (guarded by uname) + fi + if [ "$_res" = "reloaded" ]; then + [ "$QUIET" -eq 0 ] && echo " reloaded the open $REPORT_FILE tab" else - mv -f "$OUT_TMP" "$OUT" + open "$ROOT/architecture/$REPORT_FILE" >/dev/null 2>&1 || true # portability-ok: --open is macOS-only by design (guarded by uname) + [ "$QUIET" -eq 0 ] && echo " opened architecture/$REPORT_FILE" fi - [ "$QUIET" -eq 0 ] && echo "" && echo " wrote ${OUT#$ROOT/}" + else + [ "$QUIET" -eq 0 ] && echo " --open does nothing outside macOS; open architecture/$REPORT_FILE yourself" fi fi diff --git a/project-bin/exec.sh b/project-bin/exec.sh index 352f44ea..7cf1684c 100755 --- a/project-bin/exec.sh +++ b/project-bin/exec.sh @@ -1951,12 +1951,13 @@ if [ "$PATCH_MODE" = 0 ] && [ -f "$(dirname "$0")/look-ledger.sh" ]; then fi # ── Build plan page ────────────────────────────────────────────────────────── -# Refresh architecture/build-plan.html so it tracks the build without anyone remembering to +# Refresh architecture/progress-report.html so it tracks the build without anyone remembering to # (its only trigger used to be iterative-build-loop.md step 16, so a page written at Stage 4 # stayed empty for the whole build). --refresh is quiet, writes only over a page that script # generated, and leaves the file alone when nothing but the timestamp would change. if [ -f "$(dirname "$0")/build-plan-status.sh" ]; then - bash "$(dirname "$0")/build-plan-status.sh" "$PROJECT_ROOT" --refresh >/dev/null 2>&1 || true + # A crashed generator leaves one visible WARN line (the script prints it on stderr); never a failed step. + { bash "$(dirname "$0")/build-plan-status.sh" "$PROJECT_ROOT" --refresh 2>&1 >/dev/null || true; } | grep -m1 '^WARN: ' || true fi # ── Watch compatibility (advisory) ─────────────────────────────────────────── diff --git a/project-bin/progress-report.py b/project-bin/progress-report.py new file mode 100755 index 00000000..d063cca2 --- /dev/null +++ b/project-bin/progress-report.py @@ -0,0 +1,1717 @@ +#!/usr/bin/env python3 +"""Progress report: one self-contained HTML page for a conversion project, stages P to 6. + +usage: progress-report.py PROJECT_DIR [--out FILE] [--toolkit DIR] [--rates FILE] [--fragment FILE] + [--logs-dir DIR] [--session-log PATH ...] [--no-log] + +Called by project-bin/build-plan-status.sh (--html, --refresh, --open), which writes +architecture/progress-report.html. It replaces the older architecture/build-plan.html. + +Reads, never writes, the project folder (PROJECT.md, index.html, architecture/build-plan.json), +the toolkit artifact manifest (bin/lib/artifact-manifest.tsv) and, when present, the Claude Code +session logs of this project on this machine (/projects//*.jsonl and the +subagents/ folders). Without a session log the project-only sections render and the time and +token sections say they are unavailable. Python 3 standard library only. + +Privacy: the logs are read for numbers (token counters, timestamps, tool names) and for sub-agent +short descriptions. No prompt or message text is copied into the page. +Every tunable lives in the CONFIG block below so the page is easy to change. +""" +import sys, os, re, json, glob, html, fnmatch, calendar, collections, datetime, pathlib, argparse, time, urllib.parse + +# ----------------------------------------------------------------- CONFIG +STAGES = ["P", "0", "1", "2", "3", "4", "5", "6"] +STAGE_NAME = {"P": "Kickoff", "0": "Triage", "1": "Analysis", "2": "Requirements", + "3": "Architecture and design", "4": "Build plan", "5": "Build and improve", + "6": "Verify and report"} +REPORT_FILE = "progress-report.html" # the one place the page name lives here (build-plan-status.sh asks for it with --print-name) +IDLE_GAP_S = 600 # a gap longer than this between two events counts as waiting for the user +CACHE_TTL_S = 300 # prompt cache time to live assumed for the cache rewrite observation +ACTIVE_S = 600 # a build counts as active when its last session or exec event is younger than this +# Placeholder list prices in USD per million tokens: glob, input, cache write, cache read, output. +# These are ASSUMED rates, not read from any source. Override with --rates FILE (same 5 columns). +DEFAULT_RATES = [("*opus*", 5.0, 6.25, 0.50, 25.0), ("*sonnet*", 3.0, 3.75, 0.30, 15.0), + ("*haiku*", 1.0, 1.25, 0.10, 5.0)] +# Sub-agent description -> stage override (first match wins); otherwise the time window decides. +PHASE_LEAD_S = 1800 +AGENT_STAGE_RULES = [(r"Stage\s*(\d)", None), (r"inventory", "0"), (r"extractor|Path [AB]\b|extraction", "1"), + (r"\bBRD|Validate and fix|Consolidate", "2")] +# Extra artifact globs per stage on top of the toolkit manifest (project relative). +EXTRA_ARTIFACTS = { + "P": ["intake.md", "PROJECT.md", "docs/BUILD-LOG.md"], + "0": ["triage.md", "analysis/triage.html", "analysis/source-sufficiency.html", "analysis/source-sufficiency.json", + "analysis/inventory-batches/*.json"], + "1": ["knowledge-base/summary.md", "extractor/QUALITY.md", + "extraction-quality.json", "analysis/facts.lock.json", "analysis/README.md"], + "2": ["analysis/brd-report.html", "analysis/questions.html", "analysis/stage2-closeout.md", + "knowledge-base/brd-contract.md", "questions/review-sheet.md", + "questions/consolidated.json"], + "3": ["architecture/blueprint.html", "module-design.html", "design/*.html", "design/*.md", + "architecture/integration-contracts.md", "architecture/fit-gap.md", + "architecture/blueprint.md", "design/wireframes/*.html", "architecture/modules/*/definition.md"], + "4": ["architecture/build-plan.md", "architecture/build-plan.json", + "architecture/modules/*/module-brief.md", "architecture/modules/*/coverage-ledger.md"], + "5": [], "6": [], +} +# Project advisory thresholds (each rule says which one fired). +ADV = {"opus_cost_share": 0.40, "ctx_avg_tokens": 180_000, "ctx_peak_frac": 0.98, "compactions": 1, # context: average under 180k is fine; the window counts as reached only at this share of the real window (or after a compaction). Context is never red + "hang_amber_s": 600, "hang_red_s": 1800, "repeat_fail_red_n": 3, "min_lost_s": 60, # red only for a key script failing on every run (at least this many runs) or one hang or stall past hang_red_s; amber from hang_amber_s + "inherent_scripts": r"^(exec|gate-check|doctor|verify-module|review-module|build[\w-]*|mxbuild|mxcli|stack-up[\w-]*|test-stack-up)\.sh$", # time in these is how the toolkit works, not a project choice + "cache_rewrites": 3, + "stage_outlier_x": 1.5, "agent_short_s": 120, "agent_long_s": 1800, "short_agents_n": 3, "long_agents_n": 2, + "wait_share": 0.15, "cache_hit_min": 0.85, "max_items": 6, + # tips from tool calls (numbers and tool or script names only) + "hang_min_s": 300, "hang_x": 5, "hang_n": 1, # a call is hanging past both: this many seconds and this many times its tool's median + "repeat_fail_n": 3, # the same script failing this many times + "edit_loop_n": 6, "edit_loop_window_s": 1800, # the same file edited this many times inside this window + "ci_wait_s": 600, # seconds spent in sleep, watch or monitor calls + "large_read_chars": 40000, "large_read_n": 3, # file reads this large, this many times + "slow_script_s": 60, "slow_script_runs": 3, "slow_script_total_s": 300, # a script slow on every run + "serial_agents_n": 3, "serial_agent_s": 60, # sub-agents run one after another that could overlap + "full_test_n": 3, "full_test_s": 120, # full test suite runs + "slice_outlier_x": 2.0, "slice_outlier_n": 1, # exec.sh runs above this multiple of the median + "fail_share_amber": 0.25} # run health: amber from this failure rate (of all calls, or of one key script); red is never rate-based +SCRIPT_FILE_RE = re.compile(r"\.(sh|py|js)$") +REAL_CMDS = frozenset("mxcli mxbuild mx git gh npm npx node python python3 make curl jq docker".split()) # the only non-file commands a tip may name +TRIVIAL_CMDS = frozenset("cd sleep echo true false test [ ls cat pwd export source . printf grep diff".split()) # shell builtins and trivial commands: a non-zero exit is routine, never a repeat-failure tip +INHERENT_TOOLS = frozenset(("AskUserQuestion", "ExitPlanMode")) # required interview and approval time: waiting on the user, not a slow step or a hang +TOKENS_PER_MIN = 30_000 # assumed exchange rate used only to rank token waste against time waste +KEY_SCRIPTS = (("doctor", r"^doctor\.sh$"), ("gate-check", r"^gate-check\.sh$"), ("exec", r"^exec\.sh$"), + ("tests", r"^(test-[\w.-]*\.sh|run-all\.sh|run-tests\.sh|verify-module\.sh|pytest)$")) +FULL_TEST_SCRIPTS = ("run-all.sh", "run-tests.sh") +WAITY_RE = re.compile(r"\bsleep\s+\d|gh\s+(run|pr)\s+(watch|checks)|--watch\b|\buntil\b") +SCRIPT_RE = re.compile(r"(?:^|[\s/;&|(])([A-Za-z0-9_.-]+\.(?:sh|py|js))\b") +# Context window per model id (first match wins), read from the session log's model id. Only 200k and 1M exist. +WINDOWS = [(r"\[1m\]|-1m\b", 1_000_000), (r"^claude-", 200_000)] # regexes on the model id +KNOWN_WINDOWS = (200_000, 1_000_000) # when an observed context exceeds the assumed window, use the next of these (never an invented size) +def window_for(model, observed=0): + """Context window for one model id, or None when the model is not in WINDOWS. A context seen above 200k proves the + larger window is in use, so the result steps up to the next size in KNOWN_WINDOWS.""" + base = next((w_ for g, w_ in WINDOWS if re.search(g, model or "")), None) + if base is None: return None + if observed <= base: return base + return next((k for k in KNOWN_WINDOWS if k >= observed), None) +def ctx_pct(n, window): + """Share of the window in percent, never above 100. Nothing when the window is unknown.""" + return min(100.0, 100.0 * n / window) if window else None +def ctx_pct_txt(n, window): return "%.0f%%" % ctx_pct(n, window) if window else "n/a" +def context_window(msgs, compacts=(), flag_1m=False): + """(window, note) for the main thread, read from the log. 1M only when the log says so: a model id with [1m] or a + context-1m beta/flag (flag_1m). Otherwise 200k. A context above 200k without a 1M marker is NOT proof of 1M: when + automatic compactions fired, the window is capped at the highest compaction point (labelled inferred); with no compaction + it steps to the next known size (labelled inferred). (None, note) when the model is not in the table.""" + if not msgs: return None, "no requests" + model = collections.Counter(m["model"] for m in msgs).most_common(1)[0][0] + obs = max(m["in"] + m["cr"] + m["cw"] for m in msgs) + base = window_for(model) + if base is None: return None, "model %s is not in the WINDOWS table" % model + if flag_1m and base < 1_000_000: return 1_000_000, "1M read from the log's context flag" + if base >= 1_000_000: return base, "1M read from the model id" + pres = [c["pre"] for c in compacts if c.get("pre") and c.get("trig", "auto") == "auto"] + if obs <= base: return base, "" + if pres: return max(pres), "inferred: no 1M marker in the log, so the window is capped at the compaction point (%s)" % fmt_tok(max(pres)) + nxt = window_for(model, obs) + 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"), + ("Build complete", "Everything, once"), + ("Before every later deploy", "Everything"), + ("User asks", "Everything")] + +# ----------------------------------------------------------------- helpers +def esc(s): return html.escape(str(s), quote=True) +def epoch(ts): + try: + t = calendar.timegm(datetime.datetime.strptime(ts[:19], "%Y-%m-%dT%H:%M:%S").timetuple()) + m = re.match(r"\.(\d+)", ts[19:]) + return t + (float("0." + m.group(1)) if m else 0.0) + except Exception: return None +def fmt_tok(n): + n = float(n) + if n >= 1e6: return "%.2fM" % (n / 1e6) + if n >= 1e3: return "%.1fk" % (n / 1e3) + return "%d" % n +def fmt_dur(s): + s = int(round(s)); h, m = divmod(s // 60, 60) + return "%dh %02dm" % (h, m) if h else "%dm" % m if m else "%ds" % s +TICK_FMT = ["%H:%M"] +def local_hm(t): return datetime.datetime.fromtimestamp(t).strftime(TICK_FMT[0]) +def pct(a, b): return 100.0 * a / b if b else 0.0 +def read(p): + try: return open(p, encoding="utf-8", errors="replace").read() + except Exception: return "" + +# ----------------------------------------------------------------- project +def load_project(proj): + md = read(os.path.join(proj, "PROJECT.md")) + try: mt = os.path.getmtime(os.path.join(proj, "PROJECT.md")) + except OSError: mt = None + info = {"dir": proj, "name": os.path.basename(proj.rstrip("/")), "current_line": None, "current": None, "passed": [], "decisions": [], "signoff": {}, "md_mtime": None, + "entry_mode": None, "has_md": bool(md)} + m = re.search(r"^## Current stage\s*\n+\*\*(.+?)\*\*(.*)$", md, re.M) + if m: + info["current_line"] = (m.group(1) + m.group(2)).strip() + s = re.match(r"Stage\s+(\w)", m.group(1)); info["current"] = s.group(1) if s else None + g = re.search(r"gates passed:\s*([^()\n]+)", m.group(2)) + if g: info["passed"] = [x.strip() for x in re.split(r"[\u00b7|;]", g.group(1))[0].split(",") if x.strip() in STAGES] # "P, 0, 1, 2, 3, 4 · manual: 5": only the part before the dot is the passed list + sec = re.search(r"^## Decisions\s*\n(.*?)(?=^## )", md, re.M | re.S) + if sec: + for line in sec.group(1).splitlines(): + c = [x.strip() for x in line.strip().strip("|").split("|")] + if len(c) >= 3 and c[0] in STAGES and c[0] != "Stage": + st = re.match(r"[A-Za-z]+", c[2]); info["decisions"].append((c[0], c[1], st.group(0).upper() if st else "OTHER")) + if re.search(r"sign-?off", c[1], re.I) and st and st.group(0).upper() in ("CONFIRMED", "ASSUMED"): # a stage sign-off row is the evidence the stage passed, and when + t = decision_time(" ".join(c[2:]) or c[1]) or decision_time(c[1]) + sm = re.search(r"Stage\s*(\w)\b", c[1]); sg = c[0] if c[0] in STAGES else (sm.group(1) if sm else None) + if sg in STAGES and (sg not in info["signoff"] or (t and t < info["signoff"][sg][0])): info["signoff"][sg] = (t, c[1][:80], bool(re.search(r"20\d\d-\d\d-\d\d[ T]\d\d:\d\d", " ".join(c[2:]) + " " + c[1]))) + m = re.search(r"Entry mode:\s*\**\s*(\w[\w-]*)", md) + em = (m.group(1).lower() if m else "migration") + info["entry_mode"] = "requirements" if em.startswith("requirements") else "existing-app-change" if em.startswith("existing") else em + info["waived"] = set(re.findall(r"Waived artifact\s+([\w-]+)\s*:", md)) + m = re.search(r"^[\s>*_-]*Progress report\s*:\s*\**\s*(off|ask|auto)\b", md, re.M | re.I) + info["setting"] = m.group(1).lower() if m else "ask" + info["md_mtime"] = mt + return info + +def decision_time(txt): + """Epoch of the first date (and time when given) in a decision row cell; local time; None without a date.""" + m = re.search(r"(20\d\d)-(\d\d)-(\d\d)(?:[ T](\d\d):(\d\d))?", txt or "") + if not m: return None + try: return datetime.datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4) or 0), int(m.group(5) or 0)).timestamp() + except ValueError: return None + +def reconcile_gates(info, gate): + """One source of truth for which stages passed. PROJECT.md (the stage line and the sign-off rows in its decision + register) wins over the generated gate-check page when it is the newer file; when the page is the newer file its + PASS rows are added. The page's own age is recorded so a card can say it is stale.""" + md_t, g_t = info.get("md_mtime"), gate.get("mtime") + info["passed_md"] = list(info["passed"]); info["gate_stale_s"] = 0 + if not info["has_md"]: return + gp = [s for s in STAGES if gate["rows"].get(s, {}).get("status") == "PASS"] + ps = set(info["passed"]) + if info["current"] or ps: + ps |= {s for s in info["signoff"] if s in STAGES and (info["current"] is None or STAGES.index(s) < STAGES.index(info["current"]) or s in info["passed"])} + if g_t and md_t and g_t > md_t: ps |= set(gp) + if g_t and md_t and g_t < md_t - 60: info["gate_stale_s"] = md_t - g_t + info["passed"] = [s for s in STAGES if s in ps] + +def load_gate_index(proj): + p = os.path.join(proj, "index.html"); out = {"rows": {}, "mtime": None, "extra": {}} + h = read(p) + if not h: return out + out["mtime"] = os.path.getmtime(p) + for row in re.findall(r"(.*?)", h, re.S): + cells = [re.sub(r"<[^>]+>", "", c).strip() for c in re.findall(r"]*>(.*?)", row, re.S)] + if len(cells) >= 4: + cells = [html.unescape(c) for c in cells] + if cells[0] in STAGES: out["rows"][cells[0]] = {"title": cells[1], "status": cells[2], "detail": cells[3]} + elif "Open questions" in cells[1]: + m = re.search(r"RAISED=(\d+)", cells[3]); out["extra"]["raised"] = int(m.group(1)) if m else None + return out + +WAIVED = set() +def load_manifest(toolkit, mode): + rows = [] + txt = read(os.path.join(toolkit, "bin", "lib", "artifact-manifest.tsv")) if toolkit else "" + for line in txt.splitlines(): + if not line.strip() or line.startswith("#"): continue + c = line.split("\t") + if len(c) >= 7 and c[1] in STAGES and mode in c[5].split(",") and c[0] not in WAIVED: + rows.append({"id": c[0], "stage": c[1], "paths": c[2].split("|"), "absence": c[6].strip()}) + return rows + +def resolve(proj, pattern): + pattern = pattern.replace("${JOURNEY_DIR}", "journeys") + hits = sorted(glob.glob(os.path.join(proj, pattern))) + if not hits: + hits = sorted(glob.glob(os.path.join(proj, "analysis", "*", pattern))) + return [h for h in hits if os.path.isfile(h)] + +SELF_OUT = None # the page being written: it counts as present, or the first render and the next differ +def collect_artifacts(proj, manifest): + stages = {s: {"owed": [], "files": collections.OrderedDict()} for s in STAGES} + for r in manifest: + found = [] + for pat in r["paths"]: found += resolve(proj, pat) + if SELF_OUT and not found and any(pat.endswith(REPORT_FILE) for pat in r["paths"]): found = [SELF_OUT] # counts as present, never listed (see below) + for f in found: + if os.path.basename(f) == REPORT_FILE or os.path.relpath(f, proj).startswith(".."): continue # the report never lists itself, and nothing outside the project folder + stages[r["stage"]]["files"].setdefault(f, r["id"]) + stages[r["stage"]]["owed"].append({"id": r["id"], "found": len(found), "absence": r["absence"]}) + for s, pats in EXTRA_ARTIFACTS.items(): + for pat in pats: + for f in resolve(proj, pat): stages[s]["files"].setdefault(f, "extra") + return stages + +def link_kind(path): + e = os.path.splitext(path)[1].lower() + return "html" if e == ".html" else "md" if e == ".md" else "data" + +# ----------------------------------------------------------------- session +def rate_for(model, rates): + for g, i, cw, cr, o in rates: + if fnmatch.fnmatch(model or "", g): return (i, cw, cr, o) + return None +def load_rates(path): + if not path: return DEFAULT_RATES + out = [] + for l in read(path).splitlines(): + l = l.split("#")[0].split() + if len(l) == 5: + try: out.append((l[0], *map(float, l[1:]))) + except ValueError: pass + return out or DEFAULT_RATES + +def cost_of(u, model, rates): + r = rate_for(model, rates) + if not r: return None + return (u["in"] * r[0] + u["cw"] * r[1] + u["cr"] * r[2] + u["out"] * r[3]) / 1e6 + +def scan_transcript(path, is_main=False, clip=None): + """Returns messages (dedup by message.id, max of each counter), event times, compactions, prompts, tool events.""" + flag_1m = False; msgs = {}; times = []; compacts = []; prompts = 0; tools = []; gate = []; calls = {} + with open(path, encoding="utf-8", errors="replace") as f: + for line in f: + try: o = json.loads(line) + except Exception: continue + t = o.get("type"); ts = epoch(o.get("timestamp", "")) if o.get("timestamp") else None + if clip and ts and not any(a_ <= ts <= b_ for a_, b_ in clip): continue + if ts and t in ("user", "assistant"): times.append(ts) + if not flag_1m: + bt = (o.get("params") or {}).get("betas") if isinstance(o.get("params"), dict) else None + if (isinstance(bt, list) and any("context-1m" in str(x) for x in bt)) or re.search(r"\[1m\]", str(o.get("requestedModel") or "")): flag_1m = True + if "compactMetadata" in o: + cm = o["compactMetadata"]; compacts.append({"t": ts, "pre": cm.get("preTokens"), "post": cm.get("postTokens"), "trig": cm.get("trigger") or "auto"}) + if t == "user" and is_main and not o.get("isMeta") and not o.get("isCompactSummary"): + c = o.get("message", {}).get("content") + if isinstance(c, str) and not c.lstrip().startswith("<"): prompts += 1 + elif isinstance(c, list) and any(b.get("type") == "text" for b in c) and not any(b.get("type") == "tool_result" for b in c): prompts += 1 + if t == "user" and isinstance(o.get("message", {}).get("content"), list): + for b in o["message"]["content"]: + if isinstance(b, dict) and b.get("type") == "tool_result" and b.get("tool_use_id") in calls: + c_ = calls[b["tool_use_id"]]; txt = b.get("content") + if isinstance(txt, list): txt = "".join(x.get("text", "") for x in txt if isinstance(x, dict)) + txt = txt if isinstance(txt, str) else "" + head_ = txt[:300]; c_["t1"] = ts; c_["rchars"] = len(txt) + c_["err"] = bool(b.get("is_error")) or bool(re.match(r"\s*Exit code:?\s*[1-9]", head_)) + c_["timeout"] = bool(re.search(r"(?i)timed out", head_)) and c_["name"] != "Read" + c_["killed"] = bool(re.search(r"\[killed\]|was killed|Terminated", head_)) and c_["name"] != "Read" + if t != "assistant": continue + m = o.get("message", {}); u = m.get("usage") or {}; mid = m.get("id") or o.get("uuid") + if m.get("model") in (None, ""): continue + if "[1m]" in str(m.get("model")): flag_1m = True + cur = msgs.setdefault(mid, {"t": ts, "model": m["model"], "in": 0, "out": 0, "cr": 0, "cw": 0}) + for k, uk in (("in", "input_tokens"), ("out", "output_tokens"), ("cr", "cache_read_input_tokens"), ("cw", "cache_creation_input_tokens")): + cur[k] = max(cur[k], u.get(uk) or 0) + for b in m.get("content", []): + if b.get("type") == "tool_use" and b.get("id") and ts: + i = b.get("input") or {}; n = b.get("name"); cmd = i.get("command") or "" if n == "Bash" else "" + sm = SCRIPT_RE.search(cmd) if cmd else None + word = re.match(r"\s*(?:\w+=\S+\s+)*([A-Za-z0-9_.-]{1,30})", cmd.split("&&")[-1] if "&&" in cmd else cmd) if cmd and not sm else None + arg = re.search(r"gate-check\.sh\s+\S+\s+(P|[0-6]|build-ready)\b", cmd) if cmd else None + f_ = i.get("file_path") or i.get("notebook_path") or "" + calls[b["id"]] = {"t0": ts, "t1": None, "name": n, "script": (sm.group(1) if sm else word.group(1) if word else ""), + "variant": arg.group(1) if arg else "", "file": f_ if n in ("Write", "Edit", "MultiEdit", "NotebookEdit") else "", + "bg": bool(i.get("run_in_background")), "waity": bool(WAITY_RE.search(cmd)) or n == "Monitor", + "killed": n in ("TaskStop", "KillShell", "KillBash"), "timeout": False, "err": False, "rchars": 0, "who": "main" if is_main else "agent"} + if is_main: + for b in m.get("content", []): + if b.get("type") == "tool_use": + i = b.get("input", {}); n = b.get("name") + ev = {"t": ts, "name": n} + if n == "Bash": ev["cmd"] = (i.get("command") or "")[:400] + elif n in ("Write", "Edit"): ev["file"] = i.get("file_path", "") + elif n in ("Agent", "Task"): ev["desc"] = i.get("description", "") + elif n == "Skill": ev["skill"] = i.get("skill", "") + tools.append(ev) + return {"flag_1m": flag_1m, "msgs": list(msgs.values()), "times": times, "compacts": compacts, "prompts": prompts, "tools": tools, "calls": list(calls.values())} + +def touches(name, inp, cwd, roots): + """Does this tool call operate on the project: a path under the root in its input, a Bash command that cds into the + root or names a path under it, or a working directory at or under it. Words in prose never count.""" + for r in roots: + if cwd and (cwd == r or cwd.startswith(r + "/")): return True + for k in ("file_path", "notebook_path", "path"): + v = inp.get(k) + if isinstance(v, str) and (v == r or v.startswith(r + "/")): return True + if name == "Bash": + c = inp.get("command") or "" + if re.search(r"(?= EARLY_CALLS: return {"touch": 0, "total": 0, "share": 0.0, "spans": []} + t_ = sorted(t for t, x in calls if x) + if not t_: return {"touch": 0, "total": 0, "share": 0.0, "spans": []} + inwin = [1 for t, x in calls if t_[0] <= t <= t_[-1]] + spans = []; a_ = b_ = t_[0] + for t in t_[1:]: + if t - b_ > SPAN_GAP_S: spans.append((a_, b_)); a_ = t + b_ = t + spans.append((a_, b_)) + return {"touch": len(t_), "total": len(inwin), "share": len(t_) / max(1, len(inwin)), "spans": spans} + +def load_cache(path): + """Discovery cache: one line per examined session file (verdict, mtime, size, path, details of a used one).""" + c = {"scanned": 0.0, "entries": {}, "root": None, "rules": None} + try: lines = open(path, encoding="utf-8").read().splitlines() + except OSError: return None + for ln in lines: + if ln.startswith("# scanned="): + for part in ln[2:].split("\t"): + k, _, v = part.partition("=") + if k == "scanned": + try: c["scanned"] = float(v) + except ValueError: pass + elif k in ("root", "rules"): c[k] = v + continue + f = ln.split("\t") + if len(f) >= 4 and f[0] in ("used", "no"): + try: c["entries"][f[3]] = {"v": f[0], "m": float(f[1]), "s": int(f[2]), "info": json.loads(f[4]) if len(f) > 4 and f[4] else None} + except ValueError: continue + return c + +def cache_rules(): + """The attribution rule constants a cached verdict depends on; a cache written under other rules is stale.""" + return "share=%g;calls=%d" % (TOOL_SHARE, TOOL_MIN) + +def save_cache(path, scanned, entries, root): + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = path + ".tmp.%d" % os.getpid() + with open(tmp, "w", encoding="utf-8") as fh: + fh.write("# scanned=%.0f\troot=%s\trules=%s\tprogress-report session cache: verdict, mtime, size, path, details. Safe to delete.\n" % (scanned, root, cache_rules())) + for f, e in sorted(entries.items()): + fh.write("%s\t%.6f\t%d\t%s\t%s\n" % (e["v"], e["m"], e["s"], f, json.dumps(e["info"]) if e.get("info") else "")) + os.replace(tmp, path) + except OSError: pass + +SWITCH = {} +DISCOVERY = {} +TOOL_MIN = 20; TOOL_SHARE = 0.25; SPAN_GAP_S = 1800 +SCAN_DAYS = 30; SCAN_MAX_FILES = int(os.environ.get("PROGRESS_REPORT_SCAN_MAX_FILES") or 300); SCAN_MAX_BYTES = 200_000_000; +def slug(path): + """Claude Code's folder name for a launch directory: every non-alphanumeric character becomes a dash.""" + return re.sub(r"[^A-Za-z0-9]", "-", path) + +def have_own(out): return set(out) + +def find_sessions(proj, logs_dir=None, cache_path=None): + """Main-thread session logs that belong to this project on this machine, oldest first. + Looks in the project's own slug folder, its ancestors' and its descendants'; a session counts when + at least one record's cwd is at or under the project folder (the folder name alone is not proof).""" + base = logs_dir or os.path.join(os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(os.path.expanduser("~"), ".claude"), "projects") + if not os.path.isdir(base): return [], base + me = slug(os.path.realpath(proj)); out = [] + for name in sorted(os.listdir(base)): + d = os.path.join(base, name) + if not (os.path.isdir(d) and (name == me or me.startswith(name + "-") or name.startswith(me + "-"))): continue + for f in sorted(glob.glob(os.path.join(d, "*.jsonl"))): + if session_in_project(f, proj): out.append(f) + # Sessions started elsewhere (another folder's slug): last 30 days, newest first, a capped number of files. A log counts + # when a record's cwd is the project, or when at least TOOL_SHARE of its tool calls (and TOOL_MIN of them) operate on the + # project path. Text mentions never count. Listed on the page. + DISCOVERY.update({"other_dirs": 0, "other_files": 0, "mention": [], "searched": True, "used": [], "skipped_report": 0, "cache": "cold"}) + cutoff = time.time() - SCAN_DAYS * 86400; started = time.time() + roots = sorted({os.path.realpath(proj), os.path.abspath(proj)}, key=len, reverse=True) + needle = re.compile(b"|".join(re.escape(n.encode()) for n in roots)) + cpath = cache_path or os.path.join(proj, ".claude", "progress-report-sessions.txt") + cache = load_cache(cpath); entries = {}; cands = [] + root_id = os.path.realpath(proj) + # A cache belongs to one project path and one rule set: a copied or moved project, or a toolkit update that changed the + # thresholds, reads it as cold (its verdicts are about another folder or another rule). + if cache and (cache["root"] != root_id or cache["rules"] != cache_rules()): cache = None + def own_dir(name): return name == me or me.startswith(name + "-") or name.startswith(me + "-") + if cache: # warm: known files are stat-ed; only a changed or a new file is read; only directories touched since the last run are listed + DISCOVERY["cache"] = "warm" + for f, e in cache["entries"].items(): + if not f.startswith(base.rstrip(os.sep) + os.sep): continue # another config folder's entry + try: st = os.stat(f) + except OSError: continue + if st.st_mtime < cutoff: continue + if abs(st.st_mtime - e["m"]) < 1e-3 and st.st_size == e["s"]: entries[f] = e + else: cands.append((st.st_mtime, f, st.st_size)) + for name in os.listdir(base): + d = os.path.join(base, name) + try: dm = os.stat(d).st_mtime + except OSError: continue + if not os.path.isdir(d) or own_dir(name) or dm < cache["scanned"] - 2: continue + DISCOVERY["other_dirs"] += 1 + for f in glob.glob(os.path.join(d, "*.jsonl")): + if f in entries or f in {c[1] for c in cands}: continue + try: st = os.stat(f) + except OSError: continue + if st.st_mtime >= cutoff: cands.append((st.st_mtime, f, st.st_size)) + else: # cold: every other folder's logs of the last SCAN_DAYS days (stat only), newest first, capped + for name in os.listdir(base): + d = os.path.join(base, name) + if not os.path.isdir(d) or own_dir(name): continue + DISCOVERY["other_dirs"] += 1 + for f in glob.glob(os.path.join(d, "*.jsonl")): + try: st = os.stat(f) + except OSError: continue + if st.st_mtime >= cutoff: cands.append((st.st_mtime, f, st.st_size)) + cands.sort(reverse=True) + # Files past the cap are recorded as "no" (mtime and size), so a warm run never hands them back as candidates. + for mt, f, size in cands[SCAN_MAX_FILES:]: entries[f] = {"v": "no", "m": mt, "s": size, "info": None} + cands = cands[:SCAN_MAX_FILES] + nread = 0 + for mt, f, size in sorted(cands, reverse=True): + nread += 1 + if size > SCAN_MAX_BYTES: entries[f] = {"v": "no", "m": mt, "s": size, "info": None}; continue + r = tool_share(f, roots, needle) + if r is None: DISCOVERY["skipped_report"] += 1; entries[f] = {"v": "no", "m": mt, "s": size, "info": None}; continue + if r["touch"] >= TOOL_MIN and r["share"] >= TOOL_SHARE: + entries[f] = {"v": "used", "m": mt, "s": size, "info": {"share": r["share"], "touch": r["touch"], "total": r["total"], "spans": r["spans"]}} + else: entries[f] = {"v": "no", "m": mt, "s": size, "info": None} + # the page's "looked in N folders (M logs)" counts must not depend on cold or warm: count them the same way both times + DISCOVERY["other_dirs"] = sum(1 for n in os.listdir(base) if os.path.isdir(os.path.join(base, n)) and not own_dir(n)) + DISCOVERY["other_files"] = len(entries) + for f, e in entries.items(): + if e["v"] != "used" or f in have_own(out): continue + i = e["info"]; sp = [tuple(x) for x in i["spans"]] + out.append(f); DISCOVERY["mention"].append(f); DISCOVERY.setdefault("clip", {})[f] = sp + DISCOVERY["used"].append({"id": os.path.splitext(os.path.basename(f))[0][:8], "t": sp[0][0], "share": i["share"], "touch": i["touch"], "total": i["total"], "span_s": sum(b_ - a_ for a_, b_ in sp)}) + save_cache(cpath, started, entries, root_id) + dbg("discovery %s, %d file(s) read, %.2f s" % (DISCOVERY["cache"], nread, time.time() - started)) + out.sort(key=lambda f: os.path.getmtime(f)) + return out, base + +def explicit_sessions(paths): + """--session-log inputs: a .jsonl file, or a folder of them (not searched below it). Used as given, no cwd test.""" + out = [] + for p_ in paths: + p_ = os.path.abspath(os.path.expanduser(p_)) + if os.path.isdir(p_): out += sorted(glob.glob(os.path.join(p_, "*.jsonl")), key=os.path.getmtime) + elif os.path.isfile(p_): out.append(p_) + seen = set() + return [f for f in out if not (f in seen or seen.add(f))] + +def session_in_project(path, proj): + roots = {os.path.realpath(proj), os.path.abspath(proj)} + try: + with open(path, encoding="utf-8", errors="replace") as f: + for i, line in enumerate(f): + if i > 4000: break + m = re.search(r'"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"', line) + if m: + c = m.group(1) + if any(c == r or c.startswith(r.rstrip("/") + "/") for r in roots): return True + except Exception: pass + return False + +def load_sessions(paths): + mains = []; agents = []; extra_calls = [] + for session in paths: + clip = DISCOVERY.get("clip", {}).get(session) + mains.append(scan_transcript(session, True, clip)) + sdir = session[:-6] + for f in sorted(glob.glob(os.path.join(sdir, "subagents", "**", "agent-*.jsonl"), recursive=True)): + try: meta = json.load(open(f[:-6] + ".meta.json")) + except Exception: meta = {} + d = scan_transcript(f, False, clip) + if not d["times"]: continue + extra_calls.extend(d["calls"]) + wf = "workflows" in f.split(os.sep) + model = (d["msgs"][0]["model"] if d["msgs"] else meta.get("model") or "unknown") + agents.append({"desc": str(meta.get("description") or meta.get("label") or "sub-agent")[:80], "model": model, "wf": wf, + "wf_id": os.path.basename(os.path.dirname(f)) if wf else None, "phase": meta.get("phase"), + "t0": min(d["times"]), "t1": max(d["times"]), "times": d["times"], "turns": len(d["msgs"]), "msgs": d["msgs"]}) + main = {"msgs": [], "times": [], "compacts": [], "prompts": 0, "tools": [], "calls": [], "flag_1m": any(m["flag_1m"] for m in mains)} + for m in mains: + for k in ("msgs", "times", "compacts", "tools", "calls"): main[k] += m[k] + main["prompts"] += m["prompts"] + main["calls"] += extra_calls + main["msgs"].sort(key=lambda m: m["t"] or 0); main["tools"].sort(key=lambda e: e["t"] or 0); main["times"].sort() + return main, agents + +# ----------------------------------------------------------------- stage windows +GATE_RE = re.compile(r"gate-check\.sh\s+(?:\.\s+)?(?:--closeout\s+\.\s*)?([P0-6])?\b") +GENERIC = set("fix fixes patch domain docs phase scaffold app skeleton model models module journey test tests look add the and for of new data".split()) + +def run_kind(result, gate=""): + """ok = the script was applied (also applied-unverified); blocked = it never ran; fail = anything else (rolled back, failed).""" + r = (result or "").lower() + if re.search(r"blocked|not-run", r) or (gate or "").lower() == "not-run": return "blocked" + if "applied" in r and not re.search(r"rolled|fail|error", r): return "ok" + return "fail" + +def ledger_runs(proj): + """Rows of .claude/loop/metrics-ledger.tsv, the log exec.sh writes itself (ts in UTC), oldest first.""" + ml = read(os.path.join(proj, ".claude", "loop", "metrics-ledger.tsv")).splitlines() + if len(ml) < 2: return [] + h = ml[0].split("\t"); out = [] + for line in ml[1:]: + c = line.split("\t"); g = lambda k: c[h.index(k)].strip() if k in h and h.index(k) < len(c) else "" + t = epoch(g("ts")) + if not t or not g("script"): continue + try: el = float(g("elapsed_s")) + except ValueError: el = None + try: sc = float(g("score")) + except ValueError: sc = None + try: rs = int(float(g("restored") or 0)) > 0 + except ValueError: rs = False + out.append({"t": t, "script": g("script"), "result": g("result"), "kind": run_kind(g("result"), g("gate")), "elapsed": el, "restored": rs, "score": sc}) + out.sort(key=lambda r: r["t"]); return out + +def build_runs(proj): + """(runs, source name): the metrics ledger when it has rows, else docs/BUILD-LOG.md.""" + r = ledger_runs(proj) + if r: return r, ".claude/loop/metrics-ledger.tsv" + bl = [{"t": t, "script": sc, "result": res, "kind": run_kind(res), "elapsed": None, "restored": False, "score": None} for t, sc, res in build_log_rows(proj)] + return bl, "docs/BUILD-LOG.md" + +def phase_for_script(script, phases): + """The plan phase a build script belongs to: its file name in a plan row, else its mdlsource// folder, else the + script's name matched to phase names (then row texts). None when nothing matches.""" + txt = script or "" + bn = os.path.basename(txt.strip().split()[-1]).lower() if txt.strip() else "" + if len(bn) > 4: + for p in phases: + if any(bn in (x["step"] or "").lower() for x in p["steps"]): return p + m = re.search(r"mdlsource/([^/\s]+)/", txt) + if m: + f = m.group(1).lower(); fm = re.match(r"(?:phase[-_ ]?)?0*(\d+)(?:[-_ ](.*))?$", f) + for p in phases: + pid = str(p["id"]).lower(); nm = re.sub(r"[^a-z0-9]+", "", p["name"].lower()); fr = re.sub(r"[^a-z0-9]+", "", (fm.group(2) if fm and fm.group(2) else "")) + if f == pid or (fm and fm.group(1) == pid.lstrip("0") and (not fr or fr in nm or nm in fr)) or re.sub(r"[^a-z0-9]+", "", f) == nm: return p + stem = os.path.splitext(bn)[0] + nm_ = re.match(r"0*(\d+)", stem) + if nm_: # a phase name that states its script numbers ("rows 350 to 399") + for p in phases: + r_ = re.search(r"rows?\s+(\d+)\s*(?:to|-)\s*(\d+)", p["name"]) + if r_ and int(r_.group(1)) <= int(nm_.group(1)) <= int(r_.group(2)): return p + toks = [t for t in re.split(r"[-_\s]+", stem) if t and not t.isdigit() and t not in GENERIC and len(t) > 2] + best, bs = None, 0 + for p in phases: + nm = re.findall(r"[a-z0-9]+", p["name"].lower()) + sc = 3 * sum(1 for t in toks if t in nm or any(w_.startswith(t) or t.startswith(w_) for w_ in nm if len(w_) > 3)) + sc += sum(1 for x in p["steps"] for t in toks if t in (x["step"] or "").lower()) * 0.2 + if sc >= 3 and sc >= bs: best, bs = p, sc # a phase NAME must match; row texts only break ties; the later phase wins a tie + return best + +def script_phases(runs, phases): + """script -> phase for every script in the runs. Direct matches first; a numbered script with no match takes the phase of + the nearest lower-numbered script that matched (scripts are numbered in build order). Unnumbered ones stay unmapped.""" + direct = {}; num = {} + for r in runs: + sc = r["script"] + if sc in direct: continue + direct[sc] = phase_for_script(sc, phases) + m = re.match(r"0*(\d+)", os.path.splitext(os.path.basename(sc.strip().split()[-1]) if sc.strip() else "")[0]) + num[sc] = int(m.group(1)) if m else None + known = sorted((num[k], direct[k]) for k in direct if direct[k] and num[k] is not None) + out = {} + for k, p in direct.items(): + if not p and num[k] is not None: + lower = [pp for n, pp in known if n <= num[k]] + p = lower[-1] if lower else None + out[k] = p + return out + +def now_phase(bp, proj): + """The phase the last applied build step belongs to (blocked and failed steps build nothing); else the highest phase + with a built or partial row. Earlier phases with partial rows stay amber only.""" + phases = bp["phases"] + runs, _ = build_runs(proj) + ok = [r for r in runs if r["kind"] == "ok"] + sp = script_phases(runs, phases) + for r in reversed(ok[-12:]): # the last applied step that maps to a phase (experiments and patches may not) + if sp.get(r["script"]): return sp[r["script"]] + act = [p for p in phases if p["built"] or p["partial"]] + return act[-1] if act else None + +def exec_facts(proj, bp): + """Stage 5 build facts from the exec log (ledger first): counts, exec seconds, span, per-phase runs, latest score.""" + runs, src = build_runs(proj) + if not runs: return None + f = {"src": src, "n": len(runs), "ok": sum(r["kind"] == "ok" for r in runs), "fail": sum(r["kind"] == "fail" for r in runs), "blocked": sum(r["kind"] == "blocked" for r in runs), + "restored": sum(1 for r in runs if r["restored"]), "first": runs[0]["t"], "last": runs[-1]["t"], "last_run": runs[-1]} + el = [r["elapsed"] for r in runs if r["elapsed"] is not None] + f["exec_s"] = sum(el) if el else None + f["bad_s"] = sum(r["elapsed"] or 0 for r in runs if r["kind"] == "fail" or r["restored"]) if el else None + sc = [r for r in runs if r["score"] is not None]; f["score"] = sc[-1] if sc else None + per = {}; order = []; sp = script_phases(runs, bp["phases"]) if bp else {} + for r in runs: + p = sp.get(r["script"]) + k = (p["id"], p["name"]) if p else ("", "not mapped to a phase") + if k not in per: per[k] = {"runs": 0, "ok": 0, "fail": 0, "blocked": 0, "s": 0.0}; order.append(k) + d = per[k]; d["runs"] += 1; d[r["kind"]] += 1; d["s"] += r["elapsed"] or 0 + if bp: order.sort(key=lambda k: ([str(p["id"]) for p in bp["phases"]].index(str(k[0])) if k[0] != "" and str(k[0]) in [str(p["id"]) for p in bp["phases"]] else 10**6)) + f["phases"] = [(k, per[k]) for k in order] + return f + +def switch_from_runs(proj, day0): + """First exec-log row on or after the sign-off date: the metrics ledger, else BUILD-LOG. (epoch, source) or None.""" + for rows, src in ((ledger_runs(proj), ".claude/loop/metrics-ledger.tsv"), ([{"t": t} for t, _, _ in build_log_rows(proj)], "docs/BUILD-LOG.md")): + c = [r["t"] for r in rows if r["t"] >= day0] + if c: return min(c), src + return None + +def facts_line(f): + """One short line: runs, passed, failed or blocked, and the share of build time lost to failed or restored runs.""" + bad = f["fail"] + f["blocked"]; ex = f["exec_s"]; sh = "" + if ex and f["bad_s"] is not None: + x = f["bad_s"] / ex; words = [(0.25, "a quarter"), (1 / 3.0, "a third"), (0.5, "half"), (2 / 3.0, "two thirds")] + near = [wd for v, wd in words if abs(x - v) < 0.04] + sh = " (about %s of build time)" % (near[0] if near else "%d%%" % round(100 * x)) + return "%d build runs, %d passed, %d failed or blocked%s" % (f["n"], f["ok"], bad, sh) + +def stage_boundaries(main, agents, info=None): + """Start time per stage from session signals; honest fallbacks; monotonic.""" + tools = main["tools"]; sig = {} + def first(pred): + c = [e["t"] for e in tools if e["t"] and pred(e)]; return min(c) if c else None + gate_any = lambda e: e["name"] == "Bash" and "gate-check" in e.get("cmd", "") + gate_arg = lambda n: (lambda e: gate_any(e) and re.search(r"gate-check\.sh\s+(?:\.\s+)?%s\b" % n, e["cmd"])) + sig["P"] = first(lambda e: e["name"] == "Skill" and "toolkit-init" in e.get("skill", "")) + sig["0"] = first(gate_any) + sig["1"] = first(gate_arg("0")) or first(lambda e: e["name"] == "Agent" and re.search(r"extract|Path [AB]", e.get("desc", ""))) + sig["2"] = first(lambda e: ("brd-contract" in e.get("file", "") + e.get("cmd", ""))) or first(lambda e: e["name"] == "Agent" and "BRD" in e.get("desc", "")) + sig["3"] = first(gate_arg("2")) or first(lambda e: e["name"] == "Agent" and re.search(r"Stage 3", e.get("desc", ""))) + sig["4"] = first(lambda e: e["name"] == "Agent" and re.search(r"Stage 4|build plan", e.get("desc", ""), re.I)) + sig["5"] = first(lambda e: "build" in e.get("skill", "") and "plan" not in e.get("skill", "")) + sig["6"] = None + t5, _ = stage_switch(info, tools, sig.get("5")) + sig["5"] = t5 + return sig + +def stage_switch(info, tools, skill_t=None): + """THE Stage 4 to 5 boundary, used by every per-stage view (windows, sub-agent tables, tokens, chart band) and by the + no-log page. It is the EARLIEST evidenced build activity: the first row of the exec log (metrics ledger, else BUILD-LOG) + and the Stage 4 sign-off time when the row has one. Only when there is no exec log row does the session log decide: the + first exec.sh call, the build skill, the first write under mdlsource/. With ledger rows, session signals are ignored. A sign-off row with a date but no time gives no moment of its + own: midnight is not evidence, so activity before that date is ignored and the switch is marked inferred. A later + gate-check run never moves it. Returns (epoch or None, source text).""" + so = (info or {}).get("signoff", {}).get("4") + day0 = so[0] if so and so[0] and len(so) > 2 and not so[2] else None + ok = lambda t: t and (day0 is None or t >= day0) + exec_p = lambda e: e["name"] == "Bash" and re.search(r"(^|[\s/])exec\.sh\b", e.get("cmd", "")) + mdl_p = lambda e: e["name"] in ("Write", "Edit") and "/mdlsource/" in e.get("file", "") + SWITCH.clear(); cands = [] + r = switch_from_runs(info["dir"], day0 or 0) if info and info.get("dir") else None + if r: cands.append((r[0], r[1])) + if so and so[0] and day0 is None: cands.append((so[0], "the Stage 4 sign-off row in PROJECT.md")) + if not r: # the exec log is the primary source: with rows in it, session-log signals never move the switch + ex = [e["t"] for e in tools if e["t"] and ok(e["t"]) and exec_p(e)] + if ex: cands.append((min(ex), "the first exec.sh call in the session log")) + if skill_t and ok(skill_t): cands.append((skill_t, "the build skill in the session log")) + if not r: + md = [e["t"] for e in tools if e["t"] and ok(e["t"]) and mdl_p(e)] + if md: cands.append((min(md), "the first mdlsource write in the session log")) + if not cands: return None, "" + t, src = min(cands) + if day0: SWITCH.update(inferred=True, t=t, day=day0, src=src) + else: SWITCH.update(t=t, src=src) + return t, src + +def build_windows(sig, t_start, t_end): + order = [s for s in STAGES if sig.get(s)]; wins = [] + last = t_start + if order and sig[order[0]] > t_start: wins.append(("pre", t_start, sig[order[0]])); last = sig[order[0]] + prev = None + for s in order: + st = max(sig[s], last) + if prev: wins[-1] = (wins[-1][0], wins[-1][1], st) + wins.append((s, st, t_end)); prev = s; last = st + return wins +def stage_at(wins, t): + for s, a, b in wins: + if a <= t < b: return s + return wins[-1][0] if wins and t >= wins[-1][1] else (wins[0][0] if wins else "P") + +def agent_stage(a, wins): + w5 = next((x for s_, x, _ in wins if s_ == "5"), None) + if w5 and re.search(r"\bPhase\s*\d", a["desc"]) and not re.search(r"Stage\s*\d", a["desc"]) and a["t0"] >= w5 - PHASE_LEAD_S: return "5" # a plan phase starting just before the first build run is build work + for pat, fixed in AGENT_STAGE_RULES: + m = re.search(pat, a["desc"]) + if m: + s = fixed or m.group(1) + if s in STAGES: return s + return stage_at(wins, a["t0"]) + +# ----------------------------------------------------------------- build plan +PHASE_RE = re.compile(r"^#{1,6}\s+(?:Phase|Slice)\s+(\d[0-9A-Za-z.]*)\s*(?:[\u2014\u2013\u00b7:-]\s*|\s+)(.*?)\s*$") +def classify_state(raw): + """Row status by prefix, 'not built' checked before 'built'. built | partial | todo | skip.""" + l = (raw or "").strip().lower().replace("**", "") + if l.startswith(("not built", "not done", "not started", "planned", "to do", "todo", "open", "pending", "blocked", "failed")): return "todo" + if l.startswith(("not needed", "n/a", "dropped", "skipped", "waived", "deferred")): return "skip" + if l.startswith(("built", "done", "complete", "verified", "proven", "passed")): return "built" + if l.startswith(("partial", "in progress", "started", "wip")): return "partial" + return "todo" + +def _cells(line): + c = re.split(r"(? 1 else ""), c[3] if len(c) > 3 else "")) + except Exception: continue + rows.sort(); return rows + +def _num(txt): + m = re.search(r"([\d.]+)\s*([kKmM]?)", txt or "") + if not m: return None + try: v = float(m.group(1)) + except ValueError: return None + return int(v * {"": 1, "k": 1e3, "m": 1e6}[m.group(2).lower()]) + +def load_ledgers(proj): + """Fallback when no session log exists: docs/progress/TIME-LEDGER.md (minutes, sub-agent tokens, exec runs per phase), + docs/BUILD-LOG.md exec rows, .claude/loop/metrics-ledger.tsv (seconds per exec). None when none of them is there.""" + out = {"phases": [], "exec_rows": 0, "exec_blocked": 0, "active_s": 0, "exec_s": None, "first": None, "last": None, "sources": []} + tl = read(os.path.join(proj, "docs", "progress", "TIME-LEDGER.md")) + cols = {"start": 1, "min": 2, "tok": 3, "runs": 4} # default layout; each table header re-maps it (a second table can differ) + for line in tl.splitlines(): + c = [x.strip() for x in line.strip().strip("|").split("|")] + if not line.lstrip().startswith("|") or len(c) < 4: continue + if c[0].lower() == "phase": + h = [x.lower() for x in c] + fi = lambda *k: next((i for i, x in enumerate(h) if any(w in x for w in k)), None) + cols = {"start": fi("start"), "min": fi("minute"), "tok": fi("token"), "runs": fi("exec", "run", "script")} + continue + if set(c[0]) <= set("-: ") or cols["min"] is None or len(c) <= cols["min"]: continue + g = lambda k: c[cols[k]] if cols.get(k) is not None and len(c) > cols[k] else "" + out["phases"].append({"phase": c[0], "start": g("start"), "min": _num(g("min")), "min_txt": g("min"), "tok": _num(g("tok")), "tok_txt": g("tok"), "runs": g("runs")}) + if out["phases"]: out["sources"].append("docs/progress/TIME-LEDGER.md") + rows = build_log_rows(proj) + if rows: + out["exec_rows"] = len(rows); out["exec_blocked"] = sum(1 for r in rows if re.search(r"blocked|not-run|\U0001F6AB", r[2])) + ts = [r[0] for r in rows]; out["first"], out["last"] = ts[0], ts[-1] + out["active_s"] = sum(b - a for a, b in zip(ts, ts[1:]) if b - a <= IDLE_GAP_S) + out["sources"].append("docs/BUILD-LOG.md") + ml = read(os.path.join(proj, ".claude", "loop", "metrics-ledger.tsv")).splitlines() + if len(ml) > 1: + h = ml[0].split("\t") + if "elapsed_s" in h: + k = h.index("elapsed_s"); v = [float(r.split("\t")[k]) for r in ml[1:] if len(r.split("\t")) > k and re.match(r"^[\d.]+$", r.split("\t")[k])] + if v: out["exec_s"] = sum(v); out["exec_n"] = len(v); out["sources"].append(".claude/loop/metrics-ledger.tsv") + return out if out["sources"] else None + +def load_build_plan(path, proj=None): + """The plan counts. build-plan.json is a derived copy of build-plan.md; when it is older than the md or than the + newest docs/BUILD-LOG.md row, the md is read instead (and the page says so), never a stale JSON with 0 built.""" + proj = proj or os.path.dirname(os.path.dirname(os.path.abspath(path))) + md = os.path.join(proj, "architecture", "build-plan.md") + try: d = json.load(open(path)); jt = os.path.getmtime(path) + except Exception: d = None; jt = None + mdp = parse_plan_md(md) if os.path.isfile(md) else [] + log_t = (build_log_rows(proj) or [(0,)])[-1][0] + stale = jt is not None and ((os.path.isfile(md) and os.path.getmtime(md) > jt) or log_t > jt) + if mdp and (d is None or stale): + bp = finish_plan(mdp, proj, datetime.datetime.fromtimestamp(os.path.getmtime(md)).strftime("%Y-%m-%d %H:%M"), "md") + if d is not None: bp["note"] = "plan JSON was stale, counted from build-plan.md" + (" (build-plan.json is older than build-plan.md)" if os.path.getmtime(md) > jt else " (build-plan.json is older than the newest BUILD-LOG.md row)") + return bp + if d is None: return None + ph = [(p["id"], p["name"], p.get("steps", [])) for p in d.get("phases", [])] + bp = finish_plan([(i, n, [{"number": x.get("number"), "kind": x.get("kind"), "step": x.get("step"), "state": x.get("state")} for x in st]) for i, n, st in ph], proj, d.get("generatedAt"), "json") + if stale: bp["note"] = "plan JSON is older than the newest BUILD-LOG.md row and there is no readable build-plan.md to recount from; built counts may lag" + return bp + +# ----------------------------------------------------------------- analysis +def zero(): return {"in": 0, "out": 0, "cr": 0, "cw": 0, "cost": 0.0, "n": 0} +def add(a, m, rates): + for k in ("in", "out", "cr", "cw"): a[k] += m[k] + c = cost_of(m, m["model"], rates); a["n"] += 1 + if c is None: a["unpriced"] = True + else: a["cost"] += c +def head(a): return a["in"] + a["out"] + a["cw"] + +def analyse(proj, info, gate, manifest, arts, main, agents, rates, bp): + all_t = main["times"] + [x for a in agents for x in (a["t0"], a["t1"])] + t0, t1 = min(all_t), max(all_t) + sig = stage_boundaries(main, agents, info); wins = build_windows(sig, t0, t1) + S = {s: {"main": zero(), "sub": zero(), "wf": zero(), "agents": [], "wf_n": 0, "min": collections.Counter()} for s in ["pre"] + STAGES} + for s_ in S: S[s_]["ctx"] = [] + mctx = [] + for m in main["msgs"]: + if m["t"] is not None: + add(S[stage_at(wins, m["t"])]["main"], m, rates) + c_ = m["in"] + m["cr"] + m["cw"]; S[stage_at(wins, m["t"])]["ctx"].append(c_); mctx.append((m["t"], c_)) + for a in agents: + a["stage"] = agent_stage(a, wins); a["tok"] = zero() + cx = [m["in"] + m["cr"] + m["cw"] for m in a["msgs"]] or [0] + a["ctx_avg"] = sum(cx) / len(cx); a["ctx_peak"] = max(cx) + for m in a["msgs"]: add(a["tok"], m, rates) + st = S[a["stage"]]; st["agents"].append(a) + add(st["wf" if a["wf"] else "sub"], {**{k: a["tok"][k] for k in ("in", "out", "cr", "cw")}, "model": "__sum__"}, rates) if False else None + tgt = st["wf" if a["wf"] else "sub"] + for k in ("in", "out", "cr", "cw", "cost", "n"): tgt[k] += a["tok"][k] + if a["tok"].get("unpriced"): tgt["unpriced"] = True + # minute states + ev = sorted(set(int(x) for x in main["times"]) | set(int(x) for a in agents for x in a["times"])); idle = [] + for x, y in zip(ev, ev[1:]): + if y - x > IDLE_GAP_S: idle.append((x, y)) + sub_iv = [(a["t0"], a["t1"]) for a in agents] + minutes = []; m0 = int(t0 // 60) + for k in range(int((t1 - t0) // 60) + 1): + a = (m0 + k) * 60; mid = a + 30 + st = "sub" if any(x <= mid <= y for x, y in sub_iv) else "idle" if any(x < mid < y for x, y in idle) else "main" + minutes.append((a, st)); S[stage_at(wins, a)]["min"][st] += 1 + # token buckets of 10 minutes + buckets = collections.Counter() + for m in main["msgs"] + [m for a in agents for m in a["msgs"]]: + if m["t"] is not None: buckets[int((m["t"] - t0) // 600)] += m["in"] + m["out"] + m["cw"] + return {"mctx": sorted(mctx), "t0": t0, "t1": t1, "wins": wins, "sig": sig, "S": S, "minutes": minutes, "buckets": buckets, "idle": idle} + +# ----------------------------------------------------------------- advice +def advice(info, an, main, agents, rates): + S = an["S"]; out = [] + tot = zero() + for s in S.values(): + for part in ("main", "sub", "wf"): + for k in ("in", "out", "cr", "cw", "cost"): tot[k] += s[part][k] + htot = head(tot) + stage_head = {k: head(v["main"]) + head(v["sub"]) + head(v["wf"]) for k, v in S.items()} + top = max(stage_head, key=stage_head.get) + if htot: + lab = "before Stage P" if top == "pre" else "Stage " + top + out.append(("Where tokens went", "%s used %.0f%% of the new tokens (input + output + cache write): %s of %s." % (lab, pct(stage_head[top], htot), fmt_tok(stage_head[top]), fmt_tok(htot)))) + subs = [a for a in agents]; sub_tok = sum(head(a["tok"]) for a in subs) + out.append(("Sub-agent share", "%d sub-agents carried %.0f%% of new tokens and %.0f%% of estimated cost; the main thread carried the rest." % + (len(subs), pct(sub_tok, htot), pct(sum(a["tok"]["cost"] for a in subs), tot["cost"])))) + opus_a = [a for a in subs if "opus" in a["model"]] + rule = [d for d in info["decisions"] if "ubagent" in d[1] or "Subagent" in d[1]] + if opus_a: + oc = sum(a["tok"]["cost"] for a in opus_a) + txt = "%d sub-agents ran on Opus (%s, est. $%.0f, %.0f%% of sub-agent cost): %s." % (len(opus_a), ", ".join(esc(a["desc"]) for a in opus_a[:4]), oc, pct(oc, sum(a["tok"]["cost"] for a in subs)), "") + if rule: txt += " The register says \"%s\"; consider Sonnet for any that are not design or judgment work." % rule[0][1] + out.append(("Model choice", txt.replace(": .", "."))) + mc = collections.Counter(); + for m in main["msgs"]: mc[m["model"]] += 1 + mm = mc.most_common(1)[0] if mc else None + if mm and "opus" in mm[0]: + mcost = sum(S[s]["main"]["cost"] for s in S) + out.append(("Main thread model", "The main thread ran on %s for %d requests (est. $%.0f, %.0f%% of total). Routine orchestration turns could run on a cheaper model; keep the expensive one for review and judgment." % (mm[0], mm[1], mcost, pct(mcost, tot["cost"])))) + peak = max([m["in"] + m["cw"] + m["cr"] for m in main["msgs"]] or [0]) + comp = main["compacts"] + cw_, cn_ = context_window(main["msgs"], main["compacts"], main.get("flag_1m", False)) + ctxt = "Peak main-thread context was %s tokens (%s)." % (fmt_tok(peak), ("%s of the %s window for this model%s" % (ctx_pct_txt(peak, cw_), fmt_tok(cw_), "; " + cn_ if cn_ else "")) if cw_ else "window unknown: " + cn_) + if comp: ctxt += " Auto-compacted %d times (at %s tokens), each time dropping the context to about %s." % (len(comp), ", ".join(fmt_tok(c["pre"] or 0) for c in comp), fmt_tok(sum(c["post"] or 0 for c in comp) / len(comp))) + out.append(("Context size", ctxt + (" Hand bulk reading to sub-agents and park or clear at a stage boundary, before the next auto-compaction." if comp else ""))) + inp = tot["in"] + tot["cw"] + tot["cr"]; hit = pct(tot["cr"], inp) + out.append(("Cache hit ratio", "%.0f%% of all input tokens were served from cache. %s" % (hit, "Healthy." if hit >= 85 else "Low: long idle gaps or changing prompt prefixes are rewriting the cache."))) + reqs = sorted(main["msgs"], key=lambda m: m["t"] or 0); rew = 0; rewtok = 0 + for a, b in zip(reqs, reqs[1:]): + if b["t"] and a["t"] and b["t"] - a["t"] > CACHE_TTL_S and b["cw"] > 50_000: rew += 1; rewtok += b["cw"] + if rew: out.append(("Cache rewrites after waiting", "%d times the main thread resumed after more than %d minutes and rewrote %s tokens of cache. Waiting on the user lets the 5 minute cache expire; batch questions and answer in one go." % (rew, CACHE_TTL_S // 60, fmt_tok(rewtok)))) + idle_s = sum(y - x for x, y in an["idle"]); wall = an["t1"] - an["t0"] + out.append(("Waiting time", "%s of %s wall time (%.0f%%) was gaps longer than %d minutes, i.e. waiting for the user." % (fmt_dur(idle_s), fmt_dur(wall), pct(idle_s, wall), IDLE_GAP_S // 60))) + return out, tot, peak + +def _union(iv): + iv = sorted(x for x in iv if x[1] > x[0]); out = [] + for a_, b_ in iv: + if out and a_ <= out[-1][1]: out[-1][1] = max(out[-1][1], b_) + else: out.append([a_, b_]) + return out +def _total(iv): return sum(b_ - a_ for a_, b_ in iv) +def _median(v): + v = sorted(v); return v[len(v) // 2] if v else 0 +def stage_label(an, t): + s_ = stage_at(an["wins"], t) + return "before Stage P" if s_ == "pre" else "Stage " + s_ + +def call_stats(info, an, main): + """Per tool call numbers: health, speed and the raw groups the tips read. Numbers and tool or script names only.""" + calls = [c for c in main.get("calls", []) if c["t0"]] + done = [c for c in calls if c["t1"]] + for c in done: c["dur"] = max(0.0, c["t1"] - c["t0"]) + med = {} + for n in {c["name"] for c in done}: med[n] = _median([c["dur"] for c in done if c["name"] == n]) + inh = lambda c: c["name"] == "Bash" and bool(c["script"]) and bool(re.match(ADV["inherent_scripts"], c["script"])) # required gates and builds never count as hangs + failed = [c for c in done if c["err"]] + killed = [c for c in calls if (c["killed"] or c["timeout"]) and not inh(c)] + hung = [c for c in done if c["dur"] >= ADV["hang_min_s"] and c["dur"] >= ADV["hang_x"] * max(med.get(c["name"], 0), 1) and not c["waity"] and not c["bg"] and c["name"] not in INHERENT_TOOLS and not inh(c)] + hung_all = {id(c): c for c in hung + killed}.values() + keyed = collections.OrderedDict() + for label, rx in KEY_SCRIPTS: + rs = [c for c in done if c["name"] == "Bash" and c["script"] and re.match(rx, c["script"])] + keyed[label] = {"runs": len(rs), "fail": sum(1 for c in rs if c["err"]), "calls": rs} + # time split of the whole session, approximate + t0, t1 = an["t0"], an["t1"]; wall = max(t1 - t0, 1) + idle = _union([list(x) for x in an["idle"]]) + ci = _union([[c["t0"], c["t1"]] for c in done if c["waity"] or c["bg"]]) + ask = [[c["t0"], c["t1"]] for c in done if c["name"] in INHERENT_TOOLS] # interview time is waiting on the user + tool = _union([[c["t0"], c["t1"]] for c in done if not (c["waity"] or c["bg"]) and c["name"] not in ("Agent", "Task") and c["name"] not in INHERENT_TOOLS]) + ci_s = _total(ci); tool_s = _total(tool); idle_s = _total(_union(idle + ask)) + think_s = max(0.0, wall - idle_s - ci_s - tool_s) + # slices: one exec.sh run + sl = [c for c in done if c["name"] == "Bash" and c["script"] == "exec.sh"] + sl_med = _median([c["dur"] for c in sl]) + sl_out = [c for c in sl if sl_med and c["dur"] > ADV["slice_outlier_x"] * sl_med] + slow = sorted([c for c in done if c["name"] not in ("Agent", "Task") and not c["waity"] and not c["bg"] and c["name"] not in INHERENT_TOOLS], key=lambda c: -c["dur"])[:3] + return {"calls": calls, "done": done, "med": med, "failed": failed, "killed": killed, "hung": list(hung_all), "keyed": keyed, + "split": {"tool": tool_s, "think": think_s, "user": idle_s, "ci": ci_s, "wall": wall}, + "slices": sl, "slice_med": sl_med, "slice_out": sl_out, "slow": slow} + +def callname(c): + if c["name"] == "Bash" and c["script"]: return c["script"] + ((" " + c["variant"]) if c.get("variant") else "") + return c["name"] or "tool" + +def run_health(cs): + """Green, amber or red per measure, each with its counts. Red is rare: a key script failing on every run (at least + repeat_fail_red_n runs) or one hang or stall past hang_red_s. Amber: a failure rate from fail_share_amber, or a hang past hang_amber_s.""" + nd = len(cs["done"]); share = len(cs["failed"]) / nd if nd else 0.0 + items = [("Tool calls failed", "amber" if share >= ADV["fail_share_amber"] else "green", "%d of %d (%.0f%%)" % (len(cs["failed"]), nd, 100 * share))] + kl = []; worst = "green"; rk = {"green": 0, "amber": 1, "red": 2} + for label, v in cs["keyed"].items(): + if not v["runs"]: continue + rate = v["fail"] / v["runs"] + l_ = "red" if (v["fail"] == v["runs"] and v["runs"] >= ADV["repeat_fail_red_n"]) else "amber" if rate >= ADV["fail_share_amber"] else "green" + kl.append("%s %d/%d passing" % (label, v["runs"] - v["fail"], v["runs"])) + if rk[l_] > rk[worst]: worst = l_ + items.append(("Key scripts", worst if kl else "green", "; ".join(kl) or "none ran (doctor, gate-check, exec, tests)")) + longest = max([c.get("dur", 0.0) for c in cs["hung"]] + [c.get("dur", 0.0) for c in cs["killed"] if c.get("dur") is not None] + [0.0]) + nh = len({id(c) for c in list(cs["hung"]) + list(cs["killed"])}) + items.append(("Hung or killed", "red" if longest > ADV["hang_red_s"] else "amber" if longest > ADV["hang_amber_s"] else "green", + "%d command(s)%s" % (nh, ", longest %s" % fmt_dur(longest) if longest else ""))) + overall = max((i[1] for i in items), key=lambda x: rk[x]) + return overall, items + +def advisory(info, an, main, agents, rates, tot, peak): + """Ranked, rule-based tips on tokens, speed and health. Each: impact (minutes-equivalent), ratio over its threshold, + what was seen, why it matters, one fix, why it fired. Returns (fired, quiet, health, speed).""" + S = an["S"]; fired = []; quiet = [] + cs = call_stats(info, an, main) + cost_all = sum(S[s][p]["cost"] for s in S for p in ("main", "sub", "wf")) or 1e-9 + proj = info["dir"] + def rel(f): + try: r = os.path.relpath(f, proj) + except ValueError: r = f + return os.path.basename(f) if r.startswith("..") else r + GROUP = {"tokens": "Tokens and cost", "speed": "Speed", "health": "Health"} + hidden = [0] + def rule(ratio, kind, title, seen, matters, fix, why, lost_s=0.0, lost_tok=0.0, group=None, sev=None, inherent=False): + """inherent: the cost comes from how the toolkit or mxcli works (builds, required gates and reviews, the mandated review model, waiting on builds or the user). Never shown.""" + if ratio >= 1 and inherent: hidden[0] += 1; return + if ratio >= 1 and not lost_tok and 0 < lost_s < ADV["min_lost_s"]: ratio = 0 # a tip that loses under a minute is noise + d = {"ratio": ratio, "kind": kind, "group": group or GROUP[kind], "sev": sev or ("amber" if ratio >= 2 else "info"), "title": title, "seen": seen, "matters": matters, "fix": fix, "why": why, + "lost_s": lost_s, "lost_tok": lost_tok, "impact": lost_s / 60.0 + lost_tok / TOKENS_PER_MIN} + (fired if ratio >= 1 else quiet).append(d) + lostfmt = lambda ls, lt: " / ".join(x for x in (fmt_dur(ls) if ls else "", fmt_tok(lt) + " tokens" if lt else "") if x) + # ---- tokens + opus_main = sum(S[s]["main"]["cost"] for s in S) if any("opus" in m["model"] for m in main["msgs"]) else 0 + oa = [a for a in agents if "opus" in a["model"]]; opus_sub = sum(a["tok"]["cost"] for a in oa) + share = (opus_main + opus_sub) / cost_all + rule(share / ADV["opus_cost_share"], "tokens", "Model mix: Opus carries most of the cost", + "Opus is %.0f%% of estimated cost ($%.0f of $%.0f): main thread $%.0f, %d sub-agents $%.0f (%s)." % (100 * share, opus_main + opus_sub, cost_all, opus_main, len(oa), opus_sub, ", ".join(a["desc"] for a in oa[:3])), + "The most capable model is the slowest and dearest; routine batches do not need it.", + "Keep Opus for architecture, build plan and review. Run routine batches (BRD drafting, extraction, validation) on a cheaper model.", + "Fired when the Opus share of cost is at least %.0f%% (opus_cost_share). Measured %.0f%%." % (100 * ADV["opus_cost_share"], 100 * share), + lost_tok=sum(head(a["tok"]) for a in oa), inherent=True) + mc = [m["in"] + m["cr"] + m["cw"] for m in main["msgs"]] or [0]; avg = sum(mc) / len(mc) + win, wnote = context_window(main["msgs"], main["compacts"], main.get("flag_1m", False)); comp = len(main["compacts"]) + big = [c for c in cs["done"] if c["name"] == "Read" and c["rchars"] >= ADV["large_read_chars"]] + reached = bool(win) and peak >= ADV["ctx_peak_frac"] * win + r = max(avg / ADV["ctx_avg_tokens"], 1.0 if reached else 0.0, comp / ADV["compactions"] if ADV["compactions"] else 0, len(big) / ADV["large_read_n"]) + csev = "amber" if (reached or comp or avg >= ADV["ctx_avg_tokens"]) else "info" # context is never red + ctitle = ("Context: the window was reached and compacted %d time(s)" % comp if (reached and comp) else "Context: the window was reached" if reached + else "Context: compacted %d time(s)" % comp if comp else "Context: the main thread runs large" if avg >= ADV["ctx_avg_tokens"] else "Context: large file reads") + rule(r, "tokens", ctitle, + "Average context %s, peak %s (%s); %d automatic compaction(s); %d file read(s) over %s characters." % + (fmt_tok(avg), fmt_tok(peak), ("%s window, %s avg, %s peak" % (fmt_tok(win), ctx_pct_txt(avg, win), ctx_pct_txt(peak, win)) + ("; " + wnote if wnote else "")) if win else "window unknown: " + wnote, comp, len(big), fmt_tok(ADV["large_read_chars"])), + "Every request re-reads the whole context, so a full window costs more on every later turn, and compaction loses detail.", + "Hand bulk reading (source files, long logs) to sub-agents so only a summary returns, and park or clear at each stage boundary instead of waiting for auto-compaction.", + "Fired when average context reaches %s, the peak reaches %.0f%% of the window, compactions reach %d, or %d large reads occur (ctx_avg_tokens, ctx_peak_frac, compactions, large_read_n)." % (fmt_tok(ADV["ctx_avg_tokens"]), 100 * ADV["ctx_peak_frac"], ADV["compactions"], ADV["large_read_n"]), + lost_tok=sum(c["rchars"] for c in big) / 4.0, group="Context", sev=csev) + reqs = sorted(main["msgs"], key=lambda m: m["t"] or 0); rew = 0; rewtok = 0; rewcost = 0.0 + for a, b in zip(reqs, reqs[1:]): + if b["t"] and a["t"] and b["t"] - a["t"] > CACHE_TTL_S and b["cw"] > 50_000: + rew += 1; rewtok += b["cw"]; rt = rate_for(b["model"], rates); rewcost += b["cw"] * rt[1] / 1e6 if rt else 0 + rule(rew / ADV["cache_rewrites"], "tokens", "Cache rewrites after waiting", + "%d resumptions after more than %d minutes rewrote %s tokens of cache (about $%.0f at cache write price)." % (rew, CACHE_TTL_S // 60, fmt_tok(rewtok), rewcost), + "A cache that expired while the session waited is paid for again in full on the next request.", + "Batch answers: let the agent collect questions and answer them in one sitting, and avoid leaving a long context idle past the 5 minute cache lifetime.", + "Fired when at least %d such rewrites occur (cache_rewrites). Counted %d." % (ADV["cache_rewrites"], rew), lost_tok=rewtok, inherent=True) + sc = {s: sum(S[s][p]["cost"] for p in ("main", "sub", "wf")) for s in STAGES if sum(S[s][p]["cost"] for p in ("main", "sub", "wf")) > 0} + if len(sc) >= 3: + vals = sorted(sc.values()); med_ = vals[len(vals) // 2]; top = max(sc, key=sc.get); x = sc[top] / med_ if med_ else 0 + stok = {s: sum(head(S[s][p]) for p in ("main", "sub", "wf")) for s in sc} + rule(x / ADV["stage_outlier_x"], "tokens", "Cost outlier: Stage %s" % top, + "Stage %s cost $%.0f, %.1fx the median stage ($%.0f)." % (top, sc[top], x, med_), + "One stage that dwarfs the rest usually hides repeated reading or an over-sized agent.", + "Check what dominated Stage %s (its card and the sub-agent table). If it was repeated reading, give that work a cheaper model or a smaller context." % top, + "Fired when a stage costs at least %.1fx the median stage (stage_outlier_x). Measured %.1fx." % (ADV["stage_outlier_x"], x), + lost_tok=max(0, stok[top] - sorted(stok.values())[len(stok) // 2]), inherent=True) + # ---- sub-agents + short = [a for a in agents if a["t1"] - a["t0"] < ADV["agent_short_s"]]; long_ = [a for a in agents if a["t1"] - a["t0"] > ADV["agent_long_s"]] + tpa = sum(head(a["tok"]) for a in agents) / len(agents) if agents else 0 + r = max(len(short) / ADV["short_agents_n"], len(long_) / ADV["long_agents_n"]) + lg = max(agents, key=lambda a: a["t1"] - a["t0"]) if agents else None + rule(r, "tokens", "Sub-agent use: many very short or very long agents", + "%d agents averaging %s new tokens each; %d ran under %d s (spawn overhead dominates); %d ran over %d min (longest: %s, %s)." % (len(agents), fmt_tok(tpa), len(short), ADV["agent_short_s"], len(long_), ADV["agent_long_s"] // 60, lg["desc"] if lg else "none", fmt_dur(lg["t1"] - lg["t0"]) if lg else "0"), + "Tiny agents pay start-up cost for no gain, and a very long one loses everything if it fails late.", + "Merge tiny tasks into one agent, and split agents that run past 30 minutes into checkpointed pieces.", + "Fired when at least %d agents are under %d s or %d agents over %d s." % (ADV["short_agents_n"], ADV["agent_short_s"], ADV["long_agents_n"], ADV["agent_long_s"]), + lost_s=sum(a["t1"] - a["t0"] for a in short), group="Sub-agents") + wall = an["t1"] - an["t0"]; idle_s = sum(y - x for x, y in an["idle"]); ws = idle_s / wall if wall else 0 + rule(ws / ADV["wait_share"], "speed", "Waiting on the user", + "%s of %s wall time (%.0f%%) was gaps longer than %d minutes between turns." % (fmt_dur(idle_s), fmt_dur(wall), 100 * ws, IDLE_GAP_S // 60), + "Waiting is wall time nobody can speed up, and it also lets the cache expire.", + "Queue the questions for the next checkpoint and let the agent work on something independent while the answer is pending.", + "Fired when waiting is at least %.0f%% of wall time (wait_share). Measured %.0f%%." % (100 * ADV["wait_share"], 100 * ws), lost_s=idle_s, inherent=True) + inp = tot["in"] + tot["cw"] + tot["cr"]; hit = tot["cr"] / inp if inp else 1 + rule((1 - hit) / (1 - ADV["cache_hit_min"]) if hit < 1 else 0, "tokens", "Cache hit ratio is low", "Only %.0f%% of input tokens came from cache." % (100 * hit), + "Cache reads cost a fraction of fresh input, so every miss is paid in full.", + "Keep the system prompt and early context stable; avoid editing earlier turns or swapping tools mid-session.", + "Fired when the cache hit ratio is below %.0f%% (cache_hit_min). Measured %.0f%%." % (100 * ADV["cache_hit_min"], 100 * hit), lost_tok=max(0, inp * (ADV["cache_hit_min"] - hit)), inherent=True) + # ---- health + hg = cs["hung"]; med = cs["med"] + if hg or True: + names = collections.Counter(callname(c) for c in hg) + longest = max(hg, key=lambda c: c.get("dur", 0)) if hg else None + excess = sum(max(0.0, c.get("dur", 0) - med.get(c["name"], 0)) for c in hg) + stg = sorted({stage_label(an, c["t0"]) for c in hg}) + rule(len(hg) / ADV["hang_n"] if ADV["hang_n"] else 0, "health", "Hanging or killed commands", + "%d command(s) hung, timed out or were stopped (%s)%s; %s." % (len(hg), ", ".join("%s x%d" % kv for kv in names.most_common(4)) or "none", + (", longest %s in %s" % (fmt_dur(longest["dur"]), callname(longest))) if longest else "", ", ".join(stg) or "no stage"), + "A hung command blocks the whole session until it times out, and the time is lost even when the work is then redone.", + "Give long commands a timeout and run them in the background; if the same one hangs twice, fix or file the tool instead of retrying.", + "Fired when %d or more calls ran past %d s and %dx their tool's median, timed out, or were stopped (hang_n, hang_min_s, hang_x)." % (ADV["hang_n"], ADV["hang_min_s"], ADV["hang_x"]), lost_s=excess + 60.0 * len([c for c in hg if "dur" not in c]), sev="red" if (longest and longest.get("dur", 0) > ADV["hang_red_s"]) else "amber") + byfail = collections.defaultdict(list) + for c in cs["failed"]: + if c["name"] == "Bash" and c["script"] and c["script"] not in TRIVIAL_CMDS and (SCRIPT_FILE_RE.search(c["script"]) or c["script"] in REAL_CMDS): byfail[(c["script"], c.get("variant", ""))].append(c) # real scripts and tools only, by name + worst = max(byfail.items(), key=lambda kv: len(kv[1])) if byfail else None + if worst: + (sn, var), fl = worst; nm = sn + ((" " + var) if var else "") + tot_runs = len([c for c in cs["done"] if c["name"] == "Bash" and c["script"] == sn and c.get("variant", "") == var]) + stg = collections.Counter(stage_label(an, c["t0"]) for c in fl) + others = ", ".join("%s x%d" % ((k[0] + (" " + k[1] if k[1] else "")), len(v)) for k, v in sorted(byfail.items(), key=lambda kv: -len(kv[1]))[1:4]) + rule(len(fl) / ADV["repeat_fail_n"], "health", "Failing tool on repeat: %s" % nm, + "%s failed %d of %d runs (%s)%s." % (nm, len(fl), tot_runs, ", ".join("%s x%d" % kv for kv in stg.most_common(3)), ("; also failing: " + others) if others else ""), + "A tool that fails repeatedly is either broken or being rerun without changing its input, and each rerun costs the call plus the turn that reads its output.", + "Read the failure once, fix the cause or the script, and file the tool if the failure is not yours. Stop rerunning it unchanged.", + "Fired when the same script fails %d or more times (repeat_fail_n). Counted %d." % (ADV["repeat_fail_n"], len(fl)), + lost_s=sum(c.get("dur", 0) for c in fl), sev="red" if (len(fl) == tot_runs and tot_runs >= ADV["repeat_fail_red_n"] and any(re.match(rx, sn) for _, rx in KEY_SCRIPTS)) else "amber") + else: + rule(0, "health", "Failing tool on repeat", "no script failed repeatedly", "", "", "Fired when the same script fails %d or more times (repeat_fail_n). Counted 0." % ADV["repeat_fail_n"]) + edits = collections.defaultdict(list) + for c in cs["calls"]: + if c["file"]: edits[c["file"]].append(c["t0"]) + best = (0, None, 0.0) + for f, ts_ in edits.items(): + ts_.sort(); j = 0 + for i in range(len(ts_)): + while ts_[i] - ts_[j] > ADV["edit_loop_window_s"]: j += 1 + if i - j + 1 > best[0]: best = (i - j + 1, f, ts_[i] - ts_[j]) + rule(best[0] / ADV["edit_loop_n"], "health", "Rework loop: one file edited again and again", + "%s was edited %d times within %s%s." % (rel(best[1]) if best[1] else "no file", best[0], fmt_dur(best[2]), (" (" + stage_label(an, edits[best[1]][0]) + ")") if best[1] else ""), + "Many small edits to one file usually mean the target is unclear or each attempt is checked too late.", + "Write down what the file must do and the check that proves it before the next edit, then edit once and run the check.", + "Fired when one file is edited %d or more times inside %d minutes (edit_loop_n, edit_loop_window_s). Peak %d." % (ADV["edit_loop_n"], ADV["edit_loop_window_s"] // 60, best[0]), + lost_s=best[2]) + # ---- speed + wy = [c for c in cs["done"] if c["waity"] or c["bg"]]; wy_s = _total(_union([[c["t0"], c["t1"]] for c in wy])) + rule(wy_s / ADV["ci_wait_s"], "speed", "Waiting on CI or background work", + "%s spent in sleep, watch or monitor calls (%d call(s))." % (fmt_dur(wy_s), len(wy)), + "Time spent polling is time the session is not building.", + "Start the next independent step while the check runs, and poll less often.", + "Fired when waiting calls total at least %d s (ci_wait_s). Measured %d s." % (ADV["ci_wait_s"], wy_s), lost_s=wy_s, inherent=True) + byscript = collections.defaultdict(list) + for c in cs["done"]: + if c["name"] == "Bash" and c["script"] and not c["waity"] and not c["bg"]: byscript[c["script"]].append(c["dur"]) + slow = [(sn, v) for sn, v in byscript.items() if len(v) >= ADV["slow_script_runs"] and _median(v) >= ADV["slow_script_s"] and sum(v) >= ADV["slow_script_total_s"]] + slow = [(k, v) for k, v in slow if not re.match(ADV["inherent_scripts"], k)] # exec, gate checks, builds: how the toolkit works + sw = max(slow, key=lambda kv: sum(kv[1])) if slow else None + rule((sum(sw[1]) / ADV["slow_script_total_s"]) if sw else 0, "speed", "Slow on every run: %s" % (sw[0] if sw else "no script"), + "%s ran %d times, median %s, %s in total%s." % (sw[0], len(sw[1]), fmt_dur(_median(sw[1])), fmt_dur(sum(sw[1])), "; also slow: " + ", ".join(k for k, _ in slow if k != sw[0]) if len(slow) > 1 else "") if sw else "no script was slow on every run", + "A script that is slow each time is paid for in full every time it runs.", + "Cache what does not change between runs, or run it less often (per module and before deploys, not after every edit).", + "Fired when a script runs %d or more times, median at least %d s, total at least %d s (slow_script_*)." % (ADV["slow_script_runs"], ADV["slow_script_s"], ADV["slow_script_total_s"]), + lost_s=(sum(sw[1]) - _median(sw[1])) if sw else 0) + ag = sorted([c for c in cs["done"] if c["name"] in ("Agent", "Task") and c["dur"] >= ADV["serial_agent_s"]], key=lambda c: c["t0"]) + chain = 0; best_chain = 0; ser_s = 0.0; cur_s = 0.0 + for p_, n_ in zip(ag, ag[1:]): + if 0 <= n_["t0"] - p_["t1"] < 60: + chain = chain + 1 if chain else 2; cur_s += min(p_["dur"], n_["dur"]) + if chain > best_chain: best_chain, ser_s = chain, cur_s + else: chain = 0; cur_s = 0.0 + rule(best_chain / ADV["serial_agents_n"], "speed", "Serial sub-agents that could have overlapped", + "%d sub-agents of at least %d s ran one after another with no overlap (about %s of overlappable time)." % (best_chain, ADV["serial_agent_s"], fmt_dur(ser_s)), + "Independent agents started together finish in the time of the slowest one.", + "Start independent sub-agents in one message so they run in parallel, and keep the serial order only where one needs another's result.", + "Fired when %d or more sub-agents of %d s or more run back to back (serial_agents_n, serial_agent_s). Counted %d." % (ADV["serial_agents_n"], ADV["serial_agent_s"], best_chain), lost_s=ser_s, group="Sub-agents") + full = [c for c in cs["done"] if c["name"] == "Bash" and c["script"] in FULL_TEST_SCRIPTS] + fmed = _median([c["dur"] for c in full]) + rule((len(full) / ADV["full_test_n"]) if fmed >= ADV["full_test_s"] else 0, "speed", "Full test runs where impacted tests would do", + "The full suite ran %d times, median %s, %s in total." % (len(full), fmt_dur(fmed), fmt_dur(sum(c["dur"] for c in full))), + "Every full run re-proves code that did not change.", + "Run only the tests that touch the change after each slice, the whole suite at build complete and before a deploy.", + "Fired when the full suite runs %d or more times with a median of %d s or more (full_test_n, full_test_s)." % (ADV["full_test_n"], ADV["full_test_s"]), + lost_s=sum(c["dur"] for c in full) - (fmed if full else 0)) + so = cs["slice_out"] + rule(len(so) / ADV["slice_outlier_n"] if cs["slice_med"] else 0, "speed", "Slow slices", + "%d exec.sh run(s) took more than %.0fx the median slice (median %s over %d slices; slowest %s)." % (len(so), ADV["slice_outlier_x"], fmt_dur(cs["slice_med"]), len(cs["slices"]), fmt_dur(max([c["dur"] for c in so] or [0]))), + "An outlier slice is where the time went; the rest are routine.", + "Open the outlier's BUILD-LOG row: if it was a gate or build repeating, fix that step; if it was one large script, split it.", + "Fired when %d or more slices exceed %.1fx the median (slice_outlier_x, slice_outlier_n)." % (ADV["slice_outlier_n"], ADV["slice_outlier_x"]), + lost_s=sum(c["dur"] - cs["slice_med"] for c in so), inherent=True) + fired.sort(key=lambda d: (-d["impact"], -d["ratio"])) + for d in fired + quiet: + d["lost"] = lostfmt(d["lost_s"], d["lost_tok"]) + health = run_health(cs) + return fired[:ADV["max_items"]], quiet + fired[ADV["max_items"]:], health, cs, hidden[0] + +JS = "" +# ----------------------------------------------------------------- render +CSS = """ +:root{--bg:#f6f7f9;--card:#fff;--ink:#1c2430;--mut:#667085;--line:#e3e7ee;--accent:#2f6fed;--ok:#1f9d63;--warn:#d98a0b;--bad:#c93c3c;--idle:#d9dde5;--main:#2f6fed;--sub:#8b5cf6;--soft:#eef2fb;--shadow:0 1px 2px rgba(20,30,50,.06)} +@media(prefers-color-scheme:dark){:root{--bg:#12161c;--card:#1a2029;--ink:#e6eaf0;--mut:#98a2b3;--line:#2a3340;--accent:#6b9bff;--ok:#3fc283;--warn:#e6a23c;--bad:#ef6b6b;--idle:#2d3542;--main:#6b9bff;--sub:#a78bfa;--soft:#202838;--shadow:none}} +*{box-sizing:border-box}html{-webkit-text-size-adjust:100%} +body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif} +main{max-width:1280px;margin:0 auto;padding:16px} +h1{font-size:24px;margin:0 0 2px}h2{font-size:18px;margin:32px 0 4px}h3{font-size:15px;margin:0} +.sub{color:var(--mut);font-size:13px}.mut{color:var(--mut)} +a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline} +.card,.rail>*,.adv>*,.phases>*{min-width:0}.chip,dd,.sub,.adv p{overflow-wrap:anywhere}.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;box-shadow:var(--shadow)} +.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin:14px 0} +.kpi .v{font-size:22px;font-weight:650}.kpi .l{font-size:12px;color:var(--mut)} +.bar{height:8px;border-radius:5px;background:var(--idle);overflow:hidden}.bar>i{display:block;height:100%;background:var(--accent)} +.bar.big{height:14px}.bar.ok>i{background:var(--ok)} +.rail{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:12px;margin-top:10px} +.stage{display:flex;flex-direction:column;gap:8px;border-top:4px solid var(--idle)} +.stage.passed{border-top-color:var(--ok)}.stage.progress{border-top-color:var(--accent)}.stage.waived{border-top-color:var(--warn)}.stage.pre{opacity:.85} +.hd{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:4px 8px}.hd h3{min-width:0;flex:1 1 220px}.hd .sub{white-space:nowrap;overflow-wrap:normal} +.badge{font-size:11px;padding:2px 8px;border-radius:99px;background:var(--soft);white-space:nowrap;font-weight:600} +.passed .badge{color:var(--ok)}.progress .badge{color:var(--accent)}.waived .badge{color:var(--warn)} +dl{display:grid;grid-template-columns:auto minmax(0,1fr);gap:3px 10px;margin:0;font-size:13px}dt{color:var(--mut)}dd{margin:0;text-align:right} +.chips{display:flex;flex-wrap:wrap;gap:5px}.chip{font-size:12px;padding:2px 8px;border-radius:6px;background:var(--soft);display:inline-block} +.chip.miss{color:var(--mut);border:1px dashed var(--line);background:transparent} +.dec span{font-size:12px;padding:1px 7px;border-radius:5px;margin-right:4px;background:var(--soft)} +.c{color:var(--ok)}.a{color:var(--warn)}.r{color:var(--bad)} +details{margin-top:2px}summary{cursor:pointer;font-size:13px;color:var(--mut)} +table{border-collapse:collapse;width:100%;font-size:13px}th,td{padding:5px 8px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top} +th{font-size:12px;color:var(--mut);font-weight:600}td.n,th.n{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap} +.tw{overflow-x:auto} +svg text{fill:var(--ink);font-size:11px}svg .m{fill:var(--mut)} +.legend{display:flex;gap:14px;flex-wrap:wrap;font-size:12px;color:var(--mut);margin:6px 0}.legend b{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:5px;vertical-align:-1px} +.adv{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:10px}.adv .card{padding:12px}.adv h3{font-size:13px;color:var(--accent);margin-bottom:3px}.adv p{margin:0;font-size:13px} +.phases{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:10px} +.ph b{font-size:13px}.ph .k{font-size:11px;color:var(--mut);margin-top:4px} +.stack3{display:flex;height:8px;border-radius:5px;overflow:hidden;background:var(--idle)}.stack3>i{display:block;height:100%}.stack3 .b{background:var(--ok)}.stack3 .p{background:var(--warn)} +.ph.now{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}.nowtag{font-size:11px;color:var(--accent);font-weight:600;margin-left:4px} +.stale{color:var(--warn)}.r-partial td:last-child{color:var(--warn)}.r-built td:last-child{color:var(--ok)} +.stack{display:flex;height:16px;border-radius:5px;overflow:hidden;background:var(--idle)}.stack>i{display:block;height:100%} +.note{font-size:12px;color:var(--mut);margin:4px 0} +html{scroll-behavior:smooth}[id^=stage-],[id^=agents-],[id^=phase-],#tips{scroll-margin-top:56px} +.snav{position:sticky;top:0;z-index:20;background:var(--bg);border-bottom:1px solid var(--line);padding:8px 16px;display:flex;gap:6px;align-items:center;flex-wrap:wrap} +.snav b{font-size:12px;color:var(--mut);margin-right:4px}.snav a{min-width:34px;text-align:center;padding:4px 10px;border-radius:8px;background:var(--card);border:1px solid var(--line);font-weight:650;font-size:13px;color:var(--ink)} +.snav a.passed{border-color:var(--ok)}.snav a.cur{background:var(--accent);color:#fff;border-color:var(--accent)}.snav a:hover{text-decoration:none;border-color:var(--accent)} +.hd h3 a{color:inherit}.updated{display:inline-block;margin-top:6px;padding:3px 10px;border-radius:8px;background:var(--soft);font-size:13px;font-weight:600} +.layout{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:16px;align-items:start}.layout.solo{display:block}.content{min-width:0}.side{position:sticky;top:52px;max-height:calc(100vh - 64px);overflow:auto;padding-right:2px}.side h2{margin-top:0}.health{margin-top:8px}.health .hi{font-size:13px;margin-top:3px}.grp{margin-top:8px;border:1px solid var(--line);border-radius:10px;background:var(--card)}.grp>summary{cursor:pointer;padding:9px 12px;font-size:14px}.grp .speed,.grp>.split,.grp>div,.grp .tip{padding:0 12px 10px}.tip{border-top:1px solid var(--line);padding-top:10px}.tip h3{font-size:13px;margin:0 0 4px}.tip p{margin:0 0 4px;font-size:12.5px}.p-info{background:#8793a6;color:#fff}@media(max-width:1000px){.layout{grid-template-columns:minmax(0,1fr)}.side{position:static;max-height:none}}.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:4px}.p-green{background:#2e9e5b;color:#fff}.p-amber{background:#e0a100;color:#fff}.p-red{background:#d1383d;color:#fff}.pill{padding:1px 9px;margin-left:6px;border-radius:99px;font-size:12px}.speed{margin-top:8px;font-size:13px}.split{display:flex;gap:2px;margin:6px 0}.seg{background:var(--accent);color:#fff;font-size:11px;padding:3px 6px;border-radius:4px;white-space:nowrap;overflow:hidden}.seg:nth-child(2){opacity:.75}.seg:nth-child(3){background:var(--idle);color:var(--ink)}.seg:nth-child(4){opacity:.55}.muted{color:var(--mut);font-size:12px}\n.advl{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:10px;margin-top:8px} +.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} +.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)} +.frag .st-todo,.frag .pending{color:var(--mut)}.frag .st-person{color:#8b5cf6}.frag .st-unknown{color:var(--mut);font-style:italic} +.frag .FINDINGS,.frag .drift{color:var(--bad)}.frag .drift{font-size:12px}.frag .claims{font-size:12px}.frag .warn summary{color:var(--warn)} +.frag .badge{margin-left:6px;border:1px solid currentColor;background:transparent} +footer{margin-top:36px;font-size:13px;color:var(--mut)}footer li{margin:3px 0} +@media(max-width:520px){code,pre,.stage dd,.stage dt{overflow-wrap:anywhere;min-width:0}.stage dl{grid-template-columns:minmax(0,1fr)}.stage dd{text-align:left}.hd .sub{white-space:normal;flex:1 1 100%}.snav{padding:6px 12px;flex-wrap:nowrap;overflow-x:auto;max-width:100%}.snav a{flex:0 0 auto}.snav b{flex:0 0 auto}.updated{max-width:100%;overflow-wrap:anywhere}.advl{grid-template-columns:minmax(0,1fr)}.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.rail{grid-template-columns:minmax(0,1fr)}main{padding:12px}h1{font-size:20px}.kpi .v{font-size:19px}} +""" +KC = {"BRIEF": "#8b5cf6", "BUILD": "#2f6fed", "PROVE": "#1f9d63", "HARNESS": "#d98a0b", "RUN": "#c93c3c"} + +def stage_state(s, info, gate): + g = gate["rows"].get(s, {}).get("status", "") + if g == "WAIVED": return "waived" + if info["has_md"] and info["passed"] or info["current"]: + if s in info["passed"]: return "passed" + return "progress" if s == info["current"] else "pending" + return {"PASS": "passed", "PENDING": "pending"}.get(g, "pending") + +def ago(t): + d = time.time() - t + if d < 0: return "just now" + return ("%d min ago" % max(1, round(d / 60))) if d < 5400 else ("%.0f h ago" % (d / 3600)) if d < 172800 else ("%d days ago" % round(d / 86400)) + +def render_ledgers(w, lg): + w("

Time and effort from the project ledgers

source: %s; not a session log
" % ", ".join(esc(x) for x in lg["sources"])) + k = [] + if lg["phases"]: + mins = [p["min"] for p in lg["phases"] if p["min"] is not None]; toks = [p["tok"] for p in lg["phases"] if p["tok"]] + k.append(("Minutes logged", "%s" % fmt_dur(sum(mins) * 60) if mins else "n/a", "TIME-LEDGER.md (optional, hand written), finished phases")) + if toks: k.append(("Sub-agent tokens", fmt_tok(sum(toks)), "TIME-LEDGER.md; lead session not counted")) + w("
" + "".join("
%s
%s
%s
" % (esc(v), esc(l), esc(sm)) for l, v, sm in k) + "
") + if lg["phases"]: + w("
" + "".join( + "" % (esc(p["phase"]), esc(p["start"]), esc(p["min_txt"] or "-"), esc(p["tok_txt"] or "-"), esc(p["runs"] or "-")) for p in lg["phases"]) + "
PhaseStartMinutesSub-agent tokensExec runs
%s%s%s%s%s
") + w("
") + +def render(proj, info, gate, manifest, arts, main, agents, rates, bp, an, adv, tot, peak, args): + L = args.has_log + facts = exec_facts(proj, bp) + if not L: stage_switch(info, [], None) # no session log: the same function, from the exec log alone + def href(f): return urllib.parse.quote(os.path.relpath(f, args.outdir).replace(os.sep, "/")) + S = an["S"]; wall = (an["t1"] - an["t0"]) if L else 0 + if wall > 86400: TICK_FMT[0] = "%d %b %H:%M" + idle_s = sum(y - x for x, y in an["idle"]) if L else 0 + nsub = len(agents); nwf = sum(1 for a in agents if a["wf"]) + states = {s: stage_state(s, info, gate) for s in STAGES} + # progress per stage + prog = {}; owed_txt = {} + for s in STAGES: + owed = [o for o in arts[s]["owed"] if o["absence"] != "optin" or o["found"]] + have = sum(1 for o in owed if o["found"]); n = len(owed) + if states[s] in ("passed", "waived"): prog[s] = 100.0 + elif s == "5" and bp and bp["total"]: prog[s] = pct(bp["built"], bp["total"]) + elif states[s] == "progress": prog[s] = min(pct(have, n), 95.0) if n else 0.0 + else: prog[s] = 0.0 + owed_txt[s] = "%d of %d promised artifacts present" % (have, n) if n else "no manifest rows" + if s == "5" and bp and bp["total"] and states[s] not in ("passed", "waived"): owed_txt[s] = "%d of %d plan rows built%s" % (bp["built"], bp["total"], (", %d partial" % bp["partial"]) if bp["partial"] else "") + overall = sum(prog.values()) / len(STAGES) + npass = sum(1 for s in STAGES if states[s] == "passed") + dec = {s: collections.Counter(d[2] for d in info["decisions"] if d[0] == s) for s in STAGES} + cost_note = "estimate at placeholder list prices" + unp = any(S[s][p].get("unpriced") for s in S for p in ("main", "sub", "wf")) + def money(c): return "n/a" if unp else "$%.0f" % c + cost_all = sum(S[s][p]["cost"] for s in S for p in ("main", "sub", "wf")) + H = []; w = H.append + gen_epoch = time.time() + gen = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + w("") + w("") + w("Progress report: %s" % (esc(info["name"]), CSS)) + w("
" % ("" if L else " solo")) + last_ev = datetime.datetime.fromtimestamp(an["t1"]).strftime("%Y-%m-%d %H:%M") if L else "no session log" + w("

Progress report: %s

" % esc(info["name"])) + w("Last updated %s %s session log last event %s%s
" % (gen, datetime.datetime.now().astimezone().strftime("%Z"), last_ev, + " · Live · refreshes every 30 s · Pause" if args.reload else "")) + w("
Stage P to 6 · entry mode %s · PROJECT.md · gate-check page · replaces the former build-plan.html
" % + (esc(info["entry_mode"]), href(os.path.join(proj, "PROJECT.md")), href(os.path.join(proj, "index.html")))) + how_parts = [] + if L and DISCOVERY.get("mention") and not args.session_log: + rows = "".join("%s%s%d%%" % (esc(u["id"]), datetime.datetime.fromtimestamp(u["t"]).strftime("%Y-%m-%d"), round(u["share"] * 100)) for u in sorted(DISCOVERY.get("used", []), key=lambda u: u["t"])) + how_parts.append("

Sessions from other folders (%d). Counted when at least %d%% of a session's tool calls, and at least %d of them, operate on this project's path; only the time between those calls counts, gaps over %d minutes are cut. Words in text never count. Pass --session-log <path> to replace discovery.

%s
SessionDateShare of tool calls on the project
" % (len(DISCOVERY["mention"]), round(TOOL_SHARE * 100), TOOL_MIN, SPAN_GAP_S // 60, rows)) + if SWITCH.get("inferred"): + how_parts.append("

Stage switch inferred. 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.

" % (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("

Build runs come from %s (UTC in the file, shown in local time); a run counts as passed when the script was applied.

" % esc(facts["src"])) + if how_parts: w("
How this was measured%s
" % "".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 "" + howto = (" To use a log from another place pass it with --session-log <path> (a .jsonl file, or a folder of them, for example ~/.claude/projects/<slug>/), then regenerate: project-bin/build-plan-status.sh <project> --html --session-log <path>.") if not args.no_log else "" + ledgers = (" Build runs below come from the exec log (%s); tokens and cost need a session log." % ", ".join("%s" % esc(x) for x in args.ledgers["sources"])) if args.ledgers else "" + w("
Time, token and cost data from a session log are unavailable. %s%s%s%s Everything else (stages, gates, artifacts, decisions, the plan) is shown as usual.
" % (esc(args.log_note), looked, howto, ledgers)) + if args.ledgers and args.ledgers["phases"]: render_ledgers(w, args.ledgers) + w("

%s

%d of 8 gates passed%s
" % (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("
" % overall) + w("
Overall progress %.0f%%: mean of the stage bars below (passed or waived stage = 100, current stage = promised artifacts present, Stage 5 = plan rows built).
" % overall) + if gate["mtime"] and info["has_md"]: + gp = [s for s in STAGES if gate["rows"].get(s, {}).get("status") == "PASS"] + if set(gp) != set(info["passed"]): + w("

Note: the gate-check page (index.html) lists passed stages %s, this page counts %s (PROJECT.md stage line and sign-off rows%s). Re-run gate-check to refresh the page.

" % (esc(", ".join(gp) or "none"), esc(", ".join(info["passed"]) or "none"), ", newer than the page" if info.get("gate_stale_s") else "")) + # KPIs + allc = [m["in"] + m["cr"] + m["cw"] for m in main["msgs"]] or [0]; ctx_avg = sum(allc) / len(allc) + cwin, cnote = context_window(main["msgs"], main["compacts"], main.get("flag_1m", False)) + pw = lambda a_: ctx_pct_txt(a_, cwin) + nart = sum(len(arts[s_]["files"]) for s_ in STAGES); ndec = len(info["decisions"]) + kp = [("Gates passed", "%d of 8" % npass, "stages P to 6, from PROJECT.md"), ("Plan rows built", ("%d of %d" % (bp["built"], bp["total"])) if bp and bp["total"] else "none yet", "from architecture/build-plan.md"), + ("Artifacts present", "%d" % nart, "files found for the eight stages"), ("Decisions", "%d" % ndec, "rows in the PROJECT.md register")] if not L else [("Wall time", fmt_dur(wall), "first to last event in the session"), ("Waiting for you", fmt_dur(idle_s), "gaps over %d min" % (IDLE_GAP_S // 60)), + ("New tokens", fmt_tok(head(tot)), "input + output + cache write"), ("Cache reads", fmt_tok(tot["cr"]), "billed at a fraction"), + ("Est. cost", money(cost_all), cost_note), ("Sub-agents", "%d" % nsub, "%d of them in %d workflow run(s)" % (nwf, len({a["wf_id"] for a in agents if a["wf"]}))), + ("Context avg / peak", "%s / %s" % (fmt_tok(ctx_avg), fmt_tok(peak)), "per main request, " + (("window %s" % fmt_tok(cwin) + (" (%s)" % cnote if cnote else "")) if cwin else "window unknown")), ("Compactions", "%d" % len(main["compacts"]), "automatic context summaries"), + ("Your prompts", "%d" % main["prompts"], "typed messages, text not read into this page")] + w("
" + "".join("
%s
%s
%s
" % (esc(v), esc(l), esc(n)) for l, v, n in kp) + "
") + # tips and tricks: a narrow side column, one collapsed box per theme, calm severity, actionable tips only + aside = "" + if L: + fired, quiet, health, cs, n_inherent = args.adv2 + overall, items = health + WORD = {"green": "OK", "amber": "Worth a look", "red": "Needs action"} + sp = cs["split"]; wl = sp["wall"] or 1 + slow = "".join("
  • %s %s, %s
  • " % (esc(callname(c)), fmt_dur(c["dur"]), esc(stage_label(an, c["t0"]))) for c in cs["slow"]) + sls = cs["slices"]; so = ", ".join("%s (%s, %s)" % ("slice " + str(sls.index(c) + 1), fmt_dur(c["dur"]), stage_label(an, c["t0"])) for c in cs["slice_out"][:3]) + speed = ("
    " + "".join("%.0f%%" % (max(sp[k] / wl, 0.04), lab, 100 * sp[k] / wl) for k, lab in (("tool", "tool runs"), ("think", "model thinking"), ("user", "waiting on you"), ("ci", "CI and background"))) + "
    " + "
    Tool runs, model thinking, waiting on you, CI and background; %s wall time, approximate. Median slice %s over %d slice(s)%s.
    Slowest calls
      %s
    " % (fmt_dur(wl), fmt_dur(cs["slice_med"]), len(sls), ("; outliers: " + esc(so)) if so else "", slow or "
  • none recorded
  • ")) + ORDER = ["Context", "Tokens and cost", "Speed", "Health", "Sub-agents"]; rank = {"red": 2, "amber": 1, "info": 0} + parts = ["" % (len(quiet), n_inherent)) + aside = "".join(parts) + # stage rail + w("

    Stage rail: Kickoff (P) to Verify and report (6)

    Each card shows gate state, artifact progress%s, decisions and links.%s
    " % (", wall time, tokens, sub-agents" if L else "", " Time and token splits per stage are approximate (see footer)." if L else "")) + wins = {s: (a, b) for s, a, b in an["wins"]} + for s in ["pre"] + STAGES: + sd = S[s] + if s == "pre": + if "pre" not in wins: continue + st = "pre"; title = "Before Stage P"; badge = "outside project" + else: + st = states[s]; title = "Stage %s: %s" % (s, STAGE_NAME[s]); badge = {"passed": "passed", "progress": "in progress", "pending": "pending", "waived": "waived"}[st] + tm = sd["min"]; mins = sum(tm.values()); act = tm["main"] + tm["sub"] + tk = zero() + for p in ("main", "sub", "wf"): + for k in ("in", "out", "cr", "cw", "cost"): tk[k] += sd[p][k] + subn = sd["sub"]["n"] and len([a for a in sd["agents"] if not a["wf"]]); wfn = len([a for a in sd["agents"] if a["wf"]]) + subh = sum(head(a["tok"]) for a in sd["agents"]) + w("

    %s

    %s
    " % (st, s, "#agents-" + s if L and sd["agents"] else "#stage-" + s, "Jump to the sub-agents of this stage" if L and sd["agents"] else "This stage", esc(title), esc(badge))) + if s != "pre": + w("
    %.0f%%: %s
    " % ("ok" if st == "passed" else "", esc(owed_txt[s]), prog[s], prog[s], esc((owed_txt[s] + ("; gate not yet passed, bar capped at 95%" if st == "progress" else "")) if st in ("progress", "pending") else "gate " + st))) + w("
    ") + cl = sd["ctx"] + if L: + w("
    Wall time
    %s (%s active, %s waiting)
    " % (fmt_dur(mins * 60) if mins else "none", fmt_dur(act * 60), fmt_dur(tm["idle"] * 60))) + w("
    New tokens
    %s · est. %s
    " % (fmt_tok(head(tk)) if tk["n"] or head(tk) else "none", money(tk["cost"]))) + w("
    Main context
    %s
    " % ("avg %s, peak %s (%s of %s)" % (fmt_tok(sum(cl) / len(cl)), fmt_tok(max(cl)), pw(max(cl)), fmt_tok(cwin) if cwin else "unknown window") if cl else "no requests")) + w("
    Sub-agents
    %d + %d workflow · %s tokens
    " % (subn, wfn, fmt_tok(subh) if sd["agents"] else "0")) + if s == "5" and bp: w("
    Build progress
    %d of %d rows built%s
    " % (bp["built"], bp["total"], (", %d partial" % bp["partial"]) if bp["partial"] else "")) + if s != "pre": + c = dec[s]; w("
    Decisions
    %d confirmed%d assumed%d raised
    " % (c["CONFIRMED"], c["ASSUMED"], c["RAISED"])) + if s == "2" and gate["extra"].get("raised"): w("
    BRD questions
    %d raised
    " % gate["extra"]["raised"]) + w("
    ") + if L: w("
    Token breakdown
    Input
    %s
    Output
    %s
    Cache write
    %s
    Cache read
    %s
    Main thread
    %s
    Sub-agents
    %s
    " % + (fmt_tok(tk["in"]), fmt_tok(tk["out"]), fmt_tok(tk["cw"]), fmt_tok(tk["cr"]), fmt_tok(head(sd["main"])), fmt_tok(subh))) + if s != "pre": + row = gate["rows"].get(s) + if states[s] == "passed" and info.get("gate_stale_s") and (not row or row["status"] != "PASS"): + age = time.time() - gate["mtime"] + w("
    Gate-check page is %s old, re-run gate-check
    " % (("%d days" % round(age / 86400)) if age >= 86400 else ("%d hours" % max(1, round(age / 3600))))) + elif row: w("
    Gate-check page: %s %s
    " % (esc(row["detail"]), esc(row["status"]), 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 "%s" % (cls, href(f), esc(os.path.relpath(f, proj))) + # collapse big groups of siblings (wireframes etc.) + groups = collections.defaultdict(list) + for f, i in prim: groups[os.path.dirname(f)].append(f) + chips = [] + for d_, fl in groups.items(): + if len(fl) > 4: chips.append("%s (%d html, see list)" % (esc(os.path.relpath(d_, proj) + "/"), len(fl))) + else: chips += [chip(f) for f in fl] + w("
    %s
    " % ("".join(chips) or "no html artifact yet")) + miss = [o["id"] for o in arts[s]["owed"] if not o["found"] and o["absence"] != "optin"] + more = [f for f, i in files if len(groups.get(os.path.dirname(f), [])) > 4 and link_kind(f) == "html"] + [f for f, i in rest] + if more or miss: + w("
    %d more files%s
    %s%s
    " % + (len(more), (", %d owed and missing" % len(miss)) if miss else "", "".join(chip(f) for f in more), + "".join("%s" % esc(m) for m in miss))) + w("
    ") + w("
    ") + if L: + # where time went + w("

    Where time went: wall clock by stage, activity and tokens

    Top band: stage (approximate). Middle: who was working. Bottom: new tokens per 10 minutes. Hours are local time.
    ") + W, LH = 1000.0, 1000.0 / max(len(an["minutes"]), 1) + tick_h = max(1, -(-len(an["minutes"]) // 600)) + svg = ["
    "] + cols = {"P": "#7c8aa5", "0": "#6b9bd6", "1": "#4fb0a6", "2": "#7bb661", "3": "#d6a84a", "4": "#d98a5f", "5": "#c06c9a", "6": "#8b6cc0", "pre": "#b5bcc9"} + for s, a, b in an["wins"]: + x = (a - an["t0"]) / 60 * LH; ww = (b - a) / 60 * LH + svg.append("%s%s" % (s if s != "pre" else "P", "Stage " + s, x, max(ww, 1), cols[s], x + 4, "" if ww < 14 else ("pre" if s == "pre" else s))) + sc = {"idle": "var(--idle)", "main": "var(--main)", "sub": "var(--sub)"} + i = 0; ms = an["minutes"] + while i < len(ms): + j = i + while j + 1 < len(ms) and ms[j + 1][1] == ms[i][1]: j += 1 + svg.append("" % (i * LH, (j - i + 1) * LH, sc[ms[i][1]])); i = j + 1 + bk = an["buckets"]; mx = max(bk.values()) if bk else 1; bw = 10 * LH + for k, v in bk.items(): + h = 50.0 * v / mx; svg.append("" % (k * bw, 124 - h, max(bw - 1, 1), h)) + tt = datetime.datetime.fromtimestamp(an["t0"]).replace(minute=0, second=0, microsecond=0) + datetime.timedelta(hours=1) + while tt.timestamp() < an["t1"]: + x = (tt.timestamp() - an["t0"]) / 60 * LH + svg.append("%s" % (x, x, min(x, 975), tt.strftime(TICK_FMT[0]))) + tt += datetime.timedelta(hours=tick_h) + svg.append("tallest bar %s tokens / 10 min" % fmt_tok(mx)) + svg.append("
    main agent onlyone or more sub-agents runningwaiting (gap over %d min)tokens per 10 min
    " % (IDLE_GAP_S // 60)) + w("".join(svg)) + w("
    ") + for s, a, b in an["wins"]: + tm = S[s]["min"]; n = sum(tm.values()) or 1 + w("" % + ("Before P" if s == "pre" else "Stage %s" % (s, s), local_hm(a), local_hm(b), fmt_dur(n * 60), fmt_dur(tm["main"] * 60), fmt_dur(tm["sub"] * 60), fmt_dur(tm["idle"] * 60), pct(tm["main"], n), pct(tm["sub"], n), pct(tm["idle"], n))) + w("
    StageWindow (local)WallMain onlySub-agents activeWaitingShare of wall time
    %s%s to %s%s%s%s%s
    ") + # context over time + w("

    Context size: main-thread context per request, over time

    Context = input + cache read + cache write of each main-thread request. Dashed line is the window (%s). Red marks are automatic compactions.
    " % ((fmt_tok(cwin) + " for this model" + ("; " + cnote if cnote else "")) if cwin else "unknown: " + cnote)) + top = max([c for _, c in an["mctx"]] + ([cwin] if cwin else [1])) * 1.05; CW = 1000.0 + sv = ["
    "] + pts = " ".join("%.1f,%.1f" % ((t - an["t0"]) / 60 * LH, 95 - 85 * c / top) for t, c in an["mctx"]) + sv.append("" % pts) + if cwin: yw = 95 - 85 * cwin / top; sv.append("window %s" % (yw, yw, yw - 3, fmt_tok(cwin))) + for cp in main["compacts"]: + if cp["t"]: x = (cp["t"] - an["t0"]) / 60 * LH; sv.append("compact %s to %s" % (x, x, min(x + 3, 800), fmt_tok(cp["pre"] or 0), fmt_tok(cp["post"] or 0))) + tt = datetime.datetime.fromtimestamp(an["t0"]).replace(minute=0, second=0, microsecond=0) + datetime.timedelta(hours=1) + while tt.timestamp() < an["t1"]: + x = (tt.timestamp() - an["t0"]) / 60 * LH; sv.append("%s" % (min(x, 975), tt.strftime(TICK_FMT[0]))); tt += datetime.timedelta(hours=tick_h) + sv.append(""); w("".join(sv)) + w("
    ") + for s_ in ["pre"] + STAGES: + cl = S[s_]["ctx"] + if cl: w("" % ("Before P" if s_ == "pre" else "Stage %s" % (s_, s_), len(cl), fmt_tok(sum(cl) / len(cl)), fmt_tok(max(cl)), pw(sum(cl) / len(cl)), pw(max(cl)))) + w("
    StageMain requestsAverage contextPeak contextAverage of windowPeak of window
    %s%d%s%s%s%s
    All%d%s%s%s%s
    " % (len(allc), fmt_tok(ctx_avg), fmt_tok(peak), pw(ctx_avg), pw(peak))) + if cnote: w("
    Window note: %s.
    " % esc(cnote)) + w("
    ") + # tokens per stage + w("

    Tokens and estimated cost per stage

    Cost is an estimate at placeholder list prices per million tokens (%s). Edit DEFAULT_RATES or pass --rates.
    " % + esc("; ".join("%s %g in, %g write, %g read, %g out" % r for r in rates))) + for s in ["pre"] + STAGES: + sd = S[s]; tk = zero() + for p in ("main", "sub", "wf"): + for k in ("in", "out", "cr", "cw", "cost"): tk[k] += sd[p][k] + if not (head(tk) or tk["cr"]): continue + w("" % + ("Before P" if s == "pre" else "Stage %s" % (s, s), fmt_tok(tk["in"]), fmt_tok(tk["out"]), fmt_tok(tk["cw"]), fmt_tok(tk["cr"]), fmt_tok(head(tk)), money(tk["cost"]), pct(tk["cost"], cost_all))) + w("
    StageInputOutputCache writeCache readNew tokensEst. costShare of cost
    %s%s%s%s%s%s%s
    Total%s%s%s%s%s%s
    " % (fmt_tok(tot["in"]), fmt_tok(tot["out"]), fmt_tok(tot["cw"]), fmt_tok(tot["cr"]), fmt_tok(head(tot)), money(cost_all))) + # sub-agents + w("

    Sub-agents: who ran, on which model, for how long

    Grouped by stage. Workflow runs (parallel inventory batches) are collapsed into one group row. Duration is first to last record of the agent.
    ") + for s in ["pre"] + STAGES: + ag = S[s]["agents"] + if not ag: continue + singles = sorted([a for a in ag if not a["wf"]], key=lambda a: a["t0"]); wfs = collections.defaultdict(list) + for a in ag: + if a["wf"]: wfs[a["wf_id"]].append(a) + stot = sum(head(a["tok"]) for a in ag) + w("
    %s · %d agents · %s new tokens · est. %s
    " % + (s, "open" if s in ("3", "4") else "", "Before P" if s == "pre" else "Stage %s: %s" % (s, STAGE_NAME[s]), len(ag), fmt_tok(stot), money(sum(a["tok"]["cost"] for a in ag)))) + for a in singles: + w("" % (esc(a["desc"]), esc(a["model"].replace("claude-", "")), local_hm(a["t0"]), fmt_dur(a["t1"] - a["t0"]), a["turns"], fmt_tok(a["ctx_avg"]), fmt_tok(a["ctx_peak"]), ctx_pct_txt(a["ctx_peak"], window_for(a["model"]) if not cwin or cwin < 1_000_000 else cwin), fmt_tok(head(a["tok"])), money(a["tok"]["cost"]))) + for wid, fl in wfs.items(): + ph = sorted({a["phase"] for a in fl if a["phase"]}) + w("" % + (esc(wid), len(fl), ", phase " + esc("/".join(ph)) if ph else "", esc(", ".join(sorted(a["desc"] for a in fl))), esc(", ".join(sorted({a["model"].replace("claude-", "") for a in fl}))), + local_hm(min(a["t0"] for a in fl)), fmt_dur(max(a["t1"] for a in fl) - min(a["t0"] for a in fl)), sum(a["turns"] for a in fl), fmt_tok(sum(a["ctx_avg"] for a in fl) / len(fl)), fmt_tok(max(a["ctx_peak"] for a in fl)), fmt_tok(sum(head(a["tok"]) for a in fl)), money(sum(a["tok"]["cost"] for a in fl)))) + w("
    DescriptionModelStartDurationTurnsContext avg / peakNew tokensEst. cost
    %s%s%s%s%d%s / %s (%s)%s%s
    Workflow %s (%d parallel agents%s)
    %s
    %s%s%s%d%s / %s%s%s
    ") + # advice + w("

    All observations: token and context facts computed from this session

    Plain measurements behind the advisory above.
    ") + for t, p in adv: w("

    %s

    %s

    " % (esc(t), p if "&" in p else esc(p))) + top5 = sorted(agents, key=lambda a: -a["tok"]["cost"])[:5] + w("

    Five most expensive sub-agents

    " + "".join("" % (esc(a["desc"]), esc(a["model"].replace("claude-", "")), money(a["tok"]["cost"])) for a in top5) + "
    %s%s%s
    ") + # build progress (Stage 5): the plan's own phases, stacked built / partial / not built + w("

    Build progress: Stage 5 plan, scripts and proof

    ") + if bp: + kinds = bp["kinds"]; tr = "" + runs_by = {str(k_[0]): d["runs"] for k_, d in (facts["phases"] if facts else []) if k_[0] != ""} + cur = now_phase(bp, proj) + w("
    %d of %d rows built%sJump to the plan tables
    " % + (bp["built"], bp["total"], (", %d partial" % bp["partial"]) if bp["partial"] else "", pct(bp["built"], bp["total"]), pct(bp["partial"], bp["total"]))) + w("
    built %dpartial %dnot built %d%s
    " % (bp["built"], bp["partial"], bp["todo"], ("not needed %d (not counted)" % bp["skip"]) if bp["skip"] else "")) + w("
    " + "".join("" % (k, v, pct(v, bp["total"]), KC.get(k, "#888")) for k, v in kinds.items()) + "
    ") + w("
    " + "".join("%s %d" % (KC.get(k, "#888"), k, v) for k, v in kinds.items()) + "
    ") + lb = (facts["last_run"]["t"], facts["last_run"]["script"], facts["last_run"]["result"]) if facts else None + if lb: w("
    %s. Last build step: %s (%s), %s at %s.
    " % (esc(facts_line(facts)), esc(lb[1][:90]), esc(re.sub(r"[^\w .,/()+-]", "", lb[2])[:40].strip() or "no result"), esc(ago(lb[0])), esc(datetime.datetime.fromtimestamp(lb[0]).strftime("%Y-%m-%d %H:%M")))) + if facts and facts["score"]: w("
    Latest model score %.1f (%s).
    " % (facts["score"]["score"], esc(facts["score"]["script"][:40]))) + if bp.get("note"): w("
    %s.
    " % esc(bp["note"])) + w("
    Kinds are the plan's own Kind column; row state is the plan's State column read by prefix (built or done = built, partial or in progress = partial, anything else = not built). Plan read %s from %s.
    " % (esc(bp.get("generated") or "unknown"), "architecture/build-plan.md" if bp.get("source") == "md" else "architecture/build-plan.json")) + w("
    ") + for p in bp["phases"]: + now = p is cur + w("
    Phase %s: %s%s
    %d of %d rows built%s%s
    %s
    module files: %s
    Click to show the rows
    " % + (" now" if now else "", esc(p["id"]), esc(p["id"]), esc(p["name"]), " building now" if now else "", pct(p["built"], p["n"]), pct(p["partial"], p["n"]), p["built"], p["n"], (", %d partial" % p["partial"]) if p["partial"] else "", (" · %d runs" % runs_by.get(str(p["id"]), 0)) if runs_by.get(str(p["id"])) else "", esc(" ".join("%s %d" % kv for kv in p["kinds"].items())), + esc(" ".join(("%s %s" % (n, "yes" if v else "no")) for n, v in (("definition", p["def"]), ("brief", p["brief"]), ("ledger", p["ledger"])))))) + w("
    " + "".join("" % (x["cls"], esc(x["n"]), esc(x["kind"]), esc(x["step"]), esc(x["state"][:90])) for x in p["steps"]) + "
    #KindRowState
    %s%s%s%s
    ") + w("
    ") + else: w("
    No plan to summarise yet: %s
    " % esc(args.plan_msg or "architecture/build-plan.md was not read.")) + w("
    ") + w(args.fragment or "") + w("
    ") + # footer + w("

    How these numbers are derived

      ") + if L: + sg = an["sig"] + w("
    • Source. The Claude Code session logs of this project on this machine (%d session file(s), %d sub-agent log(s)). Usage is counted once per message id, taking the maximum of each counter, because the log writes one record per streamed block (same method as the toolkit's token-burn.sh). Synthetic records are skipped. New tokens = input + output + cache write; cache reads are shown apart. Only numbers, timestamps, tool names and sub-agent short descriptions are used; no prompt or message text is copied into this page.
    • " % (args.n_sessions, len(agents))) + w("
    • Cost. Estimate only: tokens times placeholder list prices (see the token table), not an invoice. Cache write and read are priced separately. Set your own rates with a rates file (same format as token-burn.sh --rates) at .claude/progress-report-rates.txt.
    • ") + w("
    • Stage windows (approximate). A stage starts at the first matching signal in the main thread: P = toolkit-init skill; 0 = first gate-check run; 1 = gate-check for stage 0 (else the extractor agent); 2 = first write of the BRD contract (else first BRD agent); 3 = gate-check for stage 2 (else a Stage 3 agent); 4 = a Stage 4 or build plan agent; 5 = the first row of the exec log (metrics ledger, else BUILD-LOG) or the timed Stage 4 sign-off, whichever is earlier; only with no exec log rows, the first exec.sh call, a build skill or an mdlsource write in the session log. Windows are made monotonic. Detected starts: %s.
    • " % + esc(", ".join("%s %s" % (s, local_hm(sg[s])) for s in STAGES if sg.get(s)) or "none")) + w("
    • Attribution. Main-thread requests go to the window they start in. Sub-agents go by description first (Stage N, inventory, extractor, BRD) and by start time otherwise, so a Stage 3 agent started during Stage 4 counts to Stage 3, while work done by the main thread in that window counts to Stage 4. Session time before the project existed is shown as Before Stage P.
    • ") + w("
    • Time. Wall time is the window length. A gap of more than %d minutes between any two events is waiting for the user, overnight breaks included. A minute is sub-agent time when any sub-agent record span covers it, even if that agent was itself waiting on a tool.
    • " % (IDLE_GAP_S // 60)) + w("
    • Gate state. From the PROJECT.md line under Current stage (gates passed list). The generated index.html table supplies titles and details; when it disagrees, PROJECT.md wins and a note appears. Progress for a stage that has not passed is promised artifacts present (toolkit artifact-manifest.tsv, entry mode %s); Stage 5 uses plan rows built.
    • " % esc(info["entry_mode"])) + w("
    • Abbreviations. BRD: business requirements document. This page uses no libraries and no network. Decisions: rows of the PROJECT.md Decisions table by Stage column; CONFIRMED means the user agreed, ASSUMED means the agent proceeded on a stated assumption, RAISED means an open question.
    • ") + w("
    • Known gaps. Gate verdicts for Stages 5 and 6 cannot be evaluated before build; interactive time the user spends outside the session is not visible.
    • ") + shown = [esc(x) for x in args.sessions[:6]] + logs_txt = ("" + ", ".join(shown) + "" + (" and %d more" % (len(args.sessions) - 6) if len(args.sessions) > 6 else "")) if args.sessions else ("none found under %s" % esc(args.logs_base) if args.logs_base else "not read") + how = "chosen with --session-log" if args.session_log else "found by discovery" + (", %d of them from other folders by tool calls on the project" % len(DISCOVERY["mention"]) if DISCOVERY.get("mention") else "") + rates_txt = ("%s" % esc(args.rates_used)) if args.rates_used else "built-in placeholder rates (a file at %s or --rates would override them)" % esc(args.rates_default) + regen = "project-bin/build-plan-status.sh <project> --html" + "".join(" --session-log <path>" for _ in args.session_log[:1]) + direct = "progress-report.py <project> " + (" ".join("--session-log %s" % esc(x) for x in args.session_log) if args.session_log else "--logs-dir <dir>") + w("
    • Inputs. Project folder: %s. Session log(s) (%s): %s. Rates file: %s. To regenerate with the same inputs: %s, or %s.
    • " % (esc(proj), how, logs_txt, rates_txt, regen, direct)) + w("
    ") + w("
    Rebuild: project-bin/build-plan-status.sh --html. Setting in PROJECT.md: Progress report: off, ask or auto (now %s). Generated %s.
    %s
    " % (esc(info["setting"]), gen, aside)) + w(JS) + if args.reload: + w("" % ("(function(){\nvar VK='pr-view:'+location.pathname,PK='pr-paused',IDLE=20000,EVERY=30000,last=%d,active=%d,lastInput=0,de=document.documentElement;\nfunction get(k){try{return sessionStorage.getItem(k)}catch(e){return null}}\nfunction put(k,v){try{if(v===null)sessionStorage.removeItem(k);else sessionStorage.setItem(k,v)}catch(e){}}\ntry{history.scrollRestoration='manual'}catch(e){}\nfunction restore(){try{var st=JSON.parse(get(VK)||'null');if(!st)return;\n var d=st.d||{};for(var id in d){var e=document.getElementById(id);if(e&&e.tagName==='DETAILS')e.open=!!d[id]}\n var sb=de.style.scrollBehavior;de.style.scrollBehavior='auto';window.scrollTo(0,st.y||0);de.style.scrollBehavior=sb;\n }catch(e){}}\nrestore();window.addEventListener('load',function(){restore();put(VK,null)});\nfunction save(){var d={};var all=document.querySelectorAll('details[id]');for(var i=0;i") + out = "\n".join(H) + return out.replace("\u2014", " - ").replace("\u2013", " - ") + +def main_(): + if "--print-name" in sys.argv: print(REPORT_FILE); return + ap = argparse.ArgumentParser(description="Render the progress report page.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""Inputs (also printed in the page footer, so a later regenerate need not guess): + project folder PROJECT: PROJECT.md, index.html, architecture/build-plan.json; written under PROJECT/architecture/. + session logs /projects//*.jsonl and /subagents/; + is the project path with every non-alphanumeric character replaced by '-'. + A log counts for the project when a record's cwd is at or under it. + --session-log use this log instead of discovery (a .jsonl file, or a folder of them; repeat for several). The sub-agent logs + next to each log (/subagents/) are read too. Needed when the session was started outside the project + folder and discovery cannot find it. Discovery stays the default. + rates file prices per million tokens: --rates FILE, else /.claude/progress-report-rates.txt when present, else + the placeholder rates in the script. One line per model: glob input cache-write cache-read output. + context window 200k unless the log records 1M (a [1m] model id or a context-1m beta/flag). Above 200k with no marker the + window is capped at the auto-compaction point and labelled "inferred". Percentages never exceed 100. + page name REPORT_FILE in the script (--print-name prints it).""") + ap.add_argument("project") + ap.add_argument("--out"); ap.add_argument("--toolkit") + ap.add_argument("--rates", help="rates file (glob input cache-write cache-read output per line); default /.claude/progress-report-rates.txt if present, else placeholder rates") + ap.add_argument("--fragment", help="HTML file with the plan tables and views A and B, embedded as the build detail") + ap.add_argument("--plan-json", help="parsed build plan (build-plan-status.sh --json shape)") + ap.add_argument("--plan-msg", default="") + ap.add_argument("--logs-dir", help="folder holding the / session log folders (default: /projects)") + ap.add_argument("--session-log", action="append", default=[], metavar="PATH", help="session log to use instead of discovery: a .jsonl file or a folder of them; repeatable") + ap.add_argument("--session-cache", help="discovery cache file (default /.claude/progress-report-sessions.txt)") + ap.add_argument("--no-log", action="store_true", help="skip session log discovery") + a = ap.parse_args() + proj = os.path.abspath(os.path.expanduser(a.project)) + a.outdir = os.path.dirname(os.path.abspath(a.out)) if a.out else os.path.join(proj, "architecture") + out_path = a.out or os.path.join(a.outdir, REPORT_FILE) + tk = a.toolkit or os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + if not os.path.isfile(os.path.join(tk, "bin", "lib", "artifact-manifest.tsv")): + tk = "" + for f in (os.path.join(proj, ".claude", "toolkit.env"), os.path.join(os.path.expanduser("~"), ".mxcli-toolkit.env")): + m = re.search(r"^\s*(?:export\s+)?(?:MXTK_TOOLKIT_DIR|TOOLKIT_DIR|MXCLI_TOOLKIT)\s*=\s*[\"']?([^\"'\n]+)", read(f), re.M) + if m and os.path.isfile(os.path.join(os.path.expanduser(m.group(1)), "bin", "lib", "artifact-manifest.tsv")): + tk = os.path.expanduser(m.group(1)); break + info = load_project(proj); gate = load_gate_index(proj) + reconcile_gates(info, gate) + WAIVED.update(info["waived"]) + global SELF_OUT + SELF_OUT = re.sub(r"\.tmp\.\d+$", "", out_path) + manifest = load_manifest(tk, info["entry_mode"]); arts = collect_artifacts(proj, manifest) + rf = a.rates or os.path.join(proj, ".claude", "progress-report-rates.txt") + rates = load_rates(rf if os.path.isfile(rf) else None) + a.rates_used = rf if os.path.isfile(rf) else ""; a.rates_default = rf + bp = load_build_plan(a.plan_json or os.path.join(proj, "architecture", "build-plan.json"), proj) + a.fragment = read(a.fragment) if a.fragment else ""; a.plan_msg = a.plan_msg + if a.session_log and not a.no_log: + sessions, base = explicit_sessions(a.session_log), "" + else: sessions, base = ([], "") if a.no_log else find_sessions(proj, a.logs_dir, a.session_cache) + a.n_sessions = len(sessions); a.has_log = False; a.log_note = ""; a.sessions = sessions; a.logs_base = base + main = {"msgs": [], "times": [], "compacts": [], "prompts": 0, "tools": [], "calls": []}; agents = [] + if sessions: + main, agents = load_sessions(sessions) + a.has_log = bool(main["times"] or agents) + a.ledgers = None + if not a.has_log: + a.ledgers = load_ledgers(proj) + a.log_note = "No Claude Code session log was found for this project folder on this machine." if not a.no_log else "Session logs were not read." + ledger_t = [os.path.getmtime(f) for f in (os.path.join(proj, ".claude", "loop", "metrics-ledger.tsv"), os.path.join(proj, "docs", "BUILD-LOG.md")) if os.path.isfile(f)] + log_t = max(main["times"] + [x for g in agents for x in g["times"]] or [0]) + a.last_act = max(ledger_t + [log_t] or [0]) + a.reload = (time.time() - a.last_act) < ACTIVE_S if a.last_act else False + if a.has_log: + an = analyse(proj, info, gate, manifest, arts, main, agents, rates, bp) + adv, tot, peak = advice(info, an, main, agents, rates) + a.adv2 = advisory(info, an, main, agents, rates, tot, peak) + else: + an = {"S": {s: {"main": zero(), "sub": zero(), "wf": zero(), "agents": [], "wf_n": 0, "min": collections.Counter(), "ctx": []} for s in ["pre"] + STAGES}, + "wins": [], "sig": {}, "idle": [], "minutes": [], "buckets": {}, "mctx": [], "t0": 0, "t1": 0} + adv, tot, peak = [], zero(), 0 + out = render(proj, info, gate, manifest, arts, main, agents, rates, bp, an, adv, tot, peak, a) + tmp = out_path + ".tmp.%d" % os.getpid() + os.makedirs(os.path.dirname(out_path), exist_ok=True) + with open(tmp, "w", encoding="utf-8") as f: f.write(out) + os.replace(tmp, out_path) + print("wrote", out_path, len(out), "bytes") + +if __name__ == "__main__": + main_() diff --git a/skills/brd-to-build-plan.md b/skills/brd-to-build-plan.md index 6c33bcdb..99ced703 100644 --- a/skills/brd-to-build-plan.md +++ b/skills/brd-to-build-plan.md @@ -44,7 +44,7 @@ A single **build plan document** per project (or per phase, for large projects), 8. Navigation wire point for every page (Step 8), and the prototype route it is built against when a prototype exists 9. **A CONFIRMED-decision reconciliation** — every decision marked `CONFIRMED` in `PROJECT.md` from Stages 0–3 mapped to a specific build-plan script, or an explicit `descoped` note with the reason. A confirmed decision with no build disposition is how scoped work silently vanishes (e.g. a CONFIRMED extra navigation profile, wireframe and all, that never got built). The Stage-6 `ba-agent` conformance cross-check re-verifies this against the actual build — but it belongs in the plan first. -10. **A rendered `build-plan.html`** — a single self-contained (inline CSS, no external deps) HTML **progress tracker** derived from the markdown: the phases, their rows (#, kind, step, produces, depends on, State), each row's named scripts checked against `mdlsource/` (`done-` / written / not yet), and the phase's claims. A row whose State says built while its scripts are not all `done-` (or the reverse) is flagged on the page. The markdown remains the source of truth; the HTML is a derived *view* — never edited by hand. Write it once here with `project-bin/build-plan-status.sh --html`; after that it refreshes itself after every `exec.sh` run and every full `gate-check.sh` run (`--refresh`: never over a page that script did not write, no rewrite when only the timestamp would change). That script also reads `mdlsource//` `done-` prefixes for phase progress and cross-references `verify-module.sh` summaries plus `docs/improvement-register.md` for a second, honestly-separate view of what's actually *proven* — a phase at 100% built with no module reviewed there is visible as built, not proven, rather than silently passing as "done." It doubles as the demo progress board. Keep it beside the markdown (`architecture/build-plan.html`). This mirrors the other Stage-3/4 HTML renders (`blueprint.html`, `workflow-definition.html`, `design-system.html`) — every primary architecture/design artifact gets a human-viewable HTML companion. +10. **A rendered `progress-report.html`** (`architecture/`) — self-contained progress report derived from the markdown: phases, rows, `done-` versus written scripts, built versus proven, stages P to 6, and, when a session log exists, time, tokens and tips. A row whose State disagrees with its scripts is flagged. The markdown stays the source of truth; never edit the page. Write it with `project-bin/build-plan-status.sh --html`; it refreshes after every `exec.sh` run and full `gate-check.sh` run. PROJECT.md `Progress report: off | ask | auto` (default `ask`): at a stage gate or module close, `ask` offers once to open it (`--open`), `auto` opens it, `off` writes nothing. Its Tips column shows only actionable patterns, collapsed, calm severity; costs inherent to the toolkit or mxcli (builds, required gates, waiting) are never shown. An older `build-plan.html` is left alone. Per-module detail (domain summary, UI pointers, validation rules, the access-table slice for that module) is synthesized into a **module brief** — one per module, produced just-in-time — see `module-brief.md`. The build plan is the project-wide order and scope; the brief is the module-level input the `mdl-agent` reads. This becomes the checklist `iterative-build-loop.md` executes against. @@ -404,7 +404,7 @@ schema in use was `# / script / produces / depends on / write mode / state`, eve describes a build. There was nowhere to write "prove the mapping returns 202" or "run the journey", so no one did, and a day's work went unverified on machines where the mxbuild gate was also silently skipping. Give verification a row and it can be numbered, depended on, counted, and -rendered in `build-plan.html` like anything else. +rendered in `progress-report.html` like anything else. ### A module carrying a workflow gets a construct row diff --git a/skills/conversion-runbook.md b/skills/conversion-runbook.md index 0abcb6aa..6d08a21f 100644 --- a/skills/conversion-runbook.md +++ b/skills/conversion-runbook.md @@ -589,7 +589,7 @@ The biggest gap before this runbook existed. Module boundaries, wiring diagrams |---|---| | **User defines** | **Acceptance criteria per module** — what "done" means beyond CE-error-free. **Environment / DTAP / deployment target.** Iteration granularity — the default is one use case per `BUILD` row; a plan outside 0.5–1.5 rows per use case goes to the user, never `ASSUMED` (`brd-to-build-plan.md` Step 3). | | **Agent produces** | `architecture/build-plan.md` — numbered, dependency-ordered (marketplace imports → module roles → entities + entity grants → associations → microflows + execute grants → pages + view grants → demo users), grants **co-located** with the element they protect (never a deferred security script), the role-to-access table for every element, with pending decisions promoted to the top. Plus the first module's **module brief** (`architecture/modules//module-brief.md`, per `module-brief.md`) — subsequent briefs are produced just-in-time as each module's build begins. Plus the **coverage ledger** (`architecture/modules//coverage-ledger.md` per module; `architecture/coverage-ledger.md` for a single-BRD project — `coverage-ledger.md` §"Where the ledger lives"), generated per `coverage-ledger.md` from the BRDs and the plan's `claims:` blocks. `claims` is authored *here*, row by row, as the plan is written (`brd-to-build-plan.md` Step 5b) — required on new rows only; a plan written before the convention existed is an accepted state that reports coverage NOT MEASURED, not a defect. | -| **Surface** | `build-plan.html` | +| **Surface** | `progress-report.html` | | **Gate ✋** | Close-out block pasted (§1b rule 7) — CAC-5 opens with it. Pending-decisions list empty or fully answered. Role-to-access table complete for every element. **Coverage check run** (procedure in `coverage-ledger.md`): UNCLAIMED, PHANTOM and DOUBLE-CLAIMED all empty, and the leaf counts **pasted in chat** — like `gate-check.sh` output, never attested. Every ledger entry carries a category and a reason; every `open-question` entry appears in the pending-decisions list and some row's `blockedBy`. **Every CONFIRMED decision from Stages 0–3 maps to a build-plan row id or a ledger `descoped` entry** — a confirmed decision with no build disposition is how scoped work silently vanishes (a real WMS incident: the CONFIRMED Phone-Web nav profile, wireframe and all, was never built and nothing flagged it). User approves — the coverage verdict is signed off alongside the plan. | | **Owner** | `architect-agent` for the build plan; `ba-agent` drives each module brief (pulling `architect-agent` for the technical layer). | diff --git a/skills/iterative-build-loop.md b/skills/iterative-build-loop.md index c692f145..36904abd 100644 --- a/skills/iterative-build-loop.md +++ b/skills/iterative-build-loop.md @@ -43,7 +43,7 @@ git mv mdlsource/.mdl mdlsource/done-.mdl - The file stays in place and re-runnable — if a later phase invalidates it, rename back (drop the `done-` prefix), fix, re-exec, re-done. - Do **not** rename a script that only passed `mxcli check` or only exec'd without the happy-path/coverage check — `done-` means the *feature* works, not just that the MDL ran (mirrors the Core Principle: CE-error-free ≠ done). -`build-plan.html` phase status and the `done-` prefixes should agree — when every script for a phase is `done-`, that phase flips to ✓ in the tracker. +`progress-report.html` phase status and the `done-` prefixes should agree — when every script for a phase is `done-`, that phase flips to ✓ in the tracker. `project-bin/exec.sh` prints this rename reminder at the moment a script's gate passes clean. `project-bin/done-drift-check.sh` (surfaced automatically by `close-task.sh`) is the backstop for @@ -590,11 +590,9 @@ numbered: a gate inserted in the middle used to leave every ordinal behind it wr Diagnostic only: findings go to the punch-list, fixes are a separate approved pass. 15. **Gate: COVERAGE — business-rule coverage checklist (mandatory, never skip):** `gate-agent` walks the confirmed checklist from the Pre-Module Checklist step — every mandatory/read-only/conditional/validation item — against the built module, item by item. A module with 0 CE errors and a working happy path but an unchecked validation rule is **not done**. Document any gap as an explicit sub-task; don't mark the module done with open items on this list. 16. Mark module done ✅ — only if Gate: UI's per-module review produced no open P1. Then flip the - module's rows in `build-plan.md` to `built`. `architecture/build-plan.html` refreshes itself - after every `exec.sh` and every full `gate-check.sh` run, so the `done-` renames and the State - column show on the next of either; run `project-bin/build-plan-status.sh --html` only to see - it now. Mechanical, not judged: it reads `build-plan.md`, `done-` prefixes and verify-module.sh - summaries, and flags a row whose State disagrees with its scripts — it decides nothing. + module's rows in `build-plan.md` to `built`. `architecture/progress-report.html` refreshes after + every `exec.sh` and full `gate-check.sh` run (mechanical, decides nothing). Per PROJECT.md + `Progress report:` (`brd-to-build-plan.md` Step 10), `ask` offers it once here: `build-plan-status.sh --open`. 17. **Every N=2–3 modules, run `process-coherence-pass.md`** on the cluster just closed — a cross-module seam check (does the persona journey actually chain across these modules, not just within each). This is not a per-module step and does not gate an individual module, but diff --git a/skills/small-project-tier.md b/skills/small-project-tier.md index 25075bca..dee9ade6 100644 --- a/skills/small-project-tier.md +++ b/skills/small-project-tier.md @@ -65,7 +65,7 @@ not a ceiling. Exceeding one silently is the defect. | 4 | `build-plan.md` | | ≤ 12 numbered scripts; §9 regression ledger stays | | 4 | module brief | one per module | one | | 4 | coverage ledger | per module | the single-file form `architecture/coverage-ledger.md` (`coverage-ledger.md` → "Where the ledger lives") | -| 4 | `build-plan.html` | derived render | **waived**: `Waived artifact build-plan-html: small tier — status read from build-plan.md` | +| 4 | `progress-report.html` | derived render | **waived**: `Waived artifact build-plan-html: small tier — status read from build-plan.md` | | 5 | `improvement-register.html` | render | **waived**: `Waived artifact improvement-register-html: small tier — the .md is the register` | | 5–6 | everything else | | unchanged: `exec.sh`, gate-agent after every exec, one ui-review, journeys, `report.json` | diff --git a/tests/wave2/test-build-plan-json.sh b/tests/wave2/test-build-plan-json.sh index 07410fdf..c1c906f7 100644 --- a/tests/wave2/test-build-plan-json.sh +++ b/tests/wave2/test-build-plan-json.sh @@ -42,6 +42,9 @@ # T11 --refresh keeps an existing build-plan.json current (a State flipped in the md shows in # the json), rewrites nothing when only generatedAt would differ, and never creates a json. # +# Since the progress report (this PR) the page is architecture/progress-report.html; the +# older build-plan.html is no longer written. test-progress-report.sh pins the rest. +# # Usage: bash tests/wave2/test-build-plan-json.sh /path/to/build-plan-status.sh set -uo pipefail @@ -289,7 +292,7 @@ J6="$P6/architecture/build-plan.json" # ── T7: --json with --html ──────────────────────────────────────────────── OUT7="$(bash "$SCRIPT" "$P1" --json --html --quiet 2>&1)"; RC7=$? -[ "$RC7" -eq 0 ] && [ -f "$P1/architecture/build-plan.html" ] && [ -f "$J1" ] && ok "T7 --json and --html both write" || bad "T7 rc=$RC7" "$OUT7" +[ "$RC7" -eq 0 ] && [ -f "$P1/architecture/progress-report.html" ] && [ -f "$J1" ] && ok "T7 --json and --html both write" || bad "T7 rc=$RC7" "$OUT7" # ── T8: a build product is gitignored by its producer ───────────────────── GI1="$P1/.gitignore" @@ -345,7 +348,7 @@ cat > "$P10/architecture/build-plan.md" <<'MD' MD : > "$P10/mdlsource/done-01-app-scaffold.mdl" : > "$P10/mdlsource/02-enums.mdl" -H10="$P10/architecture/build-plan.html" +H10="$P10/architecture/progress-report.html" OUT10="$(bash "$SCRIPT" "$P10" --html --quiet 2>&1)"; RC10=$? [ "$RC10" -eq 0 ] && [ -f "$H10" ] && ok "T10 --html writes the page" || bad "T10 rc=$RC10" "$OUT10" grep -q 'App Scaffold' "$H10" 2>/dev/null && grep -q 'Orders' "$H10" && grep -q '10-orders-domain.mdl' "$H10" \ @@ -356,7 +359,7 @@ grep -q 'State says built, but not every script it names is done-' "$H10" 2>/dev && ok "T10 a built row whose script is not done- is flagged" || bad "T10 State/disk drift not flagged" SUM_A="$(cksum < "$H10")" bash "$SCRIPT" "$P10" --refresh >/dev/null 2>&1 -[ "$(cksum < "$H10")" = "$SUM_A" ] && ok "T10 --refresh leaves an unchanged page byte-identical" || bad "T10 --refresh rewrote a page whose content did not change" +[ "$(cksum < "$H10")" = "$SUM_A" ] && ok "T10 --refresh leaves an unchanged page byte-identical (stamp lines excluded)" || bad "T10 --refresh rewrote a page whose content did not change" mv "$P10/mdlsource/02-enums.mdl" "$P10/mdlsource/done-02-enums.mdl" bash "$SCRIPT" "$P10" --refresh >/dev/null 2>&1 grep -q 'State says built, but not every script' "$H10" && bad "T10 --refresh did not pick up a done- rename" || ok "T10 --refresh picks up a done- rename" @@ -365,7 +368,7 @@ bash "$SCRIPT" "$P10" --refresh >/dev/null 2>&1 [ "$(cat "$H10")" = "written by hand" ] && ok "T10 --refresh never overwrites a page it did not generate" || bad "T10 --refresh overwrote a foreign page" P10B="$(new_project htmlnoplan)" bash "$SCRIPT" "$P10B" --refresh >/dev/null 2>&1 -[ ! -f "$P10B/architecture/build-plan.html" ] && ok "T10 --refresh writes nothing without build-plan.md" || bad "T10 --refresh wrote a page with no plan" +[ ! -f "$P10B/architecture/progress-report.html" ] && ok "T10 --refresh writes nothing without build-plan.md" || bad "T10 --refresh wrote a page with no plan" # ── T11: --refresh keeps an existing build-plan.json current, never creates one ── P11="$(new_project jsonrefresh)" diff --git a/tests/wave2/test-progress-report-field-fixes.sh b/tests/wave2/test-progress-report-field-fixes.sh new file mode 100644 index 00000000..8923a645 --- /dev/null +++ b/tests/wave2/test-progress-report-field-fixes.sh @@ -0,0 +1,545 @@ +#!/usr/bin/env bash +# test-progress-report-field-fixes.sh: pin the field-test fixes to project-bin/progress-report.py found on two real +# projects. Every name, path and number below is invented. The generator is run directly with --out outside the project. +# +# F1 one source of truth for stage state: PROJECT.md (stage line + sign-off rows) beats a stale gate-check page; +# the card says "Gate-check page is N ... old, re-run gate-check"; "6 of 8" agrees with the list P,0,1,2,3,4 +# (the "· manual: 5" tail of the stage line must not swallow the last stage). +# F2 the progress report never lists itself as a stage artifact (not even when --out is outside the project). +# F3 a build-plan.json older than build-plan.md is not trusted: rows are counted from the md State column by prefix +# ("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. +# 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, +# last build step with time ago; the section is linked from the Stage 5 card; the report itself is never linked. +# F7 "building now" is the phase of the last build step (folder, script name, else highest active phase). +# F8 discovery counts tool calls on the project (25% and 20), never text mentions; progress-report.py sessions out. +# F9 a date-only Stage 4 sign-off puts the switch at the first build activity on or after the date, marked inferred. +# F10 an existing-project fixture: the metrics ledger (UTC) is the primary source of build activity, with no session log. +# F11 a project set up by an older toolkit and updated with bin/sync-project.sh renders and leaves build-plan.html alone. +# F12 the Stage 4 to 5 switch is one function: the windows table, the chart band and the sub-agent tables agree, and a +# sub-agent that names a plan phase and starts within 30 minutes before it counts to Stage 5. +# F15 the discovery cache records the project's real path and the attribution rules: a copied project, or a changed +# threshold, reads it as cold; the copy does not list the original's sessions. +# F16 files skipped by the 300-file cap are recorded as "no": a cached run never reads more files than the uncached one. +# F17 with exec log rows the Stage 4 to 5 switch follows the log; an earlier exec.sh call in the session log is ignored. +# +# Usage: bash tests/wave2/test-progress-report-field-fixes.sh + +set -uo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +GEN="$ROOT/project-bin/progress-report.py" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/pr-fixes.XXXXXX")" +trap '[ -n "${KEEP:-}" ] || rm -rf "$WORK"' EXIT +# shellcheck disable=SC1091 +. "$ROOT/bin/lib/portable.sh" +PY="$(resolve_py 2>/dev/null || true)" +if [ -z "$PY" ]; then echo "SKIP: no Python 3 found"; echo "PASS=0 FAIL=0"; exit 0; fi +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " PASS: $1"; } +bad() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } +has() { grep -q -- "$2" "$1" && ok "$3" || bad "$3"; } +hasnt() { grep -q -- "$2" "$1" && bad "$3" || ok "$3"; } + +# A project whose gate-check page and plan JSON are stale, and whose PROJECT.md says stage 4 passed (signed off at a +# known time). Prints the sign-off epoch. +mkproj() { # dir + "$PY" - "$1" <<'PYEOF' +import os, sys, time, datetime +d = sys.argv[1] +for sub in ("architecture", "docs", "docs/progress", ".claude/loop", "analysis"): os.makedirs(os.path.join(d, sub), exist_ok=True) +open(os.path.join(d, "PROJECT.md"), "w").write("""# PROJECT.md + +## Current stage + +**Stage 5 - Build**, in progress - gates passed: P, 0, 1, 2, 3, 4 · manual: 5 (derived by gate-check) + +Entry mode: requirements-driven + +## Decisions + +| Stage | Decision | Status | Notes | +|---|---|---|---| +| 3 | Stage 3 sign-off: architecture | CONFIRMED 2026-10-09 | ok | +| 4 | Stage 4 sign-off: build plan | CONFIRMED 2026-10-10 12:00 | ok | + +## Notes +""") +rows = "".join("%st%sdetail" % (s, st) for s, st in (("P", "PASS"), ("0", "PASS"), ("1", "PASS"), ("2", "PASS"), ("3", "PASS"), ("4", "PENDING"))) +open(os.path.join(d, "index.html"), "w").write("%s
    " % rows) +open(os.path.join(d, "architecture", "build-plan.json"), "w").write('{"schema":1,"generatedAt":"2026-10-01T00:00:00Z","phases":[{"id":"1","name":"Orders","steps":[{"number":"1","kind":"BUILD","step":"a","state":"not built"},{"number":"2","kind":"BUILD","step":"b","state":"not built"}]}]}') +open(os.path.join(d, "architecture", "build-plan.md"), "w").write("""# Build plan + +### Phase 1 - Orders + +| # | Kind | Step | State | +|---|---|---|---| +| 1 | BRIEF | brief | built | +| 2 | BUILD | order entity | built (2026-10-12; mxbuild clean) | +| 3 | BUILD | order page | partial (page missing a column) | +| 4 | PROVE | tests | not built | + +### Phase 2 - Billing + +| # | Kind | Step | State | +|---|---|---|---| +| 5 | BUILD | invoice | not built | +| 6 | BUILD | credit note | done 2026-10-12 | +| 7 | BUILD | reminders | not built (waits for Q-3) | +""") +open(os.path.join(d, "docs", "BUILD-LOG.md"), "w").write("""| when | script | gate | result | detail | +|---|---|---|---|---| +| 2026-10-12T10:00:00+0000 | `01-orders.mdl` | pass | applied | ok | +| 2026-10-12T10:02:00+0000 | `02-billing.mdl` | pass | applied | ok | +""") +open(os.path.join(d, "docs", "progress", "TIME-LEDGER.md"), "w").write("""# Build time + +| Phase | Start | Minutes | Subagent tokens | Exec runs | Notes | +|---|---|---|---|---|---| +| 1 orders | 2026-10-12 10:00 | 55 | 136k | ~25 | x | +""") +old = time.time() - 3 * 86400 +for f in ("index.html", "architecture/build-plan.json"): os.utime(os.path.join(d, f), (old, old)) +PYEOF +} + +# Session log in another folder's slug (cwd elsewhere). $3 = tool calls that operate on the project (Read of a file +# under it); env FILL = other tool calls on something else interleaved with them, TEXT = assistant text messages that +# only mention the path, REPORTRUN = a call that runs progress-report.py. All times are around the 12:00 sign-off. +mklog() { # configdir projdir touching [sessionname] + local slug="-tmp-elsewhere" + mkdir -p "$1/projects/$slug" + "$PY" - "$1/projects/$slug/${4:-s1}.jsonl" "$2" "$3" <<'PYEOF2' +import json, os, sys, datetime +out, proj, touching = sys.argv[1], sys.argv[2], int(sys.argv[3]) +so = datetime.datetime(2026, 10, 10, 12, 0).timestamp() +rows = []; n = [0] +def iso(t): return datetime.datetime.fromtimestamp(t, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z") +def asst(t, name=None, inp=None, text=None, tin=5000): + n[0] += 1 + blk = [{"type": "tool_use", "id": "t%d" % n[0], "name": name, "input": inp}] if name else [{"type": "text", "text": text or "x"}] + rows.append({"type": "assistant", "timestamp": iso(t), "cwd": "/tmp/elsewhere", "uuid": "a%d" % n[0], "message": {"id": "m%d" % n[0], "model": "claude-sonnet-5-5", "role": "assistant", "content": blk, + "usage": {"input_tokens": tin, "output_tokens": 500, "cache_read_input_tokens": 1000, "cache_creation_input_tokens": 100}}}) +fill = int(os.environ.get("FILL", "0")); total = touching +for k in range(touching): + t = so - 2400 + k * 50 + asst(t, "Read", {"file_path": "%s/architecture/f%d.md" % (proj, k)}, tin=5000 + k) + for j in range(fill // max(1, touching) + (1 if k < fill % max(1, touching) else 0)): + asst(t + 10 + j, "Bash", {"command": "ls /tmp"}) +for k in range(int(os.environ.get("TEXT", "0"))): + asst(so - 2400 + k * 20, text="the project at %s/architecture is described in" % proj) +if os.environ.get("REPORTRUN"): asst(so - 100, "Bash", {"command": "%s/project-bin/progress-report.py %s" % (proj, proj)}) +asst(so - 600, "Bash", {"command": ("ls %s" % proj) if os.environ.get("NOEARLY") else ("bash %s/bin/exec.sh 01" % proj)}) +asst(so + 300, "Bash", {"command": "cd %s && bash exec.sh 02" % proj}); asst(so + 900, "Bash", {"command": "cd %s && bash exec.sh 03" % proj}) +asst(so + 3 * 86400, "Bash", {"command": "ls"}) +open(out, "w").write("".join(json.dumps(r, separators=(",", ":")) + "\n" for r in rows)) +PYEOF2 +} + +# Text of one stage card from the page (everything between its id and the next card). +card() { # file stage + "$PY" - "$1" "$2" <<'PYEOF' +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.sub(r"<[^>]+>", " ", h[i:j if j > 0 else i + 6000])) +PYEOF +} + +echo "F1 PROJECT.md wins over a stale gate-check page" +P1="$WORK/proj1"; mkproj "$P1"; EMPTY="$WORK/emptycfg"; mkdir -p "$EMPTY/projects" +OUT1="$WORK/out1/report.html" +CLAUDE_CONFIG_DIR="$EMPTY" "$PY" "$GEN" "$P1" --out "$OUT1" >/dev/null 2>&1 +[ -f "$OUT1" ] && ok "page written with --out outside the project" || bad "page written with --out outside the project" +has "$OUT1" "6 of 8 gates passed" "counter is 6 of 8, as many as the passed list P,0,1,2,3,4" +card "$OUT1" 4 | grep -q "passed" && ok "Stage 4 card says passed" || bad "Stage 4 card says passed" +card "$OUT1" 4 | grep -qE "Gate-check page is [0-9]+ (days|hours) old, re-run gate-check" && ok "Stage 4 card carries the amber stale-page note" || bad "Stage 4 card carries the amber stale-page note" +card "$OUT1" 3 | grep -q "re-run gate-check" && bad "a stage the page lists as PASS gets no stale note" || ok "a stage the page lists as PASS gets no stale note" +has "$OUT1" "this page counts P, 0, 1, 2, 3, 4" "the disagreement note names the counted list" + +echo "F2 the report never lists itself" +P2="$WORK/proj2"; mkproj "$P2"; : > "$P2/architecture/progress-report.html" +CLAUDE_CONFIG_DIR="$EMPTY" "$PY" "$GEN" "$P2" --out "$WORK/out2/elsewhere.html" >/dev/null 2>&1 +hasnt "$WORK/out2/elsewhere.html" "class='chip [^']*' href='[^']*progress-report" "no chip links the report (existing file)" +rm -f "$P2/architecture/progress-report.html" +CLAUDE_CONFIG_DIR="$EMPTY" "$PY" "$GEN" "$P2" --out "$WORK/out2/elsewhere2.html" >/dev/null 2>&1 +hasnt "$WORK/out2/elsewhere2.html" "class='chip [^']*' href='[^']*elsewhere2" "no chip links the page being written" + +echo "F3 stale plan JSON: counted from build-plan.md" +has "$OUT1" "3 of 7 rows built, 1 partial" "headline: 3 of 7 rows built, 1 partial (built, built (...), done; not built is not built)" +has "$OUT1" "plan JSON was stale, counted from build-plan.md" "the page says the JSON was stale" +hasnt "$OUT1" "0 of 2 rows built" "never the stale JSON's 0 built" +has "$OUT1" "built 3" "legend: built 3" +has "$OUT1" "partial 1" "legend: partial 1" +has "$OUT1" "not built 3" "legend: not built 3 (a 'not built (waits for Q-3)' row stays not built)" +FRESH="$WORK/proj3"; mkproj "$FRESH"; touch "$FRESH/architecture/build-plan.json" +rm -f "$FRESH/docs/BUILD-LOG.md" +CLAUDE_CONFIG_DIR="$EMPTY" "$PY" "$GEN" "$FRESH" --out "$WORK/out3/r.html" >/dev/null 2>&1 +has "$WORK/out3/r.html" "0 of 2 rows built" "a JSON newer than the md is used as it is" +hasnt "$WORK/out3/r.html" "plan JSON was stale" "no stale note when the JSON is current" + +echo "F4 session log discovery and ledger fallback" +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" "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" "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 <path>" "banner 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" + +echo "F5 Stage 4 to 5 boundary follows PROJECT.md, not the stale gate page" +S5="$(card "$OUT1" 5)" +S5L="$(card "$WORK/out4/r.html" 5)" +printf '%s' "$S5L" | grep -E "New tokens" | grep -qv "none" && ok "Stage 5 shows tokens from the events after the sign-off" || bad "Stage 5 shows tokens from the events after the sign-off" +printf '%s' "$S5L" | grep -E "Wall time" | grep -q "none" && bad "Stage 5 wall time is not none" || ok "Stage 5 wall time is not none" +"$PY" - "$WORK/out4/r.html" <<'PYEOF' && ok "ledger rows exist: the session's exec.sh at 11:50 is ignored, Stage 5 starts at the timed 12:00 sign-off" || bad "Stage 5 window starts at 12:00, not 11:50" +import re, sys +h = open(sys.argv[1], encoding="utf-8").read() +t = re.sub(r"<[^>]+>", " ", h) +m = re.search(r"Stage 5\s+(\d\d:\d\d) to", t) +assert m and m.group(1) == "12:00", "Stage 5 window row: %r" % (m and m.group(1)) +PYEOF + +CFG3="$WORK/cfg3"; NOEARLY=1 mklog "$CFG3" "$P1R" 25 +CLAUDE_CONFIG_DIR="$CFG3" "$PY" "$GEN" "$P1" --out "$WORK/out4/r4.html" >/dev/null 2>&1 +"$PY" - "$WORK/out4/r4.html" <<'PYEOF' && ok "no exec.sh before it: Stage 5 starts at the 12:00 sign-off row of PROJECT.md" || bad "Stage 5 starts at the sign-off row" +import re, sys +t = re.sub(r"<[^>]+>", " ", open(sys.argv[1], encoding="utf-8").read()) +m = re.search(r"Stage 5\s+(\d\d:\d\d) to", t) +assert m and m.group(1) == "12:00", m and m.group(1) +PYEOF +card "$WORK/out4/r4.html" 5 | grep -E "New tokens" | grep -qv "none" && ok "events after the sign-off count toward Stage 5 (gate page stale)" || bad "events after the sign-off count toward Stage 5" + +echo "F6 Build progress" +has "$OUT1" "id=build-detail>Build progress" "section is titled Build progress" +has "$OUT1" "href='#build-detail'" "Stage 5 card links to it" +has "$OUT1" "class='stack3'" "stacked bars" +has "$OUT1" "class='card ph now'" "the phase being built now is marked" +has "$OUT1" "building now" "and says so" +has "$OUT1" "Last build step:" "last build step shown" +has "$OUT1" "ago at" "with how long ago" +hasnt "$OUT1" "href='[^']*progress-report" "the report never links itself" + +echo "F7 building now = the phase the last build step belongs to" +has "$OUT1" "class='card ph now' id='phase-2'" "last step 02-billing.mdl: Phase 2 (Billing) is marked, although Phase 1 has the partial row" +has "$OUT1" "class='card ph' id='phase-1'" "the earlier phase with a partial row is not marked now" +P7="$WORK/proj7"; mkproj "$P7" +printf '%s\n' '| 2026-10-12T10:09:00+0000 | `01-orders-fixes.mdl` | pass | applied | ok |' '| 2026-10-12T10:10:00+0000 | `05-billing.mdl` | pass | blocked | stop |' >> "$P7/docs/BUILD-LOG.md" +CLAUDE_CONFIG_DIR="$EMPTY" "$PY" "$GEN" "$P7" --out "$WORK/out7/r.html" >/dev/null 2>&1 +has "$WORK/out7/r.html" "class='card ph now' id='phase-1'" "script name matched to the plan phase name (orders), a later blocked step does not count" +P7B="$WORK/proj7b"; mkproj "$P7B" +printf '%s\n' '| 2026-10-12T10:09:00+0000 | `apps/x/mdlsource/2-whatever/04.mdl` | pass | applied | ok |' >> "$P7B/docs/BUILD-LOG.md" +CLAUDE_CONFIG_DIR="$EMPTY" "$PY" "$GEN" "$P7B" --out "$WORK/out7/b.html" >/dev/null 2>&1 +has "$WORK/out7/b.html" "class='card ph now' id='phase-2'" "mdlsource/2-whatever/ folder number picks Phase 2" +P7C="$WORK/proj7c"; mkproj "$P7C" +printf '%s\n' '| 2026-10-12T10:09:00+0000 | `99.mdl` | pass | applied | ok |' >> "$P7C/docs/BUILD-LOG.md" +CLAUDE_CONFIG_DIR="$EMPTY" "$PY" "$GEN" "$P7C" --out "$WORK/out7/c.html" >/dev/null 2>&1 +has "$WORK/out7/c.html" "class='card ph now' id='phase-2'" "no match: the highest phase with a built or partial row" + +echo "F8 discovery counts tool calls on the project, not mentions" +cnt() { grep -c "$2" "$1"; } +CFG8="$WORK/cfg8"; mklog "$CFG8" "$P1R" 25 +CLAUDE_CONFIG_DIR="$CFG8" "$PY" "$GEN" "$P1" --out "$WORK/out8/a.html" >/dev/null 2>&1 +has "$WORK/out8/a.html" "How this was measured" "the sessions used are listed in one collapsed How this was measured block" +has "$WORK/out8/a.html" "s12026-10-10100%" "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" +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" +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" +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" "s1" "two sessions: the qualifying one is listed" +hasnt "$WORK/out8/e.html" "s2" "the other (28 of 128 calls, 22%) is not listed" + +echo "F9 date-only sign-off: switch is the first build activity on or after the date" +P9="$WORK/proj9"; mkproj "$P9"; rm -f "$P9/docs/BUILD-LOG.md" "$P9/docs/progress/TIME-LEDGER.md" +sed -i.bak 's/CONFIRMED 2026-10-10 12:00/CONFIRMED (delegated) 2026-10-10/' "$P9/PROJECT.md"; rm -f "$P9/PROJECT.md.bak" +P9R="$(cd "$P9" && pwd -P)"; CFG9="$WORK/cfg9"; mkdir -p "$CFG9/projects/-tmp-elsewhere" +"$PY" - "$CFG9/projects/-tmp-elsewhere/s9.jsonl" "$P9R" <<'PYEOF3' +import json, sys, datetime +out, proj = sys.argv[1], sys.argv[2] +rows = []; n = [0] +def at(d, h, m): return datetime.datetime(2026, 10, d, h, m).timestamp() +def iso(t): return datetime.datetime.fromtimestamp(t, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z") +def call(t, name, inp): + n[0] += 1 + rows.append({"type": "assistant", "timestamp": iso(t), "cwd": "/tmp/elsewhere", "uuid": "a%d" % n[0], "message": {"id": "m%d" % n[0], "model": "claude-sonnet-5-5", "role": "assistant", + "content": [{"type": "tool_use", "id": "t%d" % n[0], "name": name, "input": inp}], "usage": {"input_tokens": 4000, "output_tokens": 300, "cache_read_input_tokens": 500, "cache_creation_input_tokens": 50}}}) +for k in range(15): call(at(9, 20, 0) + k * 60, "Read", {"file_path": proj + "/architecture/p%d.md" % k}) # evening before: planning +call(at(9, 23, 0), "Bash", {"command": "cd %s && bash exec.sh 00" % proj}) # exec.sh BEFORE the date: ignored +for k in range(12): call(at(10, 8, 0) + k * 120, "Read", {"file_path": proj + "/docs/q%d.md" % k}) # that day, planning +call(at(10, 9, 20), "Write", {"file_path": proj + "/mdlsource/1/01-orders.mdl", "content": "x"}) # first build activity on the date +call(at(10, 9, 30), "Bash", {"command": "cd %s && bash exec.sh 01" % proj}) +for k in range(5): call(at(10, 9, 40) + k * 60, "Read", {"file_path": proj + "/docs/r%d.md" % k}) +open(out, "w").write("".join(json.dumps(r, separators=(",", ":")) + "\n" for r in rows)) +PYEOF3 +CLAUDE_CONFIG_DIR="$CFG9" "$PY" "$GEN" "$P9" --out "$WORK/out9/r.html" >/dev/null 2>&1 +"$PY" - "$WORK/out9/r.html" <<'PYEOF4' && ok "Stage 5 starts at 09:20 (first mdlsource write on the date), not midnight and not the earlier exec.sh" || bad "Stage 5 starts at 09:20" +import re, sys +t = re.sub(r"<[^>]+>", " ", open(sys.argv[1], encoding="utf-8").read()) +m = re.search(r"Stage 5\s+(\d\d:\d\d) to", t) +assert m and m.group(1) == "09:20", m and m.group(1) +PYEOF4 +has "$WORK/out9/r.html" "Stage switch inferred" "the collapsed block notes the switch is inferred" +hasnt "$OUT1" "Stage switch inferred" "no inferred note when the sign-off row has a time" +hasnt "$WORK/out4/r.html" "Stage switch inferred" "no inferred note with a timed row (log)" + +echo "F10 an existing project: exec log (metrics ledger) is the primary source for build activity" +# Neutral names. Stale plan JSON, PROJECT.md newer than the gate page, a date-only sign-off, BRDs in the usual folder, no +# session log, and a metrics ledger in UTC across two phases with a failed, a restored and a blocked run. +P10="$WORK/proj10"; mkproj "$P10" +rm -f "$P10/docs/BUILD-LOG.md" "$P10/docs/progress/TIME-LEDGER.md" +sed -i.bak 's/CONFIRMED 2026-10-10 12:00/CONFIRMED (delegated) 2026-10-10/' "$P10/PROJECT.md"; rm -f "$P10/PROJECT.md.bak" +mkdir -p "$P10/analysis/knowledge-base/brd"; printf '# BRD-01 Orders\n' > "$P10/analysis/knowledge-base/brd/BRD-01-orders.md"; printf '# BRD-02 Billing\n' > "$P10/analysis/knowledge-base/brd/BRD-02-billing.md" +printf '%s\n' \ + 'ts script gate result elapsed_s mxbuild_errors lint_rc lint_total restored score security quality architecture performance naming design other violations model_commit' \ + '2026-10-08T10:00:00Z 00-scaffold.mdl pass applied 10 0 0 0 90.0 90 90 90 90 90 90 90 0 aaa1111' \ + '2026-10-10T12:00:00Z 01-orders.mdl pass applied 30 0 0 0 91.5 90 90 90 90 90 90 90 0 aaa2222' \ + '2026-10-10T12:10:00Z 02-orders-fix.mdl fail rolled-back 20 3 0 1 ' \ + '2026-10-10T12:20:00Z 02-orders-fix.mdl pass applied 25 0 0 0 92.5 90 90 90 90 90 90 90 0 aaa3333' \ + '2026-10-10T12:30:00Z 03-billing.mdl not-run blocked 2 ? 0 ' \ + '2026-10-10T12:40:00Z 03-billing.mdl pass applied 40 0 0 0 93.5 90 90 90 90 90 90 90 0 aaa4444' \ + '2026-10-10T12:50:00Z 04-billing.mdl pass applied 45 0 0 0 94.5 90 90 90 90 90 90 90 0 aaa5555' > "$P10/.claude/loop/metrics-ledger.tsv" +touch "$P10/PROJECT.md"; CLAUDE_CONFIG_DIR="$EMPTY" "$PY" "$GEN" "$P10" --out "$WORK/out10/r.html" >/dev/null 2>&1 +O10="$WORK/out10/r.html" +has "$O10" "6 of 8 gates passed" "stage state from PROJECT.md (P,0,1,2,3,4), the gate page is stale" +has "$O10" "3 of 7 rows built, 1 partial" "plan counts 3 built, 1 partial from build-plan.md (the JSON is stale)" +has "$O10" "not built 3" "3 not built" +has "$O10" "class='card ph now' id='phase-2'" "building now: 04-billing.mdl is in Phase 2 (Billing)" +has "$O10" "class='card ph' id='phase-1'" "Phase 1 is not marked now" +"$PY" - "$O10" <<'PYEOF5' && ok "build line: 7 build runs, 5 passed, 2 failed or blocked (12% of build time), last run, latest model score" || bad "build line" +import re, sys +t = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", open(sys.argv[1], encoding="utf-8").read())) +assert "7 build runs, 5 passed, 2 failed or blocked (about 12% of build time). Last build step: 04-billing.mdl (applied)" in t, re.findall(r"\d+ build runs.{0,120}", t) +assert re.search(r"Latest model score 94\.5", t), "score" +PYEOF5 +"$PY" - "$O10" <<'PYEOF6' && ok "runs per phase sit inside the phase cards: 3 runs in Phase 1, 3 in Phase 2, no table" || bad "runs per phase" +import re, sys +h = open(sys.argv[1], encoding="utf-8").read() +for ph in ("1", "2"): + i = h.index("id='phase-%s'" % ph); j = h.index("", i) + assert "· 3 runs" in h[i:j], "phase %s: %r" % (ph, re.sub(r"<[^>]+>", " ", h[i:j])[:200]) +assert "Applied" not in h, "no per-phase runs table" +PYEOF6 +has "$O10" "04-billing.mdl" "last build step is the newest ledger row" +"$PY" - "$O10" <<'PYEOF7' && ok "timestamps are local time and the switch is the first ledger row on or after the date" || bad "local time and switch" +import re, sys, datetime +t = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", open(sys.argv[1], encoding="utf-8").read())) +utc = datetime.datetime(2026, 10, 10, 12, 0, tzinfo=datetime.timezone.utc).astimezone().strftime("%H:%M") +assert ("the switch to Stage 5 is %s, the earliest build activity on or after it (source: .claude/loop/metrics-ledger.tsv)" % utc) in t, re.findall(r"Stage switch.{0,200}", t) +m = re.search(r"Stage 5\s+(\d\d:\d\d)", t) +first = datetime.datetime(2026, 10, 10, 12, 0, tzinfo=datetime.timezone.utc).astimezone().strftime("%H:%M") +assert "04-billing.mdl (applied), " in t +PYEOF7 +hasnt "$O10" "id=build-facts" "no extra card for build facts" +card "$O10" 5 | grep -q "Exec runs" && bad "Stage 5 card gets no extra rows" || ok "Stage 5 card gets no extra rows" +hasnt "$O10" "href='[^']*progress-report" "the report never links itself" +P10B="$WORK/proj10b"; cp -R "$P10" "$P10B" +printf '%s\n' '2026-10-10T13:00:00Z 01-misc-tweak.mdl pass applied 5 0 0 0 ' >> "$P10B/.claude/loop/metrics-ledger.tsv" +CLAUDE_CONFIG_DIR="$EMPTY" "$PY" "$GEN" "$P10B" --out "$WORK/out10/b.html" >/dev/null 2>&1 +has "$WORK/out10/b.html" "class='card ph now' id='phase-1'" "a numbered script with no name match takes the phase of the nearest lower matched script (01-misc-tweak is Phase 1)" +card "$O10" 2 | grep -qi "brd" && ok "Stage 2 card lists the BRDs under analysis/knowledge-base/brd/" || bad "Stage 2 card lists the BRDs" + +echo "F11 a project set up by an older toolkit, then bin/sync-project.sh" +export MXTK_LEAKGUARD_DENYFILE="$WORK/denylist"; export MXTK_SYNC_SKIP_CLONE_CHECK=1 +P11="$WORK/proj11"; mkdir -p "$P11"; : > "$P11/Fixture.mpr" +MXTK_NO_GUIDE=1 "$ROOT/bin/init-project.sh" "$P11" >/dev/null 2>&1 +ADD="$(git -C "$ROOT" log --diff-filter=A --format=%h -- project-bin/progress-report.py 2>/dev/null | tail -1)" +if [ -n "$ADD" ] && git -C "$ROOT" show "$ADD^:project-bin/build-plan-status.sh" > "$WORK/old-bps.sh" 2>/dev/null; then + cp "$WORK/old-bps.sh" "$P11/bin/build-plan-status.sh"; chmod +x "$P11/bin/build-plan-status.sh"; rm -f "$P11/bin/progress-report.py" + mkdir -p "$P11/architecture"; printf 'old build-plan page\n' > "$P11/architecture/build-plan.html"; OLDSUM="$(cksum < "$P11/architecture/build-plan.html")" + cp "$P10/architecture/build-plan.md" "$P11/architecture/build-plan.md" + "$ROOT/bin/sync-project.sh" "$P11" >"$WORK/sync11.out" 2>&1; RC=$? + [ "$RC" -eq 0 ] && ok "sync-project.sh exits 0 on the older project" || { bad "sync-project.sh exit $RC"; tail -5 "$WORK/sync11.out"; } + [ -f "$P11/bin/progress-report.py" ] && ok "sync installed bin/progress-report.py" || bad "sync installed bin/progress-report.py" + cmp -s "$P11/bin/build-plan-status.sh" "$ROOT/project-bin/build-plan-status.sh" && ok "the older unedited build-plan-status.sh was refreshed" || bad "older build-plan-status.sh refreshed" + CLAUDE_CONFIG_DIR="$EMPTY" "$P11/bin/build-plan-status.sh" "$P11" --html >"$WORK/bps11.out" 2>&1; RC=$? + [ "$RC" -eq 0 ] && ok "build-plan-status.sh --html runs without error" || { bad "build-plan-status.sh --html exit $RC"; tail -5 "$WORK/bps11.out"; } + hasnt "$WORK/bps11.out" "Traceback\|not written\|WARN" "no error or warning in its output" + [ -s "$P11/architecture/progress-report.html" ] && ok "architecture/progress-report.html written" || bad "architecture/progress-report.html written" + has "$P11/architecture/progress-report.html" "replaces the former build-plan.html" "the page says it replaces build-plan.html" + [ "$(cksum < "$P11/architecture/build-plan.html")" = "$OLDSUM" ] && ok "the old build-plan.html is left exactly as it was (documented)" || bad "old build-plan.html untouched" + hasnt "$P11/architecture/progress-report.html" "href='[^']*progress-report" "the report never links itself" +else + ok "SKIP: toolkit git history not available for the older-toolkit fixture" +fi + +echo "F12 one stage switch for every per-stage view" +# Ledger: first row 09:00 local. Sign-off row 12:00 (later). Gate-check run at 09:50 must not move the switch. +P12="$WORK/proj12"; mkproj "$P12"; rm -f "$P12/docs/BUILD-LOG.md" "$P12/docs/progress/TIME-LEDGER.md"; P12R="$(cd "$P12" && pwd -P)" +CFG12="$WORK/cfg12"; mkdir -p "$CFG12/projects/-p12" +"$PY" - "$CFG12/projects/-p12/m.jsonl" "$P12R" "$P12R/.claude/loop/metrics-ledger.tsv" <<'PYEOF8' +import json, os, sys, datetime +out, proj, ledger = sys.argv[1:4] +def at(h, m): return datetime.datetime(2026, 10, 10, h, m).timestamp() +def iso(t): return datetime.datetime.fromtimestamp(t, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z") +n = [0] +def rec(rows, t, blocks, tin=1000): + n[0] += 1 + rows.append({"type": "assistant", "timestamp": iso(t), "cwd": proj, "uuid": "u%d" % n[0], "message": {"id": "m%d" % n[0], "model": "claude-sonnet-5-5", "role": "assistant", "content": blocks, + "usage": {"input_tokens": tin, "output_tokens": 100, "cache_read_input_tokens": 100, "cache_creation_input_tokens": 10}}}) +main = [] +rec(main, at(6, 55), [{"type": "tool_use", "id": "g0", "name": "Bash", "input": {"command": "bash bin/gate-check.sh 0"}}]) +rec(main, at(7, 5), [{"type": "tool_use", "id": "a4", "name": "Agent", "input": {"description": "Stage 4 build plan", "prompt": "x", "subagent_type": "general-purpose"}}]) +rec(main, at(9, 50), [{"type": "tool_use", "id": "g1", "name": "Bash", "input": {"command": "bash bin/gate-check.sh 4"}}]) +rec(main, at(10, 30), [{"type": "text", "text": "x"}]) +open(out, "w").write("".join(json.dumps(r, separators=(",", ":")) + "\n" for r in main)) +sd = out[:-6] + "/subagents"; os.makedirs(sd, exist_ok=True) +for i, (h, m, desc) in enumerate([(7, 5, "Stage 4 build plan"), (7, 30, "Phase 1 old planning notes"), (8, 45, "Phase 0 walking skeleton"), (9, 20, "Author Phase 4")]): + rows = []; rec(rows, at(h, m), [{"type": "text", "text": "x"}]); rec(rows, at(h, m) + 60, [{"type": "text", "text": "x"}]) + open("%s/agent-%d.jsonl" % (sd, i), "w").write("".join(json.dumps(r, separators=(",", ":")) + "\n" for r in rows)); json.dump({"description": desc}, open("%s/agent-%d.meta.json" % (sd, i), "w")) +utc = lambda t: datetime.datetime.fromtimestamp(t, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") +open(ledger, "w").write("ts\tscript\tgate\tresult\telapsed_s\tmxbuild_errors\tlint_rc\tlint_total\trestored\tscore\n%s\t00-first.mdl\tnot-run\tblocked\t2\t?\t\t\t0\t\n%s\t01-orders.mdl\tpass\tapplied\t40\t0\t0\t\t0\t90.0\n" % (utc(at(9, 0)), utc(at(9, 20)))) +PYEOF8 +CLAUDE_CONFIG_DIR="$EMPTY" "$PY" "$GEN" "$P12" --out "$WORK/out12/r.html" --session-log "$CFG12/projects/-p12/m.jsonl" >/dev/null 2>&1 +"$PY" - "$WORK/out12/r.html" <<'PYEOF9' && ok "windows table, chart band and sub-agent tables all use the 09:00 switch (earliest ledger row; gate-check at 09:50 and the 12:00 sign-off do not move it)" || bad "one stage switch for every view" +import re, sys +h = open(sys.argv[1], encoding="utf-8").read() +t = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", h)) +m4 = re.search(r"Stage 4 (\d\d:\d\d) to (\d\d:\d\d)", t); m5 = re.search(r"Stage 5 (\d\d:\d\d) to", t) +assert m4 and m5 and m4.group(2) == "09:00" and m5.group(1) == "09:00", "windows table: %r %r" % (m4 and m4.groups(), m5 and m5.group(1)) +def agents(stage): + i = h.find("id='agents-%s'" % stage) + if i < 0: return "" + return re.sub(r"<[^>]+>", " ", h[i:h.index("", i)]) +a4, a5 = agents("4"), agents("5") +assert "Stage 4 build plan" in a4 and "Phase 1 old planning notes" in a4 and "Phase 0" not in a4 and "Author Phase 4" not in a4, "stage 4 agents: " + a4[:300] +assert "Phase 0 walking skeleton" in a5 and "Author Phase 4" in a5 and "Stage 4 build plan" not in a5, "stage 5 agents: " + a5[:300] +rects = [(float(x), float(w), st) for st, x, w in re.findall(r"Stage (\w+)"$WORK/out13.cold" 2>&1 +CLAUDE_CONFIG_DIR="$CFG13" PROGRESS_REPORT_DEBUG=1 "$PY" "$GEN" "$P13" --out "$WORK/out13/b.html" >"$WORK/out13.warm" 2>&1 +[ -s "$P13/.claude/progress-report-sessions.txt" ] && ok "discovery cache written to .claude/progress-report-sessions.txt" || bad "discovery cache written" +grep -q "discovery cold" "$WORK/out13.cold" && grep -q "opened .*s1.jsonl" "$WORK/out13.cold" && ok "cold run reads the session file" || bad "cold run reads the session file" +grep -q "discovery warm, 0 file(s) read" "$WORK/out13.warm" && ok "warm run reads no file during discovery" || bad "warm run reads no file during discovery" +grep -q "opened .*s1.jsonl" "$WORK/out13.warm" && bad "warm run reopened the session file" || ok "warm run does not reopen the session file" +has "$WORK/out13/b.html" "Sessions from other folders (1)" "a warm run lists the same sessions" +touch "$CFG13/projects/-tmp-elsewhere/s1.jsonl"; echo >> "$CFG13/projects/-tmp-elsewhere/s1.jsonl" +CLAUDE_CONFIG_DIR="$CFG13" PROGRESS_REPORT_DEBUG=1 "$PY" "$GEN" "$P13" --out "$WORK/out13/c.html" >"$WORK/out13.chg" 2>&1 +grep -q "opened .*s1.jsonl" "$WORK/out13.chg" && ok "a changed session file is read again" || bad "a changed session file is read again" + +echo "F15 the cache is tied to the project path and the attribution rules" +P15="$WORK/proj15"; mkproj "$P15"; P15R="$(cd "$P15" && pwd -P)" +CFG15="$WORK/cfg15"; mklog "$CFG15" "$P15R" 25 +CLAUDE_CONFIG_DIR="$CFG15" PROGRESS_REPORT_DEBUG=1 "$PY" "$GEN" "$P15" --out "$WORK/out15/a.html" >"$WORK/out15.a" 2>&1 +has "$WORK/out15/a.html" "Sessions from other folders (1)" "the original project lists the session" +head -1 "$P15/.claude/progress-report-sessions.txt" | grep -q "root=$P15R" && head -1 "$P15/.claude/progress-report-sessions.txt" | grep -q "rules=share=0.25;calls=20" && ok "cache header holds the project path and the 25% / 20 calls rules" || bad "cache header holds path and rules" +P15B="$WORK/proj15-copy"; cp -R "$P15" "$P15B" +CLAUDE_CONFIG_DIR="$CFG15" PROGRESS_REPORT_DEBUG=1 "$PY" "$GEN" "$P15B" --out "$WORK/out15/b.html" >"$WORK/out15.b" 2>&1 +grep -q "discovery cold" "$WORK/out15.b" && ok "a copied project with its cache starts cold" || bad "a copied project starts cold" +hasnt "$WORK/out15/b.html" "Sessions from other folders (1)" "the copy does not show the original project's sessions" +CLAUDE_CONFIG_DIR="$CFG15" PROGRESS_REPORT_DEBUG=1 "$PY" "$GEN" "$P15" --out "$WORK/out15/c.html" >"$WORK/out15.c" 2>&1 +grep -q "discovery warm" "$WORK/out15.c" && ok "the original's own cache is still warm" || bad "the original's cache is warm" +sed -i.bak '1s/rules=[^\t]*/rules=share=0.10;calls=5/' "$P15/.claude/progress-report-sessions.txt"; rm -f "$P15/.claude/progress-report-sessions.txt.bak" +CLAUDE_CONFIG_DIR="$CFG15" PROGRESS_REPORT_DEBUG=1 "$PY" "$GEN" "$P15" --out "$WORK/out15/d.html" >"$WORK/out15.d" 2>&1 +grep -q "discovery cold" "$WORK/out15.d" && ok "a cache written under other thresholds is rebuilt" || bad "other thresholds rebuild the cache" + +echo "F16 a cached run reads no more files than the uncached one (cap)" +P16="$WORK/proj16"; mkproj "$P16"; P16R="$(cd "$P16" && pwd -P)" +CFG16="$WORK/cfg16"; for n in 1 2 3 4 5; do mklog "$CFG16" "$P16R" 3 "n$n"; done +PROGRESS_REPORT_SCAN_MAX_FILES=2 CLAUDE_CONFIG_DIR="$CFG16" PROGRESS_REPORT_DEBUG=1 "$PY" "$GEN" "$P16" --out "$WORK/out16/a.html" >"$WORK/out16.cold" 2>&1 +PROGRESS_REPORT_SCAN_MAX_FILES=2 CLAUDE_CONFIG_DIR="$CFG16" PROGRESS_REPORT_DEBUG=1 "$PY" "$GEN" "$P16" --out "$WORK/out16/b.html" >"$WORK/out16.warm" 2>&1 +RC="$(sed -n 's/.*discovery cold, \([0-9]*\) file(s) read.*/\1/p' "$WORK/out16.cold")"; RW="$(sed -n 's/.*discovery warm, \([0-9]*\) file(s) read.*/\1/p' "$WORK/out16.warm")" +[ "$RC" = "2" ] && ok "cold run with a cap of 2 reads 2 files" || bad "cold run reads 2 files (got '$RC')" +[ -n "$RW" ] && [ "$RW" -le "$RC" ] && ok "the cached run read $RW file(s), not more than the uncached $RC" || bad "cached run reads no more than uncached (cold '$RC', warm '$RW')" +grep -c $'^no\t' "$P16/.claude/progress-report-sessions.txt" | grep -q "^[3-9]" && ok "files past the cap are recorded as no" || bad "files past the cap recorded as no" + +echo "F17 the exec log decides the switch; session-log signals are ignored when it has rows" +P17="$WORK/proj17"; mkproj "$P17" +sed -i.bak 's/CONFIRMED 2026-10-10 12:00/CONFIRMED (delegated) 2026-10-10/' "$P17/PROJECT.md"; rm -f "$P17/PROJECT.md.bak" +P17R="$(cd "$P17" && pwd -P)"; CFG17="$WORK/cfg17"; mklog "$CFG17" "$P17R" 25 s17 +TZ=UTC CLAUDE_CONFIG_DIR="$EMPTY" "$PY" "$GEN" "$P17" --out "$WORK/out17/r.html" --session-log "$CFG17/projects/-tmp-elsewhere/s17.jsonl" >/dev/null 2>&1 +"$PY" - "$WORK/out17/r.html" <<'PYEOF17' && ok "log has an exec.sh call at 11:50 on the 10th; the switch stays at the first BUILD-LOG row (12 Oct 10:00)" || bad "switch follows the ledger, not the session log" +import re, sys +t = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", open(sys.argv[1], encoding="utf-8").read())) +assert "Stage 5 is 12 Oct 10:00" in t, "switch not at the first ledger row" +assert "first exec.sh call in the session log" not in t, "session signal named as the source" +PYEOF17 +P17B="$WORK/proj17b"; mkproj "$P17B"; rm -f "$P17B/docs/BUILD-LOG.md" +sed -i.bak 's/CONFIRMED 2026-10-10 12:00/CONFIRMED (delegated) 2026-10-10/' "$P17B/PROJECT.md"; rm -f "$P17B/PROJECT.md.bak" +TZ=UTC CLAUDE_CONFIG_DIR="$EMPTY" "$PY" "$GEN" "$P17B" --out "$WORK/out17/b.html" --session-log "$CFG17/projects/-tmp-elsewhere/s17.jsonl" >/dev/null 2>&1 +"$PY" - "$WORK/out17/b.html" <<'PYEOF17B' && ok "control: with no ledger rows the session's exec.sh call (on the 10th) still sets the switch" || bad "control: no ledger, session log decides" +import re, sys +t = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", open(sys.argv[1], encoding="utf-8").read())) +assert "Stage 5 is 10 Oct" in t and "12 Oct" not in t.split("Detected starts")[1][:30], "session log should set the switch on the 10th" +PYEOF17B + +echo "F14 autoreload keeps the view, waits for idle and can be paused" +P14="$WORK/proj14"; mkproj "$P14"; mkdir -p "$P14/.claude/loop" +NOWU="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +printf 'ts\tscript\tgate\tresult\telapsed_s\tmxbuild_errors\tlint_rc\tlint_total\trestored\tscore\n%s\t01-orders.mdl\tpass\tapplied\t40\t0\t0\t\t0\t90.0\n' "$NOWU" > "$P14/.claude/loop/metrics-ledger.tsv" +CLAUDE_CONFIG_DIR="$EMPTY" "$PY" "$GEN" "$P14" --out "$WORK/out14/r.html" >/dev/null 2>&1 +has "$WORK/out14/r.html" "history.scrollRestoration='manual'" "scrollRestoration is set to manual" +has "$WORK/out14/r.html" "sessionStorage" "scroll position and open details go to sessionStorage" +has "$WORK/out14/r.html" "id=livebtn" "Pause control present" +has "$WORK/out14/r.html" "Live · refreshes every 30 s" "live indicator present" +if command -v node >/dev/null 2>&1; then +node - "$WORK/out14/r.html" <<'JSEOF' && ok "node: state saved and restored, no reload within 20 s of input, Pause stops it" || bad "autoreload behaviour (node)" +const fs = require("fs"); +const h = fs.readFileSync(process.argv[2], "utf8"); +const src = h.match(/