From f0295a3d1a4bca7f8a76bd29ec839b646ca96f4a Mon Sep 17 00:00:00 2001 From: MendixMau Date: Fri, 16 Oct 2026 16:41:41 +0800 Subject: [PATCH 1/9] new(process-board): Stage 3 board that pins each question to its process step One swimlane board per business process. Each step sits in its role lane, shows its wireframe and what the system does, and carries a pin for every open question and ASSUMED decision about that moment, so a reviewer can answer a question where it happens instead of in a flat list. - skills/process-board.md: when, model authoring, anchoring, build, verify by looking - project-bin/process-board.js + template: validate, anchor (derived plus judged by fingerprint, global counted), build (self-contained, light and dark, deterministic) - Stage 3 Agent produces and Surface rows, process-board manifest row, swimlanes opt-in discharged by the model, CAC-4 line, routing row, install pair, check_stage_3 FAIL on a missing or stale board - tests/wave2/test-process-board.sh on a synthetic order approval fixture Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 1 + ROUTING.md | 1 + agents/architect-agent.md | 1 + agents/review-agent.md | 1 + bin/gate-check.sh | 20 +- bin/lib/artifact-manifest.tsv | 3 +- bin/lib/install-manifest.sh | 6 +- bin/lib/skill-routing.tsv | 1 + project-bin/process-board.js | 484 ++++++++++++++++ project-bin/process-board.template.html | 516 ++++++++++++++++++ skills/checkpoints/checkpoint-design.md | 3 + skills/conversion-runbook.md | 4 +- skills/process-board.md | 151 +++++ tests/wave2/fixtures/process-board/PROJECT.md | 16 + .../fixtures/process-board/design/ds.css | 3 + .../process-board/design/process-model.json | 29 + .../design/question-anchors.json | 7 + .../design/wireframes/Order_Approve.html | 9 + .../design/wireframes/Order_NewEdit.html | 9 + .../design/wireframes/Order_Overview.html | 9 + .../design/wireframes/Settings_Edit.html | 9 + tests/wave2/test-process-board.sh | 166 ++++++ 23 files changed, 1445 insertions(+), 5 deletions(-) create mode 100644 project-bin/process-board.js create mode 100644 project-bin/process-board.template.html create mode 100644 skills/process-board.md create mode 100644 tests/wave2/fixtures/process-board/PROJECT.md create mode 100644 tests/wave2/fixtures/process-board/design/ds.css create mode 100644 tests/wave2/fixtures/process-board/design/process-model.json create mode 100644 tests/wave2/fixtures/process-board/design/question-anchors.json create mode 100644 tests/wave2/fixtures/process-board/design/wireframes/Order_Approve.html create mode 100644 tests/wave2/fixtures/process-board/design/wireframes/Order_NewEdit.html create mode 100644 tests/wave2/fixtures/process-board/design/wireframes/Order_Overview.html create mode 100644 tests/wave2/fixtures/process-board/design/wireframes/Settings_Edit.html create mode 100755 tests/wave2/test-process-board.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aa535dc..d3bff4c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased +- new(process-board): **Stage 3 process board: one swimlane board per business process, each step on its wireframe, every open question and ASSUMED decision pinned to the step it is about.** `skills/process-board.md` (when, model, anchoring, build, verify by looking) and `project-bin/process-board.js` (`validate`, `anchor`, `build`; Node stdlib, deterministic) with its template. `validate` fails a step page with no wireframe, a use case on no process and not standalone, a decision with one outcome, two steps in one cell. `anchor` places items mechanically (named microflow, listed or named use case, named screen, overlapping source lines) and keeps judged placements by fingerprint, so a reworded question is unplaced again instead of silently wrong; global buckets are allowed and counted. Wired into the Stage 3 Agent produces and Surface rows, a `process-board` manifest row (report), the `swimlanes` opt-in row (the model discharges it), CAC-4, and a check_stage_3 FAIL when the board is older than its model or anchors. `tests/wave2/test-process-board.sh` on a synthetic order approval fixture. Field evidence: a requirements-driven rebuild POC, 2026-10-16, where the reviewer could not tell where the sixty questions fit; the board placed 102 items with 0 unplaced. MendixMau - learn(skills): **five lab findings land as compact rows, each paid for by trimming prose in the same files, so always-loaded words do not grow (baseline 75,225 to 75,204, budget 80,000).** Enum value `New` and `AutoCreatedDate`/`AutoOwner` (detection gaps), `[%CurrentDateTime%]` not `currentDateTime()`, expression return types (Decimal from date diffs and `div`, absolute `secondsBetween`, `formatDecimal` on pages), CE2729 from the default home page and page microflow grants, Atlas selector carriers for list views, page body and timelines, `-small` spacing suffixes do not exist, `display: contents` rows paint nothing, CSS pseudo text is invisible to `innerText` — lab field runs, Refs #276 (TK-020, TK-021, TK-022, TK-024) - fix(design-check): **a design system whose palette lives only in scoped `:root.` blocks no longer reads as "0 of N tokens carry the design's value".** Pass 2b compared every token against a bare `:root` of the built sheet, so a themed-only palette always warned, a false warning that teaches agents to ignore the check. A token the design declares only in scoped blocks is now compared block for block against the same-named block in the built sheet, and a wrong value is still named with its block (knob binding stays bare-only: a knob bound inside a theme the app is not wearing is not bound). Synthetic fixtures `ds-themed.css`, `built-themed.css`, `built-themed-wrong.css`; 5 new cases in `test-design-reaches-app.sh` (50 total) — TK-015 part (b) - fix(tests): **`test-exec-hash-verify.sh` case K passes on macOS.** `$TMPDIR` ends in `/`, so the bare `mktemp` path held `//` and the exact `MXPATCH_BACKUP_DIR` path assert could not match; `WORK` is now `cd`-ed into and read back with `pwd -P`. Test only, no script change. 42 pass / 1 fail before, 43 / 0 after — #384 review diff --git a/README.md b/README.md index 6b5bf96f..219d3770 100644 --- a/README.md +++ b/README.md @@ -595,6 +595,7 @@ Every mxcli project has a `.ai-context/skills/` directory (bundled by `mxcli ini | After every wireframe edit: assembles design/wireframes/*.html into design/prototype.html, one hash-routed page a stakeholder can click through instead of twenty separate files. Generated, never edited (design-artifacts.md Step 3) | `project-bin/assemble-prototype.js` | | Before wireframes pass to the build loop, and with --brd before a BRD is signed off: dead #/route links, orphan screens, controls with no data-bind and no data-cut, BRD routes no screen has, screens no use case walks (design-artifacts.md Step 3c, brd-validation.md check 8) | `project-bin/check-prototype-links.js` | | Designing the brand and ONE ANNOTATED WIREFRAME PER SCREEN before building pages — the design system alone is half the deliverable | `skills/design-artifacts.md` | +| After the wireframes, when a reviewer cannot tell where the open questions and ASSUMED decisions fit: one swimlane board per process, each step on its wireframe, every question pinned to a step (project-bin/process-board.js validate, anchor, build) | `skills/process-board.md` | | Stage 3, after the role model is CONFIRMED and BEFORE THE FIRST WIREFRAME — and any time a page shows ONE object, a menu item or home page is added, or a data source retrieves the current/first record: role homes, inboxes, and per page how it is reached and which object it takes as parameter | `skills/interaction-model.md` | | Before a wireframe or a design commits to a WIDGET, when a requirement shows a widget to one role and not another, and when a page script hits a parse error that looks like a syntax mistake: the short list of things MDL cannot write at all, and the four-minute probe that answers it at Stage 3 instead of at build time | `skills/learned-mdl-cannot-express.md` | diff --git a/ROUTING.md b/ROUTING.md index 5b110dd8..a4e2cf95 100644 --- a/ROUTING.md +++ b/ROUTING.md @@ -131,6 +131,7 @@ picks the row up. That is the whole procedure — there is no second list to rem | After every wireframe edit: assembles design/wireframes/*.html into design/prototype.html, one hash-routed page a stakeholder can click through instead of twenty separate files. Generated, never edited (design-artifacts.md Step 3) | `project-bin/assemble-prototype.js` | architect,review | 3 | ondemand | | Before wireframes pass to the build loop, and with --brd before a BRD is signed off: dead #/route links, orphan screens, controls with no data-bind and no data-cut, BRD routes no screen has, screens no use case walks (design-artifacts.md Step 3c, brd-validation.md check 8) | `project-bin/check-prototype-links.js` | architect,review | 3 | ondemand | | Designing the brand and ONE ANNOTATED WIREFRAME PER SCREEN before building pages — the design system alone is half the deliverable | `skills/design-artifacts.md` | architect | 3 | ondemand | +| After the wireframes, when a reviewer cannot tell where the open questions and ASSUMED decisions fit: one swimlane board per process, each step on its wireframe, every question pinned to a step (project-bin/process-board.js validate, anchor, build) | `skills/process-board.md` | architect,review | 3 | ondemand | | Stage 3, after the role model is CONFIRMED and BEFORE THE FIRST WIREFRAME — and any time a page shows ONE object, a menu item or home page is added, or a data source retrieves the current/first record: role homes, inboxes, and per page how it is reached and which object it takes as parameter | `skills/interaction-model.md` | lead,ba,architect | 3 | ondemand | | Before a wireframe or a design commits to a WIDGET, when a requirement shows a widget to one role and not another, and when a page script hits a parse error that looks like a syntax mistake: the short list of things MDL cannot write at all, and the four-minute probe that answers it at Stage 3 instead of at build time | `skills/learned-mdl-cannot-express.md` | architect,mdl | 3,5 | ondemand | | A CSS/SCSS or style change that appears to have done nothing (a colour or class that won't show), a focus ring or outline that paints on every page landing or load, or an app still grey after a design port every instrument called green — the three ways a correct rule paints nothing (matches nothing / matches chrome / loses the cascade), the two reads that tell them apart, and the class that arrived in the stylesheet and is bound to no widget | `skills/learned-css-that-never-applied.md` | mdl?,review,gate | 5,6 | baseline | diff --git a/agents/architect-agent.md b/agents/architect-agent.md index 576772fe..127a89a1 100644 --- a/agents/architect-agent.md +++ b/agents/architect-agent.md @@ -68,6 +68,7 @@ This table is your whole list. The baseline table in the project's CLAUDE.local. | `skills/architecture-blueprint.md` | Diagramming target architecture | | `skills/modularize-domain.md` | Deciding module boundaries before "create module" | | `skills/design-artifacts.md` | Designing the brand and ONE ANNOTATED WIREFRAME PER SCREEN before building pages | +| `skills/process-board.md` | After the wireframes, when a reviewer cannot tell where the open questions and ASSUMED decisions fit: one swimlane board per process, each step on its wireframe, every question pinned to a step (project-bin/process-board.js validate, anchor, build) | | `skills/interaction-model.md` | Stage 3, after the role model is CONFIRMED and BEFORE THE FIRST WIREFRAME | | `skills/brd-to-build-plan.md` | Turning BRDs plus architecture into a numbered, dependency-ordered build plan | | `skills/coverage-ledger.md` | Building the Stage 4 coverage ledger | diff --git a/agents/review-agent.md b/agents/review-agent.md index f8590697..f4335750 100644 --- a/agents/review-agent.md +++ b/agents/review-agent.md @@ -64,6 +64,7 @@ This table is your whole list. The baseline table in the project's CLAUDE.local. | `skills/app-analysis.md` | Building or refreshing the standing dossier of an EXISTING app before changing it: inventory, module dependency shape, loop risk patterns, each section with a verdict and a fault where nothing was measured (a standing document; nothing else in the toolkit reads it yet) | | `skills/module-dependency-review.md` | Judging module tangles, bidirectional pairs, cohesion and hubs from analysis/app-facts/dependencies.json; stock graph_module_* views split names on the first dot and are not trusted | | `skills/microflow-loop-antipatterns.md` | Reading what loop bodies do (LOOP_TQ, deferred commit, nested loop, REST in loop, transaction control per item, scheduled-event reachability) from described MDL; the catalog holds top-level activities only and cannot see inside a loop | +| `skills/process-board.md` | After the wireframes, when a reviewer cannot tell where the open questions and ASSUMED decisions fit: one swimlane board per process, each step on its wireframe, every question pinned to a step (project-bin/process-board.js validate, anchor, build) | | `project-bin/conformance-check.sh` | Running the ledger rung alone | | `project-bin/graph-sweep.sh` | Running the wiring rung alone | | `skills/fixture-seeding.md` | Seed data, test data, demo data or fixtures are needed, or pages come up empty | diff --git a/bin/gate-check.sh b/bin/gate-check.sh index 233dc15e..aa6c68e4 100755 --- a/bin/gate-check.sh +++ b/bin/gate-check.sh @@ -1159,6 +1159,24 @@ check_stage_3() { return fi done + # The process board (skills/process-board.md) is optional, but once its model exists the + # render must be current: a reviewer signs off on what the board shows, so a board older than + # its model or its anchors shows the wrong process. Same rule as blueprint.html above. + local pb_model pb_html pb_src + pb_model="$(resolve_artifact_nonempty "design/process-model.json")" + if [ -n "$pb_model" ]; then + pb_html="$(dirname "$pb_model")/process-board.html" + if [ ! -s "$pb_html" ]; then + echo "FAIL|design/process-model.json exists but design/process-board.html does not; render it (process-board.md Step 4: process-board.js build)" + return + fi + for pb_src in "$pb_model" "$(dirname "$pb_model")/question-anchors.json"; do + if [ -f "$pb_src" ] && [ "$pb_html" -ot "$pb_src" ]; then + echo "FAIL|design/process-board.html is older than $(basename "$pb_src"): stale render at a sign-off gate; regenerate (process-board.md Step 4)" + return + fi + done + fi if reg_unavailable; then reg_unavailable_note; return; fi if has_confirmed_decision 3; then echo "PASS|fit-gap, blueprint render, design system, wireframes present and a Stage-3 CONFIRMED decision is in $REGISTER" @@ -1769,7 +1787,7 @@ stage_protocol_paths() { 0) echo "skills/interview-protocol.md skills/grill-mode.md skills/checkpoints/checkpoint-template.md skills/checkpoints/checkpoint-scope.md skills/source-triage.md skills/image-transcription.md skills/small-project-tier.md skills/existing-app-change.md skills/decompose-app.md skills/app-analysis.md skills/layering-review.md skills/module-dependency-review.md skills/microflow-loop-antipatterns.md skills/assess-migration.md skills/migration-pipeline.md skills/rehost-app.md skills/migrate-general.md skills/migrate-outsystems.md skills/source-os11.md skills/os-xml-schema.md skills/source-node-express-react.md skills/document-discovery.md skills/extractor-quality-loop.md skills/qa-loop-goal-pattern.md skills/mendix-epics-api.md skills/corpus-extraction-integrity.md skills/gate-check-file-locations.md" ;; 1) echo "skills/interview-protocol.md skills/grill-mode.md skills/checkpoints/checkpoint-template.md skills/checkpoints/checkpoint-extraction.md skills/dispatching.md skills/image-transcription.md skills/small-project-tier.md skills/migration-pipeline.md skills/rehost-app.md skills/source-os11.md skills/os-xml-schema.md skills/source-node-express-react.md skills/document-discovery.md skills/extractor-quality-loop.md skills/kb-generation.md skills/corpus-extraction-integrity.md" ;; 2) echo "skills/interview-protocol.md skills/change-intake.md skills/change-intake-detail.md skills/grill-mode.md skills/checkpoints/checkpoint-template.md skills/checkpoints/checkpoint-brd.md skills/checkpoints/checkpoint-architecture.md skills/dispatching.md skills/image-transcription.md skills/small-project-tier.md skills/kb-generation.md skills/brd-generation.md skills/brd-validation.md" ;; - 3) echo "skills/interview-protocol.md skills/change-intake.md skills/change-intake-detail.md skills/grill-mode.md skills/checkpoints/checkpoint-template.md skills/checkpoints/checkpoint-design.md skills/dispatching.md skills/small-project-tier.md skills/mendix-best-practices-index.md skills/decompose-app.md skills/mendix-service-seam.md skills/layering-review.md skills/architecture-blueprint.md skills/modularize-domain.md skills/design-artifacts.md skills/interaction-model.md skills/brd-to-build-plan.md skills/workflow-structure-rules.md skills/rest-between-mendix-apps.md skills/learned-mdl-cannot-express.md" ;; + 3) echo "skills/interview-protocol.md skills/change-intake.md skills/change-intake-detail.md skills/grill-mode.md skills/checkpoints/checkpoint-template.md skills/checkpoints/checkpoint-design.md skills/dispatching.md skills/small-project-tier.md skills/mendix-best-practices-index.md skills/decompose-app.md skills/mendix-service-seam.md skills/layering-review.md skills/architecture-blueprint.md skills/modularize-domain.md skills/design-artifacts.md skills/process-board.md skills/interaction-model.md skills/brd-to-build-plan.md skills/workflow-structure-rules.md skills/rest-between-mendix-apps.md skills/learned-mdl-cannot-express.md" ;; 4) echo "skills/interview-protocol.md skills/change-intake.md skills/change-intake-detail.md skills/grill-mode.md skills/checkpoints/checkpoint-template.md skills/checkpoints/checkpoint-build.md skills/agent-roles.md skills/dispatching.md skills/small-project-tier.md skills/module-brief.md skills/module-folder-convention.md skills/brd-to-build-plan.md skills/coverage-ledger.md skills/workflow-structure-rules.md skills/rest-integration-first-time-right.md skills/rest-between-mendix-apps.md skills/learned-constants-and-secrets.md" ;; 5|build-ready) echo "skills/interview-protocol.md skills/change-intake.md skills/change-intake-detail.md skills/grill-mode.md skills/agent-roles.md skills/dispatching.md skills/module-brief.md skills/learned-mdl-preflight.md skills/module-folder-convention.md skills/learned-microflow-patterns.md skills/microflow-preflight.md skills/mendix-best-practices-index.md skills/ui-preflight-pages.md skills/design-spacing.md skills/learned-stylegallery.md skills/ui-loop.md skills/module-review.md skills/testing-shape.md skills/decompose-app.md skills/mendix-service-seam.md skills/microflow-loop-antipatterns.md skills/rehost-app.md skills/iterative-build-loop.md skills/mdl-cookbook-microflows.md skills/build/mdl/oneshot-mdl-method.md skills/learned-page-patterns.md skills/oneshot-page-structure-patterns.md skills/mendix-agents.md skills/mendix-agent-ui.md skills/mendix-agent-setup.md skills/fixture-seeding.md skills/journey-proof.md skills/unhappy-path-testing.md skills/monkey-test.md skills/e2e-locators-that-lie.md skills/report-schema.md skills/harness-architecture.md skills/process-coherence-pass.md skills/lint-that-actually-runs.md skills/improvement-register.md skills/journey-examples.md skills/wiring-sweep.md skills/learned-workflow-patterns.md skills/workflow-structure-rules.md skills/rest-integration-first-time-right.md skills/rest-between-mendix-apps.md skills/bug-submission-checklist.md skills/empty-widget-triage.md skills/learned-sidebar-collapse-icons.md skills/learned-popup-navigation.md skills/learned-datagrid-customcontent-binding.md skills/learned-popup-feedback-pattern.md skills/learned-mdl-cannot-express.md skills/learned-css-that-never-applied.md skills/learned-detection-gaps.md skills/learned-dg2-patterns.md skills/learned-file-upload-widget.md skills/security-is-not-a-later-script.md skills/learned-local-db-confusion.md skills/full-harness-audit.md skills/test-result-audit.md skills/finding-disposition.md skills/preview-over-hub-tunnel.md skills/walking-skeleton.md skills/platform-link.md skills/teamserver-alignment.md skills/learned-constants-and-secrets.md" ;; 6) echo "skills/interview-protocol.md skills/change-intake.md skills/change-intake-detail.md skills/grill-mode.md skills/checkpoints/checkpoint-template.md skills/checkpoints/checkpoint-cutover.md skills/dispatching.md skills/mendix-best-practices-index.md skills/module-review.md skills/testing-shape.md skills/existing-app-assurance.md skills/decompose-app.md skills/app-analysis.md skills/module-dependency-review.md skills/microflow-loop-antipatterns.md skills/rehost-app.md skills/qa-loop-goal-pattern.md skills/mendix-agent-setup.md skills/e2e-harness-base.md skills/learned-db-assertions.md skills/fixture-seeding.md skills/journey-proof.md skills/unhappy-path-testing.md skills/monkey-test.md skills/e2e-locators-that-lie.md skills/learned-skill-ux-audit.md skills/learned-skill-scope-delta.md skills/report-schema.md skills/harness-architecture.md skills/process-coherence-pass.md skills/e2e-evidence-report.md skills/record-demo-video.md skills/share-demo-package.md skills/lint-that-actually-runs.md skills/improvement-register.md skills/journey-examples.md skills/wiring-sweep.md skills/workflow-structure-rules.md skills/rest-between-mendix-apps.md skills/bug-submission-checklist.md skills/empty-widget-triage.md skills/anonymize-client-app-for-demo.md skills/learned-css-that-never-applied.md skills/learned-detection-gaps.md skills/learned-local-db-confusion.md skills/full-harness-audit.md skills/test-result-audit.md skills/finding-disposition.md skills/handoff-to-studio-pro.md skills/preview-over-hub-tunnel.md skills/platform-link.md skills/teamserver-alignment.md skills/learned-constants-and-secrets.md" ;; diff --git a/bin/lib/artifact-manifest.tsv b/bin/lib/artifact-manifest.tsv index 93c912ab..ed77a71d 100644 --- a/bin/lib/artifact-manifest.tsv +++ b/bin/lib/artifact-manifest.tsv @@ -86,6 +86,7 @@ module-design 3 architecture/module-design.html|module-design.html skills/modula fit-gap 3 architecture/fit-gap.md skills/architecture-blueprint.md Step 4 bin/gate-check.sh:912 (check_stage_3), bin/gate-check.sh:939 (blueprint.html staleness input) migration,requirements,existing-app-change gate blueprint-md 3 architecture/blueprint.md skills/architecture-blueprint.md Steps 2-6 — the markdown is the source of truth bin/gate-check.sh:939 (staleness input), skills/architecture-blueprint.md:54 (render source for blueprint.html) migration,requirements,existing-app-change report blueprint-html 3 architecture/blueprint.html skills/architecture-blueprint.md Step 7 (generated render, never hand-edited) bin/gate-check.sh:931-943 (check_stage_3 presence + freshness) migration,requirements,existing-app-change gate +process-board 3 design/process-board.html skills/process-board.md Step 4 (project-bin/process-board.js build; generated from design/process-model.json, never hand-edited) bin/gate-check.sh:1166-1177 (check_stage_3 freshness against design/process-model.json and its anchors), skills/checkpoints/checkpoint-design.md:16 (CAC-4 walks the questions on the board) migration,requirements,existing-app-change report design-system 3 design/design-system.html skills/design-artifacts.md:79 (annotated showcase linking ds.css) bin/gate-check.sh:913 (check_stage_3), bin/gate-check.sh:1135 (build-ready) migration,requirements,existing-app-change gate ds-css 3 design/ds.css skills/design-artifacts.md:75 (the physical token/component source) skills/design-artifacts.md:68 (two-files rule — every wireframe links it), bin/gate-check.sh:1135 (build-ready accepts either file) migration,requirements,existing-app-change report 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 @@ -109,7 +110,7 @@ verification-html 6 docs/verification/report.html|test-report.html|reports/test- # alternative is the name a real project already used (workflow-design: a field project's # architecture/workflow-definition.md, written unprompted and never tracked by anything). workflow-design 3 architecture/workflow-definition.md|architecture/workflow-design.md|architecture/workflows/*.md skills/architecture-blueprint.md Step 3b (state diagram per process: states, transitions, who moves it, SLA) + skills/learned-workflow-patterns.md for the native-Workflow shape skills/checkpoints/checkpoint-architecture.md Q3 (asks whether the process is Workflow or enum-driven — either way this is where it is drawn), skills/learned-workflow-patterns.md (the build-time reader), bin/lib/closeout.sh (offers it at the Stage-3 open, lists it at the close) migration,requirements,greenfield,existing-app-change optin -swimlanes 3 architecture/swimlanes.md|architecture/swimlane-*.md|architecture/diagrams/swimlane*.md Mermaid per business process, one lane per role from the Stage-3 role model, one section per process; the cross-persona journey diagrams of skills/architecture-blueprint.md Step 3d are the multi-persona case of the same picture skills/checkpoints/checkpoint-architecture.md (the optional-artifacts question), skills/architecture-blueprint.md Step 3d (journey list it feeds), skills/journey-proof.md (the journeys a swimlane names are the ones Stage 5–6 must prove), bin/lib/closeout.sh migration,requirements,greenfield,existing-app-change optin +swimlanes 3 architecture/swimlanes.md|architecture/swimlane-*.md|architecture/diagrams/swimlane*.md|design/process-model.json Mermaid per business process, one lane per role from the Stage-3 role model, one section per process; the cross-persona journey diagrams of skills/architecture-blueprint.md Step 3d are the multi-persona case of the same picture; or the process board model of skills/process-board.md Step 2, which draws the same lanes and adds the screens and questions skills/checkpoints/checkpoint-architecture.md (the optional-artifacts question), skills/architecture-blueprint.md Step 3d (journey list it feeds), skills/journey-proof.md (the journeys a swimlane names are the ones Stage 5–6 must prove), bin/lib/closeout.sh migration,requirements,greenfield,existing-app-change optin sequence-diagrams 3 architecture/sequence-diagrams.md|architecture/sequence-*.md|architecture/diagrams/sequence*.md Mermaid sequenceDiagram per integration or agent call — caller, Mendix microflow, external system, sync/async, timeout, fallback; the agent-wiring diagram of skills/architecture-blueprint.md Step 3c is one instance skills/checkpoints/checkpoint-architecture.md Q3 agent-wiring half, skills/rest-integration-first-time-right.md (build-time reader for the REST ones), skills/mendix-agents.md, bin/lib/closeout.sh migration,requirements,greenfield,existing-app-change optin erd 3 architecture/erd.md|architecture/domain-model.md|architecture/diagrams/erd*.md Mermaid erDiagram of the TARGET domain across modules — entities, associations, ownership per module; the blueprint's layer diagram shows modules, not entities, and .mx-brd.json shows one module at a time skills/checkpoints/checkpoint-architecture.md Q2 (cross-module data strategy — the picture that question is about), skills/modularize-domain.md, bin/lib/closeout.sh migration,requirements,greenfield,existing-app-change optin nfr-sheet 3 architecture/nfr.md|architecture/nfrs.md|architecture/non-functional-requirements.md One table: NFR, target, how measured, where in the model it is enforced (XPath, index, batch size, timeout) — the per-NFR expansion of the list CAC-3's open question writes to project-profile.md skills/checkpoints/checkpoint-architecture.md open question (the list), skills/architecture-blueprint.md Step 5 (the register that must not lose them), bin/lib/closeout.sh migration,requirements,greenfield,existing-app-change optin diff --git a/bin/lib/install-manifest.sh b/bin/lib/install-manifest.sh index f8003662..e815b5cd 100644 --- a/bin/lib/install-manifest.sh +++ b/bin/lib/install-manifest.sh @@ -115,7 +115,11 @@ MXTK_AGENTS_STAGE_BUILD="mdl-agent.md gate-agent.md test-agent.md review-agent.m # may run itself (stops only the pid run --local recorded for THIS project), and `mxcli test` on a # scratch copy under the test-port lock. exec.sh names app-restart.sh in its watch advisory, so # it has to be installed beside it. -MXTK_PROJECT_BIN="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" +# process-board.js + process-board.template.html joined on 2026-10-16 and install as a PAIR: +# the generator reads the template from its own directory. The project runs it at Stage 3 +# (skills/process-board.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-board.js process-board.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" # 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 c425d8e9..e87d5271 100644 --- a/bin/lib/skill-routing.tsv +++ b/bin/lib/skill-routing.tsv @@ -152,6 +152,7 @@ brd-validation skills/brd-validation.md Validating BRDs against the code and doc architecture-blueprint skills/architecture-blueprint.md Diagramming target architecture — module defs, wiring, fit-gap, marketplace, security, NFRs, integrations architect 3 ondemand architecture modularize-domain skills/modularize-domain.md Deciding module boundaries before "create module" architect 3 ondemand architecture design-artifacts skills/design-artifacts.md Designing the brand and ONE ANNOTATED WIREFRAME PER SCREEN before building pages — the design system alone is half the deliverable architect 3 ondemand design +process-board skills/process-board.md After the wireframes, when a reviewer cannot tell where the open questions and ASSUMED decisions fit: one swimlane board per process, each step on its wireframe, every question pinned to a step (project-bin/process-board.js validate, anchor, build) architect,review 3 ondemand design interaction-model skills/interaction-model.md Stage 3, after the role model is CONFIRMED and BEFORE THE FIRST WIREFRAME — and any time a page shows ONE object, a menu item or home page is added, or a data source retrieves the current/first record: role homes, inboxes, and per page how it is reached and which object it takes as parameter lead,ba,architect 3 ondemand design brd-to-build-plan skills/brd-to-build-plan.md Turning BRDs plus architecture into a numbered, dependency-ordered build plan architect 3,4 ondemand requirements coverage-ledger skills/coverage-ledger.md Building the Stage 4 coverage ledger — every requirement either claimed by a build-plan row or catalogued with a reason, never invisible architect,gate 4 ondemand requirements diff --git a/project-bin/process-board.js b/project-bin/process-board.js new file mode 100644 index 00000000..2e16ae8c --- /dev/null +++ b/project-bin/process-board.js @@ -0,0 +1,484 @@ +#!/usr/bin/env node +// process-board.js: draw the Stage-3 process board. One swimlane board per business process, +// each step showing the wireframe it happens on, what the system does between screens, and +// every open question and assumption pinned to the step or screen it is about. +// +// WHY. A reviewer at Stage 3 gets sixty consolidated questions, thirty ASSUMED decisions and +// forty wireframes as three separate lists. A business owner reading "Should a rejected order go +// back to draft?" cannot tell where in the work that happens, so the question is answered blind or +// not at all. The board puts the three side by side: the process across screens, and each +// question stuck to the moment it changes. Field origin: a requirements-driven rebuild POC, +// 2026-10-16, where the reviewer said exactly this about the question list. +// +// SINGLE SOURCE. The board is GENERATED, never hand-edited, like blueprint.html: +// design/process-model.json authored by the architect agent from the BRDs +// (skills/process-board.md Step 2) +// written by `anchor` (derived) plus the agent's judged pass +// (skills/process-board.md Step 3) +// BRDs, wireframes, PROJECT.md, optional consolidated questions file: read-only inputs +// Producers of the inputs: BRDs = Stage 2 (mandatory); wireframes = design-artifacts.md Step 3 +// (Stage 3, mandatory); PROJECT.md = bin/init-project.sh + every checkpoint. +// +// Usage (run from the project root, or pass --project): +// process-board.js validate [--strict] check the model (and anchors, when present) +// process-board.js anchor derive anchors mechanically, keep judged ones, write +// the anchors file, list what is still unanchored +// process-board.js build validate, then write design/process-board.html +// options: --project --model --out --json (anchor: todo as JSON) +// +// Exit: 0 ok; 1 validation errors (or, with --strict, unanchored items); 2 usage/input error. +// No libraries, no network, deterministic output (same input, same bytes). +'use strict'; +const fs = require('fs'); +const path = require('path'); + +const die = msg => { console.error('process-board: ' + msg); process.exit(2); }; + +// ---------- args ---------- +const argv = process.argv.slice(2); +let cmd = null, root = '.', modelArg = null, outArg = null, strict = false, asJson = false; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--project') root = argv[++i]; + else if (a === '--model') modelArg = argv[++i]; + else if (a === '--out') outArg = argv[++i]; + else if (a === '--strict') strict = true; + else if (a === '--json') asJson = true; + else if (a === '-h' || a === '--help') { + console.log('usage: process-board.js validate|anchor|build [--project ] [--model ] [--out ] [--strict] [--json]'); + process.exit(0); + } else if (a.startsWith('-')) die('unknown option ' + a); + else if (!cmd) cmd = a; + else die('unexpected argument ' + a); +} +if (!['validate', 'anchor', 'build'].includes(cmd || '')) die('give a command: validate, anchor or build'); +root = path.resolve(root); +const rel = p => path.resolve(root, p); +const modelFile = modelArg ? path.resolve(modelArg) : rel('design/process-model.json'); +if (!fs.existsSync(modelFile)) die('no process model at ' + modelFile + ' (skills/process-board.md Step 2 authors it)'); + +const readJson = (f, what) => { + try { return JSON.parse(fs.readFileSync(f, 'utf8')); } + catch (e) { die('cannot parse ' + (what || f) + ': ' + e.message); } +}; +const byteSort = (a, b) => (a < b ? -1 : a > b ? 1 : 0); +const model = readJson(modelFile, 'process model'); +const designDir = path.dirname(modelFile); + +// ---------- inputs ---------- +function globDir(dir, re) { + try { return fs.readdirSync(dir).filter(f => re.test(f)).sort(byteSort).map(f => path.join(dir, f)); } + catch (e) { return []; } +} +function brdFiles() { + if (Array.isArray(model.brdDirs) && model.brdDirs.length) { + return model.brdDirs.flatMap(d => globDir(rel(d), /\.brd\.json$/)); + } + // Same three shapes as bin/lib/discover-brds.sh and the artifact manifest. + let out = []; + for (const a of globDir(rel('analysis'), /./)) { + out = out.concat(globDir(path.join(a, 'knowledge-base', 'brd'), /\.brd\.json$/)); + } + out = out.concat(globDir(rel('knowledge-base/brd'), /\.brd\.json$/)); + return [...new Set(out)]; +} + +const BRDS = {}; // brdId -> { file, base, doc } +const UC = {}; // "F001:UC001" -> { brd, id, title, actors, text } +const OQ = {}; // "F001:Q01" -> { brd, id, oq, text } +const BRD_PAGES = {}; // short page name -> [brdId] +for (const f of brdFiles()) { + const doc = readJson(f); + const base = path.basename(f); + const id = String(doc.id || (base.match(/^[A-Za-z]+\d+/) || [base])[0]); + BRDS[id] = { file: f, base, doc }; + for (const u of doc.useCases || []) { + UC[id + ':' + u.id] = { brd: id, id: u.id, title: u.title || '', actors: u.actors || [], json: JSON.stringify(u), raw: u }; + } + for (const q of doc.openQuestions || []) { + if (!q || !q.id) continue; + OQ[id + ':' + q.id] = { brd: id, base, id: q.id, raw: q }; + } + for (const p of doc.pages || []) { + if (!p || !p.name) continue; + (BRD_PAGES[p.name] = BRD_PAGES[p.name] || []).push(id); + } +} +const brdByFile = {}; +for (const [id, b] of Object.entries(BRDS)) brdByFile[b.base] = id; + +// Wireframes: page key is data-mx-page ("Module.Page") when present, else the filename stem. +const wfDir = model.wireframes ? rel(model.wireframes) : path.join(designDir, 'wireframes'); +const WF = {}; // pageKey -> { key, short, file, route, html } +const WF_SHORT = {}; // short name -> key +const kebab = name => name.replace(/\.html$/i, '') + .replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') + .replace(/[_\s]+/g, '-').replace(/[^A-Za-z0-9.\/-]/g, '-').replace(/-+/g, '-') + .replace(/^-|-$/g, '').toLowerCase(); +const attr = (tag, name) => { const m = (tag || '').match(new RegExp('\\b' + name + '\\s*=\\s*["\']([^"\']*)["\']', 'i')); return m ? m[1] : ''; }; +for (const f of globDir(wfDir, /\.html$/i)) { + const html = fs.readFileSync(f, 'utf8'); + const bodyTag = (html.match(/]*>/i) || [''])[0]; + const htmlTag = (html.match(/]*>/i) || [''])[0]; + const stem = path.basename(f).replace(/\.html$/i, ''); + const key = attr(bodyTag, 'data-mx-page') || attr(htmlTag, 'data-mx-page') || stem; + const short = key.includes('.') ? key.split('.').pop() : key; + const route = attr(bodyTag, 'data-route') || attr(htmlTag, 'data-route') || kebab(path.basename(f)); + WF[key] = { key, short, file: path.relative(designDir, f).split(path.sep).join('/'), route, html }; + WF_SHORT[short] = key; + WF_SHORT[stem] = WF_SHORT[stem] || key; +} +const resolvePage = p => (!p ? null : WF[p] ? p : WF_SHORT[p] ? WF_SHORT[p] : WF_SHORT[String(p).split('.').pop()] || null); +const shortOf = p => String(p || '').split('.').pop(); + +// ---------- model checks ---------- +const errors = [], warnings = []; +const err = m => errors.push(m), warn = m => warnings.push(m); +const LANE_KINDS = ['role', 'system', 'scheduled', 'external']; +const STEP_KINDS = ['task', 'system', 'decision', 'end']; +const lanes = Array.isArray(model.lanes) ? model.lanes : []; +const processes = Array.isArray(model.processes) ? model.processes : []; +if (!lanes.length) err('model has no lanes'); +if (!processes.length) err('model has no processes'); +const laneIdx = {}; +lanes.forEach((l, i) => { + if (!l.id) return err('lane ' + i + ' has no id'); + if (laneIdx[l.id] !== undefined) err('lane id ' + l.id + ' is used twice'); + laneIdx[l.id] = i; + if (l.kind && !LANE_KINDS.includes(l.kind)) err('lane ' + l.id + ': kind "' + l.kind + '" is not one of ' + LANE_KINDS.join(', ')); +}); +const normUc = r => String(r || '').trim().replace(/\s+/, ':'); +const STEP = {}; // stepId -> { step, proc } +const ucToSteps = {}; // ucRef -> [stepId] +const pageToSteps = {}; // pageKey -> [stepId] +const actionToSteps = {}; +const procSeen = {}; +for (const p of processes) { + if (!p.id) { err('a process has no id'); continue; } + if (procSeen[p.id]) err('process id ' + p.id + ' is used twice'); + procSeen[p.id] = true; + if (!Array.isArray(p.steps) || !p.steps.length) { err(p.id + ': no steps'); continue; } + let col = -1; + const cells = {}; + for (const s of p.steps) { + if (!s.id) { err(p.id + ': a step has no id'); continue; } + if (STEP[s.id]) err('step id ' + s.id + ' is used twice'); + STEP[s.id] = { step: s, proc: p }; + s.kind = s.kind || (s.page ? 'task' : 'system'); + if (!STEP_KINDS.includes(s.kind)) err(s.id + ': kind "' + s.kind + '" is not one of ' + STEP_KINDS.join(', ')); + if (laneIdx[s.lane] === undefined) err(s.id + ': lane "' + s.lane + '" is not declared in lanes'); + col = Number.isInteger(s.col) ? s.col : col + 1; + s._col = col; + const cell = s.lane + '@' + col; + if (cells[cell]) err(s.id + ': shares lane ' + s.lane + ' column ' + col + ' with ' + cells[cell] + ' (give one a different col)'); + cells[cell] = s.id; + if (s.page) { + const k = resolvePage(s.page); + if (k) { s._page = k; (pageToSteps[k] = pageToSteps[k] || []).push(s.id); } + else if (s.wireframeMissing) { s._pageMissing = true; warn(s.id + ': page ' + s.page + ' has no wireframe (' + s.wireframeMissing + ')'); } + else err(s.id + ': page "' + s.page + '" has no wireframe in ' + path.relative(root, wfDir) + ' (no data-mx-page or filename matches; set wireframeMissing with a reason if that is intended)'); + } else if (s.kind === 'task') { + err(s.id + ': a task step needs a page (the screen the person works on); use kind "system" for automated work'); + } + for (const r of s.useCases || []) { + const ref = normUc(r); + if (!UC[ref]) err(s.id + ': use case ' + r + ' is not in any BRD (expected ":")'); + (ucToSteps[ref] = ucToSteps[ref] || []).push(s.id); + } + for (const a of s.actions || []) (actionToSteps[a] = actionToSteps[a] || []).push(s.id); + } +} +// next-links checked after every step id is known (a link may continue in another process) +for (const { step: s } of Object.values(STEP)) { + s._next = (s.next || []).map(n => (typeof n === 'string' ? { to: n } : n)); + for (const n of s._next) if (!STEP[n.to]) err(s.id + ': next "' + n.to + '" is not a step id'); + if (s.kind === 'decision' && s._next.length < 2) err(s.id + ': a decision needs at least two outcomes in next'); +} +const standalone = Array.isArray(model.standalone) ? model.standalone : []; +const standaloneSet = {}; +for (const st of standalone) { + const ref = normUc(st.useCase); + if (!UC[ref]) err('standalone use case ' + st.useCase + ' is not in any BRD'); + if (!st.reason) err('standalone use case ' + st.useCase + ' has no reason'); + standaloneSet[ref] = st.reason || ''; +} +for (const ref of Object.keys(UC).sort(byteSort)) { + const onBoard = !!ucToSteps[ref], alone = ref in standaloneSet; + if (!onBoard && !alone) err('use case ' + ref + ' (' + UC[ref].title + ') is on no process and not listed as standalone'); + if (onBoard && alone) warn('use case ' + ref + ' is on a process and also listed as standalone'); +} +const pagesOffBoard = Object.keys(WF).filter(k => !pageToSteps[k]).sort(byteSort); +if (pagesOffBoard.length) warn(pagesOffBoard.length + ' wireframe(s) on no process step: ' + pagesOffBoard.join(', ')); + +// ---------- pinnable items: questions + ASSUMED decisions ---------- +const questionsFile = model.questions ? rel(model.questions) : null; +const anchorsFile = model.anchors ? rel(model.anchors) : path.join(designDir, 'question-anchors.json'); +const registerFile = rel(model.register || 'PROJECT.md'); +const ITEMS = []; // { id, kind: question|decision, title, text, blocking, assumption, risk, askWho, refs:[oqKey], fingerprint } +const itemById = {}; +const oqToItem = {}; +const clean = s => String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); +const fp = s => clean(s).slice(0, 80); +function addItem(it) { if (itemById[it.id]) return; ITEMS.push(it); itemById[it.id] = it; } +if (questionsFile) { + if (!fs.existsSync(questionsFile)) die('questions file ' + model.questions + ' named in the model does not exist'); + const qj = readJson(questionsFile, 'questions file'); + const list = Array.isArray(qj) ? qj : (qj.questions || []); + for (const q of list) { + const id = q.cid || q.id; + if (!id) continue; + const refs = []; + for (const s of q.sources || []) { + const b = brdByFile[s.brd] || brdByFile[path.basename(String(s.brd || ''))] || (BRDS[s.brd] ? s.brd : null); + if (b && OQ[b + ':' + s.id]) { refs.push(b + ':' + s.id); oqToItem[b + ':' + s.id] = id; } + } + addItem({ id, kind: 'question', title: clean(q.title) || clean(q.question).slice(0, 80), text: clean(q.question), + blocking: q.blocking === true, assumption: clean(q.assumption), risk: clean(q.risk), askWho: clean(q.askWho), + category: clean(q.category), refs, fingerprint: fp(q.question) }); + } +} +for (const key of Object.keys(OQ).sort(byteSort)) { + if (oqToItem[key]) continue; + const q = OQ[key].raw; + if (q.consolidatedId && itemById[q.consolidatedId]) { oqToItem[key] = q.consolidatedId; itemById[q.consolidatedId].refs.push(key); continue; } + addItem({ id: key, kind: 'question', title: clean(q.question).slice(0, 80), text: clean(q.question), blocking: q.blocking === true, + assumption: clean(q.assumption), risk: clean(q.risk), askWho: clean(q.askWho), category: BRDS[OQ[key].brd].doc.title || '', + refs: [key], fingerprint: fp(q.question) }); + oqToItem[key] = key; +} +// ASSUMED decisions: rows of any register table whose header has Decision and Status columns. +if (fs.existsSync(registerFile)) { + const lines = fs.readFileSync(registerFile, 'utf8').split(/\r?\n/); + let hdr = null; const ord = {}; + const cellsOf = l => l.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim()); + for (const l of lines) { + if (!/^\s*\|/.test(l)) { hdr = null; continue; } + const c = cellsOf(l); + if (!hdr) { + const lc = c.map(x => x.toLowerCase()); + const di = lc.findIndex(x => /^decision/.test(x)), si = lc.findIndex(x => /^status/.test(x)); + hdr = (di >= 0 && si >= 0) ? { di, si, st: lc.findIndex(x => /^stage/.test(x)) } : { skip: true }; + continue; + } + if (hdr.skip || /^[-:\s|]+$/.test(l.replace(/\|/g, ''))) continue; + const status = c[hdr.si] || '', text = clean(c[hdr.di] || ''); + if (!/ASSUMED/.test(status) || !text) continue; + const stage = hdr.st >= 0 ? clean(c[hdr.st]) : ''; + const own = text.match(/^([A-Z][A-Z0-9]*-[A-Z]*\d+)\s*:/); + ord[stage] = (ord[stage] || 0) + 1; + const id = own ? own[1] : 'D' + (stage || 'x') + '-' + String(ord[stage]).padStart(2, '0'); + addItem({ id, kind: 'decision', title: text.replace(/^[A-Z][A-Z0-9]*-[A-Z]*\d+\s*:\s*/, '').slice(0, 90), text, blocking: false, + assumption: text, risk: clean((c.slice(-1)[0] || '').match(/risk:(.*)$/i) ? c.slice(-1)[0].match(/risk:(.*)$/i)[1] : ''), + status: clean(status), stage, refs: [], fingerprint: fp(text) }); + } +} + +// ---------- derive anchors (mechanical) ---------- +const tok = (s, t) => new RegExp('(^|[^A-Za-z0-9_-])' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '($|[^A-Za-z0-9_-])').test(s); +// "a/b.ts:10-20,31; c.sql:5" -> [{f:'b.ts' path, a:10, b:20}, ...]. Only cited line numbers count. +function ranges(s) { + const out = []; + for (const m of String(s).matchAll(/([A-Za-z0-9_.\/\[\]-]+\.[A-Za-z]{1,5}):([0-9][0-9,\s-]*)/g)) { + const f = m[1].replace(/^.*?(?=[^\/]+\/[^\/]+$)/, ''); + for (const part of m[2].split(',')) { + const n = part.trim().match(/^(\d+)(?:-(\d+))?$/); + if (n) out.push({ f, a: +n[1], b: +(n[2] || n[1]) }); + } + } + return out; +} +const overlaps = (xs, ys) => xs.some(x => ys.some(y => x.f === y.f && x.a <= y.b && y.a <= x.b)); +const stepOrder = {}; let so = 0; +for (const p of processes) for (const s of p.steps || []) stepOrder[s.id] = so++; +function derive(it) { + const score = {}, basis = {}, pages = new Set(); + const hit = (sid, w, why) => { if (!STEP[sid]) return; if ((score[sid] || 0) < w) { score[sid] = w; basis[sid] = why; } }; + const texts = [it.text, it.assumption, it.title]; + for (const key of it.refs) { + const o = OQ[key]; + const q = o.raw; + texts.push(clean(q.question), clean(q.assumption), clean(q.source), clean(q.evidence)); + // a) use cases that list this open question; c) use cases whose text names it + for (const [ref, u] of Object.entries(UC)) { + if (u.brd !== o.brd) continue; + const listed = (u.raw.openQuestions || []).includes(o.id); + if (listed || tok(u.json, o.id)) for (const sid of ucToSteps[ref] || []) hit(sid, listed ? 2 : 1, key + ' -> ' + ref + ' -> ' + sid); + } + // b) business rules that land in a use case and name this question + for (const br of BRDS[o.brd].doc.businessRules || []) { + const t = JSON.stringify(br); + if (!tok(t, o.id)) continue; + for (const m of String(br.landsIn || '').match(/UC[-A-Za-z0-9]+/g) || []) { + for (const sid of ucToSteps[o.brd + ':' + m] || []) hit(sid, 1, key + ' -> ' + (br.id || 'rule') + ' -> ' + o.brd + ':' + m + ' -> ' + sid); + } + } + // d) BRD pages whose text names it + for (const pg of BRDS[o.brd].doc.pages || []) { + if (tok(JSON.stringify(pg), o.id)) { const k = resolvePage(pg.name); if (k) pages.add(k); } + } + // source citations: the question and a use case cite overlapping lines of the same file + // (migration BRDs cite "path/file.ts:10-40"); the same file alone is too common to count + const qr = ranges([q.source, q.evidence].map(clean).join(' ')); + if (qr.length) for (const [ref, u] of Object.entries(UC)) { + if (u.brd !== o.brd) continue; + if (overlaps(qr, ranges(String(u.raw.source || '')))) for (const sid of ucToSteps[ref] || []) hit(sid, 1, key + ' cites the lines of ' + ref + ' -> ' + sid); + } + // use case ids named inside the question itself + const own = [q.question, q.assumption, q.source, q.evidence].map(clean).join(' '); + for (const ref of Object.keys(UC)) { + if (UC[ref].brd === o.brd && tok(own, UC[ref].id)) for (const sid of ucToSteps[ref] || []) hit(sid, 1, key + ' names ' + ref + ' -> ' + sid); + } + } + const all = texts.filter(Boolean).join(' '); + // e) microflow names and page names in the text (most specific) + for (const a of Object.keys(actionToSteps)) if (tok(all, a)) for (const sid of actionToSteps[a]) hit(sid, 3, 'names ' + a + ' -> ' + sid); + // A one-word page name (Home, Login, Dashboard) is also an ordinary word or a module name, so + // it counts only in its full Module.Page form; Order_Overview-style names count bare. + for (const k of Object.keys(WF)) { + const sh = WF[k].short, distinctive = /_|[a-z][A-Z]/.test(sh); + if ((distinctive && tok(all, sh)) || (k !== sh && tok(all, k))) pages.add(k); + } + // f) "F002 UC005" style references (decisions and consolidated questions use them) + for (const m of all.matchAll(/\b([A-Z]+\d{3})[ :]+(UC[-A-Za-z0-9]+)/g)) { + for (const sid of ucToSteps[m[1] + ':' + m[2]] || []) hit(sid, 2, 'names ' + m[1] + ':' + m[2] + ' -> ' + sid); + } + // g) a decision that names an open question or consolidated question inherits its steps + if (it.kind === 'decision') { + for (const k of Object.keys(OQ)) if (tok(all, OQ[k].id) && OQ[k].id.length > 3) { const ii = itemById[oqToItem[k]]; if (ii && ii._derived) for (const t of ii._derived) if (t.type === 'step') hit(t.step, 1, 'names ' + OQ[k].id + ' -> ' + t.step); } + for (const q of ITEMS) if (q.kind === 'question' && q.id !== it.id && /^[A-Z]+-\d+$/.test(q.id) && tok(all, q.id) && q._derived) for (const t of q._derived) if (t.type === 'step') hit(t.step, 1, 'names ' + q.id + ' -> ' + t.step); + } + for (const k of pages) for (const sid of pageToSteps[k] || []) hit(sid, 2, 'names screen ' + WF[k].short + ' -> ' + sid); + const top = Math.max(0, ...Object.values(score)); + if (top > 0) { + const sids = Object.keys(score).filter(s => score[s] === top).sort((a, b) => stepOrder[a] - stepOrder[b]).slice(0, 3); + return { targets: sids.map(s => ({ type: 'step', step: s })), basis: sids.map(s => basis[s]).join('; ') }; + } + if (pages.size) { + const ks = [...pages].sort(byteSort).slice(0, 3); + return { targets: ks.map(k => ({ type: 'page', page: k })), basis: 'names screen(s) ' + ks.map(shortOf).join(', ') + ' (on no step)' }; + } + return null; +} +for (const it of ITEMS.filter(i => i.kind === 'question')) { const d = derive(it); it._derived = d ? d.targets : null; it._basis = d ? d.basis : ''; } +for (const it of ITEMS.filter(i => i.kind === 'decision')) { const d = derive(it); it._derived = d ? d.targets : null; it._basis = d ? d.basis : ''; } + +// ---------- existing anchors (judged entries survive; derived are recomputed) ---------- +const prev = fs.existsSync(anchorsFile) ? readJson(anchorsFile, 'anchors file') : { anchors: [] }; +const prevById = {}; +for (const a of prev.anchors || []) prevById[a.id] = a; +function targetOk(t) { + if (!t || !t.type) return 'no type'; + if (t.type === 'step') return STEP[t.step] ? '' : 'step ' + t.step + ' does not exist'; + if (t.type === 'page') return resolvePage(t.page) ? '' : 'page ' + t.page + ' has no wireframe'; + if (t.type === 'global') return t.bucket ? '' : 'global target needs a bucket'; + return 'unknown target type ' + t.type; +} +const ANCH = {}; // id -> { targets, anchoredBy, basis } +const staleJudged = []; +for (const it of ITEMS) { + const p = prevById[it.id]; + if (p && p.anchoredBy === 'judged') { + if (p.fingerprint && p.fingerprint !== it.fingerprint) { staleJudged.push(it.id); } + else { + const bad = (p.targets || []).map(targetOk).filter(Boolean); + if (bad.length) err('anchor ' + it.id + ': ' + bad.join('; ')); + else { ANCH[it.id] = { targets: p.targets, anchoredBy: 'judged', basis: p.basis || p.why || '' }; continue; } + } + } + if (it._derived) ANCH[it.id] = { targets: it._derived, anchoredBy: 'derived', basis: it._basis }; +} +for (const id of Object.keys(prevById)) if (!itemById[id]) warn('anchors file names ' + id + ', which is no longer a question or ASSUMED decision'); +for (const id of staleJudged) warn('judged anchor ' + id + ' dropped: the item text changed since it was judged; judge it again'); + +const unanchored = ITEMS.filter(it => !ANCH[it.id]); +const stats = { derived: 0, judged: 0, global: 0, unanchored: unanchored.length, total: ITEMS.length }; +for (const a of Object.values(ANCH)) { + stats[a.anchoredBy]++; + if (a.targets.every(t => t.type === 'global')) stats.global++; +} +const statLine = () => 'anchors: ' + stats.total + ' item(s), ' + stats.derived + ' derived, ' + stats.judged + ' judged, ' + + stats.global + ' global, ' + stats.unanchored + ' unanchored'; + +function report() { + for (const w of warnings) console.log('WARN ' + w); + for (const e of errors) console.log('ERROR ' + e); + const ucs = Object.keys(UC).length; + console.log('model: ' + processes.length + ' process(es), ' + Object.keys(STEP).length + ' step(s), ' + ucs + ' use case(s) (' + + Object.keys(standaloneSet).length + ' standalone), ' + Object.keys(WF).length + ' wireframe(s), ' + errors.length + ' error(s), ' + warnings.length + ' warning(s)'); + console.log(statLine()); +} + +// ---------- commands ---------- +if (cmd === 'validate') { + report(); + if (unanchored.length) console.log('unanchored: ' + unanchored.map(i => i.id).join(', ')); + process.exit(errors.length || (strict && unanchored.length) ? 1 : 0); +} + +if (cmd === 'anchor') { + const out = { + schema: 'process-anchors/1', + note: 'Generated by process-board.js anchor. Edit only judged entries (anchoredBy "judged"); derived entries are recomputed on every run.', + anchors: ITEMS.filter(it => ANCH[it.id]).map(it => ({ + id: it.id, kind: it.kind, anchoredBy: ANCH[it.id].anchoredBy, targets: ANCH[it.id].targets, + basis: ANCH[it.id].basis, fingerprint: it.fingerprint, + })), + }; + fs.mkdirSync(path.dirname(anchorsFile), { recursive: true }); + fs.writeFileSync(anchorsFile, JSON.stringify(out, null, 2) + '\n'); + report(); + console.log('wrote ' + path.relative(root, anchorsFile)); + if (unanchored.length) { + if (asJson) { + console.log(JSON.stringify(unanchored.map(it => ({ id: it.id, kind: it.kind, anchoredBy: 'judged', targets: [], basis: '', fingerprint: it.fingerprint, text: it.text })), null, 2)); + } else { + console.log('to judge (' + unanchored.length + '): add an entry with anchoredBy "judged" for each'); + for (const it of unanchored) console.log(it.id + '\t' + it.kind + '\t' + it.text.slice(0, 160)); + } + } + process.exit(errors.length ? 1 : 0); +} + +// ---------- build ---------- +report(); +if (errors.length) { console.error('process-board: not building, fix the model first'); process.exit(1); } +const outFile = outArg ? path.resolve(outArg) : path.join(designDir, 'process-board.html'); +const protoFile = model.prototype ? rel(model.prototype) : path.join(designDir, 'prototype.html'); +const protoHref = fs.existsSync(protoFile) ? path.relative(path.dirname(outFile), protoFile).split(path.sep).join('/') : ''; +const dsFile = model.ds ? rel(model.ds) : path.join(designDir, 'ds.css'); +const dsCss = fs.existsSync(dsFile) ? fs.readFileSync(dsFile, 'utf8') : ''; + +// strip + + diff --git a/skills/checkpoints/checkpoint-design.md b/skills/checkpoints/checkpoint-design.md index 7e2b0852..55f96833 100644 --- a/skills/checkpoints/checkpoint-design.md +++ b/skills/checkpoints/checkpoint-design.md @@ -13,6 +13,9 @@ Pull from `.mx-brd.json` files and source KB screens: - Key user flows (group screens by journey: auth, main feature, settings, etc.) - Source layout patterns detected (sidebar, top nav, card grid, list-detail, modal forms) - Any screens with no direct Atlas widget equivalent (flag these specifically) +- When `design/process-board.html` exists (`process-board.md`): walk the open questions and ASSUMED + decisions on the board, step by step, instead of as a flat list; a reviewer answers a question + better when they see the screen and the moment it changes ## What's Next diff --git a/skills/conversion-runbook.md b/skills/conversion-runbook.md index 59d7e8c6..aabcee3b 100644 --- a/skills/conversion-runbook.md +++ b/skills/conversion-runbook.md @@ -578,8 +578,8 @@ The biggest gap before this runbook existed. Module boundaries, wiring diagrams | | | |---|---| | **User defines** | ① One Mendix app or several (if flagged at Stage 0). ② **Module boundaries** (agent proposes with `modularize-domain.md` criteria). ③ **Buy vs build vs stub, per fit-gap item** — the confirming step `brd-to-build-plan.md` assumed already happened. ④ **Target security / role model** — not just whether auth existed in the source, but what the target should be. ⑤ **Data volumes, concurrency, NFRs** — these decide indexing, pagination, datagrid-vs-paged-gallery, loop batch sizes. ⑥ **Integration contracts** — real or stub, endpoint, credentials, owner, test environment. ⑦ **Branding inputs** — logo, palette, type, spacing, per `design-artifacts.md`; with no brand, the design direction (Step 0b question D1: ideation-library directions or Atlas defaults). | -| **Agent produces** | `.mx-brd.json`, `architecture/` (module defs, layer diagram, wiring diagram, `fit-gap.md`, `blueprint.html` checkpoint render — plus a workflow diagram and/or agent-wiring diagram in `blueprint.md`/`blueprint.html` when CAC-3's Q3 flags real scope for either, plus cross-persona journey diagrams + journey list — `architecture-blueprint.md` Step 3d — whenever the BRDs carry more than one persona; single-persona skip recorded as a one-line note, never silent), `design/` per `design-artifacts.md`'s full output list: `design/interaction-model.md` (first) + `ds.css` + `design-system.html` + **`wireframes/*.html`, one annotated wireframe per screen** — the design system without the wireframes is half the deliverable and fails the gate. | -| **Surface** | `module-design.html` · `architecture/blueprint.html` (generated render of `blueprint.md` — architecture-blueprint.md Step 7, never hand-edited) · `design-system.html` + `wireframes/*.html` | +| **Agent produces** | `.mx-brd.json`, `architecture/` (module defs, layer diagram, wiring diagram, `fit-gap.md`, `blueprint.html` checkpoint render — plus a workflow diagram and/or agent-wiring diagram in `blueprint.md`/`blueprint.html` when CAC-3's Q3 flags real scope for either, plus cross-persona journey diagrams + journey list — `architecture-blueprint.md` Step 3d — whenever the BRDs carry more than one persona; single-persona skip recorded as a one-line note, never silent), `design/` per `design-artifacts.md`'s full output list: `design/interaction-model.md` (first) + `ds.css` + `design-system.html` + **`wireframes/*.html`, one annotated wireframe per screen** — the design system without the wireframes is half the deliverable and fails the gate. Once the wireframes exist and the BRDs carry more than a handful of open questions: `design/process-model.json` + `design/process-board.html` per `process-board.md` (one swimlane board per process, each step on its wireframe, every open question and ASSUMED decision pinned to a step; generated, never hand-edited). | +| **Surface** | `module-design.html` · `architecture/blueprint.html` (generated render of `blueprint.md` — architecture-blueprint.md Step 7, never hand-edited) · `design-system.html` + `wireframes/*.html` · `design/process-board.html` (process-board.md Step 4, when the model exists) | | **Gate ✋** | Boundaries approved. Marketplace calls made. Interaction model walked with two objects, two roles. Role model, volumes, integrations and branding **each asked and answered**: `CONFIRMED`, or explicitly delegated by the user ("you decide" → `ASSUMED` with risk). Never `ASSUMED` without the question having reached the user. Close-out block pasted before the ✋ decision is asked (§1b rule 7) — this stage has no closing checkpoint, so the gate is where the Stage-4 open (its approach, skills and optional artifacts) is shown. **No architecture/design artifact is produced before its checkpoint ran.** | | **Owner** | `architect-agent` (interviews run by `ba-agent`) | diff --git a/skills/process-board.md b/skills/process-board.md new file mode 100644 index 00000000..7d1ce45d --- /dev/null +++ b/skills/process-board.md @@ -0,0 +1,151 @@ +# Process Board: Where Each Question Fits in the Work +**Applies to:** migration, requirements-driven build and existing-app change, at Stage 3, once the wireframes exist. +**Requires:** Node 18 or later (no packages). Windows: Git Bash, same commands. +**Purpose:** Draw one swimlane board per business process. Each step sits in the lane of the person or system that does it, shows the wireframe it happens on, says what the system does between screens, and carries a pin for every open question and ASSUMED decision about that moment. A reviewer who cannot place "Should a rejected order go back to draft?" in a list of sixty can answer it on the board. +**Upstream:** BRDs (Stage 2, mandatory: `useCases`, `openQuestions`, `pages`); `design/wireframes/*.html` (`design-artifacts.md` Step 3, mandatory at Stage 3); the `PROJECT.md` decisions table (every checkpoint); optionally a consolidated questions file the project already keeps. +**Downstream:** `checkpoints/checkpoint-design.md` (CAC-4 walks the questions on the board); `bin/gate-check.sh` check_stage_3 (fails a board older than its model or anchors); `journey-proof.md` (a process on the board is a journey to prove). +**Companion:** `architecture-blueprint.md` Step 3d (journey diagrams; the board is the screen-level version of the same picture and discharges the optional `swimlanes` artifact); `assemble-prototype` (the board links each step into `design/prototype.html` when it exists). +**Source:** field run on a requirements-driven rebuild POC, 2026-10-16. The reviewer said of the question list: "I really don't understand where in the process they fit." Result there: 9 processes, 73 steps, 102 pinnable items, 48 placed mechanically, 54 by review, 19 of those in a whole-app bucket, 0 unplaced. + +--- + +## When + +Run it at Stage 3 after the wireframes are drawn and before the Stage-3 gate interview, when any of these hold: + +- the BRDs carry more than about ten open questions, or the register more than about ten ASSUMED decisions; +- more than one role touches the same record (an approval, a hand-over, a queue); +- the reviewer is a business owner, not a builder. + +Skip it for a one-screen app or a project whose questions are all closed. Record the skip as one line in `PROJECT.md`. + +## Inputs and outputs + +| File | Who writes it | Notes | +|---|---|---| +| `design/process-model.json` | you, Step 2 | the processes, lanes and steps. The only hand-authored input. | +| `design/question-anchors.json` | `process-board.js anchor` plus you, Step 3 | where each item is pinned. Path is `model.anchors` if set. | +| `design/process-board.html` | `process-board.js build`, Step 4 | generated, never hand-edited, self-contained, light and dark. | + +The script is `project-bin/process-board.js` (installed into a wired project's `bin/`). Run it from the project root or pass `--project`. For the nested layout (`analysis//design/`), pass `--model analysis//design/process-model.json`; outputs go next to the model. + +## Step 1: Find the processes + +Read the BRD use cases and the blueprint journeys. A process is one piece of work with a start and an end that a business person would name: "submit an order and get it approved", "report a fault and follow it up". Usually 4 to 10 for a small app. Every use case must end up on a step or in `standalone` with a reason. Lookups, exports and settings screens that belong to no flow go in `standalone`. + +## Step 2: Write design/process-model.json + +```json +{ + "schema": "process-model/1", + "app": "Order desk", + "questions": "analysis/questions/consolidated.json", + "anchors": "design/question-anchors.json", + "lanes": [ + { "id": "requester", "label": "Requester", "kind": "role" }, + { "id": "approver", "label": "Approver", "kind": "role" }, + { "id": "system", "label": "System", "kind": "system" } + ], + "processes": [ + { "id": "P1", "title": "Order approval: submit, approve or reject, notify", + "summary": "One plain sentence a business owner recognises.", + "steps": [ + { "id": "P1-01", "lane": "requester", "title": "Fill in and submit the order", + "page": "Orders.Order_NewEdit", "useCases": ["F001:UC001"], "actions": ["ACT_Order_Submit"], + "status": "Draft", "next": ["P1-02"] }, + { "id": "P1-02", "lane": "system", "kind": "system", "title": "Check and store the order", + "does": "Validates the amount and sets the status to Submitted.", "next": ["P1-03"] }, + { "id": "P1-04", "lane": "approver", "kind": "decision", "title": "Approve or reject?", + "next": [ { "to": "P1-05", "label": "Approve" }, { "to": "P1-06", "label": "Reject" } ] } + ] } + ], + "standalone": [ { "useCase": "F001:UC005", "reason": "Nightly job, no screen, on no flow." } ] +} +``` + +Rules the validator enforces: + +- **Lanes**: `kind` is `role`, `system`, `scheduled` or `external`. One lane per role from the Stage-3 role model, one for the app, one per scheduled job or outside system that acts on its own. +- **Steps**: `kind` is `task` (a person on a screen; needs `page`), `system` (the app does it, no screen), `decision` (needs two or more `next` outcomes, each with a `label`) or `end`. Default: `task` when `page` is set, else `system`. +- **page** resolves against the wireframes by `data-mx-page` (`Module.Page`), else the short name or the filename. A page with no wireframe is an error; set `"wireframeMissing": ""` when that is a recorded decision, and it becomes a warning. +- **useCases** are `":"`. An unknown one is an error. +- **next** may point into another process (the card shows "continues in"). A step that loops back draws a dashed edge over the top. +- **col** is the column. It counts up by itself; set it only to line steps up (two lanes in the same column) or to leave a gap. Two steps in one lane and column is an error. +- **actions**, **status**, **does** and **events** are what the reviewer reads as "what happens". Use microflow names in `actions`: they are also the strongest anchoring signal. +- **questions** (optional): a consolidated questions file, an array or `{ "questions": [...] }` with `cid`, `title`, `question`, `blocking`, `assumption`, `risk`, `askWho`, `sources: [{ "brd", "id" }]`. Without it, each BRD open question is its own item. +- **register** (optional, default `PROJECT.md`): any table with a Decision and a Status column. Rows whose status says ASSUMED become decision items. + +Write titles as plain verbs a business owner says: "Approve or reject?", not "ACT_Approve". Microflow names go in `actions`, behind the technical details click. + +Run `process-board.js validate` until it reports 0 errors. Read every warning; a wireframe on no step usually means a missing step. + +## Step 3: Anchor every question and assumption + +```bash +node bin/process-board.js anchor # derive, keep judged entries, write the anchors file +node bin/process-board.js anchor --json # the still-unanchored items as skeletons to fill in +``` + +`anchor` places what it can prove (`anchoredBy: "derived"`), strongest signal first: + +1. the question text names a microflow in a step's `actions`; +2. a use case lists the question in its `openQuestions`, or the text names a use case (`F002 UC005`) or a screen (`Order_Overview`, or `Module.Page` for one-word names); +3. a business rule that names the question lands in a use case; the question and a use case cite overlapping source lines; an ASSUMED decision names a question that is already placed. + +Derived entries are recomputed on every run. For each item left over, and for any derived placement that is wrong, add a judged entry: + +```json +{ "id": "F001:Q04", "kind": "question", "anchoredBy": "judged", + "targets": [ { "type": "global", "bucket": "Money" } ], + "basis": "Applies to every amount in the app", + "fingerprint": "" } +``` + +- Targets: `{ "type": "step", "step": "P1-03" }`, `{ "type": "page", "page": "Order_Overview" }` (a screen on no step), or `{ "type": "global", "bucket": "Hosting and operations" }`. +- Pin to the step where the answer changes what happens. Up to three targets. +- Use a global bucket only for items about the whole app or the way of working (hosting, build plan, look and feel, app structure). Global is allowed and counted, never hidden. +- `basis` is one plain sentence; the reviewer sees it as "Placed by review (...)". +- The fingerprint ties the judgment to the item text. When the item is reworded, the judged entry is dropped with a warning and the item is unanchored again, so a stale placement never survives silently. + +Re-run `anchor` until it reports `0 unanchored`. `validate --strict` fails on any unanchored item. + +## Step 4: Build + +```bash +node bin/process-board.js build +``` + +Validates first and refuses on errors. Writes `design/process-board.html`: an index of process cards (a mini map of lanes, pin counts) plus the whole-app bucket, and one board per process. Rebuild after any change to the model, the anchors, a wireframe, a BRD or the register. `gate-check.sh` check_stage_3 fails when the board is older than `process-model.json` or `question-anchors.json`. + +What the reviewer gets: + +- red pin: open question that blocks the build; amber: open question; violet: an assumption to confirm; +- click a step: the wireframe large, a link to the screen full size and into the clickable prototype, what happens next, and each question as Question, We assume, If wrong, Ask; the use cases and microflows sit behind "Technical details"; +- click a pin or a list row: the question card with links to every step it sits on; +- "Blocking only" hides the rest. + +## Step 5: Verify by looking + +A clean validate proves the data, not the picture. Screenshot the index and at least the two busiest boards, then open the PNGs and read them as the reviewer would. + +```bash +CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" # Linux: google-chrome +B="file://$PWD/design/process-board.html" +"$CHROME" --headless=new --hide-scrollbars --window-size=1800,1400 --virtual-time-budget=8000 --screenshot=/tmp/board-index.png "$B#/" +"$CHROME" --headless=new --hide-scrollbars --window-size=3000,1300 --virtual-time-budget=8000 --screenshot=/tmp/board-P1.png "$B#/P1" +``` + +Use `--headless=new`: the old headless mode often paints the wireframe thumbnails blank. Check: + +- every arrow reads as the real order (no line that seems to enter the wrong card); +- decision labels are readable and sit on their own branch; +- thumbnails show the screens; +- the busiest step does not carry ten pins (split the step, or the question is too broad); +- titles make sense to someone who has never seen the BRDs; +- dark mode (`--force-dark-mode`) is readable. + +Fix the model or the anchors, rebuild, look again. Do not open the board for the user unasked; give the path. + +## Gate + +At the Stage-3 interview, walk the board process by process: blocking pins first, then the assumptions. Record each answer in `PROJECT.md` (CONFIRMED, or ASSUMED with the user's delegation), rebuild, and note the board path in the close-out block. diff --git a/tests/wave2/fixtures/process-board/PROJECT.md b/tests/wave2/fixtures/process-board/PROJECT.md new file mode 100644 index 00000000..cffc69fb --- /dev/null +++ b/tests/wave2/fixtures/process-board/PROJECT.md @@ -0,0 +1,16 @@ +# PROJECT.md: synthetic process board fixture +Toolkit commit: none +Entry mode: requirements-driven + +## Decisions + +| Stage | Decision | Status | Notes | +|---|---|---|---| +| 3 | Module structure: one module Orders | CONFIRMED 2026-09-02 | matches the BRD | +| 3 | Rejection reason is required and shown on Order_Approve | ASSUMED | risk: approvers reject without a reason | +| 3 | Hosting: one environment for the demo | ASSUMED | risk: none for the demo | + +## Open questions + +| # | Question | Raised at | Status | +|---|---|---|---| diff --git a/tests/wave2/fixtures/process-board/design/ds.css b/tests/wave2/fixtures/process-board/design/ds.css new file mode 100644 index 00000000..2a14957d --- /dev/null +++ b/tests/wave2/fixtures/process-board/design/ds.css @@ -0,0 +1,3 @@ +.ds-body{font-family:system-ui,sans-serif;margin:0;background:#fff;color:#123} +.page{padding:40px} +.btn{background:#2563eb;color:#fff;border:0;border-radius:6px;padding:10px 20px;font-size:24px} diff --git a/tests/wave2/fixtures/process-board/design/process-model.json b/tests/wave2/fixtures/process-board/design/process-model.json new file mode 100644 index 00000000..83ad5254 --- /dev/null +++ b/tests/wave2/fixtures/process-board/design/process-model.json @@ -0,0 +1,29 @@ +{ + "schema": "process-model/1", + "app": "Order desk", + "lanes": [ + { "id": "requester", "label": "Requester", "kind": "role" }, + { "id": "approver", "label": "Approver", "kind": "role" }, + { "id": "system", "label": "System", "kind": "system" } + ], + "processes": [ + { + "id": "P1", + "title": "Order approval: submit, approve or reject, notify", + "summary": "A requester submits an order, an approver approves or rejects it, and the requester is told.", + "steps": [ + { "id": "P1-01", "lane": "requester", "title": "Fill in and submit the order", "page": "Orders.Order_NewEdit", "useCases": ["F001:UC001"], "actions": ["ACT_Order_Submit"], "status": "Draft", "next": ["P1-02"] }, + { "id": "P1-02", "lane": "system", "kind": "system", "title": "Check and store the order", "does": "Validates the amount and sets the status to Submitted.", "status": "Draft to Submitted", "next": ["P1-03"] }, + { "id": "P1-03", "lane": "approver", "title": "Open the order", "page": "Order_Approve", "useCases": ["F001:UC002"], "next": ["P1-04"] }, + { "id": "P1-04", "lane": "approver", "kind": "decision", "title": "Approve or reject?", "does": "The approver decides.", "next": [ { "to": "P1-05", "label": "Approve" }, { "to": "P1-06", "label": "Reject" } ] }, + { "id": "P1-05", "lane": "system", "kind": "system", "title": "Record the approval", "actions": ["ACT_Order_Approve"], "status": "Approved", "next": ["P1-07"] }, + { "id": "P1-06", "lane": "system", "kind": "system", "title": "Record the rejection", "actions": ["ACT_Order_Reject"], "status": "Rejected", "next": ["P1-07"] }, + { "id": "P1-07", "lane": "system", "kind": "system", "title": "Tell the requester", "useCases": ["F001:UC003"], "actions": ["SUB_Order_Notify"], "next": ["P1-08"] }, + { "id": "P1-08", "lane": "requester", "title": "See the outcome in the order list", "page": "Order_Overview", "useCases": ["F001:UC004"], "next": [] } + ] + } + ], + "standalone": [ + { "useCase": "F001:UC005", "reason": "A nightly job with no screen and no person; it touches no step of the approval process." } + ] +} diff --git a/tests/wave2/fixtures/process-board/design/question-anchors.json b/tests/wave2/fixtures/process-board/design/question-anchors.json new file mode 100644 index 00000000..eb1e01d4 --- /dev/null +++ b/tests/wave2/fixtures/process-board/design/question-anchors.json @@ -0,0 +1,7 @@ +{ + "schema": "process-anchors/1", + "anchors": [ + { "id": "F001:Q04", "kind": "question", "anchoredBy": "judged", "targets": [ { "type": "global", "bucket": "Money" } ], "basis": "Applies to every amount in the app", "fingerprint": "Which currency do amounts use?" }, + { "id": "D3-02", "kind": "decision", "anchoredBy": "judged", "targets": [ { "type": "global", "bucket": "Hosting" } ], "basis": "Not tied to a screen", "fingerprint": "Hosting: one environment for the demo" } + ] +} diff --git a/tests/wave2/fixtures/process-board/design/wireframes/Order_Approve.html b/tests/wave2/fixtures/process-board/design/wireframes/Order_Approve.html new file mode 100644 index 00000000..ca7761a5 --- /dev/null +++ b/tests/wave2/fixtures/process-board/design/wireframes/Order_Approve.html @@ -0,0 +1,9 @@ + + +Order_Approve + +

