Project Pages Plan
Plan · done; switch-on after the review by Alan R. Vazquez

Implementation Plan, Milestone Project pages

EXPERTA

Implementation plan for the project pages of EXPERTA: records, decisions and progress, built from the repository and GitHub.

Plan
replication_materials/plans/2026-10-19-project-pages-plan.md
Specification
replication_materials/plans/2026-10-19-project-pages-design.md
Status
Done; switch-on after the review by Alan R. Vazquez
Tasks
9, each test-first and committed on its own
Maintained by
Alan R. Vazquez (Tecnológico de Monterrey) and Fadel M. Megahed (Miami University)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A static site with a landing page and three pages (Records, Decisions, Progress), built from the records in the repository and from GitHub, deployed to GitHub Pages at project.expertadoe.app by a GitHub Actions workflow.

Architecture: One Node script, scripts/build-project-site.mjs, with modules in scripts/project-site/. Parsers read the Markdown records (plans, ledgers, decision tables, spec Section 17.1), three JSON files in replication_materials/project/, git log and the GitHub API (through gh api). Renderers write HTML with inline CSS, inline SVG figures and one small inline script. The workflow runs the script and deploys the folder. The switch-on waits for the end of the review by Alan R. Vazquez (Checkpoint).

Tech Stack: Node 22 ESM (.mjs), no npm package. Vitest for unit tests, Playwright with @axe-core/playwright (already in the repository) for the page tests. GitHub Actions, GitHub Pages.

Spec: replication_materials/plans/2026-10-19-project-pages-design.md (the design of this plan; decisions PS1 to PS16). The app specification 2026-10-14-ff-design-translator-design.md does not change.