Page parameter: Orders.Order

+

Approve order

Order details and history.

+ + + diff --git a/tests/wave2/fixtures/process-board/design/wireframes/Order_NewEdit.html b/tests/wave2/fixtures/process-board/design/wireframes/Order_NewEdit.html new file mode 100644 index 00000000..419261ff --- /dev/null +++ b/tests/wave2/fixtures/process-board/design/wireframes/Order_NewEdit.html @@ -0,0 +1,9 @@ + + +Order_NewEdit + +

Page parameter: Orders.Order

+

New order

Item, quantity, amount.

+ + + diff --git a/tests/wave2/fixtures/process-board/design/wireframes/Order_Overview.html b/tests/wave2/fixtures/process-board/design/wireframes/Order_Overview.html new file mode 100644 index 00000000..7ca3e7a6 --- /dev/null +++ b/tests/wave2/fixtures/process-board/design/wireframes/Order_Overview.html @@ -0,0 +1,9 @@ + + +Order_Overview + +

Page parameter: Orders.Order

+

My orders

A list of past orders.

+ + + diff --git a/tests/wave2/fixtures/process-board/design/wireframes/Settings_Edit.html b/tests/wave2/fixtures/process-board/design/wireframes/Settings_Edit.html new file mode 100644 index 00000000..3a037872 --- /dev/null +++ b/tests/wave2/fixtures/process-board/design/wireframes/Settings_Edit.html @@ -0,0 +1,9 @@ + + +Settings_Edit + +

Page parameter: Orders.Order

+

Settings

App settings.

+ + + diff --git a/tests/wave2/test-process-board.sh b/tests/wave2/test-process-board.sh new file mode 100755 index 00000000..4a1c9abf --- /dev/null +++ b/tests/wave2/test-process-board.sh @@ -0,0 +1,166 @@ +#!/bin/bash +# test-process-board.sh: pin what process-board.js validates, how it anchors questions, what +# `build` writes, and the Stage-3 gate rule that a process board must not be older than its model. +# +# Golden input: tests/wave2/fixtures/process-board/, a synthetic order approval app (one BRD with +# five use cases and four open questions, four wireframes, a PROJECT.md decisions table with two +# ASSUMED rows, a process model and two judged anchors). Its shapes follow the BRD contract +# (useCases, openQuestions, pages) and the wireframe contract (data-mx-page, data-route) the real +# Stage-2 and Stage-3 producers write. Each defect case copies the fixture and changes one thing, +# so a pass means that one defect was seen. +# +# Usage: bash test-process-board.sh [path-to-process-board.js] + +set -u + +TOOLKIT="$(cd "$(dirname "$0")/../.." && pwd)" +SUT="${1:-$TOOLKIT/project-bin/process-board.js}" +SUT="$(cd "$(dirname "$SUT")" && pwd)/$(basename "$SUT")" +GATE="$TOOLKIT/bin/gate-check.sh" +FIX="$TOOLKIT/tests/wave2/fixtures/process-board" +export MXTK_NO_FETCH=1 + +PASS=0; FAIL=0 +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n %s\n' "$1" "${2:-}"; } +check(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "expected '$3', got '$2'"; fi; } +has() { case "$2" in *"$3"*) ok "$1" ;; *) bad "$1" "missing '$3' in: $(printf '%s' "$2" | head -c 400)" ;; esac; } +hasnt(){ case "$2" in *"$3"*) bad "$1" "unexpected '$3'" ;; *) ok "$1" ;; esac; } + +command -v node >/dev/null 2>&1 || { echo "SKIP: node not on PATH"; exit 0; } + +# fresh copy of the golden fixture +fresh() { rm -rf "$TMP/$1"; cp -R "$FIX" "$TMP/$1"; echo "$TMP/$1"; } +# edit a JSON file in place with a JavaScript expression over `j` +jedit() { node -e 'const fs=require("fs");const f=process.argv[1];const j=JSON.parse(fs.readFileSync(f,"utf8"));'"$2"';fs.writeFileSync(f,JSON.stringify(j,null,2));' "$1"; } +run() { node "$SUT" "$@" 2>&1; } + +echo "== T1: the golden fixture validates clean ==" +P="$(fresh golden)" +OUT="$(run validate --project "$P")"; RC=$? +check "exit 0" "$RC" "0" +has "summary line" "$OUT" "1 process(es), 8 step(s), 5 use case(s) (1 standalone), 4 wireframe(s), 0 error(s), 1 warning(s)" +has "a wireframe on no step is a warning, not an error" "$OUT" "WARN 1 wireframe(s) on no process step: Orders.Settings_Edit" + +echo "== T2: anchor derives four, keeps two judged, leaves none unanchored ==" +OUT="$(run anchor --project "$P")"; RC=$? +check "exit 0" "$RC" "0" +has "stat line" "$OUT" "anchors: 6 item(s), 4 derived, 2 judged, 2 global, 0 unanchored" +A="$(cat "$P/design/question-anchors.json")" +has "use case lists the question: Q01 on the submit step" "$A" '"basis": "F001:Q01 -> F001:UC001 -> P1-01"' +has "cited source lines overlap: Q02 on the approve screen step" "$A" '"basis": "F001:Q02 cites the lines of F001:UC002 -> P1-03"' +has "named screen: Q03 on the order list step" "$A" '"basis": "names screen Order_Overview -> P1-08"' +has "ASSUMED register row becomes a decision item" "$A" '"id": "D3-01"' +has "judged global anchor survives a re-run" "$A" '"bucket": "Money"' +hasnt "CONFIRMED decisions are not pinned" "$A" "Module structure" + +echo "== T3: build writes a self-contained, deterministic board ==" +OUT="$(run build --project "$P")"; RC=$? +check "exit 0" "$RC" "0" +B="$P/design/process-board.html" +[ -s "$B" ] && ok "design/process-board.html written" || bad "design/process-board.html written" "no file" +H="$(cat "$B" 2>/dev/null)" +has "app name in the page" "$H" "Order desk" +has "process title embedded" "$H" "Order approval: submit, approve or reject, notify" +has "wireframe embedded for thumbnails" "$H" "Approve order" +hasnt "wireframe scripts are stripped" "$H" "stripped from thumbnails" +hasnt "no external script" "$H" ' """ diff --git a/bin/gate-check.sh b/bin/gate-check.sh index 87fb892e..36432e79 100755 --- a/bin/gate-check.sh +++ b/bin/gate-check.sh @@ -3318,14 +3318,15 @@ if [ -n "$REQUESTED_STAGE" ]; then echo "" >&2 echo "Gate BLOCKED: $blk question(s) at stage $st have never been raised with the user." >&2 echo " $counts" >&2 - # Write the triage report the user reads, alongside the batch the agent reads. - # A terminal dump scrolls away and only the agent ever sees it; the HTML is the artefact - # a human can open, sort by state, and hand to someone else. --no-html suppresses it for - # the same reason it suppresses the board: a read-only query must not dirty a tree. - QR_SCRIPT="$TOOLKIT_DIR/bin/questions-report.sh" - QR_OUT="$PROJECT_DIR/docs/open-questions.html" + # Write the report the user reads, alongside the batch the agent reads. A terminal dump + # scrolls away and only the agent ever sees it; the HTML is the artefact a human can open and + # hand to someone else. Since 2026-10-16 the questions live in the Stage 2 BRD report, under + # the process map (bin/brd-report.sh), not in docs/open-questions.html. --no-html suppresses + # it for the same reason it suppresses the board: a read-only query must not dirty a tree. + QR_SCRIPT="$TOOLKIT_DIR/bin/brd-report.sh" + QR_OUT="$PROJECT_DIR/analysis/brd-report.html" if [ "$HTML_MODE" != "never" ] && [ -x "$QR_SCRIPT" ]; then - "$QR_SCRIPT" "$PROJECT_DIR" $arg -o "$QR_OUT" --quiet >/dev/null 2>&1 + "$QR_SCRIPT" "$PROJECT_DIR" $arg --html "$QR_OUT" --quiet >/dev/null 2>&1 [ -f "$QR_OUT" ] || QR_OUT="" else QR_OUT="" @@ -3334,7 +3335,9 @@ if [ -n "$REQUESTED_STAGE" ]; then echo "" >&2 echo " See them, as a chat-ready batch:" >&2 echo " $OQ_SCRIPT $PROJECT_DIR${arg:+ $arg}" >&2 - [ -n "$QR_OUT" ] && echo " Triage report (open this, and give the user the path): $QR_OUT" >&2 + [ -n "$QR_OUT" ] && echo " BRD report, questions under the process map (open this, and give the user the path): $QR_OUT" >&2 + # Back-compat: the retired page is never read and never fails a gate; it only gets pointed away from. + [ -f "$PROJECT_DIR/docs/open-questions.html" ] && echo " (docs/open-questions.html is the retired page: its content is in analysis/brd-report.html now. Safe to delete.)" >&2 echo "" >&2 echo " Raising is mandatory; assuming is not forbidden. Anything the user hands back" >&2 echo " ('you decide') is recorded ASSUMED with consentBy/consentAt and stops blocking." >&2 diff --git a/bin/init-project.sh b/bin/init-project.sh index cabed48e..69cd4c81 100755 --- a/bin/init-project.sh +++ b/bin/init-project.sh @@ -472,7 +472,7 @@ fi # analysis/ — THE OUTPUT SIDE, and it was never scaffolded (fixed 2026-08-20). # # Every rendered surface in the pipeline lands here: triage.html (bin/triage-report.sh), -# source-sufficiency.html, brd-report.html (bin/brd-report.sh), open-questions.html. gate-check.sh +# source-sufficiency.html, brd-report.html (bin/brd-report.sh; the open questions live in it). gate-check.sh # resolves $ANALYSIS_BASE against it. sources/ was created here from day one; analysis/ was not, # on the unstated assumption that an extraction pipeline would `mkdir -p` it on its way past. # @@ -504,8 +504,7 @@ and the honest way to change one is to re-run its producer. | `source-sufficiency.html` | `bin/source-sufficiency.sh report` | 0 | | `triage.html` | `bin/triage-report.sh ` | 0 | | `extraction-report.html` | the extraction pipeline, or `kb-generation.md` for a document corpus | 1 | -| `brd-report.html` | `bin/brd-report.sh ` | 2 | -| `open-questions.html` | `bin/questions-report.sh --stage N` | any gate | +| `brd-report.html` | `bin/brd-report.sh [--stage N]`: process map, requirements and open questions | 2, any gate | **This folder exists in every entry mode.** A requirements-driven or greenfield project runs no extraction pipeline, but it still produces a sufficiency grade, a scope decision and a BRD report diff --git a/bin/install-claude-hooks.sh b/bin/install-claude-hooks.sh index 1dff2e00..758c7eac 100755 --- a/bin/install-claude-hooks.sh +++ b/bin/install-claude-hooks.sh @@ -68,7 +68,7 @@ TIER 2 --full Changes your working rhythm. Useful after a bad bill; precompact-guard Warns once before a compaction discards unfiled work. interview-gate BLOCKS the end of a turn while questions the pipeline generated have never been put to you, and writes - docs/open-questions.html for you to review. Only fires on + analysis/brd-report.html for you to review. Only fires on projects that actually have BRDs; never blocks the same question set twice, so it cannot trap a session. diff --git a/bin/lib/artifact-manifest.tsv b/bin/lib/artifact-manifest.tsv index 6808cadd..ea572541 100644 --- a/bin/lib/artifact-manifest.tsv +++ b/bin/lib/artifact-manifest.tsv @@ -81,7 +81,7 @@ extraction-report 1 analysis/*/knowledge-base/extraction-report.html|analysis/kn source-ledger 1 analysis/source-ledger.html bin/source-ledger.sh report — every inventoried source file and what consumed it (Stage 1 owes a disposition per row; gate-check blocks 1–2 on the check) bin/gate-check.sh (LEDGER block — the check; the html is the human-facing table of the same rows) migration,requirements,existing-app-change report brds 2 analysis/*/knowledge-base/brd/*.brd.json|analysis/knowledge-base/brd/*.brd.json|knowledge-base/brd/*.brd.json pipeline generators (migration), skills/kb-generation.md (documents), skills/brd-generation.md (by hand) — the reader must not care which bin/lib/discover-brds.sh:43 (the one path authority), bin/gate-check.sh:773 (check_stage_2), bin/brd-report.sh, bin/open-questions.sh migration,requirements,existing-app-change gate brd-validation 2 analysis/*/knowledge-base/reports/validation-report.md|analysis/knowledge-base/reports/validation-report.md|knowledge-base/reports/validation-report.md skills/brd-validation.md loop, run to Clean bin/gate-check.sh:777-807 (check_stage_2 Stop-condition test) migration,requirements,existing-app-change gate -brd-surface 2 analysis/brd-report.html bin/brd-report.sh (default output, bin/brd-report.sh:113) conversion-runbook.md §2 Stage-2 Surface row, parsed at bin/gate-check.sh:1284 (stage_surface_cells) migration,requirements,existing-app-change report +brd-surface 2 analysis/brd-report.html bin/brd-report.sh (default output, bin/brd-report.sh:113); also holds the pass-1 process map and the open questions (replaced docs/open-questions.html, 2026-10-16) conversion-runbook.md §2 Stage-2 Surface row, parsed at bin/gate-check.sh:1284 (stage_surface_cells) migration,requirements,existing-app-change report module-design 3 architecture/module-design.html|module-design.html skills/modularize-domain.md:88 (the human-facing sign-off artifact) skills/modularize-domain.md:109 (gate checklist), conversion-runbook.md:478 Stage-3 Surface row (parsed at bin/gate-check.sh:1284) migration,requirements,existing-app-change report fit-gap 3 architecture/fit-gap.md skills/architecture-blueprint.md Step 4 bin/gate-check.sh:912 (check_stage_3), bin/gate-check.sh:939 (blueprint.html staleness input) migration,requirements,existing-app-change gate blueprint-md 3 architecture/blueprint.md skills/architecture-blueprint.md Steps 2-6 — the markdown is the source of truth bin/gate-check.sh:939 (staleness input), skills/architecture-blueprint.md:54 (render source for blueprint.html) migration,requirements,existing-app-change report diff --git a/bin/questions-report.sh b/bin/questions-report.sh deleted file mode 100755 index 15cee9c2..00000000 --- a/bin/questions-report.sh +++ /dev/null @@ -1,299 +0,0 @@ -#!/bin/bash -# questions-report.sh — render a project's open questions as a reviewable HTML page. -# -# WHY THIS EXISTS -# --------------- -# bin/open-questions.sh already produces a chat-ready batch. That solves "the agent has the -# questions". It does not solve the two things the user actually reported missing: -# -# 1. There is no surface showing WHAT WAS FOUND — what got examined, what is still open, -# and which open items are blocking — that a human can read away from the terminal. -# 2. Most questions arrive with no proposed answer. A question with no proposed answer is -# work handed back to the user. A question with two named options and a recommendation -# is a decision they can make in five seconds. -# -# This script renders (1), and marks every question that lacks (2) as a defect in the -# generating stage rather than quietly presenting a bare question. -# -# Usage: -# bin/questions-report.sh [--stage N] [-o ] [--quiet] -# -# Default output: /docs/open-questions.html -# -# Exit codes: 0 = report written, nothing blocking. 1 = report written, blocking questions -# exist. 2 = usage/environment error. 3 = nothing was examined (NOT a clean -# result — same meaning as open-questions.sh). - -set -uo pipefail - -# Portability: resolve a real Python 3 by EXECUTING candidates, not by looking one up on PATH. -# On Windows `python3` is usually the Microsoft Store alias stub, which `command -v` finds and -# which then opens the Store instead of running. See bin/lib/portable.sh. -# shellcheck disable=SC1091 -. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/portable.sh" - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -COLLECTOR="$SCRIPT_DIR/open-questions.sh" - -PROJECT_DIR="" -STAGE_ARGS=() -OUT="" -QUIET=0 - -while [ $# -gt 0 ]; do - case "$1" in - --stage) STAGE_ARGS+=(--stage "${2:-}"); shift 2 ;; - -o|--output) OUT="${2:-}"; shift 2 ;; - --quiet) QUIET=1; shift ;; - -h|--help) sed -n '2,28p' "${BASH_SOURCE[0]}"; exit 0 ;; - -*) echo "questions-report.sh: unknown option $1" >&2; exit 2 ;; - *) if [ -z "$PROJECT_DIR" ]; then PROJECT_DIR="$1"; else STAGE_ARGS+=("$1"); fi; shift ;; - esac -done - -[ -n "$PROJECT_DIR" ] || { echo "usage: questions-report.sh [--stage N] [-o out.html]" >&2; exit 2; } -[ -d "$PROJECT_DIR" ] || { echo "questions-report.sh: not a directory: $PROJECT_DIR" >&2; exit 2; } -[ -x "$COLLECTOR" ] || [ -f "$COLLECTOR" ] || { echo "questions-report.sh: collector not found: $COLLECTOR" >&2; exit 2; } - -PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)" -[ -n "$OUT" ] || OUT="$PROJECT_DIR/docs/open-questions.html" - -JSON=$(bash "$COLLECTOR" "$PROJECT_DIR" "${STAGE_ARGS[@]+"${STAGE_ARGS[@]}"}" --json 2>/dev/null) -COLLECT_RC=$? - -# rc 3 means no BRDs and no sme-questions.md were found. That is not "no open questions"; -# it means the instrument found nothing to read, and reporting it as clean is the exact -# class of false-green this toolkit keeps getting bitten by. -# Belt and braces: the flag is read from the payload as well as the exit code. rc=3 was -# unreachable in --json mode until 2026-08-12, and this script trusting it alone is how that -# went unnoticed — it rendered a clean-looking report over an empty directory. -NOTHING=$(printf '%s' "$JSON" | grep -c '"nothingExamined": *true' | head -1) -if [ "$COLLECT_RC" -eq 3 ] || [ "${NOTHING:-0}" -gt 0 ] || [ -z "$JSON" ]; then - echo "questions-report.sh: nothing was examined (no BRDs, no sme-questions.md) — refusing to" >&2 - echo " write a report that would read as 'no open questions'. Exit 3." >&2 - exit 3 -fi - -mkdir -p "$(dirname "$OUT")" || { echo "questions-report.sh: cannot create $(dirname "$OUT")" >&2; exit 2; } - -require_py - -PROJECT_NAME="$(basename "$PROJECT_DIR")" -GENERATED="$(date '+%Y-%m-%d %H:%M')" - -# The collector's JSON goes through a temp FILE, not a pipe. `"$PY" - <<'PYEOF'` takes its -# program from stdin, so a pipe into the same command is swallowed by the heredoc and json.load -# reads an empty string. Costing a temp file is cheaper than an inline-quoted program. -JSON_TMP=$(mktemp "${TMPDIR:-/tmp}/qreport.XXXXXX.json") || exit 2 -trap 'rm -f "$JSON_TMP"' EXIT -printf '%s' "$JSON" > "$JSON_TMP" - -PROJECT_NAME="$PROJECT_NAME" GENERATED="$GENERATED" JSON_TMP="$JSON_TMP" \ - PROJECT_DIR="$PROJECT_DIR" OUT="$OUT" "$PY" - <<'PYEOF' -import html, json, os, sys - -with open(os.environ["JSON_TMP"], encoding="utf-8") as fh: - d = json.load(fh) -qs = d.get("questions", []) -counts = d.get("counts", {}) -ex = d.get("examined", {}) -blocking = d.get("blocking", 0) -project = os.environ["PROJECT_NAME"] -generated = os.environ["GENERATED"] -out_path = os.environ["OUT"] - -BLOCKING_STATES = {"UNRAISED", "UNRECOGNISED"} - -# Order: what needs the user first, then what is merely on record. -ORDER = ["UNRECOGNISED", "UNRAISED", "RAISED", "ASSUMED", "ANSWERED", "MOOT"] -STATE_NOTE = { - "UNRAISED": "Never put to you. These block the gate.", - "UNRECOGNISED": "Status text no rule could map. Treated as unraised — a status nobody can parse is not an answer.", - "RAISED": "Put to you, awaiting your reply.", - "ASSUMED": "A position was taken AND you consented to it. Recorded, not blocking.", - "ANSWERED": "Settled with a recorded answer.", - "MOOT": "Descoped — genuinely dead, not silently dropped.", -} - -def esc(s): - return html.escape(str(s or "")) - -# A question the user can act on needs a proposed answer. One without is not "a question", -# it is unfinished work by the stage that produced it — so it gets called out as such. -def has_proposal(q): - return bool((q.get("assumption") or "").strip()) - -groups = {} -for q in qs: - groups.setdefault(q.get("state", "UNRECOGNISED"), []).append(q) - -actionable = [q for q in qs if q.get("state") in BLOCKING_STATES] -no_proposal = [q for q in actionable if not has_proposal(q)] - -rows = [] -for st in ORDER: - n = counts.get(st, 0) - if n: - cls = "b" if st in BLOCKING_STATES else "" - rows.append(f'{st}{n}{esc(STATE_NOTE.get(st,""))}') -tbl = "\n".join(rows) - -stage_filter = ex.get("stageFilter") -scope = f"stage ≤ {esc(stage_filter)}" if stage_filter else "all stages" - -cards = [] -for st in ORDER: - items = groups.get(st, []) - if not items: - continue - badge = "block" if st in BLOCKING_STATES else "ok" - cards.append(f'