1Global Constraints

  • Node 22.14 or later. No new npm package, in dependencies or devDependencies. The build script imports only node: modules and repository modules, so the workflow runs it without npm ci.
  • People by name (PS5): "Fadel", "Alan" and later co-authors by name, from roadmap.json. Never the words "Author" or "Reviewer" as a label for a person.
  • Chart colors: at most four, Dark2 reordered: purple #7570B3 (people), orange #D95F02 (Claude), teal #1B9E77 (measurement), gray #666666 (other or unknown). The interface accent stays #625d96 (from scripts/spec-page.css).
  • WCAG 2.1 AA: axe reports no violation (tags wcag2a, wcag2aa) on each page, in the light and the dark theme. Each figure has role="img", an aria-label, and a table or a list with the same data.
  • Layout works at a width of 360 px: 16 px side gutter, no horizontal page scroll; wide tables scroll in their own box.
  • Every link inside the site is relative, so the site works at project.expertadoe.app/ and at fmegahed.github.io/experta/.
  • Text on the pages: plain academic tone, about 80% ASD-STE100. No marketing words.
  • No secret on any page or in any data file. The code never reads, prints or passes a token (PS15): GitHub data comes from gh api, which takes its credentials from its own configuration or from GH_TOKEN in the workflow.
  • main stays frozen. All work is on the branch project-pages. Commits end with the trailers of the session (Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> and Claude-Session: https://claude.ai/code/session_01P666jingg9iAXQGi9VGGd6).
  • Subagents run node scripts/secret-scan.mjs only with --env <made-up file>; they never open .env.
  • Escape every text from a record before it goes into HTML (&, <, >, ").

2Review Focus

  1. A decision table in an unusual form. Headers differ between plans (| # | Decision | Reason | Cost if wrong |, | # | Decision | By |, | # | Topic | Decision | Source |, | # | Question | Decision |), cells contain \|, and files on Windows have CRLF line ends. Each row must parse to the right fields. Test: Task 2, parses tables with escaped pipes, CRLF and four header forms.
  2. A ruling written in another form. Ledgers write Ruling: …, - Ruling 3: …, Ruling 29: Parked: … and Final: parked — … — Ruling: …; prose mentions "Rulings 1-17". Each ruling must count once, and prose must not count. Test: Task 2, counts each ruling form once and ignores prose.
  3. GitHub is not available (no gh login, rate limit, no network). The build must finish, and the pages must say "GitHub data is not available" where the data would be. Test: Task 5, readGitHub returns available false when the runner throws; Task 9, builds offline.
  4. Markup in a record. A decision text with <script>, & or " must show as text. Test: Task 6, escapes markup from records.
  5. A renamed or missing document. If roadmap.json names a file that does not exist, the build must stop with the file name, not drop the row. Test: Task 1, reports a missing document; Task 9, exits 1 when the roadmap names a missing file.

3File structure

File Responsibility
replication_materials/project/roadmap.json People, areas, milestones in display order, the next milestone, blockers that are not in spec Section 17.1
replication_materials/project/decisions.json Who proposed and who decided each decision, with evidence (Task 4)
replication_materials/project/decision-corrections.json Corrections by a person
replication_materials/project/README.md What the files are, how the attribution was made, how to build the site
scripts/project-site/data.mjs Read and validate the three JSON files; map owner text to people
scripts/project-site/decisions.mjs Parse decision tables and rulings; attribution; --list command
scripts/project-site/git.mjs Dates of files and the head commit from git log
scripts/project-site/records.mjs The list of documents with kind, date, size and status
scripts/project-site/github.mjs Pull requests, issues, branches and the state of main through gh api
scripts/project-site/progress.mjs Open items of Section 17.1, blockers, progress by milestone
scripts/project-site/client.mjs The browser script: filters (pure functions plus DOM glue)
scripts/project-site/site.css Styles that scripts/spec-page.css does not have
scripts/project-site/render/html.mjs esc, the page shell, the navigation, the footer
scripts/project-site/render/figures.mjs Stacked bars, Gantt bars, the roadmap strip
scripts/project-site/render/landing.mjs, records.mjs, decisions.mjs, progress.mjs One renderer for each page
scripts/lib/process-figure.mjs The process figure, moved out of build-spec-page.mjs so both pages use it
scripts/build-project-site.mjs Entry: reads, validates, renders, writes the site
.github/workflows/project-site.yml Build and deploy to GitHub Pages
playwright.site.config.ts, replication_materials/tests/site/site.spec.ts Page tests on the built site (axe, filters)
replication_materials/tests/unit/project-site/*.test.ts Unit tests
replication_materials/tests/fixtures/project-site/ Small Markdown records for the tests

Shared types (JSDoc in the modules; listed here so that each task uses the same names):

/** @typedef {{ id: string, name: string, full_name: string, affiliation: string, url: string }} Person */
/** @typedef {{ id: string, phase: number|null, title: string, state: "done"|"now"|"next"|"planned",
 *   area: string, documents: string[], page: string|null, probes: string|null, prs: number[],
 *   merged: string|null, deployed: string|null, review: string|null, needed_by: string[] }} Milestone */
/** @typedef {{ milestone: string, text: string, owners: string[], since: string, source: string,
 *   github_label?: string }} Blocker */
/** @typedef {{ key: string, file: string, id: string, kind: "table"|"ruling", text: string,
 *   topic: string|null, source: string, cost: string|null, proposedRaw: string|null, decidedRaw: string|null }} Decision */
/** @typedef {Decision & { proposedBy: string[], decidedBy: string[], how: string, evidence: string,
 *   outcome: "stood"|"changed"|null, replacedBy: string|null, basis: "corrected"|"table"|"classified"|"text"|"rule"|"unknown",
 *   checkedBy: string|null, date: string, milestone: string|null, copies: string[] }} AttributedDecision */

Owner and decider ids: the id of a person, "claude", or "measurement".


4Task 1: Project data files and their validation

Files:

  • Create: replication_materials/project/roadmap.json, replication_materials/project/decision-corrections.json, replication_materials/project/README.md
  • Create: scripts/project-site/data.mjs
  • Test: replication_materials/tests/unit/project-site/data.test.ts

Interfaces:

  • Produces: readProject(root: string) → { roadmap, records: { records: Record<string, object> }, corrections: { corrections: object[] } } (a missing decisions.json reads as { records: {} }); validateRoadmap(roadmap, exists: (relPath) => boolean) → string[]; ownerIds(text: string, people: Person[]) → string[]; PLANS_DIR = "replication_materials/plans", PROJECT_DIR = "replication_materials/project".
import { describe, expect, it } from "vitest";
import { ownerIds, readProject, validateRoadmap } from "@/scripts/project-site/data.mjs";
import { existsSync } from "node:fs";
import path from "node:path";

// Vitest runs from the repository root, as the other unit tests assume (paths relative to it).
const ROOT = process.cwd();

describe("project data", () => {
  it("the committed roadmap is valid", () => {
    const { roadmap } = readProject(ROOT);
    expect(validateRoadmap(roadmap, (p) => existsSync(path.join(ROOT, p)))).toEqual([]);
  });
  it("reports a missing document", () => {
    const { roadmap } = readProject(ROOT);
    const broken = structuredClone(roadmap);
    broken.milestones[0].documents.push("2026-01-01-gone-plan.md");
    expect(validateRoadmap(broken, (p) => !p.endsWith("gone-plan.md"))).toEqual([
      "Milestone m1: the document replication_materials/plans/2026-01-01-gone-plan.md does not exist.",
    ]);
  });
  it("reports an unknown next milestone, area and owner", () => {
    const { roadmap } = readProject(ROOT);
    const b = structuredClone(roadmap);
    b.next = "nope"; b.milestones[0].area = "Cooking"; b.blockers[0].owners = ["bob"];
    const errors = validateRoadmap(b, () => true);
    expect(errors).toContain("The next milestone nope is not in the list of milestones.");
    expect(errors).toContain("Milestone m1: the area Cooking is not in the list of areas.");
    expect(errors).toContain("Blocker 1: the owner bob is not a person, claude or measurement.");
  });
  it("maps owner text of Section 17.1 to people", () => {
    const { roadmap } = readProject(ROOT);
    const p = roadmap.people;
    expect(ownerIds("Alan R. Vazquez", p)).toEqual(["alan"]);
    expect(ownerIds("Fadel M. Megahed", p)).toEqual(["fadel"]);
    expect(ownerIds("Both authors", p)).toEqual(["fadel", "alan"]);
    expect(ownerIds("Fadel M. Megahed, Alan R. Vazquez", p)).toEqual(["fadel", "alan"]);
    expect(ownerIds("A Spanish speaker", p)).toEqual([]);
  });
  it("names people only by name", () => {
    const { roadmap } = readProject(ROOT);
    expect(roadmap.people.map((x) => x.name)).toEqual(["Fadel", "Alan"]);
    expect(JSON.stringify(roadmap)).not.toMatch(/\b(Author|Reviewer)\b/);
  });
});
{
  "repo": "fmegahed/experta",
  "app": "https://expertadoe.app",
  "site": "https://project.expertadoe.app",
  "people": [
    { "id": "fadel", "name": "Fadel", "full_name": "Fadel M. Megahed", "affiliation": "Miami University", "url": "https://miamioh.edu/fsb/directory/?up=/directory/megahefm" },
    { "id": "alan", "name": "Alan", "full_name": "Alan R. Vazquez", "affiliation": "Tecnológico de Monterrey", "url": "https://research.tec.mx/vivo-tec/display/PID_115457" }
  ],
  "areas": ["Specification and scope", "R and design rules", "Browser engine", "Translation and LLMs", "Interface and help", "Exports and report", "Deployment and operations", "Evaluation", "Analysis", "Project records"],
  "specification": { "documents": ["2026-10-14-ff-design-translator-design.md", "2026-10-14-spec-review-decisions.md", "2026-10-16-spec-review-decisions-v3.md"], "page": "spec-page", "area": "Specification and scope" },
  "milestones": [
    { "id": "m1", "phase": 1, "title": "Milestone 1: R core", "state": "done", "area": "R and design rules", "documents": ["2026-10-15-m1-r-core-plan.md", "2026-10-15-m1-execution-ledger.md"], "page": null, "probes": "m1-probes", "prs": [], "merged": "2026-10-15", "deployed": null, "review": "One fix wave; re-review: all findings addressed", "needed_by": [] },
    { "id": "m2", "phase": 1, "title": "Milestone 2: Browser engine and projects", "state": "done", "area": "Browser engine", "documents": ["2026-10-15-m2-browser-engine-plan.md", "2026-10-15-m2-execution-ledger.md"], "page": "m2-plan-page", "probes": "m2-probes", "prs": [], "merged": "2026-10-15", "deployed": null, "review": "Ready with fixes; one fix wave", "needed_by": [] },
    { "id": "m3a", "phase": 1, "title": "Milestone 3A: Translation service", "state": "done", "area": "Translation and LLMs", "documents": ["2026-10-15-m3a-translation-service-plan.md", "2026-10-16-m3a-execution-ledger.md"], "page": "m3a-plan-page", "probes": "m3a-probes", "prs": [], "merged": "2026-10-16", "deployed": null, "review": "Clean after one fix wave", "needed_by": [] },
    { "id": "pre3b", "phase": 1, "title": "Changes from the first review", "state": "done", "area": "Specification and scope", "documents": ["2026-10-16-pre3b-review-changes-plan.md", "2026-10-16-pre3b-execution-ledger.md"], "page": "pre3b-plan-page", "probes": null, "prs": [], "merged": "2026-10-16", "deployed": null, "review": "Ready to merge; 7 minor findings deferred", "needed_by": [] },
    { "id": "m3b", "phase": 1, "title": "Milestone 3B: Interface", "state": "done", "area": "Interface and help", "documents": ["2026-10-16-m3b-interface-plan.md", "2026-10-16-m3b-execution-ledger.md"], "page": "m3b-plan-page", "probes": "m3b-probes", "prs": [], "merged": "2026-10-17", "deployed": null, "review": "1 Important finding; one fix wave, re-review clean", "needed_by": [] },
    { "id": "m3c", "phase": 1, "title": "Milestone 3C: Exports, import and report", "state": "done", "area": "Exports and report", "documents": ["2026-10-17-m3c-exports-plan.md", "2026-10-17-m3c-execution-ledger.md"], "page": "m3c-plan-page", "probes": "m3c-probes", "prs": [], "merged": "2026-10-17", "deployed": null, "review": "3 Important findings; one fix wave, re-review clean", "needed_by": [] },
    { "id": "alpha", "phase": 1, "title": "Alpha release", "state": "done", "area": "Deployment and operations", "documents": ["2026-10-17-alpha-release-design.md", "2026-10-17-alpha-release-plan.md", "2026-10-17-alpha-execution-ledger.md"], "page": "alpha-plan-page", "probes": "alpha-probes", "prs": [1, 3, 4], "merged": "2026-10-18", "deployed": "2026-10-18", "review": "One fix wave, re-review clean", "needed_by": [] },
    { "id": "minors", "phase": 1, "title": "Alpha minor fixes", "state": "done", "area": "Interface and help", "documents": ["2026-10-19-alpha-minors-audit.md", "2026-10-19-alpha-minors-plan.md", "2026-10-19-alpha-minors-execution-ledger.md"], "page": "minors-plan-page", "probes": null, "prs": [5], "merged": "2026-10-19", "deployed": "2026-10-19", "review": "One fix wave (11 items), re-review clean", "needed_by": [] },
    { "id": "print", "phase": 1, "title": "Report print redesign", "state": "done", "area": "Exports and report", "documents": ["2026-10-19-report-print-plan.md", "2026-10-19-report-print-execution-ledger.md"], "page": "print-plan-page", "probes": null, "prs": [6], "merged": "2026-10-19", "deployed": "2026-10-19", "review": "One fix wave (10 items), re-review clean", "needed_by": [] },
    { "id": "review", "phase": 1, "title": "Alan's review and the fixes", "state": "next", "area": "Specification and scope", "documents": [], "page": null, "probes": null, "prs": [], "merged": null, "deployed": null, "review": null, "needed_by": [] },
    { "id": "m4", "phase": 1, "title": "Milestone 4: Evaluation and documentation", "state": "planned", "area": "Evaluation", "documents": [], "page": null, "probes": null, "prs": [], "merged": null, "deployed": null, "review": null, "needed_by": ["Milestone 4", "test-set run"] },
    { "id": "phase2", "phase": 2, "title": "Phase 2: Analysis", "state": "planned", "area": "Analysis", "documents": [], "page": null, "probes": null, "prs": [], "merged": null, "deployed": null, "review": null, "needed_by": ["Phase 2"] },
    { "id": "video", "phase": 1, "title": "Introduction video", "state": "planned", "area": "Interface and help", "documents": [], "page": null, "probes": null, "prs": [], "merged": null, "deployed": null, "review": null, "needed_by": ["Video release"] },
    { "id": "phase3", "phase": 3, "title": "Phase 3: Blocking, split-plot and foldover", "state": "planned", "area": "R and design rules", "documents": [], "page": null, "probes": null, "prs": [], "merged": null, "deployed": null, "review": null, "needed_by": ["Phase 3"] },
    { "id": "phase4", "phase": 4, "title": "Phase 4: Other design families", "state": "planned", "area": "R and design rules", "documents": ["2026-10-19-phase4-investigation.md"], "page": "phase4-investigation-page", "probes": "phase4-probes", "prs": [], "merged": null, "deployed": null, "review": null, "needed_by": ["Phase 4"] },
    { "id": "pages", "phase": null, "title": "Project pages", "state": "now", "area": "Project records", "documents": ["2026-10-19-project-pages-design.md", "2026-10-19-project-pages-plan.md"], "page": null, "probes": null, "prs": [], "merged": null, "deployed": null, "review": null, "needed_by": [] }
  ],
  "next": "review",
  "public_release": "Public release",
  "blockers": [
    { "milestone": "review", "text": "Answers to the review questions (GitHub issues with the label alpha-review)", "owners": ["alan"], "since": "2026-10-19", "source": "https://github.com/fmegahed/experta/issues?q=label%3Aalpha-review", "github_label": "alpha-review" },
    { "milestone": "review", "text": "Check in the Upstash console that the counter keys have a time limit", "owners": ["fadel"], "since": "2026-10-18", "source": "replication_materials/plans/2026-10-17-alpha-execution-ledger.md" },
    { "milestone": "review", "text": "One fix plan from the answers (waits for the answers)", "owners": ["claude"], "since": "2026-10-19", "source": "replication_materials/project/roadmap.json" }
  ]
}

decision-corrections.json:

{
  "corrections": [
    { "key": "2026-10-17-alpha-release-design.md#A3", "decided_by": ["fadel"], "how": "chat",
      "evidence": "Fadel M. Megahed, 2026-10-19: he decided on Vercel hosting, with his Vercel Pro plan as the deciding input.",
      "checked_by": "fadel", "date": "2026-10-19" }
  ]
}

A correction overrides only the fields that it has.

    • each milestone document and the specification documents exist (exists(PLANS_DIR + "/" + file)); each page and probes folder exists;
    • each milestone area is in areas;
    • next is a milestone id;
    • milestone ids are unique;
    • each blocker owner is a person id, "claude" or "measurement", and each blocker milestone exists.

    Blockers are numbered from 1 in the messages. ownerIds splits on , and and, matches the full_name of a person, and maps "Both authors" to all people in roadmap order.

    The page folder of this plan (pages-plan-page) is made in Step 6 of Task 9, so pages.page stays null until then; Task 9 sets it.


5Task 2: Decision tables and rulings

Files:

  • Create: scripts/project-site/decisions.mjs
  • Create: replication_materials/tests/fixtures/project-site/plans/ with 2026-01-01-sample-plan.md (CRLF line ends, four header forms, an escaped pipe, a <script> text) and 2026-01-02-sample-execution-ledger.md (each ruling form, the prose "Rulings 1-17 in …", a line "Task 1: complete (abc1234)")
  • Test: replication_materials/tests/unit/project-site/decisions.test.ts

Interfaces:

  • Consumes: readProject, PLANS_DIR (Task 1).
  • Produces:
    • splitRow(line: string) → string[]
    • parseDecisionTables(markdown, file) → Decision[]
    • parseRulings(markdown, file) → Decision[]
    • keywordAttribution(text, people) → { proposedBy, decidedBy, how }
    • namesToIds(text, people) → string[] (accepts "Fadel", "Alan", "Claude", "Measured", "Measurement", full names, "FMM", "ARV")
    • attribute(decision, project, context) → AttributedDecision, where context = { date, milestone, area }
    • collectDecisions(root, project) → AttributedDecision[]
    • CLI: node scripts/project-site/decisions.mjs --list prints a JSON array of { key, file, id, kind, text, source, cost } for all decisions.
    • parses tables with escaped pipes, CRLF and four header forms: for the fixture, the IDs are ["P1","P2","R1","D1","S1"]. P2.text contains a | b (the pipe is unescaped). The header | # | Decision | Reason | Cost if wrong | gives cost. The header | # | Decision | By | gives source = the "By" cell. The header | # | Topic | Decision | Source | gives topic and source. The header | # | Question | Decision | gives text = "Question: Decision". The header | # | Decision | Proposed by | Decided by | gives proposedRaw/decidedRaw. Keys are 2026-01-01-sample-plan.md#P1.
    • ignores tables that do not start with # or ID and rows without an ID: a table | Item | Value | gives nothing.
    • counts each ruling form once and ignores prose: the fixture ledger has the forms Ruling: x — cost if wrong: y, - Ruling 3: …, Ruling 29: Parked: …, Final: parked — z — Ruling: w — cost if wrong: v, and the prose line. Expected: 4 rulings with keys …ledger.md#ruling-1 to #ruling-4; cost of the first is y; ids Ruling, Ruling 3, Ruling 29, Ruling.
    • keyword attribution:
      • "AskUserQuestion: Build both" → decidedBy ["fadel"], how "chat".
      • "Review by Alan R. Vazquez" → ["alan"], how "review".
      • "Measured: 40 ms" → ["measurement"], how "measurement".
      • "FMM (b)" → ["fadel"].
      • "Research report" → [], how "unknown".
    • attribution precedence, with the same decision and three sources:
      • A correction gives basis "corrected" and checkedBy "fadel".
      • Without a correction, the table columns "Proposed by"/"Decided by" give basis "table".
      • Then a record in decisions.json gives "classified".
      • Then keywords give "text".
      • A ruling with none of these gives proposedBy and decidedBy ["claude"], how "ruling", outcome "stood", basis "rule".
    • marks the copies in the specification: with a project whose specification lists spec.md and log.md, an ID that is in both gives one decision from log.md with copies: ["spec.md"].
    • A table starts at a row whose first cell is # or ID. The next row is the separator. Data rows follow until the first line that does not start with |.
    • A data row needs a first cell that matches /^[A-Z]{1,3}\d+[a-z]?$/.
    • Field mapping by header name, ignoring case:
      • text = Decision; or "Question: Decision" when both exist; else Topic.
      • topic = Topic.
      • source = the first of Source, By, Reason, Why.
      • cost = "Cost if wrong".
      • proposedRaw and decidedRaw = "Proposed by" and "Decided by".
    • A ruling is a line that matches /\bRuling(?: [A-Z]?\d+[a-z]?)?:\s*/. The text is everything after the match, up to — cost if wrong:. The cost is the rest.
    • Normalize \r\n before parsing.
    • collectDecisions reads every top-level .md file in PLANS_DIR. It takes the date from the file name prefix, and the milestone and area from the roadmap (a specification document has the area of specification). It removes the copies as tested and sorts by date, then file, then order in the file.

6Task 3: Documents and dates

Files:

  • Create: scripts/project-site/git.mjs, scripts/project-site/records.mjs
  • Test: replication_materials/tests/unit/project-site/records.test.ts

Interfaces:

  • Consumes: parseDecisionTables, parseRulings (Task 2); readProject (Task 1).
  • Produces:
    • gitRunner(root) → (args: string[]) => string
    • fileDates(relPaths, run) → Record<string, { first: string, last: string } | null>, from git log --follow --format=%cs -- <path>, oldest = first
    • headCommit(run) → { sha: string, short: string, date: string }
    • documentKind(file, project) → "specification"|"decision-log"|"design"|"plan"|"ledger"|"audit"|"investigation"|"other"
    • summarize(markdown, file) → { title, tasks, completedTasks, rulings, decisions }
    • listDocuments(root, project, dates) → Document[], where Document = { file, path, kind, date, title, milestone, phase, area, tasks, completedTasks, rulings, decisions, page, probes, prs, status, first, last }
    • documentKind:
      • the first specification document → "specification"; the other two → "decision-log";
      • x-plan.md → "plan"; x-execution-ledger.md → "ledger"; x-audit.md → "audit"; x-investigation.md → "investigation"; x-design.md → "design";
      • notes.md → "other".
    • summarize, on the fixtures:
      • The plan fixture with ### Task 1: and ### Task 2: gives tasks 2.
      • The ledger fixture gives completedTasks 1 and rulings 4.
      • The title is the H1 without # .
      • Task ids count once: "Task 9b-1: complete" twice is 1.
    • fileDates with a fake runner that returns "2026-10-19\n2026-10-17\n2026-10-16\n" gives { first: "2026-10-16", last: "2026-10-19" }. An empty output gives null.
    • listDocuments on the real repository:
      • The row of 2026-10-19-report-print-plan.md has milestone print, page print-plan-page, prs [6] and status "live".
      • The specification row has status "living".
      • A document of a milestone in state next or planned has status "draft"; one in state now has "in progress".
      • A done milestone gives "live" with a deployed date, else "merged".

7Task 4: Who proposed and who decided (data)

This task writes data, not code. The implementer is a research agent (the most capable model). The work is a classification from the records and the session transcripts.

Files:

  • Create: replication_materials/project/decisions.json
  • Test: replication_materials/tests/unit/project-site/decisions-data.test.ts

Interfaces:

  • Consumes: node scripts/project-site/decisions.mjs --list (Task 2); roadmap.json areas and people (Task 1).
  • Produces: decisions.json = { "method": string, "classified_on": "2026-10-19", "records": { [key]: { "proposed_by": string[], "decided_by": string[], "how": "chat"|"review"|"measurement"|"plan-approval"|"ruling"|"unknown", "topic": string, "evidence": string, "outcome"?: "stood"|"changed", "replaced_by"?: string } } }
    • every key of kind table from collectDecisions has a record (rulings may have none);
    • every record key is a key of collectDecisions;
    • proposed_by and decided_by hold only person ids, "claude" or "measurement", and decided_by is not empty unless how is "unknown";
    • topic is one of roadmap.areas;
    • evidence has at most 240 characters;
    • no value matches a pattern of KEY_PATTERNS from scripts/lib/secret-scan.mjs, no value has an e-mail address, and no value has a string of 32 or more letters and digits without a space.
    • read its row and the text around it in its file;
    • search the session transcripts in C:\Users\megahefm\.claude\projects\C--Users-megahefm-Dropbox-Miami-Code-claude-fractional-factorial\*.jsonl with grep for its ID and its words. Read the author's messages and the answers to AskUserQuestion.

    Rules:

    • "chat": a person chose it in chat (an answer to a question, or his own instruction). decided_by is that person. proposed_by is the one who first wrote the option: "claude" when Claude offered it as a choice or a recommendation.
    • "review": it came from a review comment of Alan R. Vazquez. Both fields hold "alan" if he wrote the change. decided_by holds "fadel" too if Fadel M. Megahed applied it by his decision.
    • "measurement": a measurement with a rule fixed before the measurement set the value. decided_by is ["measurement"]. If a person chose the rule, add that person.
    • "plan-approval": Claude wrote it in a plan, and the author approved the plan without a change to it. proposed_by ["claude"], decided_by ["fadel"].
    • Rulings: add a record only when a person is named in the ruling or when a person later changed it. Use outcome "changed" and replaced_by = the key of the new decision.
    • evidence: one line. Quote at most 25 words of a person's message, with the date, or name the record line. Never copy a value of a secret, a key or a token, and never copy a person's e-mail address.
    • "unknown": use it when the records and transcripts do not show who decided.

    The file must also have:

    • topic, chosen from roadmap.areas;
    • method: one paragraph on how the classification was made, for the page.

8Task 5: GitHub data, open items and progress

Files:

  • Create: scripts/project-site/github.mjs, scripts/project-site/progress.mjs
  • Test: replication_materials/tests/unit/project-site/progress.test.ts

Interfaces:

  • Consumes: readProject, ownerIds (Task 1); listDocuments (Task 3).
  • Produces:
    • ghRunner() → (apiPath: string) => any, which runs gh api <apiPath> with execFileSync and parses JSON.
    • readGitHub(repo, run) → { available: true, fetchedAt, pulls, issues, branches, main } | { available: false, reason }:
      • pulls: { number, title, state, merged, mergedAt, createdAt, branch, url }[], from repos/{repo}/pulls?state=all&per_page=100;
      • issues without pull requests: { number, title, state, labels: string[], createdAt, url }[], from repos/{repo}/issues?state=all&per_page=100;
      • branches other than main: { name, sha, date, message }[], from repos/{repo}/branches?per_page=100 and repos/{repo}/commits/{sha};
      • main: { sha, state, vercel }, from repos/{repo}/commits/main/status, where vercel is the state of the context "Vercel" or null.
    • openItems(specMarkdown, people) → { item, ownerText, owners, neededBy }[]: the rows of the table under ### 17.1 Open items whose "Needed by" does not start with "Done".
    • milestoneRows(project, documents) → { id, title, phase, state, area, tasks, completedTasks, start, end, merged, deployed, review, prs }[]. Here start is the earliest first date of its documents, and end is merged or null.
    • blockersFor(milestoneId, project, items, github, today) → { text, owners, since, ageDays, source, detail }[].
    • progressModel(project, documents, items, github, today) → { next, following, nextBlockers, followingBlockers, publicRelease, otherItems, milestones, now }. Here following is the first milestone after next in roadmap order whose state is planned.
    • readGitHub returns available false when the runner throws: reason is the error message.
    • readGitHub drops pull requests from the issue list and main from the branches.
    • openItems on the real spec:
      • "The decision table of Phase 4 and the gold families." is open with owners ["alan"].
      • The row "Rate limit and spend cap values." is not open (it starts with "Done").
    • blockersFor("review", …):
      • The three roadmap blockers, in order.
      • With 3 issues of the label alpha-review, one of which also has the label withdrawn, the first blocker's detail is "2 answers received (3 filed)". An answer counts when it has the blocker's github_label and not the label withdrawn.
      • With GitHub unavailable, the detail is "GitHub data is not available".
      • The ages are counted from today.
    • blockersFor("m4", …) includes the open items whose "Needed by" contains "Milestone 4" or "test-set run".
    • progressModel(...).publicRelease holds the open items whose "Needed by" contains "Public release".
    • milestoneRows: the print milestone has tasks 8, completedTasks 8, merged "2026-10-19". The review milestone has tasks 0 and start null.

9Task 6: Page shell, shared figures, landing and records pages

Files:

  • Create: scripts/lib/process-figure.mjs; Modify: scripts/build-spec-page.mjs (import it; no output change)
  • Create: scripts/project-site/render/html.mjs, render/figures.mjs, render/landing.mjs, render/records.mjs, scripts/project-site/site.css, scripts/project-site/client.mjs
  • Test: replication_materials/tests/unit/project-site/render.test.ts

Interfaces:

  • Produces:
    • processFigure(processJson, esc) → string (the markup of today's function, unchanged);
    • esc(s);
    • pageHtml({ title, description, active: "home"|"records"|"decisions"|"progress", depth: 0|1, body, build: { short, date } }) → string. It inlines scripts/spec-page.css, site.css, scripts/spec-page.js and client.mjs. The navigation has Home, Records, Decisions, Progress, "App" (https://expertadoe.app) and "Repository" (https://github.com/fmegahed/experta). The footer says "Built from commit {short} on {date}" and names the maintainers by full name;
    • stackedBars({ bins: { label, counts: Record<kind, number> }[], kinds: { id, label, color }[], title }) → string (SVG);
    • ganttBars({ rows: { label, start, end, state }[], from, to, title }) → string (SVG);
    • roadmapStrip(milestones) → string (HTML list);
    • renderLanding(model) → string;
    • renderRecords(model) → string.
    • the spec page is unchanged: run node scripts/build-spec-page.mjs --out <tmp> before the change (save the file in the test fixture folder as spec-page-before.html in Step 2) and after. The two index.html files are byte-equal.
    • escapes markup from records: renderRecords with a document title <script>alert(1)</script> & "x" contains &lt;script&gt; and no <script>alert.
    • pageHtml:
      • It has <!doctype html>, lang="en", a viewport meta tag, the title, and aria-current="page" on the active link.
      • Each internal href is relative (no leading /, no http except the two external links and the record links to GitHub).
    • stackedBars: the result has role="img" and an aria-label that names the title. Each segment has a <title> with the bin, kind and count. The colors are only the four Dark2 colors.
    • ganttBars: a row with end: null reaches to.
    • renderRecords: one row for each document, in date order. It has kind filters as buttons with aria-pressed, a caption, and links to the page folder (../plans/{page}/index.html), the Markdown on GitHub (https://github.com/fmegahed/experta/blob/main/replication_materials/plans/{file}) and each pull request.
    • client.mjs exports matches(dataset, filters). For {kind:"plan"} and filters {kind:["plan","ledger"]} it gives true. For filters {} it gives true. With topic and decider filters, both must hold.
    • The landing page shows the project name, the status line (current phase and milestone, the next step, the state of production), three cards (one sentence each, from Section 1 of the design) and the links.
    • The records page shows the table of Section 3.2 of the design, and the note on how to read a row.
    • The figures are plain SVG strings: no script, viewBox sizing, text at least 12 px.

10Task 7: Decisions page

Files:

  • Create: scripts/project-site/render/decisions.mjs
  • Test: add to replication_materials/tests/unit/project-site/render.test.ts

Interfaces:

  • Consumes: collectDecisions (Task 2), processFigure (Task 6), stackedBars, pageHtml.
  • Produces: decisionsModel(decisions, project) → { grid, bins, rows, counts, method }; renderDecisions(model, processJson) → string.
    • grid columns are people by name, then Claude and Measured: the header cells are "Fadel", "Alan", "Claude", "Measured", in this order. With a third person in the roadmap, that name comes after "Alan".
    • grid counts: a decision decided by ["fadel","alan"] counts once in each column. A decision with decidedBy [] counts in an "Unknown" column, and that column shows only when its count is above 0.
    • bins by day when the span is 60 days or less, else by week.
    • the kinds of the bars: people are purple (#7570B3), Claude orange, measurement teal, unknown gray.
    • each grid cell and each bar is a button that sets the filter: elements with data-filter-topic/data-filter-decider and data-filter-date.
    • table rows carry data attributes data-topic, data-decider (space-separated ids) and data-date. Each row shows proposed by and decided by as names ("Fadel", "Claude", "Measured"), the basis ("Classified by Claude", "Checked by Fadel", "From the table of the plan", "From the text", "Rule for rulings"), and for a ruling its outcome.
    • the method note contains the method text of decisions.json and the counts by basis.
    • the process figure is on the page (id process-figure).
    • Page order: the grid, the bars, the clear-filter button, the table, the process figure, the method note.
    • The table starts sorted by date, newest first.
    • The filter state is in the URL hash, so that a filtered view can be shared.

11Task 8: Progress page

Files:

  • Create: scripts/project-site/render/progress.mjs
  • Test: add to replication_materials/tests/unit/project-site/render.test.ts

Interfaces:

  • Consumes: progressModel (Task 5), ganttBars, roadmapStrip (Task 6).
  • Produces: renderProgress(model, project, today) → string.
    • leads with the next milestone and its blockers: the first h2 after the roadmap strip is "Next: Alan's review and the fixes". Each blocker shows its owners by name, "open {n} days" and a link to its source.
    • shows the following milestone and its blockers: "Then: Milestone 4: Evaluation and documentation".
    • the now panel:
      • It lists the branches with their last commit date and message, and the open pull requests.
      • It shows the state of main as "Production: success at {short sha}" from the Vercel status.
      • With GitHub unavailable, it shows "GitHub data is not available".
    • completed work by plan:
      • One row for each milestone with documents, with "8 of 8 tasks" and a meter (<meter> or a bar with aria-valuenow).
      • The review text, the merge date and the deployment date are in the row.
    • the Gantt bars cover the milestones with a start date. A milestone in state now reaches today.
    • before the public release lists the open items of Section 17.1 for the public release, with owners by name.

12Task 9: Build script, workflow, page tests and documents

Files:

  • Create: scripts/build-project-site.mjs, .github/workflows/project-site.yml, playwright.site.config.ts, replication_materials/tests/site/site.spec.ts
  • Modify: .gitignore (add _site/), scripts/check.mjs (add the step "Project site tests" after "Browser tests", browser-only), README.md (Status section and a section "Project pages"), replication_materials/project/roadmap.json (pages.page = "pages-plan-page" after Step 6)
  • Test: replication_materials/tests/unit/project-site/build.test.ts

Interfaces:

  • Consumes: all modules above.

  • Produces: node scripts/build-project-site.mjs [--out _site] [--offline] [--today YYYY-MM-DD]. It writes:

    • index.html, records/index.html, decisions/index.html and progress/index.html;
    • plans/{page}/index.html: a copy of each page folder of the roadmap and of spec-page;
    • data/records.json, data/decisions.json, data/progress.json;
    • .nojekyll.

    On a validation error, it prints each message and exits with status 1.

    • Unit (build.test.ts), on node scripts/build-project-site.mjs --out <tmp> --offline --today 2026-10-20:
      • builds offline: exit status 0, and the files above exist.
      • every internal link resolves: for each href and src in the built HTML that is not http(s):, mailto: or #…, the target file exists in <tmp>. Strip the hash first.
      • exits 1 when the roadmap names a missing file: run with --roadmap <tmp copy with a wrong file name> (add this option for tests), and see the message of Task 1.
      • the workflow pins each action by commit SHA: every uses: line in .github/workflows/project-site.yml matches @[0-9a-f]{40} # v …
      • the workflow has the triggers and permissions: it has the triggers push to main, pull_request_target (opened, reopened, closed), issues, schedule and workflow_dispatch, and the permissions contents: read, pages: write, id-token: write, issues: read, pull-requests: read.
    • Page tests (site.spec.ts, run by npx playwright test -c playwright.site.config.ts). The config builds the site offline into test-results/site/ in globalSetup and opens the pages through file:// URLs:
      • axe finds no violation on each page in the light and the dark theme: set colorScheme to light, then dark, with tags wcag2a and wcag2aa.
      • a grid cell filters the decision table: click the cell (topic "Translation and LLMs", decider "Claude"). Then every visible row has that topic and "claude" in data-decider, and the visible count equals the number in the cell. "Clear filter" shows all rows again.
      • the kind filter on the records page shows only plans.
      • no horizontal scroll at 360 px on each page: document.documentElement.scrollWidth <= 360.
    • actions/checkout v7.0.1;
    • actions/configure-pages v6.0.0;
    • actions/upload-pages-artifact v5.0.0;
    • actions/deploy-pages v5.0.1;
    • actions/setup-node: the newest release that is at least 14 days old on the day of the commit (gh api repos/actions/setup-node/releases).

    Write the tag after the SHA as a comment.

name: Project site
on:
  push:
    branches: [main]
  pull_request_target:
    types: [opened, reopened, closed]
  issues:
    types: [opened, edited, closed, reopened, labeled, unlabeled]
  schedule:
    - cron: "17 6 * * *"
  workflow_dispatch:
permissions:
  contents: read
  pages: write
  id-token: write
  issues: read
  pull-requests: read
concurrency:
  group: pages
  cancel-in-progress: false
jobs:
  build:
    runs-on: ubuntu-24.04
    steps:
      # pull_request_target runs in the context of main; check out main only, never the pull request.
      - uses: actions/checkout@<sha> # v7.0.1
        with:
          ref: main
          fetch-depth: 0
          persist-credentials: false
      - uses: actions/setup-node@<sha> # v<x>
        with:
          node-version: 22
      - name: Build the site
        run: node scripts/build-project-site.mjs --out _site
        env:
          GH_TOKEN: ${{ github.token }}
      - uses: actions/configure-pages@<sha> # v6.0.0
      - uses: actions/upload-pages-artifact@<sha> # v5.0.0
        with:
          path: _site
  deploy:
    needs: build
    runs-on: ubuntu-24.04
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@<sha> # v5.0.1
    • In README.md, replace the outdated "Status" section with three sentences:

      • Phase 1 is live as an alpha at https://expertadoe.app.
      • Alan R. Vazquez reviews it.
      • The next steps are in the project pages.

      Then add a section "Project pages" with the address https://project.expertadoe.app (it goes live after the review), what each page shows, and how to build the pages locally (node scripts/build-project-site.mjs --out _site --offline).

    • Add _site/ to .gitignore.

    • Add the step to scripts/check.mjs.


13Checkpoint: switch-on (after the review by Alan R. Vazquez; each step needs the approval of Fadel M. Megahed)

The controller does these steps. A subagent does not.

  1. Merge project-pages into main with a pull request and a fast-forward. This is the usual pattern, and the same push redeploys the app.
  2. Enable GitHub Pages with the workflow as the source: gh api -X POST repos/fmegahed/experta/pages -f build_type=workflow. This needs GitHub Pro while the repository is private. If the call fails for the plan, the authors decide: make the repository public, or move the address to a second Vercel project.
  3. In Vercel DNS, add the record project CNAME fmegahed.github.io for expertadoe.app.
  4. Set the custom domain project.expertadoe.app in the Pages settings, wait for the certificate, and turn on "Enforce HTTPS".
  5. Start the workflow (gh workflow run project-site.yml). Check that the four pages answer 200 at https://project.expertadoe.app/ and that the progress page shows the open pull requests.
  6. Record the switch-on in the ledger and in roadmap.json: the pages milestone is done, with its merge date.