{esc(st)} {len(items)}

') - cards.append(f'

{esc(STATE_NOTE.get(st,""))}

') - for q in items: - prop = (q.get("assumption") or "").strip() - raw = (q.get("rawStatus") or "").strip() - reason = (q.get("reason") or "").strip() - bits = [f'
'] - bits.append(f'
{esc(q.get("id","?"))} {esc(q.get("origin",""))}
') - bits.append(f'
{esc(q.get("question",""))}
') - if prop: - bits.append( - '
Drafting position — confirm or overturn' - f'
{esc(prop)}
') - elif st in BLOCKING_STATES: - bits.append( - '
No proposed answer' - '
This question was handed over bare. Before putting it to the user, the owning ' - 'stage should propose at least two named options and a recommendation — otherwise the ' - 'user is being asked to do the analysis the stage was meant to do.
') - meta = [] - if raw: - meta.append(f'recorded as: {esc(raw[:160])}') - if reason: - meta.append(esc(reason)) - if q.get("stage") is not None: - meta.append(f'stage {esc(q.get("stage"))}') - if meta: - bits.append('
' + " · ".join(meta) + "
") - bits.append("
") - cards.append("".join(bits)) - -banner_cls = "fail" if blocking else "pass" -banner_txt = (f"{blocking} question(s) have never been put to the user — this gate is blocked." - if blocking else - "No unraised questions. Every question on record has been seen by the user.") - -gapline = "" -if no_proposal: - gapline = (f'') - -doc = f""" - - -Open questions — {esc(project)} - -
-

Open questions — {esc(project)}

-

Generated {esc(generated)} · scope: {scope} · - examined {esc(ex.get("brdFiles",0))} BRD file(s) and {esc(ex.get("smeQuestionFiles",0))} SME question file(s), - {esc(ex.get("duplicatesCollapsed",0))} duplicate(s) collapsed

- - - {gapline} - -

What was found

- - {tbl} -
StateCountMeaning
- - {"".join(cards)} - - -
-""" - -with open(out_path, "w") as f: - f.write(doc) -PYEOF - -PY_RC=$? -[ "$PY_RC" -eq 0 ] || { echo "questions-report.sh: render failed (python exit $PY_RC)" >&2; exit 2; } - -if [ "$QUIET" -eq 0 ]; then - echo "Wrote: $OUT" - # Reads the temp file, not a pipe, and no 2>/dev/null: a summary that silently prints - # nothing when it breaks is worse than one that says why. - JSON_TMP="$JSON_TMP" "$PY" - <<'PYSUM' -import json, os -with open(os.environ["JSON_TMP"], encoding="utf-8") as fh: - d = json.load(fh) -c = d.get("counts", {}) -qs = d.get("questions", []) -bare = [q for q in qs - if q.get("state") in ("UNRAISED", "UNRECOGNISED") - and not (q.get("assumption") or "").strip()] -print(" blocking: %s total: %s without a proposed answer: %s" - % (d.get("blocking", 0), c.get("total", 0), len(bare))) -PYSUM -fi - -[ "$COLLECT_RC" -eq 1 ] && exit 1 -exit 0 diff --git a/claude-hooks/hooks/interview-gate.sh b/claude-hooks/hooks/interview-gate.sh index bf7a3f1f..b36c88df 100755 --- a/claude-hooks/hooks/interview-gate.sh +++ b/claude-hooks/hooks/interview-gate.sh @@ -50,7 +50,7 @@ except Exception: print("True") PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" TOOLKIT="__TOOLKIT_ROOT__" OQ="$TOOLKIT/bin/open-questions.sh" -QR="$TOOLKIT/bin/questions-report.sh" +QR="$TOOLKIT/bin/brd-report.sh" IM="$TOOLKIT/bin/interview-mode.sh" [ -x "$OQ" ] || exit 0 @@ -92,8 +92,8 @@ printf '%s' "$FP" > "$STATE" 2>/dev/null || true REPORT="" if [ -x "$QR" ]; then - "$QR" "$PROJECT_DIR" -o "$PROJECT_DIR/docs/open-questions.html" --quiet >/dev/null 2>&1 - [ -f "$PROJECT_DIR/docs/open-questions.html" ] && REPORT="$PROJECT_DIR/docs/open-questions.html" + "$QR" "$PROJECT_DIR" --html "$PROJECT_DIR/analysis/brd-report.html" --quiet >/dev/null 2>&1 + [ -f "$PROJECT_DIR/analysis/brd-report.html" ] && REPORT="$PROJECT_DIR/analysis/brd-report.html" fi # In auto mode — and when the user is remote — the report is the whole job. Refreshing it @@ -113,7 +113,7 @@ REASON="$BLOCKING question(s) in this project have never been put to the user, a Before you stop: put them in the chat. Read $TOOLKIT/skills/interview-protocol.md — every question carries two named options and your recommendation; a bare question hands your analysis back to the user. Batch them, then end the turn and wait for answers. " [ -n "$REPORT" ] && REASON="${REASON} -Triage report written for the user to review: $REPORT — give them this path. +BRD report (open questions under the process map) written for the user to review: $REPORT — give them this path. " REASON="${REASON} This project is in interview mode '$MODE'. If the user wants speed over involvement here, diff --git a/docs/windows-test-run.md b/docs/windows-test-run.md index ba64dda7..9086ecea 100644 --- a/docs/windows-test-run.md +++ b/docs/windows-test-run.md @@ -124,7 +124,7 @@ I3 node project-bin/page-fidelity.js --no-log tests/wave2/fixtures/page-fidelit I4 node project-bin/page-fidelity.js --no-log tests/wave2/fixtures/page-fidelity-mocks/mocked-list.html DemoList tests/wave2/fixtures/page-fidelity-mocks/demo-list.mdl I5 bash bin/html-to-md.sh "tests/wave2/fixtures/html-to-md/Fleet Portal Requirements.html" > ~/mxtk-win-run/fleet.md Then: wc -l ~/mxtk-win-run/fleet.md -I6 bash bin/brd-report.sh --help ; bash bin/triage-report.sh --help ; bash bin/questions-report.sh --help +I6 bash bin/brd-report.sh --help ; bash bin/triage-report.sh --help ; bash bin/brd-report.sh --help (exit 0 and a usage text each = PASS; these are the report tools with no committed input) I7 bash bin/bug-lookup.sh CE0053 I8 bash bin/token-burn.sh --help diff --git a/process/improvement-plan-e2e-reporting.md b/process/improvement-plan-e2e-reporting.md index ddd401b6..31bc6448 100644 --- a/process/improvement-plan-e2e-reporting.md +++ b/process/improvement-plan-e2e-reporting.md @@ -270,7 +270,7 @@ These close options left open in Findings 1–4 and set the constraints for the 2. **The number stops lying instead.** The report leads with coverage, not pass-rate: *"58% of checks did not run"* above the fold, and no percentage anywhere may use a denominator that excludes faults. This blocks nobody and is the only thing that actually failed. - Precedent already in-repo: `bin/questions-report.sh:72` refuses to render a clean-looking zero. + Precedent already in-repo: `bin/brd-report.sh` (exit 3) refuses to render a clean-looking zero (it took over from the retired `questions-report.sh`). 3. **Plumbing before gating.** Findings 5–8 are fixed first, so that any later signal fires on instruments that are actually present. 4. **Four fallback levels for the ledger check**, keyed on what is present rather than declared diff --git a/project-bin/assemble-prototype.js b/project-bin/assemble-prototype.js index a4b1ae58..ca6e1fe0 100755 --- a/project-bin/assemble-prototype.js +++ b/project-bin/assemble-prototype.js @@ -283,6 +283,11 @@ const SCRIPT = ` })(); `; +// The process map (process-map.js, skills/process-map.md) is built from the same wireframes and shows +// each on its step. When the project has a process model, the prototype links to the map beside it +// and the map links back (its header). Keyed on the model, not on the built map, so the order of +// the two builds does not matter. +const hasMap = fs.existsSync(path.join(path.dirname(path.resolve(outFile)), 'process-model.json')); const index = screens.map(s => '
  • ' + esc(s.title) + ' #/' + esc(s.route) + '
  • ').join('\n'); const out = '\n' + @@ -294,7 +299,8 @@ const out = '\n' + '\n' + '\n\n' + '\n' + + '' + + (hasMap ? '
    Process map: where each screen sits in the work' : '') + '\n' + '\n' + '
    \n' + screens.map(screenSection).join('') + '
    \n' + '\n\n'; diff --git a/project-bin/process-map.js b/project-bin/process-map.js index 9957a6a1..f0274fb0 100644 --- a/project-bin/process-map.js +++ b/project-bin/process-map.js @@ -23,8 +23,17 @@ // process-map.js validate [--strict] check the model (and anchors, when present) // process-map.js anchor derive anchors mechanically, keep judged ones, write // the anchors file, list what is still unanchored +// process-map.js migrate a model whose "anchors" key points at an old path (say the +// prototype folder): move the file to design/question-anchors.json, +// keep the old one as *.bak, rewrite the key, print what carried over // process-map.js build validate, then write design/process-map.html +// process-map.js build --embed the same maps as a bare page for an ') else: map_html = f'

    Process map

    ' @@ -1039,7 +1052,7 @@ function pick(id, scroll){ window.addEventListener('message', function(e){ if (!frame || e.source !== frame.contentWindow) return; var d = e.data || {}; - if (d.pm === 'height') frame.style.height = d.h + 'px'; + if (d.pm === 'height') { if (d.h > 80) frame.style.height = d.h + 'px'; } else if (d.pm === 'step') pick(d.step, true); else if (d.pm === 'ready') post(); }); @@ -1207,11 +1220,9 @@ page = f"""
    -

    Stage 2 — what the BRDs actually say

    -

    Rendered {esc(datetime.datetime.now().strftime('%Y-%m-%d'))} from - {len(analysed)} BRD(s) in {len(kb_dirs)} knowledge-base director(ies) — whatever produced - them. Every section carries a verdict, so a section that is empty because this kind of BRD - has nothing to put there reads differently from one that is empty because something broke.

    +

    {'Open questions, stage ' + stage_arg + ' view' if (not analysed and stage_arg) else ('Open questions' if not analysed else 'Stage 2 — what the BRDs actually say')}

    +

    {('Rendered ' + esc(datetime.datetime.now().strftime('%Y-%m-%d')) + '. No BRDs exist yet: these are the questions collected from analysis/sme-questions.md.') if not analysed else ('Rendered ' + esc(datetime.datetime.now().strftime('%Y-%m-%d')) + ' from ' + str(len(analysed)) + ' BRD(s) in ' + str(len(kb_dirs)) + ' knowledge-base director(ies), whatever produced them. Every section carries a verdict, so a section that is empty because this kind of BRD has nothing to put there reads differently from one that is empty because something broke.')} + {esc('Questions shown: up to stage ' + stage_arg + '.') if stage_arg else ''}

    {map_html} diff --git a/docs/pipeline-walks.html b/docs/pipeline-walks.html index dbd31540..ad08b7bb 100644 --- a/docs/pipeline-walks.html +++ b/docs/pipeline-walks.html @@ -169,7 +169,7 @@

    Migration Legacy source code existsP KickoffScaffold the project. Answer every intake question. Propose the entry mode with evidence and record it CONFIRMED. Gate: every intake question answered. Owner ba-agent.bin/init-project.sh <project>
    bin/init-agents.sh <session-root>
    bin/gate-check.sh <project> P 0 Triage ✋Inventory every source file. Grade sufficiency (SPECIFICATION / OUTLINE / SKETCH). Decide extract-or-not per structure and fill the coverage matrix. Scan documents. Run the CAC-1 scope brainstorm, order the slices, get sign-off in triage.md. Owner ba-agent.bin/source-sufficiency.sh init <project>
    bin/source-sufficiency.sh report <project>
    bin/interview-mode.sh --set … (only after the user agrees)
    bin/triage-report.sh <project>
    bin/source-sufficiency.sh --refresh for late files
    bin/gate-check.sh <project> 0 1 AnalysisPath A: run the platform pipeline (extract, knowledge base, BRD scaffolds, reports). Path B: convert documents to Markdown and build the KB. Path C: interview the SME. Every source file must name the artifact that consumed it. Gate: source ledger clean, four extraction quality checks. CAC-1b.cd pipelines/<platform>/pipeline && npm install
    node run.js · node run.js 2 · node run.js 3 · npm run reports
    bin/html-to-md.sh <project> · bin/images-to-md.sh
    bin/source-ledger.sh report <project>
    bin/source-ledger.sh check <project>
    bin/extraction-report.sh <project>
    bin/gate-check.sh <project> 1 - 2 RequirementsTurn scaffolds into validated BRDs, one per feature. Drive blocking open questions to zero. Lock facts. CAC-2 BRD sign-off, CAC-3 architecture entry.bin/brd-report.sh <project>
    bin/open-questions.sh <project> --stage 2
    bin/questions-report.sh · bin/facts-lock.sh · bin/question-kinds.sh
    bin/gate-check.sh <project> 2 + 2 RequirementsTurn scaffolds into validated BRDs, one per feature. Drive blocking open questions to zero. Lock facts. CAC-2 BRD sign-off, CAC-3 architecture entry.bin/brd-report.sh <project> [--stage N]
    bin/open-questions.sh <project> --stage 2
    bin/facts-lock.sh · bin/question-kinds.sh
    bin/gate-check.sh <project> 2 3 Design ✋Module boundaries, blueprint, fit-gap, security model, integrations. Design system and one wireframe per screen. CAC-4. Owner architect-agent, interviews by ba-agent.skills: modularize-domain.md, architecture-blueprint.md, design-artifacts.md
    outputs: architecture/blueprint.html, design/design-system.html, design/wireframes/*.html
    bin/gate-check.sh <project> 3 4 Build plan ✋Dependency-ordered script sequence, coverage ledger, one brief per module. CAC-5, then the build-ready wiring check.bin/coverage-check.sh
    bin/gate-check.sh <project> 4
    bin/gate-check.sh <project> build-ready 5 BuildWalking skeleton, then the five-stage module loop per module (see below). Coherence pass every two or three modules../mxcli check s.mdl -p app.mpr --references
    bin/exec.sh s.mdl · ./mxcli lint -p app.mpr
    project-bin/verify-module.sh <Module>
    ./mxcli graph-report -p app.mpr
    bin/gate-check.sh <project> 5 diff --git a/docs/windows-test-run.md b/docs/windows-test-run.md index 9086ecea..08022bc1 100644 --- a/docs/windows-test-run.md +++ b/docs/windows-test-run.md @@ -124,7 +124,7 @@ I3 node project-bin/page-fidelity.js --no-log tests/wave2/fixtures/page-fidelit I4 node project-bin/page-fidelity.js --no-log tests/wave2/fixtures/page-fidelity-mocks/mocked-list.html DemoList tests/wave2/fixtures/page-fidelity-mocks/demo-list.mdl I5 bash bin/html-to-md.sh "tests/wave2/fixtures/html-to-md/Fleet Portal Requirements.html" > ~/mxtk-win-run/fleet.md Then: wc -l ~/mxtk-win-run/fleet.md -I6 bash bin/brd-report.sh --help ; bash bin/triage-report.sh --help ; bash bin/brd-report.sh --help +I6 bash bin/brd-report.sh --help ; bash bin/triage-report.sh --help (exit 0 and a usage text each = PASS; these are the report tools with no committed input) I7 bash bin/bug-lookup.sh CE0053 I8 bash bin/token-burn.sh --help diff --git a/project-bin/process-map.js b/project-bin/process-map.js index f0274fb0..8e4d8394 100644 --- a/project-bin/process-map.js +++ b/project-bin/process-map.js @@ -82,17 +82,28 @@ const designDir = path.dirname(modelFile); // as .bak and the entries are written into design/question-anchors.json in the current shape. function anchorEntries(j) { const list = Array.isArray(j) ? j : (j && (j.anchors || j.items)) || []; - return list.filter(e => e && e.id).map(e => { + const res = { entries: [], noId: 0, noTarget: [] }; + for (const e of list) { + if (!e || !e.id) { res.noId++; continue; } let targets = e.targets || (e.target ? [e.target] : null); + if (targets) targets = targets.map(t => typeof t === 'string' ? { type: 'step', step: t } : t); if (!targets) { targets = []; if (e.step) targets.push({ type: 'step', step: e.step }); if (e.page) targets.push({ type: 'page', page: e.page }); if (e.bucket) targets.push({ type: 'global', bucket: e.bucket }); } + // an entry that places the item nowhere is not an anchor: leave it out and report it + if (!targets.length) { res.noTarget.push(e.id); continue; } // an entry of an old file was placed by a person: judged, unless it says otherwise - return Object.assign({}, e, { targets, anchoredBy: e.anchoredBy || 'judged' }); - }); + res.entries.push(Object.assign({}, e, { targets, anchoredBy: e.anchoredBy || 'judged' })); + } + return res; +} +function writeAtomic(f, text) { + const tmp = f + '.tmp-' + process.pid; + fs.writeFileSync(tmp, text); + fs.renameSync(tmp, f); } if (cmd === 'migrate') { const target = path.join(designDir, 'question-anchors.json'); @@ -103,24 +114,43 @@ if (cmd === 'migrate') { process.exit(0); } if (!fs.existsSync(old)) die('the model names anchors file ' + model.anchors + ', which does not exist; nothing to carry over'); - const entries = anchorEntries(readJson(old, 'old anchors file')); - let have = []; - if (fs.existsSync(target)) have = anchorEntries(readJson(target, 'current anchors file')); - const haveIds = new Set(have.map(e => e.id)); - const carried = entries.filter(e => !haveIds.has(e.id)); + const oldE = anchorEntries(readJson(old, 'old anchors file')); + const haveE = fs.existsSync(target) ? anchorEntries(readJson(target, 'current anchors file')) : { entries: [], noId: 0, noTarget: [] }; + const have = haveE.entries.slice(); + const idx = {}; have.forEach((e, k) => { idx[e.id] = k; }); + const carried = [], notes = []; + for (const e of oldE.entries) { + if (!(e.id in idx)) { carried.push(e); continue; } + const cur = have[idx[e.id]]; + if (e.anchoredBy === 'judged' && cur.anchoredBy !== 'judged') { + have[idx[e.id]] = e; carried.push(e); + notes.push(e.id + ': the old judged placement replaced the current derived one'); + } else if (e.anchoredBy === 'judged') { + if (JSON.stringify(e.targets) !== JSON.stringify(cur.targets)) notes.push(e.id + ': both files judge it, the current placement is kept (the old one is in the .bak)'); + } + } + const all = have.concat(carried.filter(e => have.indexOf(e) < 0)); const out = { schema: 'process-anchors/1', note: 'Generated by process-map.js anchor. Edit only judged entries (anchoredBy "judged"); derived entries are recomputed on every run.', - anchors: have.concat(carried) }; + anchors: all }; fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, JSON.stringify(out, null, 2) + '\n'); - fs.renameSync(old, old + '.bak'); - const rawModel = readJson(modelFile, 'process model'); - rawModel.anchors = want; - fs.writeFileSync(modelFile, JSON.stringify(rawModel, null, 2) + '\n'); + // Order of writes: the new anchors file, then the model key, then the old file moves aside. + // A crash in between leaves a model that still points at an existing file. + writeAtomic(target, JSON.stringify(out, null, 2) + '\n'); + const rawText = fs.readFileSync(modelFile, 'utf8'); + const keyRe = /("anchors"\s*:\s*)"[^"]*"/; + if (keyRe.test(rawText)) writeAtomic(modelFile, rawText.replace(keyRe, (m, k) => k + JSON.stringify(want))); + else { const m = readJson(modelFile, 'process model'); m.anchors = want; writeAtomic(modelFile, JSON.stringify(m, null, 2) + '\n'); } + let bak = old + '.bak', n = 1; + while (fs.existsSync(bak)) bak = old + '.bak.' + (n++); + fs.renameSync(old, bak); const judged = carried.filter(e => e.anchoredBy === 'judged').length; console.log('migrate: carried over ' + carried.length + ' entr' + (carried.length === 1 ? 'y' : 'ies') + ' (' + judged + ' judged) from ' + - path.relative(root, old) + ' to ' + want + (have.length ? ' (' + have.length + ' already there kept)' : '')); - console.log('migrate: old file kept as ' + path.relative(root, old) + '.bak; model "anchors" is now ' + want); + path.relative(root, old) + ' to ' + want + (haveE.entries.length ? ' (' + haveE.entries.length + ' already there)' : '')); + for (const m of notes) console.log('migrate: ' + m); + if (oldE.noTarget.length) console.log('migrate: ' + oldE.noTarget.length + ' old entr' + (oldE.noTarget.length === 1 ? 'y' : 'ies') + ' placed nothing and ' + (oldE.noTarget.length === 1 ? 'was' : 'were') + ' left out: ' + oldE.noTarget.join(', ')); + if (oldE.noId) console.log('migrate: ' + oldE.noId + ' old entr' + (oldE.noId === 1 ? 'y' : 'ies') + ' had no id and ' + (oldE.noId === 1 ? 'was' : 'were') + ' left out'); + console.log('migrate: old file kept as ' + path.relative(root, bak) + '; model "anchors" is now ' + want + ' (only that key was changed)'); console.log('migrate: next, run anchor to place anything new and list what is still unanchored'); process.exit(0); } @@ -173,7 +203,9 @@ for (const [id, b] of Object.entries(BRDS)) brdByFile[b.base] = id; // Wireframes: page key is data-mx-page ("Module.Page") when present, else the filename stem. const wfDir = model.wireframes ? rel(model.wireframes) : path.join(designDir, 'wireframes'); const WF = {}; // pageKey -> { key, short, file, route, html } +const WF_STRONG = {}; // short names that come from a data-mx-page: they beat PAGE-MAP.tsv const WF_SHORT = {}; // short name -> key +const PAGE_MAPPED = {}; // names a PAGE-MAP.tsv row placed: they beat a wireframe whose key is only its filename stem const kebab = name => name.replace(/\.html$/i, '') .replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') .replace(/[_\s]+/g, '-').replace(/[^A-Za-z0-9.\/-]/g, '-').replace(/-+/g, '-') @@ -189,10 +221,12 @@ for (const f of globDir(wfDir, /\.html$/i)) { const route = attr(bodyTag, 'data-route') || attr(htmlTag, 'data-route') || kebab(path.basename(f)); WF[key] = { key, short, file: path.relative(designDir, f).split(path.sep).join('/'), route, html }; WF_SHORT[short] = key; + if (attr(bodyTag, 'data-mx-page') || attr(htmlTag, 'data-mx-page')) WF_STRONG[short] = true; WF_SHORT[stem] = WF_SHORT[stem] || key; } // design/wireframes/PAGE-MAP.tsv (short page name file) names wireframes that carry no data-mx-page. const PAGE_MAP_FILE = path.join(wfDir, 'PAGE-MAP.tsv'); +const PAGE_MAP_WARN = []; const PAGE_MAP_USED = fs.existsSync(PAGE_MAP_FILE); if (PAGE_MAP_USED) { const byBase = {}; @@ -201,17 +235,20 @@ if (PAGE_MAP_USED) { const c = line.split('\t').map(x => x.trim()); if (c.length < 2 || !c[0] || c[0].startsWith('#')) continue; const k = byBase[path.basename(c[1]).replace(/\.html$/i, '')]; - if (k) { WF_SHORT[c[0]] = WF_SHORT[c[0]] || k; WF_SHORT[shortName(c[0])] = WF_SHORT[shortName(c[0])] || k; } + if (!k) { PAGE_MAP_WARN.push('PAGE-MAP.tsv row "' + c[0] + '" names ' + c[1] + ', which is not a wireframe in ' + path.relative(root, wfDir)); continue; } + // order: data-mx-page, then PAGE-MAP.tsv, then the filename stem + for (const nm of [c[0], shortName(c[0])]) if (!WF_STRONG[nm]) { WF_SHORT[nm] = k; PAGE_MAPPED[nm] = k; } } } function shortName(x) { return String(x).split('.').pop(); } const pageTried = () => 'tried data-mx-page, ' + (PAGE_MAP_USED ? path.relative(root, PAGE_MAP_FILE) : 'no ' + path.relative(root, PAGE_MAP_FILE)) + ', and the filename stem in ' + path.relative(root, wfDir); -const resolvePage = p => (!p ? null : WF[p] ? p : WF_SHORT[p] ? WF_SHORT[p] : WF_SHORT[String(p).split('.').pop()] || null); +const resolvePage = p => (!p ? null : PAGE_MAPPED[p] ? PAGE_MAPPED[p] : WF[p] ? p : WF_SHORT[p] ? WF_SHORT[p] : WF_SHORT[String(p).split('.').pop()] || null); const shortOf = p => String(p || '').split('.').pop(); // ---------- model checks ---------- const errors = [], warnings = []; const err = m => errors.push(m), warn = m => warnings.push(m); +for (const m of PAGE_MAP_WARN) warn(m); const LANE_KINDS = ['role', 'system', 'scheduled', 'external']; const STEP_KINDS = ['task', 'system', 'decision', 'end']; const lanes = Array.isArray(model.lanes) ? model.lanes : []; @@ -481,7 +518,9 @@ const ANCH = {}; // id -> { targets, anchoredBy, basis } const staleJudged = []; for (const it of ITEMS) { const p = prevById[it.id]; - if (p && p.anchoredBy === 'judged') { + if (p && p.anchoredBy === 'judged' && !(p.targets || []).length) { + warn('judged anchor ' + it.id + ' has no targets, so it places nothing: give it a step, page or bucket'); + } else if (p && p.anchoredBy === 'judged') { if (p.fingerprint && p.fingerprint !== it.fingerprint) { staleJudged.push(it.id); } else { const bad = (p.targets || []).map(targetOk).filter(Boolean); diff --git a/project-bin/process-map.template.html b/project-bin/process-map.template.html index 1cf549fe..ca635788 100644 --- a/project-bin/process-map.template.html +++ b/project-bin/process-map.template.html @@ -165,6 +165,8 @@ } /* embed mode: the maps alone, inside a host page's iframe */ body.embed .top{display:none} +.embedhint{margin:6px 2px 0;font-size:12px;color:var(--muted)} +.embedhint[hidden]{display:none} body.embed main{padding:8px 4px 16px;max-width:none} body.embed section.proc{margin:0 0 22px} body.embed section.proc h2{margin:6px 0 4px;font-size:15px} @@ -355,14 +357,18 @@ } // ---------- one process ---------- +var HINT_BASE = 'Coloured pins count the questions on that step.'; function renderProcess(p, host){ var emb = !!host; host = host || view; + var hintEl = null, embHint = null; if (!emb){ view.innerHTML = ''; view.appendChild(h('p',{style:'margin:0'},[h('a',{href:'#/',text:'← All processes'})])); view.appendChild(h('h1',{text:p.title})); if (p.summary) view.appendChild(h('p',{class:'lede',text:p.summary})); - view.appendChild(howto('Click any step card to see its screen large and the questions about it','Coloured pins count the questions on that step. Scroll sideways to follow the process.')); + var hw = howto('Click any step card to see its screen large and the questions about it', HINT_BASE); + hintEl = hw.querySelector('small'); + view.appendChild(hw); view.appendChild(legend()); } var laneIds = []; p.steps.forEach(function(s){ if (laneIds.indexOf(s.lane)<0) laneIds.push(s.lane); }); @@ -403,13 +409,23 @@ el[s.id] = card; board.appendChild(card); }); wrap.appendChild(board); host.appendChild(wrap); + if (emb){ embHint = h('p',{class:'embedhint',text:'The map is wider than this page: scroll sideways inside it to follow the process.'}); embHint.hidden = true; host.appendChild(embHint); } if (!EMBED && store('pb-clicked')!=='1'){ var first = board.querySelector('.step'); if (first) first.classList.add('pulse'); } - // A long process is scaled to fit the box (not below 50%); past that it scrolls inside the box only. + // A long process, or any map in the embedded report, is scaled to fit its box (not below 50%); + // past that it scrolls inside the box only. The hint says which of the three cases the reader is in. function fit(){ - if (!board.classList.contains('long')) return; + var scalable = board.classList.contains('long') || EMBED; board.style.zoom = 1; - var z = Math.max(0.5, Math.min(1, (wrap.clientWidth - 4) / board.scrollWidth)); - board.style.zoom = z; + var z = 1, over = false; + if (scalable){ + z = Math.max(0.5, Math.min(1, (wrap.clientWidth - 4) / board.scrollWidth)); + board.style.zoom = z; + over = board.scrollWidth * z > wrap.clientWidth + 2; + } else over = board.scrollWidth > wrap.clientWidth + 2; + var many = p.steps.length > 12; + if (hintEl) hintEl.textContent = HINT_BASE + (z < 1 ? ' The map is scaled to fit the page.' : '') + + (many ? ' More than 12 steps is hard to read: split it in two.' : '') + (over ? ' Scroll sideways to follow the process.' : ''); + if (embHint) embHint.hidden = !over; } function draw(){ fit(); @@ -456,8 +472,8 @@ }); }); } - requestAnimationFrame(draw); - if (window.ResizeObserver) new ResizeObserver(function(){ draw(); }).observe(board); + requestAnimationFrame(draw); REDRAW.push(draw); + if (window.ResizeObserver){ var ro = new ResizeObserver(function(){ draw(); }); ro.observe(board); ro.observe(wrap); } if (emb) return; // the list under the map: every question on this process, in step order var seen = {}, list = []; @@ -553,7 +569,16 @@ if (id){ var c = document.querySelector('.step[data-step="'+id+'"]'); if (c) c.classList.add('sel'); } } function pickStep(s){ markStep(s.id); try{ parent.postMessage({pm:'step', step:s.id, proc:procOf[s.id].id}, '*'); }catch(e){} } -function sendHeight(){ try{ parent.postMessage({pm:'height', h:Math.ceil(view.getBoundingClientRect().bottom + (window.pageYOffset||0)) + 12}, '*'); }catch(e){} } +function sendHeight(){ + try{ + // the bottom of the last block, not of #view itself: #view can report a collapsed box in a short frame + var last = view.lastElementChild, b = (last ? last.getBoundingClientRect().bottom : view.getBoundingClientRect().bottom) + (window.pageYOffset||0); + if (!window.innerWidth || b < 40) return; // not laid out yet: a zero here would collapse the frame + parent.postMessage({pm:'height', h:Math.ceil(b) + 16}, '*'); + }catch(e){} +} +var REDRAW = []; +function draws(){ REDRAW.forEach(function(f){ try{ f(); }catch(e){} }); } function renderEmbed(){ view.className = 'wide'; view.innerHTML = ''; view.appendChild(legend()); @@ -563,7 +588,10 @@ }); window.addEventListener('message', function(e){ var d = e.data||{}; if (d.pm==='select') markStep(d.step); }); if (window.ResizeObserver) new ResizeObserver(sendHeight).observe(view); - sendHeight(); window.addEventListener('load', sendHeight); + // The frame may not have its width yet when this first runs, so report again once it has settled. + sendHeight(); window.addEventListener('load', sendHeight); window.addEventListener('resize', function(){ draws(); sendHeight(); }); + [60, 250, 700, 1500].forEach(function(ms){ setTimeout(function(){ draws(); sendHeight(); }, ms); }); + requestAnimationFrame(function(){ requestAnimationFrame(sendHeight); }); try{ parent.postMessage({pm:'ready'}, '*'); }catch(e){} } diff --git a/skills/checkpoints/checkpoint-architecture.md b/skills/checkpoints/checkpoint-architecture.md index fd50030c..45d6cc17 100644 --- a/skills/checkpoints/checkpoint-architecture.md +++ b/skills/checkpoints/checkpoint-architecture.md @@ -23,7 +23,7 @@ Pull from validated `F{NNN}.brd.json` files: - Entities used by 3+ BRDs (Common module candidates) - BRDs with no cross-module dependencies (independent) - Resolved open questions that affect architecture (e.g. auth strategy, data ownership) -- Many questions: draft `process-map.md` (pass 1) +- Many questions: draft `process-map.md` (pass 1); CAC-3 shows it at the top of `analysis/brd-report.html` ## What's Next diff --git a/tests/wave2/test-brd-report-process-map.sh b/tests/wave2/test-brd-report-process-map.sh index 4683754b..1577f88f 100755 --- a/tests/wave2/test-brd-report-process-map.sh +++ b/tests/wave2/test-brd-report-process-map.sh @@ -210,5 +210,89 @@ echo "== T8: the map renderer is not copied ==" check "brd-report.sh draws no swimlane itself" "$(grep -c 'laneband\|renderProcess' "$BR")" "0" has "it calls process-map.js build --embed" "$(cat "$BR")" "'build', '--embed'" +echo "== T9: review fixes: questions-only report, --stage, whole-app collapse, fit ==" +# a Stage 0/1 gate blocked by sme-questions.md only (no BRDs) still writes a report +Q="$TMP/smeonly"; mkdir -p "$Q/analysis/a/knowledge-base" +echo "x" > "$Q/analysis/a/knowledge-base/extraction-report.html" # Stage 1 passes on its own evidence, so the gate reaches the question check +cat > "$Q/analysis/sme-questions.md" <<'M' +# SME questions + +| # | Question | Evidence | Options | +|---|---|---|---| +| F1 | What is the approval limit for an order? | source | (a) 1000 (b) none | +| F2 | Who signs off on refunds? | source | (a) lead (b) finance | +M +printf '# Demo\n' > "$Q/PROJECT.md" +GOUT="$("$GATE" "$Q" 1 2>&1)" +has "the gate is blocked by the unraised questions" "$GOUT" "Gate BLOCKED" +has "and gives the path of the report" "$GOUT" "brd-report.html" +[ -f "$Q/analysis/brd-report.html" ] && ok "gate stage 1 with only sme-questions.md writes a report" || bad "gate stage 1 with only sme-questions.md writes a report" +QR="$(cat "$Q/analysis/brd-report.html" 2>/dev/null)" +has "the questions-only page lists the unraised question" "$QR" "What is the approval limit for an order?" +has "the page says it is a stage view" "$QR" "Open questions, stage 1 view" +has "the page header shows the stage filter" "$QR" "Questions shown: up to stage 1." +hasnt "no process map frame without a model" "$QR" '