diff --git a/.changes/adhd/feat-12650-planning-view-registry-3c8e1a52.md b/.changes/adhd/feat-12650-planning-view-registry-3c8e1a52.md new file mode 100644 index 00000000000..a4278a0d163 --- /dev/null +++ b/.changes/adhd/feat-12650-planning-view-registry-3c8e1a52.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- Shared `view-settings.mjs` and `view-registry.mjs` synced where this plugin carries them: the planning lane's design kind and shipped `artifact` default, and the resolver output's `page` and `publishes` fields (#12650); no other change to this plugin. diff --git a/.changes/ai-briefing/feat-12650-planning-view-registry-7d41b9e6.md b/.changes/ai-briefing/feat-12650-planning-view-registry-7d41b9e6.md new file mode 100644 index 00000000000..a4278a0d163 --- /dev/null +++ b/.changes/ai-briefing/feat-12650-planning-view-registry-7d41b9e6.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- Shared `view-settings.mjs` and `view-registry.mjs` synced where this plugin carries them: the planning lane's design kind and shipped `artifact` default, and the resolver output's `page` and `publishes` fields (#12650); no other change to this plugin. diff --git a/.changes/architecture/feat-12650-planning-view-registry-f55a2445.md b/.changes/architecture/feat-12650-planning-view-registry-f55a2445.md new file mode 100644 index 00000000000..613d15a2951 --- /dev/null +++ b/.changes/architecture/feat-12650-planning-view-registry-f55a2445.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- Shared `view-builder.mjs`, `view-settings.mjs` and `view-registry.mjs` synced where this plugin carries them: the view-kind registry and `render(laneId, kind, record)`, the planning lane's design kind and shipped `artifact` default, and the resolver output's `page` and `publishes` fields (#12650). The shared `reference/rendered-view.md` copy carries the planning section's one-call build and design view; no other change to this plugin. diff --git a/.changes/codebase-health/feat-12650-planning-view-registry-a92f5c07.md b/.changes/codebase-health/feat-12650-planning-view-registry-a92f5c07.md new file mode 100644 index 00000000000..a4278a0d163 --- /dev/null +++ b/.changes/codebase-health/feat-12650-planning-view-registry-a92f5c07.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- Shared `view-settings.mjs` and `view-registry.mjs` synced where this plugin carries them: the planning lane's design kind and shipped `artifact` default, and the resolver output's `page` and `publishes` fields (#12650); no other change to this plugin. diff --git a/.changes/debugging/feat-12650-planning-view-registry-422041cd.md b/.changes/debugging/feat-12650-planning-view-registry-422041cd.md new file mode 100644 index 00000000000..dafe7ae81ee --- /dev/null +++ b/.changes/debugging/feat-12650-planning-view-registry-422041cd.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- Shared `view-builder.mjs`, `view-settings.mjs` and `view-registry.mjs` synced where this plugin carries them: the view-kind registry and `render(laneId, kind, record)`, the planning lane's design kind and shipped `artifact` default, and the resolver output's `page` and `publishes` fields (#12650). The shared `skills/debug/reference/rendered-view.md` copy carries the planning section's one-call build and design view; no other change to this plugin. diff --git a/.changes/discovery/feat-12650-planning-view-registry-a74f1463.md b/.changes/discovery/feat-12650-planning-view-registry-a74f1463.md new file mode 100644 index 00000000000..613d15a2951 --- /dev/null +++ b/.changes/discovery/feat-12650-planning-view-registry-a74f1463.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- Shared `view-builder.mjs`, `view-settings.mjs` and `view-registry.mjs` synced where this plugin carries them: the view-kind registry and `render(laneId, kind, record)`, the planning lane's design kind and shipped `artifact` default, and the resolver output's `page` and `publishes` fields (#12650). The shared `reference/rendered-view.md` copy carries the planning section's one-call build and design view; no other change to this plugin. diff --git a/.changes/education/feat-12650-planning-view-registry-f621f451.md b/.changes/education/feat-12650-planning-view-registry-f621f451.md new file mode 100644 index 00000000000..1e1eb7b4b1b --- /dev/null +++ b/.changes/education/feat-12650-planning-view-registry-f621f451.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- Shared `view-builder.mjs`, `view-settings.mjs` and `view-registry.mjs` synced where this plugin carries them: the view-kind registry and `render(laneId, kind, record)`, the planning lane's design kind and shipped `artifact` default, and the resolver output's `page` and `publishes` fields (#12650); no other change to this plugin. diff --git a/.changes/harness-ops/feat-12650-planning-view-registry-e3028309.md b/.changes/harness-ops/feat-12650-planning-view-registry-e3028309.md new file mode 100644 index 00000000000..a4278a0d163 --- /dev/null +++ b/.changes/harness-ops/feat-12650-planning-view-registry-e3028309.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- Shared `view-settings.mjs` and `view-registry.mjs` synced where this plugin carries them: the planning lane's design kind and shipped `artifact` default, and the resolver output's `page` and `publishes` fields (#12650); no other change to this plugin. diff --git a/.changes/machine-health/feat-12650-planning-view-registry-85ab56a6.md b/.changes/machine-health/feat-12650-planning-view-registry-85ab56a6.md new file mode 100644 index 00000000000..1e1eb7b4b1b --- /dev/null +++ b/.changes/machine-health/feat-12650-planning-view-registry-85ab56a6.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- Shared `view-builder.mjs`, `view-settings.mjs` and `view-registry.mjs` synced where this plugin carries them: the view-kind registry and `render(laneId, kind, record)`, the planning lane's design kind and shipped `artifact` default, and the resolver output's `page` and `publishes` fields (#12650); no other change to this plugin. diff --git a/.changes/overengineering/feat-12650-planning-view-registry-5e6d0b38.md b/.changes/overengineering/feat-12650-planning-view-registry-5e6d0b38.md new file mode 100644 index 00000000000..a4278a0d163 --- /dev/null +++ b/.changes/overengineering/feat-12650-planning-view-registry-5e6d0b38.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- Shared `view-settings.mjs` and `view-registry.mjs` synced where this plugin carries them: the planning lane's design kind and shipped `artifact` default, and the resolver output's `page` and `publishes` fields (#12650); no other change to this plugin. diff --git a/.changes/planning/feat-12650-planning-view-registry-a09f740d.md b/.changes/planning/feat-12650-planning-view-registry-a09f740d.md new file mode 100644 index 00000000000..1c225cc08b5 --- /dev/null +++ b/.changes/planning/feat-12650-planning-view-registry-a09f740d.md @@ -0,0 +1,14 @@ +--- +bump: minor +--- + +### Added + +- `/planning:design` offers a design view of PLAN.md's `## Design` section after the handoff gate passes, built by `scripts/build-view.mjs design --record ` from a checked-in template (#12650). +- `scripts/build-view.mjs --option ` resolves the medium through the shared resolver, runs the publish gate over a publishing medium, builds the page and hands it to the medium's registry entry, then prints the delivery and the `view: ()` report line. A new medium needs no edit to the planning skills. + +### Changed + +- The plan, brainstorm and design views ship a private Artifact as their default medium. The publish gate keeps the view a local file for a repository that is not public or a record with anything credential-shaped in it, unless the `medium` option or the request says to publish; set the `medium` option to `file` to keep them local. The PRD and interview views stay local files. +- The plan and brainstorm views render as entries of the shared view-kind registry; their JSON shapes are unchanged. +- The design view reads code fences by the CommonMark rule: a fence closes only on a run of its own character at least as long as the one that opened it, so a shorter fence line or a `##` line inside a longer fence no longer ends the `## Design` section. diff --git a/.changes/prototype/feat-12650-planning-view-registry-b07e4f91.md b/.changes/prototype/feat-12650-planning-view-registry-b07e4f91.md new file mode 100644 index 00000000000..a4278a0d163 --- /dev/null +++ b/.changes/prototype/feat-12650-planning-view-registry-b07e4f91.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- Shared `view-settings.mjs` and `view-registry.mjs` synced where this plugin carries them: the planning lane's design kind and shipped `artifact` default, and the resolver output's `page` and `publishes` fields (#12650); no other change to this plugin. diff --git a/.changes/review/feat-12650-planning-view-registry-b1516ec7.md b/.changes/review/feat-12650-planning-view-registry-b1516ec7.md new file mode 100644 index 00000000000..1e1eb7b4b1b --- /dev/null +++ b/.changes/review/feat-12650-planning-view-registry-b1516ec7.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- Shared `view-builder.mjs`, `view-settings.mjs` and `view-registry.mjs` synced where this plugin carries them: the view-kind registry and `render(laneId, kind, record)`, the planning lane's design kind and shipped `artifact` default, and the resolver output's `page` and `publishes` fields (#12650); no other change to this plugin. diff --git a/.changes/source-control/feat-12650-planning-view-registry-2f408711.md b/.changes/source-control/feat-12650-planning-view-registry-2f408711.md new file mode 100644 index 00000000000..a4278a0d163 --- /dev/null +++ b/.changes/source-control/feat-12650-planning-view-registry-2f408711.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- Shared `view-settings.mjs` and `view-registry.mjs` synced where this plugin carries them: the planning lane's design kind and shipped `artifact` default, and the resolver output's `page` and `publishes` fields (#12650); no other change to this plugin. diff --git a/.changes/visualization/feat-12650-planning-view-registry-a655a592.md b/.changes/visualization/feat-12650-planning-view-registry-a655a592.md new file mode 100644 index 00000000000..a4278a0d163 --- /dev/null +++ b/.changes/visualization/feat-12650-planning-view-registry-a655a592.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- Shared `view-settings.mjs` and `view-registry.mjs` synced where this plugin carries them: the planning lane's design kind and shipped `artifact` default, and the resolver output's `page` and `publishes` fields (#12650); no other change to this plugin. diff --git a/.changes/work-items/feat-12650-planning-view-registry-7b52a700.md b/.changes/work-items/feat-12650-planning-view-registry-7b52a700.md new file mode 100644 index 00000000000..1e1eb7b4b1b --- /dev/null +++ b/.changes/work-items/feat-12650-planning-view-registry-7b52a700.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- Shared `view-builder.mjs`, `view-settings.mjs` and `view-registry.mjs` synced where this plugin carries them: the view-kind registry and `render(laneId, kind, record)`, the planning lane's design kind and shipped `artifact` default, and the resolver output's `page` and `publishes` fields (#12650); no other change to this plugin. diff --git a/docs/contracts/rendered-views/README.md b/docs/contracts/rendered-views/README.md index ed29e2288b5..01669737301 100644 --- a/docs/contracts/rendered-views/README.md +++ b/docs/contracts/rendered-views/README.md @@ -398,8 +398,17 @@ The deck lane (`/visualization:present`) is the second exception: a deck exists Artifact made from the account's Slides type, so it publishes behind the same gate (see Artifact types), and anything the gate keeps local stays the markdown outline. +The planning views are the third exception: the brainstorm, design and plan views +(`/planning:brainstorm`, `/planning:design`, `/planning:plan`) ship `medium: artifact` as the +planning lane's default. Each page is built only by the shared builder from a checked-in template +(`plugins/planning/scripts/build-view.mjs`), and it publishes behind the same gate as the digest: +the default publishes only a public repository's record with nothing credential-shaped in it, and +any other record falls back to `file` with the reader told how to publish it anyway. The planning +plugin's PRD and interview views are not part of this exception and stay local files. + Rendered views are untracked by default; publishing anywhere else is optional and -configured, never the default, except for the digest's and the deck's `artifact` default. A +configured, never the default, except for the digest's, the deck's and the planning views' +`artifact` default. A shared page host is one such configured place: `medium: hosted`, honored only by the digest lane (see The `hosted` medium). @@ -620,7 +629,9 @@ Emitters on the shared builder (`lib/view-builder.mjs`, interactive profile): `e (`plugins/education/skills/illustrate/scripts/build-explainer.mjs`), which has left the grandfathered set and defaults to a local `file`, then `terminal`, publishing an artifact only when an argument or a layer asks for one; `planning:plan` and -`planning:brainstorm`, each offering its view from a checked-in template plus the session's JSON as data +`planning:brainstorm`, each offering its view from a checked-in template plus the session's JSON as data, +and `planning:design`, offering a view of PLAN.md's `## Design` section, each kind an entry in the +builder's view-kind registry rendered through `render(laneId, kind, record)` (`plugins/planning/scripts/build-view.mjs`); `debugging:debug` (post-mortem) and `discovery:blindspot`, built the same way (`plugins/debugging/scripts/build-view.mjs`, `plugins/discovery/scripts/build-view.mjs`); and the `architecture` `map-*` skills, each offering a view of its JSON record from one checked-in template diff --git a/lib/rendered-view.md b/lib/rendered-view.md index 38584e906a8..d46e5d3271f 100644 --- a/lib/rendered-view.md +++ b/lib/rendered-view.md @@ -1,7 +1,7 @@ # Rendered views of a skill's record How a view lane offers an interactive view of the record its skill wrote. The lanes that carry this file: -planning (`/planning:plan`, `/planning:brainstorm`), architecture (the `map-*` skills), discovery +planning (`/planning:plan`, `/planning:brainstorm`, `/planning:design`), architecture (the `map-*` skills), discovery (`/discovery:blindspot`) and debugging (`/debugging:debug`). Each lane's section below names its record, build command and data shape. `` is the plugin's root directory, the one that holds `.claude-plugin/`: the path the calling skill gave for this file, cut before its first `skills/` or @@ -25,17 +25,9 @@ script written in the session. resolver, not on whether the resolver registers the lane. Only `planning` has migrated today; architecture, discovery and debugging are registered but read the cascade themselves until their own change migrates them. - - **Resolver lanes** (`planning`). Run the resolver. `--option` takes this plugin's `medium` option as the - calling skill states it; add `--argument` only when the reader named a medium in the request: - - ```bash - node "/lib/view-settings.mjs" resolve --lane --option "" [--argument terminal|file|artifact] - ``` - - It prints one JSON line, then the report line naming the medium and what decided it, then any notice; - print those lines as they stand. The JSON's `medium` is the one to use: `terminal`, `file` or `artifact`. - Do not read the rendered-views convention files yourself. When `node` is missing, the builder cannot run - either: deliver the record. + - **Resolver lanes** (`planning`). The lane's build command runs the resolver, the publish gate and + delivery in one call: follow its section in place of steps 3 and 5, and step 4's rule for a missing + `node` or a non-zero exit still applies. Do not read the rendered-views convention files yourself. - **Other lanes.** Read the `rendered-views` cascade surface: `~/.claude/rendered-views.md`, then `/.claude/rendered-views.md`, then `/.claude/rendered-views.local.md`, whichever exist. The last layer that states `medium:` wins (`auto`, `terminal`, `file`, `artifact`). A team layer @@ -66,31 +58,54 @@ script written in the session. ## Planning -`/planning:plan` records `PLAN.md`; `/planning:brainstorm` records the conversation list or `brainstorm.md`. -Genre: plans presented for approval, and option spreads. Resolver lane: `--lane planning`. +`/planning:plan` records `PLAN.md`; `/planning:brainstorm` records the conversation list or `brainstorm.md`; +`/planning:design` records `PLAN.md`'s `## Design` section. Genre: plans presented for approval, option +spreads, and designs handed to the plan. Resolver lane: `--lane planning`. -Build: pipe one JSON object (shapes under "Data shapes") to the builder; use `brainstorm` in place of `plan` -for the brainstorm view: +Resolve, build and deliver in one call. The builder runs the shared medium resolver +(`lib/view-settings.mjs`, the same resolution as its `resolve` command), runs the publish gate over a +publishing medium, builds the page unless the medium shows none, and hands it to the medium's entry in the +registry (`lib/view-registry.mjs`). Pipe one JSON object (shapes under "Data shapes") for the plan or +brainstorm view: ```bash -node "/scripts/build-view.mjs" plan <<'JSON' +node "/scripts/build-view.mjs" plan --option "" --visibility "" <<'JSON' {"title": "...", "goal": "...", "blast": "...", "phases": [{"status": "TODO", "name": "...", "what": "...", "needs": "none", "criteria": ["..."]}]} JSON ``` -A plan or brainstorm session reads repository files, issue text and fetched pages, which is why the builder -is the only way to the page. +- `plan` names the view: `brainstorm` for the brainstorm view; `design --record ""` for the + design view, which reads the record's `## Design` section and takes no stdin. +- `--option` takes this plugin's `medium` option as the calling skill states it. Add + `--argument ""` only when the reader named a medium in the request. +- `--visibility` is the visibility of the repository the record draws on + (`gh repo view --json visibility -q .visibility`), or `NONE` when it draws on none. Left out, the + shipped default stays local. + +A planning session reads repository files, issue text and fetched pages, which is why the builder is the +only way to the page. + +The builder prints one JSON line, then the report line naming the medium and what decided it, then one line +per notice. Print the report and notice lines as they stand, then deliver what the JSON names: + +- `publish` set: publish that file with the Artifact tool and give the link, as step 5 says for `artifact`. + When the Artifact tool is unavailable, hand back `page` and say why in one line. +- Otherwise `page` set: hand back that path. +- Neither: no page was built, and the record stands. -Reply ids: `phases-2` is the second phase in `PLAN.md`; `candidates-3` is the third candidate in the list. -Approval of a plan is stated in the conversation, at the approval gate. +Reply ids: `phases-2` is the second phase in `PLAN.md`; `candidates-3` is the third candidate in the list; +`sections-2` is the second `###` subsection of `## Design`. Approval of a plan is stated in the conversation, +at the approval gate. ### Connect a view to the session -Planning only: it ships the view server. Either view's reply can reach this session instead of being -pasted. In the plan view the reader ticks phases, picks a verdict (`change`, `question`, `approve`), adds a -note, and sends. In the brainstorm view the reader ticks the candidates that resonate, adds a note, and -sends. Only for `file` (or `auto`) with the reader at this machine: the page is served from `127.0.0.1`. -Skip it for `artifact`, and when python3 or curl is missing; the copied reply still closes the loop. +Planning only: it ships the view server. A view's reply can reach this session instead of being pasted. In +the plan view the reader ticks phases, picks a verdict (`change`, `question`, `approve`), adds a note, and +sends. In the brainstorm view the reader ticks the candidates that resonate, adds a note, and sends. In the +design view the reader ticks the sections they question, adds a note, and sends. Only when the delivery was +a local page (`page` set, no `publish`) with the reader at this machine: the page is served from +`127.0.0.1`. Skip it for a published view, and when python3 or curl is missing; the copied reply still +closes the loop. 1. Start the view server on a new data dir under the OS temp directory, never beside the record (`ensure-running` creates it private): @@ -100,7 +115,8 @@ Skip it for `artifact`, and when python3 or curl is missing; the copied reply st ``` It prints one JSON line: `url`, `origin`, `page` and `watch`. -2. Build the page into `page`, naming `origin` (`brainstorm` in place of `plan` for the brainstorm view): +2. Build the page into `page`, naming `origin` (`brainstorm`, or `design --record ""`, in + place of `plan` for the other views): ```bash node "/scripts/build-view.mjs" plan --connect "" --out "" <<'JSON' @@ -121,6 +137,8 @@ Skip it for `artifact`, and when python3 or curl is missing; the copied reply st - Brainstorm view: resolve `candidates-N` against the candidate list. The ticks are the reader's reaction, not a locked scope: reply with the scope you would propose and its route (brainstorm step 5), and lock nothing until the reader confirms it in the conversation. + - Design view: resolve `sections-N` against `## Design`. A change to a contract, boundary or verdict + routes back through `/planning:design`; the view edits nothing. 6. Write `/ops.json` with the Write tool, `{"replies": [{"seq": 1, "text": "..."}], "handled": [2]}`, a reply of at most 4000 characters per event you answer and `handled` for the rest, then run the event line's `next` as a background Bash task. It @@ -137,18 +155,22 @@ With no session listening, the page says so and its copy and save controls still ### Other optional views The interview's read-only decision table, the PRD pitch view and the design topology view are written -by the session, not the builder, and are only ever local files. Before writing one, resolve `medium` as -step 3 above does, passing a medium the reader names in the request as `--argument`. Then: +by the session, not the builder, and are only ever local files. Before writing one, run the shared medium +resolver, passing a medium the reader names in the request as `--argument`, and print its notice lines: -- `terminal`: write no file. The interview renders the same table inline as a fenced markdown table; - the PRD and design steps skip the view. -- `file`, `artifact` or `hosted`: write the local file. These views are never published, so say that a - publishing value was read as `file`. -- `auto`, an empty value or no value: the skill's shipped default. Any other value is reported and read - as `auto`. +```bash +node "/lib/view-settings.mjs" resolve --lane planning --option "" [--argument ""] +``` + +Its JSON line then decides: -Name the source of `medium` with the view or its absence: the request, the layer's path, or "shipped -default". The interview's `page` surface follows the `surface` option, not `medium`. +- `page` false: write no file, and print the report line as it stands. The interview renders the same + table inline as a fenced markdown table; the PRD and design steps skip the view. +- `page` true: write the local file. These views are never published, so when `publishes` is true, print + the report line with `file` in place of the medium and keep what decided it, as in + `view: file (plugin default)`; otherwise print it as it stands. + +The interview's `page` surface follows the `surface` option, not `medium`. ### Data shapes @@ -161,6 +183,10 @@ Every field is plain text. List order is the numbering in the record. `brainstorm`: `title`, `problem`, and `candidates` ordered cheapest first, each with `effort`, `name`, `what`, `where` (the files or mechanisms it touches), and `impact`. +`design`: no JSON. The builder reads the `PLAN.md` that `--record` names: its level-one heading as the title, +the text before the first `###` subsection of `## Design`, and each subsection's lines as written. A record +with no `## Design` section exits 1. + ## Architecture The `map-*` skills record a markdown file and a JSON record; both stay authoritative. A record holds diff --git a/lib/view-builder.mjs b/lib/view-builder.mjs index d5c143914cc..4d0986fb31a 100644 --- a/lib/view-builder.mjs +++ b/lib/view-builder.mjs @@ -15,6 +15,11 @@ // exception, in both profiles, is the optional page title (rule 5): the builder // writes it as one escaped, capped line of text into the head's . // +// View kinds: VIEW_KINDS holds one entry per kind, { id, parse(record) -> data, template, tier }, +// the template path relative to the root of the plugin carrying this file. render(laneId, kind, +// record) builds a kind the lane registers (lib/view-registry.mjs). Adding a kind is an addition +// here and in the lane's registration, never a skill edit. +// // CLI: // node view-builder.mjs --profile report|interactive --template <file> --data <file.json> --out <file> // [--connect http://127.0.0.1:<port>] (interactive: the session-bridge origin) @@ -24,8 +29,10 @@ import { createHash } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { escapeHtml, pageDigest, stampPage, validateRenderedPage } from "./html-escape.mjs"; +import { LANES } from "./view-registry.mjs"; export const INTERACTIVE_MARKER = "rv-gen:view-builder-interactive"; const MARKER_RE = /<!-- rv-gen:view-builder-interactive sha256:([0-9a-f]{64}) -->/g; @@ -186,6 +193,115 @@ export function validateView(html, runtime) { : validateRenderedPage(html); } +// ------------------------------------------------------------ view kinds + +const isText = (value) => typeof value === "string"; +const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value); + +/** + * A record that is one JSON object (or its text): top-level text fields, then one list of rows + * whose fields are text, plus `rowList`, a list of text in each row, when the shape names one. + */ +const jsonRecord = (shape) => (record) => { + const data = isText(record) ? JSON.parse(record) : record; + if (!isObject(data)) throw new ViewBuildError(["data is not an object"]); + const failures = shape.text.filter((key) => !isText(data[key])).map((key) => `${key} must be text`); + if (!Array.isArray(data[shape.list])) throw new ViewBuildError([...failures, `${shape.list} must be a list`]); + for (const [index, row] of data[shape.list].entries()) { + const at = `${shape.list}[${index}]`; + if (!isObject(row)) { + failures.push(`${at} must be an object`); + continue; + } + for (const key of shape.row) if (!isText(row[key])) failures.push(`${at}.${key} must be text`); + if (shape.rowList && !(Array.isArray(row[shape.rowList]) && row[shape.rowList].every(isText))) { + failures.push(`${at}.${shape.rowList} must be a list of text`); + } + } + if (failures.length) throw new ViewBuildError(failures); + return data; +}; + +const trimBlank = (lines) => { + let [start, end] = [0, lines.length]; + while (start < end && !lines[start].trim()) start += 1; + while (end > start && !lines[end - 1].trim()) end -= 1; + return lines.slice(start, end); +}; + +/** + * A markdown record's `## Design` section: the title from the record's first `# ` heading, the + * text before the first `### ` subsection, and each subsection's lines as written. A heading + * inside a code fence is text; a fence closes only on a bare run of its own character at least as + * long as the opening run (CommonMark). + */ +function parseDesign(record) { + if (!isText(record)) throw new ViewBuildError(["the design record must be markdown text"]); + let [fence, inDesign, found, title] = [null, false, false, null]; + const intro = []; + const sections = []; + for (const line of lf(record).split("\n")) { + const heading = fence ? null : /^(#{1,6})[ \t]+(.+?)[ \t]*$/.exec(line); + const run = /^[ \t]*(`{3,}(?=[^`]*$)|~{3,})(.*)$/.exec(line); + if (!fence && run) fence = run[1]; + else if (fence && run && run[1][0] === fence[0] && run[1].length >= fence.length && !run[2].trim()) fence = null; + const level = heading?.[1].length; + if (level === 1) title ??= heading[2]; + if (level <= 2) { + inDesign = level === 2 && heading[2] === "Design"; + found ||= inDesign; + } else if (inDesign && level === 3) { + sections.push({ name: heading[2], lines: [] }); + } else if (inDesign) { + (sections.at(-1)?.lines ?? intro).push(line); + } + } + if (!found) throw new ViewBuildError(["the record has no ## Design section"]); + return { title: title ?? "Design", intro: trimBlank(intro).join("\n"), sections: sections.map(({ name, lines }) => ({ name, lines: trimBlank(lines) })) }; +} + +const kindEntry = (id, parse, template, tier) => Object.freeze({ id, parse, template, tier }); + +/** One entry per view kind: `tier` is the rendered-views view tier; `static` builds the report profile. */ +export const VIEW_KINDS = Object.freeze({ + plan: kindEntry( + "plan", + jsonRecord({ text: ["title", "goal", "blast"], list: "phases", row: ["status", "name", "what", "needs"], rowList: "criteria" }), + "skills/plan/templates/plan-view.html", + "client-interactive", + ), + brainstorm: kindEntry( + "brainstorm", + jsonRecord({ text: ["title", "problem"], list: "candidates", row: ["effort", "name", "what", "where", "impact"] }), + "skills/brainstorm/templates/brainstorm-view.html", + "client-interactive", + ), + design: kindEntry("design", parseDesign, "skills/design/templates/design-view.html", "client-interactive"), +}); + +const PLUGIN_ROOT = fileURLToPath(new URL("../", import.meta.url)); + +/** + * Build the `kind` view of `record` for the lane `laneId`, which must register that kind. + * A record the kind cannot read throws ViewBuildError; an unknown lane or kind throws Error. + * @param {{lanes?: object, kinds?: object, root?: string, connect?: string | null, title?: string | null}} [options] + * lanes and kinds replace the registries; root is the directory the kind's template path is under + */ +export function render(laneId, kind, record, { lanes = LANES, kinds = VIEW_KINDS, root = PLUGIN_ROOT, connect = null, title = null } = {}) { + const lane = Object.hasOwn(lanes, laneId) ? lanes[laneId] : undefined; + if (!lane) throw new Error(`view-builder: unknown lane ${JSON.stringify(laneId)}`); + if (!lane.kinds.includes(kind)) throw new Error(`view-builder: lane ${laneId} does not register the view kind ${JSON.stringify(kind)}`); + const entry = Object.hasOwn(kinds, kind) ? kinds[kind] : undefined; + if (!entry) throw new Error(`view-builder: no view kind ${JSON.stringify(kind)}`); + return buildView({ + profile: entry.tier === "static" ? "report" : "interactive", + template: readFileSync(join(root, entry.template), "utf8"), + data: entry.parse(record), + connect, + title, + }); +} + // ---------------------------------------------------------------- report function parseSlots(template, failures) { diff --git a/lib/view-builder.test.sh b/lib/view-builder.test.sh index cfe821f8013..133fc145ca2 100755 --- a/lib/view-builder.test.sh +++ b/lib/view-builder.test.sh @@ -75,6 +75,56 @@ const template = readFileSync(`${root}/lib/view-builder-sample/template.html`, " const sample = JSON.parse(readFileSync(`${root}/lib/view-builder-sample/data.json`, "utf8")); const runtime = loadRuntime(); +// ------------------------------------------------------- view-kind registry +{ + const { render } = await import(lib("view-builder.mjs")); + const kinds = { + triage: { id: "triage", parse: (record) => JSON.parse(record), template: "view-builder-sample/template.html", tier: "client-interactive" }, + }; + const lanes = { "fixture:lane": { laneId: "fixture:lane", kinds: ["triage"] }, "fixture:other": { laneId: "fixture:other", kinds: [] } }; + const opts = { kinds, lanes, root: `${root}/lib` }; + const rendered = render("fixture:lane", "triage", JSON.stringify(sample), opts); + check("render builds a registered kind through its entry and the page passes", validateView(rendered).ok, validateView(rendered).failures); + check("render refuses a kind its lane does not register", failuresOf(() => render("fixture:other", "triage", "{}", opts))?.[0]?.includes("does not register")); + check("render refuses an unknown lane", failuresOf(() => render("fixture:none", "triage", "{}", opts))?.[0]?.includes("unknown lane")); + const copies = readFileSync(`${root}/scripts/shared-copies.txt`, "utf8").match(/^lib\/view-builder\.mjs \S+$/gm).map((line) => line.split(" ")[1]); // portability-ok: embedded node JavaScript regex, not a shell tool pattern + const failedImports = []; + for (const copy of copies) { + try { + await import(pathToFileURL(`${root}/${copy}`).href); + } catch (err) { + failedImports.push(`${copy}: ${err.message}`); + } + } + check("every view-builder copy imports beside its registry", failedImports.length === 0, failedImports.join("; ")); +} + +// --------------------------------------------- design record code fences +{ + const { VIEW_KINDS } = await import(lib("view-builder.mjs")); + const record = [ + "# Fenced plan", + "## Design", + "### Layout", + "````markdown", + "```", + "## Not a heading", + "~~~", + "````", + "### Contracts", + "The builder owns the page.", + "## Next", + ].join("\n"); + const { sections } = VIEW_KINDS.design.parse(record); + check( + "a shorter or other-character fence line does not close a four-backtick fence", + JSON.stringify(sections.map((s) => s.name)) === JSON.stringify(["Layout", "Contracts"]), + JSON.stringify(sections), + ); + check("the fenced ## line stays text in its subsection", sections[0]?.lines.includes("## Not a heading"), JSON.stringify(sections[0])); + check("the subsection after the fence keeps its lines", JSON.stringify(sections[1]?.lines) === JSON.stringify(["The builder owns the page."]), JSON.stringify(sections[1])); +} + // The hostile-input corpus: script tags, event handlers, javascript: URLs, SVG // script, data-block breakouts, prototype keys. const hostile = [ diff --git a/lib/view-registry.mjs b/lib/view-registry.mjs index 12d00febcea..a74d5f92dfa 100644 --- a/lib/view-registry.mjs +++ b/lib/view-registry.mjs @@ -27,6 +27,9 @@ export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS /** The one fallback chain: a view that cannot be served on a medium steps down this list. */ export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); +/** The chain's last step builds no page: the record stands on its own. */ +export const NO_PAGE = FALLBACK_CHAIN.at(-1); + /** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ export const NO_PREFERENCE = "auto"; @@ -45,7 +48,7 @@ export const LANES = Object.freeze( lane("review:explain-change", ["digest"], true, "artifact"), lane("visualization:present", ["deck"], false, "artifact"), lane("visualization:visualize", [], false, null), - lane("planning", ["plan", "brainstorm"], false, "file"), + lane("planning", ["plan", "brainstorm", "design"], false, "artifact"), lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), lane("discovery:blindspot", ["blindspot"], false, "file"), lane("debugging:debug", ["post-mortem"], false, "file"), diff --git a/lib/view-settings.mjs b/lib/view-settings.mjs index 95790109937..b5b137e61b5 100755 --- a/lib/view-settings.mjs +++ b/lib/view-settings.mjs @@ -17,7 +17,8 @@ // medium then steps down the fallback chain (lib/view-registry.mjs) until reachable. // // view-settings.mjs resolve --lane <id> --option <user_config.medium> [--argument <m>] [--team-ref <ref>] -// Prints the resolution as one JSON line, then the report line and one line per notice. +// Prints the resolution as one JSON line, then the report line and one line per notice. With a +// medium resolved, the JSON adds `page` (the medium shows a page) and `publishes`. // Exit 0 resolved, 2 usage. import { execFileSync, spawnSync } from "node:child_process"; @@ -27,7 +28,7 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { configBlockBody, readLayers } from "./config-cascade.mjs"; -import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; +import { NO_PAGE, NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; const CONCERN = "rendered-views"; const HOME = "docs/conventions"; @@ -301,7 +302,10 @@ export function main(argv, env = process.env) { if (request.argument !== null && !mediumOf(request.argument)) return usage(); const ci = Boolean(env.CI) && env.CI !== "false"; const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); - process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + // So a skill can act on the medium without naming its value: does it show a page, does it publish. + const entry = mediumOf(resolution.medium); + const shown = entry ? { ...resolution, page: entry.id !== NO_PAGE, publishes: entry.publishes } : resolution; + process.stdout.write(`${JSON.stringify(shown)}\n${reportLines(resolution).join("\n")}\n`); return 0; } diff --git a/lib/view-settings.test.mjs b/lib/view-settings.test.mjs index 8218a02925c..429034f0020 100644 --- a/lib/view-settings.test.mjs +++ b/lib/view-settings.test.mjs @@ -93,7 +93,8 @@ describe("a publishing value counts only from a trusted scope", () => { test("team artifact alone never publishes: the lane's own default applies", () => { const { dir } = repo({ [TEAM]: ["medium: artifact\n", true] }); const result = resolve({ projectRoot: dir }); - assert.deepEqual(pick(result), { medium: "file", decidedBy: "plugin default" }); + assert.deepEqual(pick(result), { medium: "artifact", decidedBy: "plugin default" }); + assert.equal(gateExplicit(result), false); assert.ok(result.notices.some((n) => n.kind === "ignored" && /a team file never publishes/.test(n.text))); }); test("team hosted on the digest lane falls to the plugin default, which the content gate still checks", () => { @@ -105,13 +106,15 @@ describe("a publishing value counts only from a trusted scope", () => { test("a tracked personal override artifact never publishes", () => { const { dir } = repo({ [PERSONAL]: ["medium: artifact\n", true] }, ["*.local.yaml"]); const result = resolve({ projectRoot: dir }); - assert.deepEqual(pick(result), { medium: "file", decidedBy: "plugin default" }); + assert.deepEqual(pick(result), { medium: "artifact", decidedBy: "plugin default" }); + assert.equal(gateExplicit(result), false); assert.ok(result.notices.some((n) => /tracked in git/.test(n.text))); }); test("an untracked personal override that is not gitignored never publishes", () => { const { dir } = repo({ [PERSONAL]: ["medium: artifact\n", false] }); const result = resolve({ projectRoot: dir }); - assert.deepEqual(pick(result), { medium: "file", decidedBy: "plugin default" }); + assert.deepEqual(pick(result), { medium: "artifact", decidedBy: "plugin default" }); + assert.equal(gateExplicit(result), false); assert.ok(result.notices.some((n) => /not gitignored/.test(n.text))); }); test("an untracked, gitignored personal override publishes", () => { @@ -125,7 +128,9 @@ describe("a publishing value counts only from a trusted scope", () => { const { dir } = repo({}, ["*.local.yaml", "docs/conventions"]); mkdirSync(join(dir, "docs")); execFileSync("ln", ["-s", join(elsewhere, "conventions"), join(dir, "docs/conventions")]); - assert.deepEqual(pick(resolve({ projectRoot: dir })), { medium: "file", decidedBy: "plugin default" }); + const result = resolve({ projectRoot: dir }); + assert.deepEqual(pick(result), { medium: "artifact", decidedBy: "plugin default" }); + assert.equal(gateExplicit(result), false); }); test("the personal override wins over userConfig among publishing values", () => { const { dir } = repo({ [PERSONAL]: ["medium: hosted\n", false] }, ["*.local.yaml"]); @@ -152,8 +157,8 @@ describe("auto equals unset", () => { assert.deepEqual(pick(resolve({ userConfig: "auto", projectRoot: dir })), { medium: "terminal", decidedBy: "team" }); }); test("auto everywhere is the plugin default, and an unrendered option is unset", () => { - assert.deepEqual(pick(resolve({ argument: "auto", userConfig: "auto" })), { medium: "file", decidedBy: "plugin default" }); - assert.deepEqual(pick(resolve({ userConfig: "${user_config.medium}" })), { medium: "file", decidedBy: "plugin default" }); + assert.deepEqual(pick(resolve({ argument: "auto", userConfig: "auto" })), { medium: "artifact", decidedBy: "plugin default" }); + assert.deepEqual(pick(resolve({ userConfig: "${user_config.medium}" })), { medium: "artifact", decidedBy: "plugin default" }); }); test("a lane with no default leaves the choice to the skill", () => { assert.deepEqual(pick(resolve({ lane: "visualization:visualize" })), { medium: null, decidedBy: "plugin default" }); @@ -174,7 +179,7 @@ describe("the team file read from a git ref", () => { commit(); for (const teamRef of ["", "-n", "no-such-branch"]) { const result = resolve({ projectRoot: dir, teamRef }); - assert.deepEqual(pick(result), { medium: "file", decidedBy: "plugin default" }, teamRef); + assert.deepEqual(pick(result), { medium: "artifact", decidedBy: "plugin default" }, teamRef); assert.ok(result.notices.some((n) => n.kind === "team-ref"), teamRef); } }); @@ -185,7 +190,7 @@ describe("the team file read from a git ref", () => { mkdirSync(join(dir, "docs/conventions"), { recursive: true }); writeFileSync(join(dir, TEAM), "medium: terminal\n"); const result = resolve({ projectRoot: dir, teamRef: base }); - assert.deepEqual(pick(result), { medium: "file", decidedBy: "plugin default" }); + assert.deepEqual(pick(result), { medium: "artifact", decidedBy: "plugin default" }); assert.deepEqual(result.notices, []); }); }); @@ -240,10 +245,16 @@ describe("a retired file's terminal or file value keeps the view local", () => { assert.deepEqual(pick(result), { medium: "terminal", decidedBy: "retired .claude/rendered-views.md" }); assert.equal(gateExplicit(result), false); }); + for (const medium of ["file", "terminal"]) { + test(`the planning lane's artifact default yields to a retired user file's ${medium} value`, () => { + const home = userHome({ [USER_FILE]: `medium: ${medium}\n` }); + assert.deepEqual(pick(resolve({ lane: "planning" }, { userHome: home })), { medium, decidedBy: "retired ~/.claude/rendered-views.md" }); + }); + } test("a retired file's publishing value is never applied: the plugin default decides", () => { const home = userHome({ [USER_FILE]: "medium: artifact\n" }); const { dir } = repo({ ".claude/rendered-views.local.md": ["medium: hosted\n", false] }); - const result = resolve({ projectRoot: dir }, { userHome: home }); + const result = resolve({ lane: "architecture", projectRoot: dir }, { userHome: home }); assert.deepEqual(pick(result), { medium: "file", decidedBy: "plugin default" }); assert.equal(gateExplicit(result), false); }); @@ -278,7 +289,7 @@ describe("a retired file's terminal or file value keeps the view local", () => { assert.deepEqual(pick(result), { medium: "terminal", decidedBy: "retired docs/conventions/rendered-views.md" }); assert.deepEqual(result.notices.filter((n) => n.kind === "retired").map(({ path, destination }) => ({ path, destination })), [{ path: "docs/conventions/rendered-views.md", destination: TEAM }]); const publishing = repo({ "docs/conventions/rendered-views.md": [block("artifact"), true] }); - assert.deepEqual(pick(resolve({ projectRoot: publishing.dir })), { medium: "file", decidedBy: "plugin default" }); + assert.deepEqual(pick(resolve({ lane: "architecture", projectRoot: publishing.dir })), { medium: "file", decidedBy: "plugin default" }); }); describe("only the config block's own medium counts, not an example fence before it", () => { const TEAM_MD = "docs/conventions/rendered-views.md"; @@ -286,14 +297,14 @@ describe("a retired file's terminal or file value keeps the view local", () => { const fenced = (result) => result.notices.find((n) => n.kind === "retired" && n.path === TEAM_MD); test("an example terminal before a config block that sets no medium gives the plugin default", () => { const project = repo({ [TEAM_MD]: [doc("terminal", "other: 1\n"), true] }); - const result = resolve({ projectRoot: project.dir }); + const result = resolve({ lane: "architecture", projectRoot: project.dir }); assert.deepEqual(pick(result), { medium: "file", decidedBy: "plugin default" }, "the example fence's terminal must not decide the medium"); assert.equal(fenced(result).value, null, "the notice reports no medium for a block that sets none"); assert.match(fenced(result).text, /is no longer read/); }); test("an example terminal before a config block setting artifact gives the plugin default", () => { const project = repo({ [TEAM_MD]: [doc("terminal", "medium: artifact\n"), true] }); - const result = resolve({ projectRoot: project.dir }); + const result = resolve({ lane: "architecture", projectRoot: project.dir }); assert.deepEqual(pick(result), { medium: "file", decidedBy: "plugin default" }, "the block's publishing value is ignored and the example's terminal is not used"); assert.equal(fenced(result).value, "artifact", "the notice reports the block's own medium"); }); @@ -328,7 +339,7 @@ describe("CLI", () => { assert.deepEqual(pick(JSON.parse(json)), { medium: "file", decidedBy: "userConfig" }); assert.equal(report[0], "view: file (userConfig)"); const without = run(["resolve", "--lane", "planning", "--option", "${user_config.medium}"], { CLAUDE_PROJECT_DIR: dir }); - assert.equal(without.stdout.split("\n")[1], "view: file (plugin default)"); + assert.equal(without.stdout.split("\n")[1], "view: artifact (plugin default)"); }); test("--team-ref and --argument reach the resolver", () => { const { dir, git, commit } = repo({ [TEAM]: ["medium: terminal\n", true] }); @@ -339,6 +350,17 @@ describe("CLI", () => { const arg = run(["resolve", "--lane", "planning", "--option", "", "--argument", "artifact"], { CLAUDE_PROJECT_DIR: dir }); assert.equal(arg.stdout.split("\n")[1], "view: artifact (argument)"); }); + test("the JSON says whether the medium shows a page and whether it publishes", () => { + const fields = (option) => { + const { page, publishes } = JSON.parse(run(["resolve", "--lane", "planning", "--option", option], { CLAUDE_PROJECT_DIR: EMPTY_HOME }).stdout.split("\n")[0]); + return { page, publishes }; + }; + assert.deepEqual(fields("terminal"), { page: false, publishes: false }); + assert.deepEqual(fields("file"), { page: true, publishes: false }); + assert.deepEqual(fields("artifact"), { page: true, publishes: true }); + const unset = JSON.parse(run(["resolve", "--lane", "visualization:visualize", "--option", ""], { CLAUDE_PROJECT_DIR: EMPTY_HOME }).stdout.split("\n")[0]); + assert.equal("page" in unset, false, "no medium resolved, so the lane's own rule decides"); + }); test("every registered copy imports its sibling modules and resolves", () => { const manifest = readFileSync(join(HERE, "..", "scripts", "shared-copies.txt"), "utf8"); const copies = [...manifest.matchAll(/^lib\/view-settings\.mjs (\S+)$/gm)].map(([, copy]) => copy); diff --git a/plugins/adhd/lib/view-registry.mjs b/plugins/adhd/lib/view-registry.mjs index 5b33fb87a25..1dd99753c9c 100644 --- a/plugins/adhd/lib/view-registry.mjs +++ b/plugins/adhd/lib/view-registry.mjs @@ -29,6 +29,9 @@ export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS /** The one fallback chain: a view that cannot be served on a medium steps down this list. */ export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); +/** The chain's last step builds no page: the record stands on its own. */ +export const NO_PAGE = FALLBACK_CHAIN.at(-1); + /** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ export const NO_PREFERENCE = "auto"; @@ -47,7 +50,7 @@ export const LANES = Object.freeze( lane("review:explain-change", ["digest"], true, "artifact"), lane("visualization:present", ["deck"], false, "artifact"), lane("visualization:visualize", [], false, null), - lane("planning", ["plan", "brainstorm"], false, "file"), + lane("planning", ["plan", "brainstorm", "design"], false, "artifact"), lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), lane("discovery:blindspot", ["blindspot"], false, "file"), lane("debugging:debug", ["post-mortem"], false, "file"), diff --git a/plugins/adhd/lib/view-settings.mjs b/plugins/adhd/lib/view-settings.mjs index c37c0127da9..d268b37da9a 100755 --- a/plugins/adhd/lib/view-settings.mjs +++ b/plugins/adhd/lib/view-settings.mjs @@ -19,7 +19,8 @@ // medium then steps down the fallback chain (lib/view-registry.mjs) until reachable. // // view-settings.mjs resolve --lane <id> --option <user_config.medium> [--argument <m>] [--team-ref <ref>] -// Prints the resolution as one JSON line, then the report line and one line per notice. +// Prints the resolution as one JSON line, then the report line and one line per notice. With a +// medium resolved, the JSON adds `page` (the medium shows a page) and `publishes`. // Exit 0 resolved, 2 usage. import { execFileSync, spawnSync } from "node:child_process"; @@ -29,7 +30,7 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { configBlockBody, readLayers } from "./config-cascade.mjs"; -import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; +import { NO_PAGE, NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; const CONCERN = "rendered-views"; const HOME = "docs/conventions"; @@ -303,7 +304,10 @@ export function main(argv, env = process.env) { if (request.argument !== null && !mediumOf(request.argument)) return usage(); const ci = Boolean(env.CI) && env.CI !== "false"; const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); - process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + // So a skill can act on the medium without naming its value: does it show a page, does it publish. + const entry = mediumOf(resolution.medium); + const shown = entry ? { ...resolution, page: entry.id !== NO_PAGE, publishes: entry.publishes } : resolution; + process.stdout.write(`${JSON.stringify(shown)}\n${reportLines(resolution).join("\n")}\n`); return 0; } diff --git a/plugins/ai-briefing/lib/view-registry.mjs b/plugins/ai-briefing/lib/view-registry.mjs index 5b33fb87a25..1dd99753c9c 100644 --- a/plugins/ai-briefing/lib/view-registry.mjs +++ b/plugins/ai-briefing/lib/view-registry.mjs @@ -29,6 +29,9 @@ export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS /** The one fallback chain: a view that cannot be served on a medium steps down this list. */ export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); +/** The chain's last step builds no page: the record stands on its own. */ +export const NO_PAGE = FALLBACK_CHAIN.at(-1); + /** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ export const NO_PREFERENCE = "auto"; @@ -47,7 +50,7 @@ export const LANES = Object.freeze( lane("review:explain-change", ["digest"], true, "artifact"), lane("visualization:present", ["deck"], false, "artifact"), lane("visualization:visualize", [], false, null), - lane("planning", ["plan", "brainstorm"], false, "file"), + lane("planning", ["plan", "brainstorm", "design"], false, "artifact"), lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), lane("discovery:blindspot", ["blindspot"], false, "file"), lane("debugging:debug", ["post-mortem"], false, "file"), diff --git a/plugins/ai-briefing/lib/view-settings.mjs b/plugins/ai-briefing/lib/view-settings.mjs index c37c0127da9..d268b37da9a 100755 --- a/plugins/ai-briefing/lib/view-settings.mjs +++ b/plugins/ai-briefing/lib/view-settings.mjs @@ -19,7 +19,8 @@ // medium then steps down the fallback chain (lib/view-registry.mjs) until reachable. // // view-settings.mjs resolve --lane <id> --option <user_config.medium> [--argument <m>] [--team-ref <ref>] -// Prints the resolution as one JSON line, then the report line and one line per notice. +// Prints the resolution as one JSON line, then the report line and one line per notice. With a +// medium resolved, the JSON adds `page` (the medium shows a page) and `publishes`. // Exit 0 resolved, 2 usage. import { execFileSync, spawnSync } from "node:child_process"; @@ -29,7 +30,7 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { configBlockBody, readLayers } from "./config-cascade.mjs"; -import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; +import { NO_PAGE, NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; const CONCERN = "rendered-views"; const HOME = "docs/conventions"; @@ -303,7 +304,10 @@ export function main(argv, env = process.env) { if (request.argument !== null && !mediumOf(request.argument)) return usage(); const ci = Boolean(env.CI) && env.CI !== "false"; const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); - process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + // So a skill can act on the medium without naming its value: does it show a page, does it publish. + const entry = mediumOf(resolution.medium); + const shown = entry ? { ...resolution, page: entry.id !== NO_PAGE, publishes: entry.publishes } : resolution; + process.stdout.write(`${JSON.stringify(shown)}\n${reportLines(resolution).join("\n")}\n`); return 0; } diff --git a/plugins/architecture/lib/view-builder.mjs b/plugins/architecture/lib/view-builder.mjs index d9df8de727e..8202be9f20c 100644 --- a/plugins/architecture/lib/view-builder.mjs +++ b/plugins/architecture/lib/view-builder.mjs @@ -17,6 +17,11 @@ // exception, in both profiles, is the optional page title (rule 5): the builder // writes it as one escaped, capped line of text into the head's <title>. // +// View kinds: VIEW_KINDS holds one entry per kind, { id, parse(record) -> data, template, tier }, +// the template path relative to the root of the plugin carrying this file. render(laneId, kind, +// record) builds a kind the lane registers (lib/view-registry.mjs). Adding a kind is an addition +// here and in the lane's registration, never a skill edit. +// // CLI: // node view-builder.mjs --profile report|interactive --template <file> --data <file.json> --out <file> // [--connect http://127.0.0.1:<port>] (interactive: the session-bridge origin) @@ -26,8 +31,10 @@ import { createHash } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { escapeHtml, pageDigest, stampPage, validateRenderedPage } from "./html-escape.mjs"; +import { LANES } from "./view-registry.mjs"; export const INTERACTIVE_MARKER = "rv-gen:view-builder-interactive"; const MARKER_RE = /<!-- rv-gen:view-builder-interactive sha256:([0-9a-f]{64}) -->/g; @@ -188,6 +195,115 @@ export function validateView(html, runtime) { : validateRenderedPage(html); } +// ------------------------------------------------------------ view kinds + +const isText = (value) => typeof value === "string"; +const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value); + +/** + * A record that is one JSON object (or its text): top-level text fields, then one list of rows + * whose fields are text, plus `rowList`, a list of text in each row, when the shape names one. + */ +const jsonRecord = (shape) => (record) => { + const data = isText(record) ? JSON.parse(record) : record; + if (!isObject(data)) throw new ViewBuildError(["data is not an object"]); + const failures = shape.text.filter((key) => !isText(data[key])).map((key) => `${key} must be text`); + if (!Array.isArray(data[shape.list])) throw new ViewBuildError([...failures, `${shape.list} must be a list`]); + for (const [index, row] of data[shape.list].entries()) { + const at = `${shape.list}[${index}]`; + if (!isObject(row)) { + failures.push(`${at} must be an object`); + continue; + } + for (const key of shape.row) if (!isText(row[key])) failures.push(`${at}.${key} must be text`); + if (shape.rowList && !(Array.isArray(row[shape.rowList]) && row[shape.rowList].every(isText))) { + failures.push(`${at}.${shape.rowList} must be a list of text`); + } + } + if (failures.length) throw new ViewBuildError(failures); + return data; +}; + +const trimBlank = (lines) => { + let [start, end] = [0, lines.length]; + while (start < end && !lines[start].trim()) start += 1; + while (end > start && !lines[end - 1].trim()) end -= 1; + return lines.slice(start, end); +}; + +/** + * A markdown record's `## Design` section: the title from the record's first `# ` heading, the + * text before the first `### ` subsection, and each subsection's lines as written. A heading + * inside a code fence is text; a fence closes only on a bare run of its own character at least as + * long as the opening run (CommonMark). + */ +function parseDesign(record) { + if (!isText(record)) throw new ViewBuildError(["the design record must be markdown text"]); + let [fence, inDesign, found, title] = [null, false, false, null]; + const intro = []; + const sections = []; + for (const line of lf(record).split("\n")) { + const heading = fence ? null : /^(#{1,6})[ \t]+(.+?)[ \t]*$/.exec(line); + const run = /^[ \t]*(`{3,}(?=[^`]*$)|~{3,})(.*)$/.exec(line); + if (!fence && run) fence = run[1]; + else if (fence && run && run[1][0] === fence[0] && run[1].length >= fence.length && !run[2].trim()) fence = null; + const level = heading?.[1].length; + if (level === 1) title ??= heading[2]; + if (level <= 2) { + inDesign = level === 2 && heading[2] === "Design"; + found ||= inDesign; + } else if (inDesign && level === 3) { + sections.push({ name: heading[2], lines: [] }); + } else if (inDesign) { + (sections.at(-1)?.lines ?? intro).push(line); + } + } + if (!found) throw new ViewBuildError(["the record has no ## Design section"]); + return { title: title ?? "Design", intro: trimBlank(intro).join("\n"), sections: sections.map(({ name, lines }) => ({ name, lines: trimBlank(lines) })) }; +} + +const kindEntry = (id, parse, template, tier) => Object.freeze({ id, parse, template, tier }); + +/** One entry per view kind: `tier` is the rendered-views view tier; `static` builds the report profile. */ +export const VIEW_KINDS = Object.freeze({ + plan: kindEntry( + "plan", + jsonRecord({ text: ["title", "goal", "blast"], list: "phases", row: ["status", "name", "what", "needs"], rowList: "criteria" }), + "skills/plan/templates/plan-view.html", + "client-interactive", + ), + brainstorm: kindEntry( + "brainstorm", + jsonRecord({ text: ["title", "problem"], list: "candidates", row: ["effort", "name", "what", "where", "impact"] }), + "skills/brainstorm/templates/brainstorm-view.html", + "client-interactive", + ), + design: kindEntry("design", parseDesign, "skills/design/templates/design-view.html", "client-interactive"), +}); + +const PLUGIN_ROOT = fileURLToPath(new URL("../", import.meta.url)); + +/** + * Build the `kind` view of `record` for the lane `laneId`, which must register that kind. + * A record the kind cannot read throws ViewBuildError; an unknown lane or kind throws Error. + * @param {{lanes?: object, kinds?: object, root?: string, connect?: string | null, title?: string | null}} [options] + * lanes and kinds replace the registries; root is the directory the kind's template path is under + */ +export function render(laneId, kind, record, { lanes = LANES, kinds = VIEW_KINDS, root = PLUGIN_ROOT, connect = null, title = null } = {}) { + const lane = Object.hasOwn(lanes, laneId) ? lanes[laneId] : undefined; + if (!lane) throw new Error(`view-builder: unknown lane ${JSON.stringify(laneId)}`); + if (!lane.kinds.includes(kind)) throw new Error(`view-builder: lane ${laneId} does not register the view kind ${JSON.stringify(kind)}`); + const entry = Object.hasOwn(kinds, kind) ? kinds[kind] : undefined; + if (!entry) throw new Error(`view-builder: no view kind ${JSON.stringify(kind)}`); + return buildView({ + profile: entry.tier === "static" ? "report" : "interactive", + template: readFileSync(join(root, entry.template), "utf8"), + data: entry.parse(record), + connect, + title, + }); +} + // ---------------------------------------------------------------- report function parseSlots(template, failures) { diff --git a/plugins/architecture/lib/view-registry.mjs b/plugins/architecture/lib/view-registry.mjs index 5b33fb87a25..1dd99753c9c 100644 --- a/plugins/architecture/lib/view-registry.mjs +++ b/plugins/architecture/lib/view-registry.mjs @@ -29,6 +29,9 @@ export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS /** The one fallback chain: a view that cannot be served on a medium steps down this list. */ export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); +/** The chain's last step builds no page: the record stands on its own. */ +export const NO_PAGE = FALLBACK_CHAIN.at(-1); + /** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ export const NO_PREFERENCE = "auto"; @@ -47,7 +50,7 @@ export const LANES = Object.freeze( lane("review:explain-change", ["digest"], true, "artifact"), lane("visualization:present", ["deck"], false, "artifact"), lane("visualization:visualize", [], false, null), - lane("planning", ["plan", "brainstorm"], false, "file"), + lane("planning", ["plan", "brainstorm", "design"], false, "artifact"), lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), lane("discovery:blindspot", ["blindspot"], false, "file"), lane("debugging:debug", ["post-mortem"], false, "file"), diff --git a/plugins/architecture/lib/view-settings.mjs b/plugins/architecture/lib/view-settings.mjs index c37c0127da9..d268b37da9a 100755 --- a/plugins/architecture/lib/view-settings.mjs +++ b/plugins/architecture/lib/view-settings.mjs @@ -19,7 +19,8 @@ // medium then steps down the fallback chain (lib/view-registry.mjs) until reachable. // // view-settings.mjs resolve --lane <id> --option <user_config.medium> [--argument <m>] [--team-ref <ref>] -// Prints the resolution as one JSON line, then the report line and one line per notice. +// Prints the resolution as one JSON line, then the report line and one line per notice. With a +// medium resolved, the JSON adds `page` (the medium shows a page) and `publishes`. // Exit 0 resolved, 2 usage. import { execFileSync, spawnSync } from "node:child_process"; @@ -29,7 +30,7 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { configBlockBody, readLayers } from "./config-cascade.mjs"; -import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; +import { NO_PAGE, NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; const CONCERN = "rendered-views"; const HOME = "docs/conventions"; @@ -303,7 +304,10 @@ export function main(argv, env = process.env) { if (request.argument !== null && !mediumOf(request.argument)) return usage(); const ci = Boolean(env.CI) && env.CI !== "false"; const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); - process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + // So a skill can act on the medium without naming its value: does it show a page, does it publish. + const entry = mediumOf(resolution.medium); + const shown = entry ? { ...resolution, page: entry.id !== NO_PAGE, publishes: entry.publishes } : resolution; + process.stdout.write(`${JSON.stringify(shown)}\n${reportLines(resolution).join("\n")}\n`); return 0; } diff --git a/plugins/architecture/reference/rendered-view.md b/plugins/architecture/reference/rendered-view.md index 666e608b274..a534e1d953b 100644 --- a/plugins/architecture/reference/rendered-view.md +++ b/plugins/architecture/reference/rendered-view.md @@ -4,7 +4,7 @@ edit the canonical source, then rerun the script. --> # Rendered views of a skill's record How a view lane offers an interactive view of the record its skill wrote. The lanes that carry this file: -planning (`/planning:plan`, `/planning:brainstorm`), architecture (the `map-*` skills), discovery +planning (`/planning:plan`, `/planning:brainstorm`, `/planning:design`), architecture (the `map-*` skills), discovery (`/discovery:blindspot`) and debugging (`/debugging:debug`). Each lane's section below names its record, build command and data shape. `<plugin-root>` is the plugin's root directory, the one that holds `.claude-plugin/`: the path the calling skill gave for this file, cut before its first `skills/` or @@ -28,17 +28,9 @@ script written in the session. resolver, not on whether the resolver registers the lane. Only `planning` has migrated today; architecture, discovery and debugging are registered but read the cascade themselves until their own change migrates them. - - **Resolver lanes** (`planning`). Run the resolver. `--option` takes this plugin's `medium` option as the - calling skill states it; add `--argument` only when the reader named a medium in the request: - - ```bash - node "<plugin-root>/lib/view-settings.mjs" resolve --lane <lane> --option "<medium option>" [--argument terminal|file|artifact] - ``` - - It prints one JSON line, then the report line naming the medium and what decided it, then any notice; - print those lines as they stand. The JSON's `medium` is the one to use: `terminal`, `file` or `artifact`. - Do not read the rendered-views convention files yourself. When `node` is missing, the builder cannot run - either: deliver the record. + - **Resolver lanes** (`planning`). The lane's build command runs the resolver, the publish gate and + delivery in one call: follow its section in place of steps 3 and 5, and step 4's rule for a missing + `node` or a non-zero exit still applies. Do not read the rendered-views convention files yourself. - **Other lanes.** Read the `rendered-views` cascade surface: `~/.claude/rendered-views.md`, then `<repo root>/.claude/rendered-views.md`, then `<repo root>/.claude/rendered-views.local.md`, whichever exist. The last layer that states `medium:` wins (`auto`, `terminal`, `file`, `artifact`). A team layer @@ -69,31 +61,54 @@ script written in the session. ## Planning -`/planning:plan` records `PLAN.md`; `/planning:brainstorm` records the conversation list or `brainstorm.md`. -Genre: plans presented for approval, and option spreads. Resolver lane: `--lane planning`. +`/planning:plan` records `PLAN.md`; `/planning:brainstorm` records the conversation list or `brainstorm.md`; +`/planning:design` records `PLAN.md`'s `## Design` section. Genre: plans presented for approval, option +spreads, and designs handed to the plan. Resolver lane: `--lane planning`. -Build: pipe one JSON object (shapes under "Data shapes") to the builder; use `brainstorm` in place of `plan` -for the brainstorm view: +Resolve, build and deliver in one call. The builder runs the shared medium resolver +(`lib/view-settings.mjs`, the same resolution as its `resolve` command), runs the publish gate over a +publishing medium, builds the page unless the medium shows none, and hands it to the medium's entry in the +registry (`lib/view-registry.mjs`). Pipe one JSON object (shapes under "Data shapes") for the plan or +brainstorm view: ```bash -node "<plugin-root>/scripts/build-view.mjs" plan <<'JSON' +node "<plugin-root>/scripts/build-view.mjs" plan --option "<medium option>" --visibility "<visibility>" <<'JSON' {"title": "...", "goal": "...", "blast": "...", "phases": [{"status": "TODO", "name": "...", "what": "...", "needs": "none", "criteria": ["..."]}]} JSON ``` -A plan or brainstorm session reads repository files, issue text and fetched pages, which is why the builder -is the only way to the page. +- `plan` names the view: `brainstorm` for the brainstorm view; `design --record "<PLAN.md path>"` for the + design view, which reads the record's `## Design` section and takes no stdin. +- `--option` takes this plugin's `medium` option as the calling skill states it. Add + `--argument "<medium>"` only when the reader named a medium in the request. +- `--visibility` is the visibility of the repository the record draws on + (`gh repo view --json visibility -q .visibility`), or `NONE` when it draws on none. Left out, the + shipped default stays local. + +A planning session reads repository files, issue text and fetched pages, which is why the builder is the +only way to the page. + +The builder prints one JSON line, then the report line naming the medium and what decided it, then one line +per notice. Print the report and notice lines as they stand, then deliver what the JSON names: + +- `publish` set: publish that file with the Artifact tool and give the link, as step 5 says for `artifact`. + When the Artifact tool is unavailable, hand back `page` and say why in one line. +- Otherwise `page` set: hand back that path. +- Neither: no page was built, and the record stands. -Reply ids: `phases-2` is the second phase in `PLAN.md`; `candidates-3` is the third candidate in the list. -Approval of a plan is stated in the conversation, at the approval gate. +Reply ids: `phases-2` is the second phase in `PLAN.md`; `candidates-3` is the third candidate in the list; +`sections-2` is the second `###` subsection of `## Design`. Approval of a plan is stated in the conversation, +at the approval gate. ### Connect a view to the session -Planning only: it ships the view server. Either view's reply can reach this session instead of being -pasted. In the plan view the reader ticks phases, picks a verdict (`change`, `question`, `approve`), adds a -note, and sends. In the brainstorm view the reader ticks the candidates that resonate, adds a note, and -sends. Only for `file` (or `auto`) with the reader at this machine: the page is served from `127.0.0.1`. -Skip it for `artifact`, and when python3 or curl is missing; the copied reply still closes the loop. +Planning only: it ships the view server. A view's reply can reach this session instead of being pasted. In +the plan view the reader ticks phases, picks a verdict (`change`, `question`, `approve`), adds a note, and +sends. In the brainstorm view the reader ticks the candidates that resonate, adds a note, and sends. In the +design view the reader ticks the sections they question, adds a note, and sends. Only when the delivery was +a local page (`page` set, no `publish`) with the reader at this machine: the page is served from +`127.0.0.1`. Skip it for a published view, and when python3 or curl is missing; the copied reply still +closes the loop. 1. Start the view server on a new data dir under the OS temp directory, never beside the record (`ensure-running` creates it private): @@ -103,7 +118,8 @@ Skip it for `artifact`, and when python3 or curl is missing; the copied reply st ``` It prints one JSON line: `url`, `origin`, `page` and `watch`. -2. Build the page into `page`, naming `origin` (`brainstorm` in place of `plan` for the brainstorm view): +2. Build the page into `page`, naming `origin` (`brainstorm`, or `design --record "<PLAN.md path>"`, in + place of `plan` for the other views): ```bash node "<plugin-root>/scripts/build-view.mjs" plan --connect "<origin>" --out "<page>" <<'JSON' @@ -124,6 +140,8 @@ Skip it for `artifact`, and when python3 or curl is missing; the copied reply st - Brainstorm view: resolve `candidates-N` against the candidate list. The ticks are the reader's reaction, not a locked scope: reply with the scope you would propose and its route (brainstorm step 5), and lock nothing until the reader confirms it in the conversation. + - Design view: resolve `sections-N` against `## Design`. A change to a contract, boundary or verdict + routes back through `/planning:design`; the view edits nothing. 6. Write `<data_dir>/ops.json` with the Write tool, `{"replies": [{"seq": 1, "text": "..."}], "handled": [2]}`, a reply of at most 4000 characters per event you answer and `handled` for the rest, then run the event line's `next` as a background Bash task. It @@ -140,18 +158,22 @@ With no session listening, the page says so and its copy and save controls still ### Other optional views The interview's read-only decision table, the PRD pitch view and the design topology view are written -by the session, not the builder, and are only ever local files. Before writing one, resolve `medium` as -step 3 above does, passing a medium the reader names in the request as `--argument`. Then: +by the session, not the builder, and are only ever local files. Before writing one, run the shared medium +resolver, passing a medium the reader names in the request as `--argument`, and print its notice lines: -- `terminal`: write no file. The interview renders the same table inline as a fenced markdown table; - the PRD and design steps skip the view. -- `file`, `artifact` or `hosted`: write the local file. These views are never published, so say that a - publishing value was read as `file`. -- `auto`, an empty value or no value: the skill's shipped default. Any other value is reported and read - as `auto`. +```bash +node "<plugin-root>/lib/view-settings.mjs" resolve --lane planning --option "<medium option>" [--argument "<medium>"] +``` + +Its JSON line then decides: -Name the source of `medium` with the view or its absence: the request, the layer's path, or "shipped -default". The interview's `page` surface follows the `surface` option, not `medium`. +- `page` false: write no file, and print the report line as it stands. The interview renders the same + table inline as a fenced markdown table; the PRD and design steps skip the view. +- `page` true: write the local file. These views are never published, so when `publishes` is true, print + the report line with `file` in place of the medium and keep what decided it, as in + `view: file (plugin default)`; otherwise print it as it stands. + +The interview's `page` surface follows the `surface` option, not `medium`. ### Data shapes @@ -164,6 +186,10 @@ Every field is plain text. List order is the numbering in the record. `brainstorm`: `title`, `problem`, and `candidates` ordered cheapest first, each with `effort`, `name`, `what`, `where` (the files or mechanisms it touches), and `impact`. +`design`: no JSON. The builder reads the `PLAN.md` that `--record` names: its level-one heading as the title, +the text before the first `###` subsection of `## Design`, and each subsection's lines as written. A record +with no `## Design` section exits 1. + ## Architecture The `map-*` skills record a markdown file and a JSON record; both stay authoritative. A record holds diff --git a/plugins/codebase-health/lib/view-registry.mjs b/plugins/codebase-health/lib/view-registry.mjs index 5b33fb87a25..1dd99753c9c 100644 --- a/plugins/codebase-health/lib/view-registry.mjs +++ b/plugins/codebase-health/lib/view-registry.mjs @@ -29,6 +29,9 @@ export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS /** The one fallback chain: a view that cannot be served on a medium steps down this list. */ export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); +/** The chain's last step builds no page: the record stands on its own. */ +export const NO_PAGE = FALLBACK_CHAIN.at(-1); + /** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ export const NO_PREFERENCE = "auto"; @@ -47,7 +50,7 @@ export const LANES = Object.freeze( lane("review:explain-change", ["digest"], true, "artifact"), lane("visualization:present", ["deck"], false, "artifact"), lane("visualization:visualize", [], false, null), - lane("planning", ["plan", "brainstorm"], false, "file"), + lane("planning", ["plan", "brainstorm", "design"], false, "artifact"), lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), lane("discovery:blindspot", ["blindspot"], false, "file"), lane("debugging:debug", ["post-mortem"], false, "file"), diff --git a/plugins/codebase-health/lib/view-settings.mjs b/plugins/codebase-health/lib/view-settings.mjs index c37c0127da9..d268b37da9a 100755 --- a/plugins/codebase-health/lib/view-settings.mjs +++ b/plugins/codebase-health/lib/view-settings.mjs @@ -19,7 +19,8 @@ // medium then steps down the fallback chain (lib/view-registry.mjs) until reachable. // // view-settings.mjs resolve --lane <id> --option <user_config.medium> [--argument <m>] [--team-ref <ref>] -// Prints the resolution as one JSON line, then the report line and one line per notice. +// Prints the resolution as one JSON line, then the report line and one line per notice. With a +// medium resolved, the JSON adds `page` (the medium shows a page) and `publishes`. // Exit 0 resolved, 2 usage. import { execFileSync, spawnSync } from "node:child_process"; @@ -29,7 +30,7 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { configBlockBody, readLayers } from "./config-cascade.mjs"; -import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; +import { NO_PAGE, NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; const CONCERN = "rendered-views"; const HOME = "docs/conventions"; @@ -303,7 +304,10 @@ export function main(argv, env = process.env) { if (request.argument !== null && !mediumOf(request.argument)) return usage(); const ci = Boolean(env.CI) && env.CI !== "false"; const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); - process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + // So a skill can act on the medium without naming its value: does it show a page, does it publish. + const entry = mediumOf(resolution.medium); + const shown = entry ? { ...resolution, page: entry.id !== NO_PAGE, publishes: entry.publishes } : resolution; + process.stdout.write(`${JSON.stringify(shown)}\n${reportLines(resolution).join("\n")}\n`); return 0; } diff --git a/plugins/debugging/lib/view-builder.mjs b/plugins/debugging/lib/view-builder.mjs index d9df8de727e..8202be9f20c 100644 --- a/plugins/debugging/lib/view-builder.mjs +++ b/plugins/debugging/lib/view-builder.mjs @@ -17,6 +17,11 @@ // exception, in both profiles, is the optional page title (rule 5): the builder // writes it as one escaped, capped line of text into the head's <title>. // +// View kinds: VIEW_KINDS holds one entry per kind, { id, parse(record) -> data, template, tier }, +// the template path relative to the root of the plugin carrying this file. render(laneId, kind, +// record) builds a kind the lane registers (lib/view-registry.mjs). Adding a kind is an addition +// here and in the lane's registration, never a skill edit. +// // CLI: // node view-builder.mjs --profile report|interactive --template <file> --data <file.json> --out <file> // [--connect http://127.0.0.1:<port>] (interactive: the session-bridge origin) @@ -26,8 +31,10 @@ import { createHash } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { escapeHtml, pageDigest, stampPage, validateRenderedPage } from "./html-escape.mjs"; +import { LANES } from "./view-registry.mjs"; export const INTERACTIVE_MARKER = "rv-gen:view-builder-interactive"; const MARKER_RE = /<!-- rv-gen:view-builder-interactive sha256:([0-9a-f]{64}) -->/g; @@ -188,6 +195,115 @@ export function validateView(html, runtime) { : validateRenderedPage(html); } +// ------------------------------------------------------------ view kinds + +const isText = (value) => typeof value === "string"; +const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value); + +/** + * A record that is one JSON object (or its text): top-level text fields, then one list of rows + * whose fields are text, plus `rowList`, a list of text in each row, when the shape names one. + */ +const jsonRecord = (shape) => (record) => { + const data = isText(record) ? JSON.parse(record) : record; + if (!isObject(data)) throw new ViewBuildError(["data is not an object"]); + const failures = shape.text.filter((key) => !isText(data[key])).map((key) => `${key} must be text`); + if (!Array.isArray(data[shape.list])) throw new ViewBuildError([...failures, `${shape.list} must be a list`]); + for (const [index, row] of data[shape.list].entries()) { + const at = `${shape.list}[${index}]`; + if (!isObject(row)) { + failures.push(`${at} must be an object`); + continue; + } + for (const key of shape.row) if (!isText(row[key])) failures.push(`${at}.${key} must be text`); + if (shape.rowList && !(Array.isArray(row[shape.rowList]) && row[shape.rowList].every(isText))) { + failures.push(`${at}.${shape.rowList} must be a list of text`); + } + } + if (failures.length) throw new ViewBuildError(failures); + return data; +}; + +const trimBlank = (lines) => { + let [start, end] = [0, lines.length]; + while (start < end && !lines[start].trim()) start += 1; + while (end > start && !lines[end - 1].trim()) end -= 1; + return lines.slice(start, end); +}; + +/** + * A markdown record's `## Design` section: the title from the record's first `# ` heading, the + * text before the first `### ` subsection, and each subsection's lines as written. A heading + * inside a code fence is text; a fence closes only on a bare run of its own character at least as + * long as the opening run (CommonMark). + */ +function parseDesign(record) { + if (!isText(record)) throw new ViewBuildError(["the design record must be markdown text"]); + let [fence, inDesign, found, title] = [null, false, false, null]; + const intro = []; + const sections = []; + for (const line of lf(record).split("\n")) { + const heading = fence ? null : /^(#{1,6})[ \t]+(.+?)[ \t]*$/.exec(line); + const run = /^[ \t]*(`{3,}(?=[^`]*$)|~{3,})(.*)$/.exec(line); + if (!fence && run) fence = run[1]; + else if (fence && run && run[1][0] === fence[0] && run[1].length >= fence.length && !run[2].trim()) fence = null; + const level = heading?.[1].length; + if (level === 1) title ??= heading[2]; + if (level <= 2) { + inDesign = level === 2 && heading[2] === "Design"; + found ||= inDesign; + } else if (inDesign && level === 3) { + sections.push({ name: heading[2], lines: [] }); + } else if (inDesign) { + (sections.at(-1)?.lines ?? intro).push(line); + } + } + if (!found) throw new ViewBuildError(["the record has no ## Design section"]); + return { title: title ?? "Design", intro: trimBlank(intro).join("\n"), sections: sections.map(({ name, lines }) => ({ name, lines: trimBlank(lines) })) }; +} + +const kindEntry = (id, parse, template, tier) => Object.freeze({ id, parse, template, tier }); + +/** One entry per view kind: `tier` is the rendered-views view tier; `static` builds the report profile. */ +export const VIEW_KINDS = Object.freeze({ + plan: kindEntry( + "plan", + jsonRecord({ text: ["title", "goal", "blast"], list: "phases", row: ["status", "name", "what", "needs"], rowList: "criteria" }), + "skills/plan/templates/plan-view.html", + "client-interactive", + ), + brainstorm: kindEntry( + "brainstorm", + jsonRecord({ text: ["title", "problem"], list: "candidates", row: ["effort", "name", "what", "where", "impact"] }), + "skills/brainstorm/templates/brainstorm-view.html", + "client-interactive", + ), + design: kindEntry("design", parseDesign, "skills/design/templates/design-view.html", "client-interactive"), +}); + +const PLUGIN_ROOT = fileURLToPath(new URL("../", import.meta.url)); + +/** + * Build the `kind` view of `record` for the lane `laneId`, which must register that kind. + * A record the kind cannot read throws ViewBuildError; an unknown lane or kind throws Error. + * @param {{lanes?: object, kinds?: object, root?: string, connect?: string | null, title?: string | null}} [options] + * lanes and kinds replace the registries; root is the directory the kind's template path is under + */ +export function render(laneId, kind, record, { lanes = LANES, kinds = VIEW_KINDS, root = PLUGIN_ROOT, connect = null, title = null } = {}) { + const lane = Object.hasOwn(lanes, laneId) ? lanes[laneId] : undefined; + if (!lane) throw new Error(`view-builder: unknown lane ${JSON.stringify(laneId)}`); + if (!lane.kinds.includes(kind)) throw new Error(`view-builder: lane ${laneId} does not register the view kind ${JSON.stringify(kind)}`); + const entry = Object.hasOwn(kinds, kind) ? kinds[kind] : undefined; + if (!entry) throw new Error(`view-builder: no view kind ${JSON.stringify(kind)}`); + return buildView({ + profile: entry.tier === "static" ? "report" : "interactive", + template: readFileSync(join(root, entry.template), "utf8"), + data: entry.parse(record), + connect, + title, + }); +} + // ---------------------------------------------------------------- report function parseSlots(template, failures) { diff --git a/plugins/debugging/lib/view-registry.mjs b/plugins/debugging/lib/view-registry.mjs index 5b33fb87a25..1dd99753c9c 100644 --- a/plugins/debugging/lib/view-registry.mjs +++ b/plugins/debugging/lib/view-registry.mjs @@ -29,6 +29,9 @@ export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS /** The one fallback chain: a view that cannot be served on a medium steps down this list. */ export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); +/** The chain's last step builds no page: the record stands on its own. */ +export const NO_PAGE = FALLBACK_CHAIN.at(-1); + /** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ export const NO_PREFERENCE = "auto"; @@ -47,7 +50,7 @@ export const LANES = Object.freeze( lane("review:explain-change", ["digest"], true, "artifact"), lane("visualization:present", ["deck"], false, "artifact"), lane("visualization:visualize", [], false, null), - lane("planning", ["plan", "brainstorm"], false, "file"), + lane("planning", ["plan", "brainstorm", "design"], false, "artifact"), lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), lane("discovery:blindspot", ["blindspot"], false, "file"), lane("debugging:debug", ["post-mortem"], false, "file"), diff --git a/plugins/debugging/lib/view-settings.mjs b/plugins/debugging/lib/view-settings.mjs index c37c0127da9..d268b37da9a 100755 --- a/plugins/debugging/lib/view-settings.mjs +++ b/plugins/debugging/lib/view-settings.mjs @@ -19,7 +19,8 @@ // medium then steps down the fallback chain (lib/view-registry.mjs) until reachable. // // view-settings.mjs resolve --lane <id> --option <user_config.medium> [--argument <m>] [--team-ref <ref>] -// Prints the resolution as one JSON line, then the report line and one line per notice. +// Prints the resolution as one JSON line, then the report line and one line per notice. With a +// medium resolved, the JSON adds `page` (the medium shows a page) and `publishes`. // Exit 0 resolved, 2 usage. import { execFileSync, spawnSync } from "node:child_process"; @@ -29,7 +30,7 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { configBlockBody, readLayers } from "./config-cascade.mjs"; -import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; +import { NO_PAGE, NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; const CONCERN = "rendered-views"; const HOME = "docs/conventions"; @@ -303,7 +304,10 @@ export function main(argv, env = process.env) { if (request.argument !== null && !mediumOf(request.argument)) return usage(); const ci = Boolean(env.CI) && env.CI !== "false"; const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); - process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + // So a skill can act on the medium without naming its value: does it show a page, does it publish. + const entry = mediumOf(resolution.medium); + const shown = entry ? { ...resolution, page: entry.id !== NO_PAGE, publishes: entry.publishes } : resolution; + process.stdout.write(`${JSON.stringify(shown)}\n${reportLines(resolution).join("\n")}\n`); return 0; } diff --git a/plugins/debugging/skills/debug/reference/rendered-view.md b/plugins/debugging/skills/debug/reference/rendered-view.md index 666e608b274..a534e1d953b 100644 --- a/plugins/debugging/skills/debug/reference/rendered-view.md +++ b/plugins/debugging/skills/debug/reference/rendered-view.md @@ -4,7 +4,7 @@ edit the canonical source, then rerun the script. --> # Rendered views of a skill's record How a view lane offers an interactive view of the record its skill wrote. The lanes that carry this file: -planning (`/planning:plan`, `/planning:brainstorm`), architecture (the `map-*` skills), discovery +planning (`/planning:plan`, `/planning:brainstorm`, `/planning:design`), architecture (the `map-*` skills), discovery (`/discovery:blindspot`) and debugging (`/debugging:debug`). Each lane's section below names its record, build command and data shape. `<plugin-root>` is the plugin's root directory, the one that holds `.claude-plugin/`: the path the calling skill gave for this file, cut before its first `skills/` or @@ -28,17 +28,9 @@ script written in the session. resolver, not on whether the resolver registers the lane. Only `planning` has migrated today; architecture, discovery and debugging are registered but read the cascade themselves until their own change migrates them. - - **Resolver lanes** (`planning`). Run the resolver. `--option` takes this plugin's `medium` option as the - calling skill states it; add `--argument` only when the reader named a medium in the request: - - ```bash - node "<plugin-root>/lib/view-settings.mjs" resolve --lane <lane> --option "<medium option>" [--argument terminal|file|artifact] - ``` - - It prints one JSON line, then the report line naming the medium and what decided it, then any notice; - print those lines as they stand. The JSON's `medium` is the one to use: `terminal`, `file` or `artifact`. - Do not read the rendered-views convention files yourself. When `node` is missing, the builder cannot run - either: deliver the record. + - **Resolver lanes** (`planning`). The lane's build command runs the resolver, the publish gate and + delivery in one call: follow its section in place of steps 3 and 5, and step 4's rule for a missing + `node` or a non-zero exit still applies. Do not read the rendered-views convention files yourself. - **Other lanes.** Read the `rendered-views` cascade surface: `~/.claude/rendered-views.md`, then `<repo root>/.claude/rendered-views.md`, then `<repo root>/.claude/rendered-views.local.md`, whichever exist. The last layer that states `medium:` wins (`auto`, `terminal`, `file`, `artifact`). A team layer @@ -69,31 +61,54 @@ script written in the session. ## Planning -`/planning:plan` records `PLAN.md`; `/planning:brainstorm` records the conversation list or `brainstorm.md`. -Genre: plans presented for approval, and option spreads. Resolver lane: `--lane planning`. +`/planning:plan` records `PLAN.md`; `/planning:brainstorm` records the conversation list or `brainstorm.md`; +`/planning:design` records `PLAN.md`'s `## Design` section. Genre: plans presented for approval, option +spreads, and designs handed to the plan. Resolver lane: `--lane planning`. -Build: pipe one JSON object (shapes under "Data shapes") to the builder; use `brainstorm` in place of `plan` -for the brainstorm view: +Resolve, build and deliver in one call. The builder runs the shared medium resolver +(`lib/view-settings.mjs`, the same resolution as its `resolve` command), runs the publish gate over a +publishing medium, builds the page unless the medium shows none, and hands it to the medium's entry in the +registry (`lib/view-registry.mjs`). Pipe one JSON object (shapes under "Data shapes") for the plan or +brainstorm view: ```bash -node "<plugin-root>/scripts/build-view.mjs" plan <<'JSON' +node "<plugin-root>/scripts/build-view.mjs" plan --option "<medium option>" --visibility "<visibility>" <<'JSON' {"title": "...", "goal": "...", "blast": "...", "phases": [{"status": "TODO", "name": "...", "what": "...", "needs": "none", "criteria": ["..."]}]} JSON ``` -A plan or brainstorm session reads repository files, issue text and fetched pages, which is why the builder -is the only way to the page. +- `plan` names the view: `brainstorm` for the brainstorm view; `design --record "<PLAN.md path>"` for the + design view, which reads the record's `## Design` section and takes no stdin. +- `--option` takes this plugin's `medium` option as the calling skill states it. Add + `--argument "<medium>"` only when the reader named a medium in the request. +- `--visibility` is the visibility of the repository the record draws on + (`gh repo view --json visibility -q .visibility`), or `NONE` when it draws on none. Left out, the + shipped default stays local. + +A planning session reads repository files, issue text and fetched pages, which is why the builder is the +only way to the page. + +The builder prints one JSON line, then the report line naming the medium and what decided it, then one line +per notice. Print the report and notice lines as they stand, then deliver what the JSON names: + +- `publish` set: publish that file with the Artifact tool and give the link, as step 5 says for `artifact`. + When the Artifact tool is unavailable, hand back `page` and say why in one line. +- Otherwise `page` set: hand back that path. +- Neither: no page was built, and the record stands. -Reply ids: `phases-2` is the second phase in `PLAN.md`; `candidates-3` is the third candidate in the list. -Approval of a plan is stated in the conversation, at the approval gate. +Reply ids: `phases-2` is the second phase in `PLAN.md`; `candidates-3` is the third candidate in the list; +`sections-2` is the second `###` subsection of `## Design`. Approval of a plan is stated in the conversation, +at the approval gate. ### Connect a view to the session -Planning only: it ships the view server. Either view's reply can reach this session instead of being -pasted. In the plan view the reader ticks phases, picks a verdict (`change`, `question`, `approve`), adds a -note, and sends. In the brainstorm view the reader ticks the candidates that resonate, adds a note, and -sends. Only for `file` (or `auto`) with the reader at this machine: the page is served from `127.0.0.1`. -Skip it for `artifact`, and when python3 or curl is missing; the copied reply still closes the loop. +Planning only: it ships the view server. A view's reply can reach this session instead of being pasted. In +the plan view the reader ticks phases, picks a verdict (`change`, `question`, `approve`), adds a note, and +sends. In the brainstorm view the reader ticks the candidates that resonate, adds a note, and sends. In the +design view the reader ticks the sections they question, adds a note, and sends. Only when the delivery was +a local page (`page` set, no `publish`) with the reader at this machine: the page is served from +`127.0.0.1`. Skip it for a published view, and when python3 or curl is missing; the copied reply still +closes the loop. 1. Start the view server on a new data dir under the OS temp directory, never beside the record (`ensure-running` creates it private): @@ -103,7 +118,8 @@ Skip it for `artifact`, and when python3 or curl is missing; the copied reply st ``` It prints one JSON line: `url`, `origin`, `page` and `watch`. -2. Build the page into `page`, naming `origin` (`brainstorm` in place of `plan` for the brainstorm view): +2. Build the page into `page`, naming `origin` (`brainstorm`, or `design --record "<PLAN.md path>"`, in + place of `plan` for the other views): ```bash node "<plugin-root>/scripts/build-view.mjs" plan --connect "<origin>" --out "<page>" <<'JSON' @@ -124,6 +140,8 @@ Skip it for `artifact`, and when python3 or curl is missing; the copied reply st - Brainstorm view: resolve `candidates-N` against the candidate list. The ticks are the reader's reaction, not a locked scope: reply with the scope you would propose and its route (brainstorm step 5), and lock nothing until the reader confirms it in the conversation. + - Design view: resolve `sections-N` against `## Design`. A change to a contract, boundary or verdict + routes back through `/planning:design`; the view edits nothing. 6. Write `<data_dir>/ops.json` with the Write tool, `{"replies": [{"seq": 1, "text": "..."}], "handled": [2]}`, a reply of at most 4000 characters per event you answer and `handled` for the rest, then run the event line's `next` as a background Bash task. It @@ -140,18 +158,22 @@ With no session listening, the page says so and its copy and save controls still ### Other optional views The interview's read-only decision table, the PRD pitch view and the design topology view are written -by the session, not the builder, and are only ever local files. Before writing one, resolve `medium` as -step 3 above does, passing a medium the reader names in the request as `--argument`. Then: +by the session, not the builder, and are only ever local files. Before writing one, run the shared medium +resolver, passing a medium the reader names in the request as `--argument`, and print its notice lines: -- `terminal`: write no file. The interview renders the same table inline as a fenced markdown table; - the PRD and design steps skip the view. -- `file`, `artifact` or `hosted`: write the local file. These views are never published, so say that a - publishing value was read as `file`. -- `auto`, an empty value or no value: the skill's shipped default. Any other value is reported and read - as `auto`. +```bash +node "<plugin-root>/lib/view-settings.mjs" resolve --lane planning --option "<medium option>" [--argument "<medium>"] +``` + +Its JSON line then decides: -Name the source of `medium` with the view or its absence: the request, the layer's path, or "shipped -default". The interview's `page` surface follows the `surface` option, not `medium`. +- `page` false: write no file, and print the report line as it stands. The interview renders the same + table inline as a fenced markdown table; the PRD and design steps skip the view. +- `page` true: write the local file. These views are never published, so when `publishes` is true, print + the report line with `file` in place of the medium and keep what decided it, as in + `view: file (plugin default)`; otherwise print it as it stands. + +The interview's `page` surface follows the `surface` option, not `medium`. ### Data shapes @@ -164,6 +186,10 @@ Every field is plain text. List order is the numbering in the record. `brainstorm`: `title`, `problem`, and `candidates` ordered cheapest first, each with `effort`, `name`, `what`, `where` (the files or mechanisms it touches), and `impact`. +`design`: no JSON. The builder reads the `PLAN.md` that `--record` names: its level-one heading as the title, +the text before the first `###` subsection of `## Design`, and each subsection's lines as written. A record +with no `## Design` section exits 1. + ## Architecture The `map-*` skills record a markdown file and a JSON record; both stay authoritative. A record holds diff --git a/plugins/discovery/lib/view-builder.mjs b/plugins/discovery/lib/view-builder.mjs index d9df8de727e..8202be9f20c 100644 --- a/plugins/discovery/lib/view-builder.mjs +++ b/plugins/discovery/lib/view-builder.mjs @@ -17,6 +17,11 @@ // exception, in both profiles, is the optional page title (rule 5): the builder // writes it as one escaped, capped line of text into the head's <title>. // +// View kinds: VIEW_KINDS holds one entry per kind, { id, parse(record) -> data, template, tier }, +// the template path relative to the root of the plugin carrying this file. render(laneId, kind, +// record) builds a kind the lane registers (lib/view-registry.mjs). Adding a kind is an addition +// here and in the lane's registration, never a skill edit. +// // CLI: // node view-builder.mjs --profile report|interactive --template <file> --data <file.json> --out <file> // [--connect http://127.0.0.1:<port>] (interactive: the session-bridge origin) @@ -26,8 +31,10 @@ import { createHash } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { escapeHtml, pageDigest, stampPage, validateRenderedPage } from "./html-escape.mjs"; +import { LANES } from "./view-registry.mjs"; export const INTERACTIVE_MARKER = "rv-gen:view-builder-interactive"; const MARKER_RE = /<!-- rv-gen:view-builder-interactive sha256:([0-9a-f]{64}) -->/g; @@ -188,6 +195,115 @@ export function validateView(html, runtime) { : validateRenderedPage(html); } +// ------------------------------------------------------------ view kinds + +const isText = (value) => typeof value === "string"; +const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value); + +/** + * A record that is one JSON object (or its text): top-level text fields, then one list of rows + * whose fields are text, plus `rowList`, a list of text in each row, when the shape names one. + */ +const jsonRecord = (shape) => (record) => { + const data = isText(record) ? JSON.parse(record) : record; + if (!isObject(data)) throw new ViewBuildError(["data is not an object"]); + const failures = shape.text.filter((key) => !isText(data[key])).map((key) => `${key} must be text`); + if (!Array.isArray(data[shape.list])) throw new ViewBuildError([...failures, `${shape.list} must be a list`]); + for (const [index, row] of data[shape.list].entries()) { + const at = `${shape.list}[${index}]`; + if (!isObject(row)) { + failures.push(`${at} must be an object`); + continue; + } + for (const key of shape.row) if (!isText(row[key])) failures.push(`${at}.${key} must be text`); + if (shape.rowList && !(Array.isArray(row[shape.rowList]) && row[shape.rowList].every(isText))) { + failures.push(`${at}.${shape.rowList} must be a list of text`); + } + } + if (failures.length) throw new ViewBuildError(failures); + return data; +}; + +const trimBlank = (lines) => { + let [start, end] = [0, lines.length]; + while (start < end && !lines[start].trim()) start += 1; + while (end > start && !lines[end - 1].trim()) end -= 1; + return lines.slice(start, end); +}; + +/** + * A markdown record's `## Design` section: the title from the record's first `# ` heading, the + * text before the first `### ` subsection, and each subsection's lines as written. A heading + * inside a code fence is text; a fence closes only on a bare run of its own character at least as + * long as the opening run (CommonMark). + */ +function parseDesign(record) { + if (!isText(record)) throw new ViewBuildError(["the design record must be markdown text"]); + let [fence, inDesign, found, title] = [null, false, false, null]; + const intro = []; + const sections = []; + for (const line of lf(record).split("\n")) { + const heading = fence ? null : /^(#{1,6})[ \t]+(.+?)[ \t]*$/.exec(line); + const run = /^[ \t]*(`{3,}(?=[^`]*$)|~{3,})(.*)$/.exec(line); + if (!fence && run) fence = run[1]; + else if (fence && run && run[1][0] === fence[0] && run[1].length >= fence.length && !run[2].trim()) fence = null; + const level = heading?.[1].length; + if (level === 1) title ??= heading[2]; + if (level <= 2) { + inDesign = level === 2 && heading[2] === "Design"; + found ||= inDesign; + } else if (inDesign && level === 3) { + sections.push({ name: heading[2], lines: [] }); + } else if (inDesign) { + (sections.at(-1)?.lines ?? intro).push(line); + } + } + if (!found) throw new ViewBuildError(["the record has no ## Design section"]); + return { title: title ?? "Design", intro: trimBlank(intro).join("\n"), sections: sections.map(({ name, lines }) => ({ name, lines: trimBlank(lines) })) }; +} + +const kindEntry = (id, parse, template, tier) => Object.freeze({ id, parse, template, tier }); + +/** One entry per view kind: `tier` is the rendered-views view tier; `static` builds the report profile. */ +export const VIEW_KINDS = Object.freeze({ + plan: kindEntry( + "plan", + jsonRecord({ text: ["title", "goal", "blast"], list: "phases", row: ["status", "name", "what", "needs"], rowList: "criteria" }), + "skills/plan/templates/plan-view.html", + "client-interactive", + ), + brainstorm: kindEntry( + "brainstorm", + jsonRecord({ text: ["title", "problem"], list: "candidates", row: ["effort", "name", "what", "where", "impact"] }), + "skills/brainstorm/templates/brainstorm-view.html", + "client-interactive", + ), + design: kindEntry("design", parseDesign, "skills/design/templates/design-view.html", "client-interactive"), +}); + +const PLUGIN_ROOT = fileURLToPath(new URL("../", import.meta.url)); + +/** + * Build the `kind` view of `record` for the lane `laneId`, which must register that kind. + * A record the kind cannot read throws ViewBuildError; an unknown lane or kind throws Error. + * @param {{lanes?: object, kinds?: object, root?: string, connect?: string | null, title?: string | null}} [options] + * lanes and kinds replace the registries; root is the directory the kind's template path is under + */ +export function render(laneId, kind, record, { lanes = LANES, kinds = VIEW_KINDS, root = PLUGIN_ROOT, connect = null, title = null } = {}) { + const lane = Object.hasOwn(lanes, laneId) ? lanes[laneId] : undefined; + if (!lane) throw new Error(`view-builder: unknown lane ${JSON.stringify(laneId)}`); + if (!lane.kinds.includes(kind)) throw new Error(`view-builder: lane ${laneId} does not register the view kind ${JSON.stringify(kind)}`); + const entry = Object.hasOwn(kinds, kind) ? kinds[kind] : undefined; + if (!entry) throw new Error(`view-builder: no view kind ${JSON.stringify(kind)}`); + return buildView({ + profile: entry.tier === "static" ? "report" : "interactive", + template: readFileSync(join(root, entry.template), "utf8"), + data: entry.parse(record), + connect, + title, + }); +} + // ---------------------------------------------------------------- report function parseSlots(template, failures) { diff --git a/plugins/discovery/lib/view-registry.mjs b/plugins/discovery/lib/view-registry.mjs index 5b33fb87a25..1dd99753c9c 100644 --- a/plugins/discovery/lib/view-registry.mjs +++ b/plugins/discovery/lib/view-registry.mjs @@ -29,6 +29,9 @@ export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS /** The one fallback chain: a view that cannot be served on a medium steps down this list. */ export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); +/** The chain's last step builds no page: the record stands on its own. */ +export const NO_PAGE = FALLBACK_CHAIN.at(-1); + /** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ export const NO_PREFERENCE = "auto"; @@ -47,7 +50,7 @@ export const LANES = Object.freeze( lane("review:explain-change", ["digest"], true, "artifact"), lane("visualization:present", ["deck"], false, "artifact"), lane("visualization:visualize", [], false, null), - lane("planning", ["plan", "brainstorm"], false, "file"), + lane("planning", ["plan", "brainstorm", "design"], false, "artifact"), lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), lane("discovery:blindspot", ["blindspot"], false, "file"), lane("debugging:debug", ["post-mortem"], false, "file"), diff --git a/plugins/discovery/lib/view-settings.mjs b/plugins/discovery/lib/view-settings.mjs index c37c0127da9..d268b37da9a 100755 --- a/plugins/discovery/lib/view-settings.mjs +++ b/plugins/discovery/lib/view-settings.mjs @@ -19,7 +19,8 @@ // medium then steps down the fallback chain (lib/view-registry.mjs) until reachable. // // view-settings.mjs resolve --lane <id> --option <user_config.medium> [--argument <m>] [--team-ref <ref>] -// Prints the resolution as one JSON line, then the report line and one line per notice. +// Prints the resolution as one JSON line, then the report line and one line per notice. With a +// medium resolved, the JSON adds `page` (the medium shows a page) and `publishes`. // Exit 0 resolved, 2 usage. import { execFileSync, spawnSync } from "node:child_process"; @@ -29,7 +30,7 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { configBlockBody, readLayers } from "./config-cascade.mjs"; -import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; +import { NO_PAGE, NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; const CONCERN = "rendered-views"; const HOME = "docs/conventions"; @@ -303,7 +304,10 @@ export function main(argv, env = process.env) { if (request.argument !== null && !mediumOf(request.argument)) return usage(); const ci = Boolean(env.CI) && env.CI !== "false"; const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); - process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + // So a skill can act on the medium without naming its value: does it show a page, does it publish. + const entry = mediumOf(resolution.medium); + const shown = entry ? { ...resolution, page: entry.id !== NO_PAGE, publishes: entry.publishes } : resolution; + process.stdout.write(`${JSON.stringify(shown)}\n${reportLines(resolution).join("\n")}\n`); return 0; } diff --git a/plugins/discovery/reference/rendered-view.md b/plugins/discovery/reference/rendered-view.md index 666e608b274..a534e1d953b 100644 --- a/plugins/discovery/reference/rendered-view.md +++ b/plugins/discovery/reference/rendered-view.md @@ -4,7 +4,7 @@ edit the canonical source, then rerun the script. --> # Rendered views of a skill's record How a view lane offers an interactive view of the record its skill wrote. The lanes that carry this file: -planning (`/planning:plan`, `/planning:brainstorm`), architecture (the `map-*` skills), discovery +planning (`/planning:plan`, `/planning:brainstorm`, `/planning:design`), architecture (the `map-*` skills), discovery (`/discovery:blindspot`) and debugging (`/debugging:debug`). Each lane's section below names its record, build command and data shape. `<plugin-root>` is the plugin's root directory, the one that holds `.claude-plugin/`: the path the calling skill gave for this file, cut before its first `skills/` or @@ -28,17 +28,9 @@ script written in the session. resolver, not on whether the resolver registers the lane. Only `planning` has migrated today; architecture, discovery and debugging are registered but read the cascade themselves until their own change migrates them. - - **Resolver lanes** (`planning`). Run the resolver. `--option` takes this plugin's `medium` option as the - calling skill states it; add `--argument` only when the reader named a medium in the request: - - ```bash - node "<plugin-root>/lib/view-settings.mjs" resolve --lane <lane> --option "<medium option>" [--argument terminal|file|artifact] - ``` - - It prints one JSON line, then the report line naming the medium and what decided it, then any notice; - print those lines as they stand. The JSON's `medium` is the one to use: `terminal`, `file` or `artifact`. - Do not read the rendered-views convention files yourself. When `node` is missing, the builder cannot run - either: deliver the record. + - **Resolver lanes** (`planning`). The lane's build command runs the resolver, the publish gate and + delivery in one call: follow its section in place of steps 3 and 5, and step 4's rule for a missing + `node` or a non-zero exit still applies. Do not read the rendered-views convention files yourself. - **Other lanes.** Read the `rendered-views` cascade surface: `~/.claude/rendered-views.md`, then `<repo root>/.claude/rendered-views.md`, then `<repo root>/.claude/rendered-views.local.md`, whichever exist. The last layer that states `medium:` wins (`auto`, `terminal`, `file`, `artifact`). A team layer @@ -69,31 +61,54 @@ script written in the session. ## Planning -`/planning:plan` records `PLAN.md`; `/planning:brainstorm` records the conversation list or `brainstorm.md`. -Genre: plans presented for approval, and option spreads. Resolver lane: `--lane planning`. +`/planning:plan` records `PLAN.md`; `/planning:brainstorm` records the conversation list or `brainstorm.md`; +`/planning:design` records `PLAN.md`'s `## Design` section. Genre: plans presented for approval, option +spreads, and designs handed to the plan. Resolver lane: `--lane planning`. -Build: pipe one JSON object (shapes under "Data shapes") to the builder; use `brainstorm` in place of `plan` -for the brainstorm view: +Resolve, build and deliver in one call. The builder runs the shared medium resolver +(`lib/view-settings.mjs`, the same resolution as its `resolve` command), runs the publish gate over a +publishing medium, builds the page unless the medium shows none, and hands it to the medium's entry in the +registry (`lib/view-registry.mjs`). Pipe one JSON object (shapes under "Data shapes") for the plan or +brainstorm view: ```bash -node "<plugin-root>/scripts/build-view.mjs" plan <<'JSON' +node "<plugin-root>/scripts/build-view.mjs" plan --option "<medium option>" --visibility "<visibility>" <<'JSON' {"title": "...", "goal": "...", "blast": "...", "phases": [{"status": "TODO", "name": "...", "what": "...", "needs": "none", "criteria": ["..."]}]} JSON ``` -A plan or brainstorm session reads repository files, issue text and fetched pages, which is why the builder -is the only way to the page. +- `plan` names the view: `brainstorm` for the brainstorm view; `design --record "<PLAN.md path>"` for the + design view, which reads the record's `## Design` section and takes no stdin. +- `--option` takes this plugin's `medium` option as the calling skill states it. Add + `--argument "<medium>"` only when the reader named a medium in the request. +- `--visibility` is the visibility of the repository the record draws on + (`gh repo view --json visibility -q .visibility`), or `NONE` when it draws on none. Left out, the + shipped default stays local. + +A planning session reads repository files, issue text and fetched pages, which is why the builder is the +only way to the page. + +The builder prints one JSON line, then the report line naming the medium and what decided it, then one line +per notice. Print the report and notice lines as they stand, then deliver what the JSON names: + +- `publish` set: publish that file with the Artifact tool and give the link, as step 5 says for `artifact`. + When the Artifact tool is unavailable, hand back `page` and say why in one line. +- Otherwise `page` set: hand back that path. +- Neither: no page was built, and the record stands. -Reply ids: `phases-2` is the second phase in `PLAN.md`; `candidates-3` is the third candidate in the list. -Approval of a plan is stated in the conversation, at the approval gate. +Reply ids: `phases-2` is the second phase in `PLAN.md`; `candidates-3` is the third candidate in the list; +`sections-2` is the second `###` subsection of `## Design`. Approval of a plan is stated in the conversation, +at the approval gate. ### Connect a view to the session -Planning only: it ships the view server. Either view's reply can reach this session instead of being -pasted. In the plan view the reader ticks phases, picks a verdict (`change`, `question`, `approve`), adds a -note, and sends. In the brainstorm view the reader ticks the candidates that resonate, adds a note, and -sends. Only for `file` (or `auto`) with the reader at this machine: the page is served from `127.0.0.1`. -Skip it for `artifact`, and when python3 or curl is missing; the copied reply still closes the loop. +Planning only: it ships the view server. A view's reply can reach this session instead of being pasted. In +the plan view the reader ticks phases, picks a verdict (`change`, `question`, `approve`), adds a note, and +sends. In the brainstorm view the reader ticks the candidates that resonate, adds a note, and sends. In the +design view the reader ticks the sections they question, adds a note, and sends. Only when the delivery was +a local page (`page` set, no `publish`) with the reader at this machine: the page is served from +`127.0.0.1`. Skip it for a published view, and when python3 or curl is missing; the copied reply still +closes the loop. 1. Start the view server on a new data dir under the OS temp directory, never beside the record (`ensure-running` creates it private): @@ -103,7 +118,8 @@ Skip it for `artifact`, and when python3 or curl is missing; the copied reply st ``` It prints one JSON line: `url`, `origin`, `page` and `watch`. -2. Build the page into `page`, naming `origin` (`brainstorm` in place of `plan` for the brainstorm view): +2. Build the page into `page`, naming `origin` (`brainstorm`, or `design --record "<PLAN.md path>"`, in + place of `plan` for the other views): ```bash node "<plugin-root>/scripts/build-view.mjs" plan --connect "<origin>" --out "<page>" <<'JSON' @@ -124,6 +140,8 @@ Skip it for `artifact`, and when python3 or curl is missing; the copied reply st - Brainstorm view: resolve `candidates-N` against the candidate list. The ticks are the reader's reaction, not a locked scope: reply with the scope you would propose and its route (brainstorm step 5), and lock nothing until the reader confirms it in the conversation. + - Design view: resolve `sections-N` against `## Design`. A change to a contract, boundary or verdict + routes back through `/planning:design`; the view edits nothing. 6. Write `<data_dir>/ops.json` with the Write tool, `{"replies": [{"seq": 1, "text": "..."}], "handled": [2]}`, a reply of at most 4000 characters per event you answer and `handled` for the rest, then run the event line's `next` as a background Bash task. It @@ -140,18 +158,22 @@ With no session listening, the page says so and its copy and save controls still ### Other optional views The interview's read-only decision table, the PRD pitch view and the design topology view are written -by the session, not the builder, and are only ever local files. Before writing one, resolve `medium` as -step 3 above does, passing a medium the reader names in the request as `--argument`. Then: +by the session, not the builder, and are only ever local files. Before writing one, run the shared medium +resolver, passing a medium the reader names in the request as `--argument`, and print its notice lines: -- `terminal`: write no file. The interview renders the same table inline as a fenced markdown table; - the PRD and design steps skip the view. -- `file`, `artifact` or `hosted`: write the local file. These views are never published, so say that a - publishing value was read as `file`. -- `auto`, an empty value or no value: the skill's shipped default. Any other value is reported and read - as `auto`. +```bash +node "<plugin-root>/lib/view-settings.mjs" resolve --lane planning --option "<medium option>" [--argument "<medium>"] +``` + +Its JSON line then decides: -Name the source of `medium` with the view or its absence: the request, the layer's path, or "shipped -default". The interview's `page` surface follows the `surface` option, not `medium`. +- `page` false: write no file, and print the report line as it stands. The interview renders the same + table inline as a fenced markdown table; the PRD and design steps skip the view. +- `page` true: write the local file. These views are never published, so when `publishes` is true, print + the report line with `file` in place of the medium and keep what decided it, as in + `view: file (plugin default)`; otherwise print it as it stands. + +The interview's `page` surface follows the `surface` option, not `medium`. ### Data shapes @@ -164,6 +186,10 @@ Every field is plain text. List order is the numbering in the record. `brainstorm`: `title`, `problem`, and `candidates` ordered cheapest first, each with `effort`, `name`, `what`, `where` (the files or mechanisms it touches), and `impact`. +`design`: no JSON. The builder reads the `PLAN.md` that `--record` names: its level-one heading as the title, +the text before the first `###` subsection of `## Design`, and each subsection's lines as written. A record +with no `## Design` section exits 1. + ## Architecture The `map-*` skills record a markdown file and a JSON record; both stay authoritative. A record holds diff --git a/plugins/education/lib/view-builder.mjs b/plugins/education/lib/view-builder.mjs index d9df8de727e..8202be9f20c 100644 --- a/plugins/education/lib/view-builder.mjs +++ b/plugins/education/lib/view-builder.mjs @@ -17,6 +17,11 @@ // exception, in both profiles, is the optional page title (rule 5): the builder // writes it as one escaped, capped line of text into the head's <title>. // +// View kinds: VIEW_KINDS holds one entry per kind, { id, parse(record) -> data, template, tier }, +// the template path relative to the root of the plugin carrying this file. render(laneId, kind, +// record) builds a kind the lane registers (lib/view-registry.mjs). Adding a kind is an addition +// here and in the lane's registration, never a skill edit. +// // CLI: // node view-builder.mjs --profile report|interactive --template <file> --data <file.json> --out <file> // [--connect http://127.0.0.1:<port>] (interactive: the session-bridge origin) @@ -26,8 +31,10 @@ import { createHash } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { escapeHtml, pageDigest, stampPage, validateRenderedPage } from "./html-escape.mjs"; +import { LANES } from "./view-registry.mjs"; export const INTERACTIVE_MARKER = "rv-gen:view-builder-interactive"; const MARKER_RE = /<!-- rv-gen:view-builder-interactive sha256:([0-9a-f]{64}) -->/g; @@ -188,6 +195,115 @@ export function validateView(html, runtime) { : validateRenderedPage(html); } +// ------------------------------------------------------------ view kinds + +const isText = (value) => typeof value === "string"; +const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value); + +/** + * A record that is one JSON object (or its text): top-level text fields, then one list of rows + * whose fields are text, plus `rowList`, a list of text in each row, when the shape names one. + */ +const jsonRecord = (shape) => (record) => { + const data = isText(record) ? JSON.parse(record) : record; + if (!isObject(data)) throw new ViewBuildError(["data is not an object"]); + const failures = shape.text.filter((key) => !isText(data[key])).map((key) => `${key} must be text`); + if (!Array.isArray(data[shape.list])) throw new ViewBuildError([...failures, `${shape.list} must be a list`]); + for (const [index, row] of data[shape.list].entries()) { + const at = `${shape.list}[${index}]`; + if (!isObject(row)) { + failures.push(`${at} must be an object`); + continue; + } + for (const key of shape.row) if (!isText(row[key])) failures.push(`${at}.${key} must be text`); + if (shape.rowList && !(Array.isArray(row[shape.rowList]) && row[shape.rowList].every(isText))) { + failures.push(`${at}.${shape.rowList} must be a list of text`); + } + } + if (failures.length) throw new ViewBuildError(failures); + return data; +}; + +const trimBlank = (lines) => { + let [start, end] = [0, lines.length]; + while (start < end && !lines[start].trim()) start += 1; + while (end > start && !lines[end - 1].trim()) end -= 1; + return lines.slice(start, end); +}; + +/** + * A markdown record's `## Design` section: the title from the record's first `# ` heading, the + * text before the first `### ` subsection, and each subsection's lines as written. A heading + * inside a code fence is text; a fence closes only on a bare run of its own character at least as + * long as the opening run (CommonMark). + */ +function parseDesign(record) { + if (!isText(record)) throw new ViewBuildError(["the design record must be markdown text"]); + let [fence, inDesign, found, title] = [null, false, false, null]; + const intro = []; + const sections = []; + for (const line of lf(record).split("\n")) { + const heading = fence ? null : /^(#{1,6})[ \t]+(.+?)[ \t]*$/.exec(line); + const run = /^[ \t]*(`{3,}(?=[^`]*$)|~{3,})(.*)$/.exec(line); + if (!fence && run) fence = run[1]; + else if (fence && run && run[1][0] === fence[0] && run[1].length >= fence.length && !run[2].trim()) fence = null; + const level = heading?.[1].length; + if (level === 1) title ??= heading[2]; + if (level <= 2) { + inDesign = level === 2 && heading[2] === "Design"; + found ||= inDesign; + } else if (inDesign && level === 3) { + sections.push({ name: heading[2], lines: [] }); + } else if (inDesign) { + (sections.at(-1)?.lines ?? intro).push(line); + } + } + if (!found) throw new ViewBuildError(["the record has no ## Design section"]); + return { title: title ?? "Design", intro: trimBlank(intro).join("\n"), sections: sections.map(({ name, lines }) => ({ name, lines: trimBlank(lines) })) }; +} + +const kindEntry = (id, parse, template, tier) => Object.freeze({ id, parse, template, tier }); + +/** One entry per view kind: `tier` is the rendered-views view tier; `static` builds the report profile. */ +export const VIEW_KINDS = Object.freeze({ + plan: kindEntry( + "plan", + jsonRecord({ text: ["title", "goal", "blast"], list: "phases", row: ["status", "name", "what", "needs"], rowList: "criteria" }), + "skills/plan/templates/plan-view.html", + "client-interactive", + ), + brainstorm: kindEntry( + "brainstorm", + jsonRecord({ text: ["title", "problem"], list: "candidates", row: ["effort", "name", "what", "where", "impact"] }), + "skills/brainstorm/templates/brainstorm-view.html", + "client-interactive", + ), + design: kindEntry("design", parseDesign, "skills/design/templates/design-view.html", "client-interactive"), +}); + +const PLUGIN_ROOT = fileURLToPath(new URL("../", import.meta.url)); + +/** + * Build the `kind` view of `record` for the lane `laneId`, which must register that kind. + * A record the kind cannot read throws ViewBuildError; an unknown lane or kind throws Error. + * @param {{lanes?: object, kinds?: object, root?: string, connect?: string | null, title?: string | null}} [options] + * lanes and kinds replace the registries; root is the directory the kind's template path is under + */ +export function render(laneId, kind, record, { lanes = LANES, kinds = VIEW_KINDS, root = PLUGIN_ROOT, connect = null, title = null } = {}) { + const lane = Object.hasOwn(lanes, laneId) ? lanes[laneId] : undefined; + if (!lane) throw new Error(`view-builder: unknown lane ${JSON.stringify(laneId)}`); + if (!lane.kinds.includes(kind)) throw new Error(`view-builder: lane ${laneId} does not register the view kind ${JSON.stringify(kind)}`); + const entry = Object.hasOwn(kinds, kind) ? kinds[kind] : undefined; + if (!entry) throw new Error(`view-builder: no view kind ${JSON.stringify(kind)}`); + return buildView({ + profile: entry.tier === "static" ? "report" : "interactive", + template: readFileSync(join(root, entry.template), "utf8"), + data: entry.parse(record), + connect, + title, + }); +} + // ---------------------------------------------------------------- report function parseSlots(template, failures) { diff --git a/plugins/education/lib/view-registry.mjs b/plugins/education/lib/view-registry.mjs index 5b33fb87a25..1dd99753c9c 100644 --- a/plugins/education/lib/view-registry.mjs +++ b/plugins/education/lib/view-registry.mjs @@ -29,6 +29,9 @@ export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS /** The one fallback chain: a view that cannot be served on a medium steps down this list. */ export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); +/** The chain's last step builds no page: the record stands on its own. */ +export const NO_PAGE = FALLBACK_CHAIN.at(-1); + /** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ export const NO_PREFERENCE = "auto"; @@ -47,7 +50,7 @@ export const LANES = Object.freeze( lane("review:explain-change", ["digest"], true, "artifact"), lane("visualization:present", ["deck"], false, "artifact"), lane("visualization:visualize", [], false, null), - lane("planning", ["plan", "brainstorm"], false, "file"), + lane("planning", ["plan", "brainstorm", "design"], false, "artifact"), lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), lane("discovery:blindspot", ["blindspot"], false, "file"), lane("debugging:debug", ["post-mortem"], false, "file"), diff --git a/plugins/education/lib/view-settings.mjs b/plugins/education/lib/view-settings.mjs index c37c0127da9..d268b37da9a 100755 --- a/plugins/education/lib/view-settings.mjs +++ b/plugins/education/lib/view-settings.mjs @@ -19,7 +19,8 @@ // medium then steps down the fallback chain (lib/view-registry.mjs) until reachable. // // view-settings.mjs resolve --lane <id> --option <user_config.medium> [--argument <m>] [--team-ref <ref>] -// Prints the resolution as one JSON line, then the report line and one line per notice. +// Prints the resolution as one JSON line, then the report line and one line per notice. With a +// medium resolved, the JSON adds `page` (the medium shows a page) and `publishes`. // Exit 0 resolved, 2 usage. import { execFileSync, spawnSync } from "node:child_process"; @@ -29,7 +30,7 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { configBlockBody, readLayers } from "./config-cascade.mjs"; -import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; +import { NO_PAGE, NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; const CONCERN = "rendered-views"; const HOME = "docs/conventions"; @@ -303,7 +304,10 @@ export function main(argv, env = process.env) { if (request.argument !== null && !mediumOf(request.argument)) return usage(); const ci = Boolean(env.CI) && env.CI !== "false"; const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); - process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + // So a skill can act on the medium without naming its value: does it show a page, does it publish. + const entry = mediumOf(resolution.medium); + const shown = entry ? { ...resolution, page: entry.id !== NO_PAGE, publishes: entry.publishes } : resolution; + process.stdout.write(`${JSON.stringify(shown)}\n${reportLines(resolution).join("\n")}\n`); return 0; } diff --git a/plugins/harness-ops/lib/view-registry.mjs b/plugins/harness-ops/lib/view-registry.mjs index 5b33fb87a25..1dd99753c9c 100644 --- a/plugins/harness-ops/lib/view-registry.mjs +++ b/plugins/harness-ops/lib/view-registry.mjs @@ -29,6 +29,9 @@ export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS /** The one fallback chain: a view that cannot be served on a medium steps down this list. */ export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); +/** The chain's last step builds no page: the record stands on its own. */ +export const NO_PAGE = FALLBACK_CHAIN.at(-1); + /** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ export const NO_PREFERENCE = "auto"; @@ -47,7 +50,7 @@ export const LANES = Object.freeze( lane("review:explain-change", ["digest"], true, "artifact"), lane("visualization:present", ["deck"], false, "artifact"), lane("visualization:visualize", [], false, null), - lane("planning", ["plan", "brainstorm"], false, "file"), + lane("planning", ["plan", "brainstorm", "design"], false, "artifact"), lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), lane("discovery:blindspot", ["blindspot"], false, "file"), lane("debugging:debug", ["post-mortem"], false, "file"), diff --git a/plugins/harness-ops/lib/view-settings.mjs b/plugins/harness-ops/lib/view-settings.mjs index c37c0127da9..d268b37da9a 100755 --- a/plugins/harness-ops/lib/view-settings.mjs +++ b/plugins/harness-ops/lib/view-settings.mjs @@ -19,7 +19,8 @@ // medium then steps down the fallback chain (lib/view-registry.mjs) until reachable. // // view-settings.mjs resolve --lane <id> --option <user_config.medium> [--argument <m>] [--team-ref <ref>] -// Prints the resolution as one JSON line, then the report line and one line per notice. +// Prints the resolution as one JSON line, then the report line and one line per notice. With a +// medium resolved, the JSON adds `page` (the medium shows a page) and `publishes`. // Exit 0 resolved, 2 usage. import { execFileSync, spawnSync } from "node:child_process"; @@ -29,7 +30,7 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { configBlockBody, readLayers } from "./config-cascade.mjs"; -import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; +import { NO_PAGE, NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; const CONCERN = "rendered-views"; const HOME = "docs/conventions"; @@ -303,7 +304,10 @@ export function main(argv, env = process.env) { if (request.argument !== null && !mediumOf(request.argument)) return usage(); const ci = Boolean(env.CI) && env.CI !== "false"; const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); - process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + // So a skill can act on the medium without naming its value: does it show a page, does it publish. + const entry = mediumOf(resolution.medium); + const shown = entry ? { ...resolution, page: entry.id !== NO_PAGE, publishes: entry.publishes } : resolution; + process.stdout.write(`${JSON.stringify(shown)}\n${reportLines(resolution).join("\n")}\n`); return 0; } diff --git a/plugins/machine-health/lib/view-builder.mjs b/plugins/machine-health/lib/view-builder.mjs index d9df8de727e..8202be9f20c 100644 --- a/plugins/machine-health/lib/view-builder.mjs +++ b/plugins/machine-health/lib/view-builder.mjs @@ -17,6 +17,11 @@ // exception, in both profiles, is the optional page title (rule 5): the builder // writes it as one escaped, capped line of text into the head's <title>. // +// View kinds: VIEW_KINDS holds one entry per kind, { id, parse(record) -> data, template, tier }, +// the template path relative to the root of the plugin carrying this file. render(laneId, kind, +// record) builds a kind the lane registers (lib/view-registry.mjs). Adding a kind is an addition +// here and in the lane's registration, never a skill edit. +// // CLI: // node view-builder.mjs --profile report|interactive --template <file> --data <file.json> --out <file> // [--connect http://127.0.0.1:<port>] (interactive: the session-bridge origin) @@ -26,8 +31,10 @@ import { createHash } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { escapeHtml, pageDigest, stampPage, validateRenderedPage } from "./html-escape.mjs"; +import { LANES } from "./view-registry.mjs"; export const INTERACTIVE_MARKER = "rv-gen:view-builder-interactive"; const MARKER_RE = /<!-- rv-gen:view-builder-interactive sha256:([0-9a-f]{64}) -->/g; @@ -188,6 +195,115 @@ export function validateView(html, runtime) { : validateRenderedPage(html); } +// ------------------------------------------------------------ view kinds + +const isText = (value) => typeof value === "string"; +const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value); + +/** + * A record that is one JSON object (or its text): top-level text fields, then one list of rows + * whose fields are text, plus `rowList`, a list of text in each row, when the shape names one. + */ +const jsonRecord = (shape) => (record) => { + const data = isText(record) ? JSON.parse(record) : record; + if (!isObject(data)) throw new ViewBuildError(["data is not an object"]); + const failures = shape.text.filter((key) => !isText(data[key])).map((key) => `${key} must be text`); + if (!Array.isArray(data[shape.list])) throw new ViewBuildError([...failures, `${shape.list} must be a list`]); + for (const [index, row] of data[shape.list].entries()) { + const at = `${shape.list}[${index}]`; + if (!isObject(row)) { + failures.push(`${at} must be an object`); + continue; + } + for (const key of shape.row) if (!isText(row[key])) failures.push(`${at}.${key} must be text`); + if (shape.rowList && !(Array.isArray(row[shape.rowList]) && row[shape.rowList].every(isText))) { + failures.push(`${at}.${shape.rowList} must be a list of text`); + } + } + if (failures.length) throw new ViewBuildError(failures); + return data; +}; + +const trimBlank = (lines) => { + let [start, end] = [0, lines.length]; + while (start < end && !lines[start].trim()) start += 1; + while (end > start && !lines[end - 1].trim()) end -= 1; + return lines.slice(start, end); +}; + +/** + * A markdown record's `## Design` section: the title from the record's first `# ` heading, the + * text before the first `### ` subsection, and each subsection's lines as written. A heading + * inside a code fence is text; a fence closes only on a bare run of its own character at least as + * long as the opening run (CommonMark). + */ +function parseDesign(record) { + if (!isText(record)) throw new ViewBuildError(["the design record must be markdown text"]); + let [fence, inDesign, found, title] = [null, false, false, null]; + const intro = []; + const sections = []; + for (const line of lf(record).split("\n")) { + const heading = fence ? null : /^(#{1,6})[ \t]+(.+?)[ \t]*$/.exec(line); + const run = /^[ \t]*(`{3,}(?=[^`]*$)|~{3,})(.*)$/.exec(line); + if (!fence && run) fence = run[1]; + else if (fence && run && run[1][0] === fence[0] && run[1].length >= fence.length && !run[2].trim()) fence = null; + const level = heading?.[1].length; + if (level === 1) title ??= heading[2]; + if (level <= 2) { + inDesign = level === 2 && heading[2] === "Design"; + found ||= inDesign; + } else if (inDesign && level === 3) { + sections.push({ name: heading[2], lines: [] }); + } else if (inDesign) { + (sections.at(-1)?.lines ?? intro).push(line); + } + } + if (!found) throw new ViewBuildError(["the record has no ## Design section"]); + return { title: title ?? "Design", intro: trimBlank(intro).join("\n"), sections: sections.map(({ name, lines }) => ({ name, lines: trimBlank(lines) })) }; +} + +const kindEntry = (id, parse, template, tier) => Object.freeze({ id, parse, template, tier }); + +/** One entry per view kind: `tier` is the rendered-views view tier; `static` builds the report profile. */ +export const VIEW_KINDS = Object.freeze({ + plan: kindEntry( + "plan", + jsonRecord({ text: ["title", "goal", "blast"], list: "phases", row: ["status", "name", "what", "needs"], rowList: "criteria" }), + "skills/plan/templates/plan-view.html", + "client-interactive", + ), + brainstorm: kindEntry( + "brainstorm", + jsonRecord({ text: ["title", "problem"], list: "candidates", row: ["effort", "name", "what", "where", "impact"] }), + "skills/brainstorm/templates/brainstorm-view.html", + "client-interactive", + ), + design: kindEntry("design", parseDesign, "skills/design/templates/design-view.html", "client-interactive"), +}); + +const PLUGIN_ROOT = fileURLToPath(new URL("../", import.meta.url)); + +/** + * Build the `kind` view of `record` for the lane `laneId`, which must register that kind. + * A record the kind cannot read throws ViewBuildError; an unknown lane or kind throws Error. + * @param {{lanes?: object, kinds?: object, root?: string, connect?: string | null, title?: string | null}} [options] + * lanes and kinds replace the registries; root is the directory the kind's template path is under + */ +export function render(laneId, kind, record, { lanes = LANES, kinds = VIEW_KINDS, root = PLUGIN_ROOT, connect = null, title = null } = {}) { + const lane = Object.hasOwn(lanes, laneId) ? lanes[laneId] : undefined; + if (!lane) throw new Error(`view-builder: unknown lane ${JSON.stringify(laneId)}`); + if (!lane.kinds.includes(kind)) throw new Error(`view-builder: lane ${laneId} does not register the view kind ${JSON.stringify(kind)}`); + const entry = Object.hasOwn(kinds, kind) ? kinds[kind] : undefined; + if (!entry) throw new Error(`view-builder: no view kind ${JSON.stringify(kind)}`); + return buildView({ + profile: entry.tier === "static" ? "report" : "interactive", + template: readFileSync(join(root, entry.template), "utf8"), + data: entry.parse(record), + connect, + title, + }); +} + // ---------------------------------------------------------------- report function parseSlots(template, failures) { diff --git a/plugins/machine-health/lib/view-registry.mjs b/plugins/machine-health/lib/view-registry.mjs new file mode 100644 index 00000000000..1dd99753c9c --- /dev/null +++ b/plugins/machine-health/lib/view-registry.mjs @@ -0,0 +1,97 @@ +// GENERATED from lib/view-registry.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// The medium registry for rendered views: one entry per medium, the one fallback chain, and each +// view lane's registration. Skills never name a medium themselves; they reach this through +// lib/view-settings.mjs. Adding a medium or a lane is an addition here, never a skill edit. +// +// MediumEntry { id, publishes, reachable(context) -> {ok, reason}, deliver(view, context) -> {location}, reportName } +// Lane { laneId, kinds, hostedCapable, default } +// +// `context.interactive` is false in a CI or other non-interactive run, where only the terminal is +// reachable (docs/contracts/rendered-views/README.md, "Reachability and preference"). A lane's +// `default` is its shipped medium, applied when no scope decides; null leaves the choice to the +// skill's own content rule. + +const interactiveOnly = (context = {}) => (context.interactive === false ? { ok: false, reason: "non-interactive run" } : { ok: true }); + +const entry = (id, publishes, reportName, reachable, deliver) => Object.freeze({ id, publishes, reportName, reachable, deliver }); + +export const MEDIUMS = Object.freeze({ + terminal: entry("terminal", false, "the terminal", () => ({ ok: true }), () => ({ location: "terminal" })), + file: entry("file", false, "a local file", interactiveOnly, (view) => ({ location: view.path })), + artifact: entry("artifact", true, "a private Artifact on claude.ai", interactiveOnly, (view) => ({ location: "artifact", publish: view.path })), + hosted: entry("hosted", true, "the shared page host", interactiveOnly, (view) => ({ location: "hosted", publish: view.path })), +}); + +/** The registry entry for `id`, own keys only, so an inherited name such as `toString` is no medium. */ +export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS, id) ? MEDIUMS[id] : undefined); + +/** The one fallback chain: a view that cannot be served on a medium steps down this list. */ +export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); + +/** The chain's last step builds no page: the record stands on its own. */ +export const NO_PAGE = FALLBACK_CHAIN.at(-1); + +/** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ +export const NO_PREFERENCE = "auto"; + +/** The rendered-views concern's schema, for the cascade reader: one key, `medium`. */ +export const SCHEMA = Object.freeze({ + type: "object", + additionalProperties: false, + properties: { medium: { type: "string", enum: [NO_PREFERENCE, ...Object.keys(MEDIUMS)] } }, +}); + +const lane = (laneId, kinds, hostedCapable, medium) => Object.freeze({ laneId, kinds: Object.freeze(kinds), hostedCapable, default: medium }); + +export const LANES = Object.freeze( + Object.fromEntries( + [ + lane("review:explain-change", ["digest"], true, "artifact"), + lane("visualization:present", ["deck"], false, "artifact"), + lane("visualization:visualize", [], false, null), + lane("planning", ["plan", "brainstorm", "design"], false, "artifact"), + lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), + lane("discovery:blindspot", ["blindspot"], false, "file"), + lane("debugging:debug", ["post-mortem"], false, "file"), + lane("education:illustrate", [], false, "file"), + lane("work-items:morning-brief", ["morning-brief"], false, "file"), + lane("work-items:triage", ["board"], false, "file"), + lane("adhd:clarify", [], false, null), + lane("codebase-health:audit", [], false, null), + lane("overengineering:audit", [], false, null), + lane("education:teach", [], false, null), + lane("prototype:explore-directions", [], false, null), + lane("prototype:pressure-test", [], false, null), + lane("ai-briefing:generate", [], false, null), + ].map((l) => [l.laneId, l]), + ), +); + +/** The lane registration for `id`, own keys only. */ +export const laneOf = (id) => (typeof id === "string" && Object.hasOwn(LANES, id) ? LANES[id] : undefined); + +/** `medium` as `lane` reads it: `hosted` is `artifact` unless the lane sends pages to a page host. */ +export const readAs = (medium, laneEntry) => (medium === "hosted" && !laneEntry.hostedCapable ? "artifact" : medium); + +/** + * The medium actually used for `preferred` on `laneEntry`: `preferred` itself when reachable, else the + * first reachable medium after it on the fallback chain (a hosted page steps to `artifact` first). + * @returns {{medium: string, fell: null | string}} fell: why `preferred` was not used + */ +export function fallBack(preferred, laneEntry, context = {}) { + const want = readAs(preferred, laneEntry); + const start = FALLBACK_CHAIN.indexOf(want === "hosted" ? "artifact" : want); + const candidates = want === "hosted" ? ["hosted", ...FALLBACK_CHAIN.slice(start)] : FALLBACK_CHAIN.slice(start); + const wanted = mediumOf(want); + if (!wanted) throw new Error(`view-registry: unknown medium ${JSON.stringify(preferred)}`); + const first = wanted.reachable(context); + if (first.ok) return { medium: want, fell: null }; + const medium = candidates.find((id) => mediumOf(id).reachable(context).ok) ?? "terminal"; + return { medium, fell: `${want} unreachable: ${first.reason}` }; +} + +/** The report line every view lane prints, then one line per notice. */ +export function reportLines({ medium, decidedBy, notices = [] }) { + return [`view: ${medium ?? NO_PREFERENCE} (${decidedBy})`, ...notices.map((n) => `notice: ${n.text}`)]; +} diff --git a/plugins/overengineering/lib/view-registry.mjs b/plugins/overengineering/lib/view-registry.mjs index 5b33fb87a25..1dd99753c9c 100644 --- a/plugins/overengineering/lib/view-registry.mjs +++ b/plugins/overengineering/lib/view-registry.mjs @@ -29,6 +29,9 @@ export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS /** The one fallback chain: a view that cannot be served on a medium steps down this list. */ export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); +/** The chain's last step builds no page: the record stands on its own. */ +export const NO_PAGE = FALLBACK_CHAIN.at(-1); + /** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ export const NO_PREFERENCE = "auto"; @@ -47,7 +50,7 @@ export const LANES = Object.freeze( lane("review:explain-change", ["digest"], true, "artifact"), lane("visualization:present", ["deck"], false, "artifact"), lane("visualization:visualize", [], false, null), - lane("planning", ["plan", "brainstorm"], false, "file"), + lane("planning", ["plan", "brainstorm", "design"], false, "artifact"), lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), lane("discovery:blindspot", ["blindspot"], false, "file"), lane("debugging:debug", ["post-mortem"], false, "file"), diff --git a/plugins/overengineering/lib/view-settings.mjs b/plugins/overengineering/lib/view-settings.mjs index c37c0127da9..d268b37da9a 100755 --- a/plugins/overengineering/lib/view-settings.mjs +++ b/plugins/overengineering/lib/view-settings.mjs @@ -19,7 +19,8 @@ // medium then steps down the fallback chain (lib/view-registry.mjs) until reachable. // // view-settings.mjs resolve --lane <id> --option <user_config.medium> [--argument <m>] [--team-ref <ref>] -// Prints the resolution as one JSON line, then the report line and one line per notice. +// Prints the resolution as one JSON line, then the report line and one line per notice. With a +// medium resolved, the JSON adds `page` (the medium shows a page) and `publishes`. // Exit 0 resolved, 2 usage. import { execFileSync, spawnSync } from "node:child_process"; @@ -29,7 +30,7 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { configBlockBody, readLayers } from "./config-cascade.mjs"; -import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; +import { NO_PAGE, NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; const CONCERN = "rendered-views"; const HOME = "docs/conventions"; @@ -303,7 +304,10 @@ export function main(argv, env = process.env) { if (request.argument !== null && !mediumOf(request.argument)) return usage(); const ci = Boolean(env.CI) && env.CI !== "false"; const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); - process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + // So a skill can act on the medium without naming its value: does it show a page, does it publish. + const entry = mediumOf(resolution.medium); + const shown = entry ? { ...resolution, page: entry.id !== NO_PAGE, publishes: entry.publishes } : resolution; + process.stdout.write(`${JSON.stringify(shown)}\n${reportLines(resolution).join("\n")}\n`); return 0; } diff --git a/plugins/planning/.claude-plugin/plugin.json b/plugins/planning/.claude-plugin/plugin.json index f58d623fe44..fbe3ce1af07 100644 --- a/plugins/planning/.claude-plugin/plugin.json +++ b/plugins/planning/.claude-plugin/plugin.json @@ -94,7 +94,7 @@ "medium": { "type": "string", "title": "Preferred delivery medium", - "description": "Where the planning views go (plan, brainstorm, design, PRD, the interview's decision table). auto (default): a local file; terminal: no page; file: a local file; artifact: a private Artifact; hosted is read as artifact. A team rendered-views file never publishes.", + "description": "Where planning views go. auto (default): plan, brainstorm and design views go to a private Artifact, kept by the publish gate for a non-public repository or credential-shaped record; PRD and interview views stay local. terminal: no page; file: local; artifact: publish anyway; hosted = artifact.", "default": "auto", "options": ["auto", "terminal", "file", "artifact", "hosted"] } diff --git a/plugins/planning/README.md b/plugins/planning/README.md index 0c95718eb04..a8c70fe79f4 100644 --- a/plugins/planning/README.md +++ b/plugins/planning/README.md @@ -157,7 +157,7 @@ reads it from. | `phase_order` | string | `"composed"` | `CLAUDE_PLUGIN_OPTION_PHASE_ORDER` | How /planning:plan orders phases: composed (default: dead-code removal, integration slice, riskiest unknown, scaffold, features), subtraction-first (removal, scaffold, features) or riskiest-first. A repository's docs/conventions/planning.yaml phase_order key overrides this value. | | `scaffold_stubs` | boolean | `false` | `CLAUDE_PLUGIN_OPTION_SCAFFOLD_STUBS` | When on, /planning:design hands off the stub files and signatures a change needs, and /planning:plan puts one stub phase right after dead-code removal (first when there is none). Default: off. A repository's docs/conventions/planning.yaml scaffold_stubs key overrides this value. | | `time_units` | boolean | `false` | `CLAUDE_PLUGIN_OPTION_TIME_UNITS` | When on, the planning skills may state an estimate or budget of work in hours, days or weeks; when off they use the work_size levels instead. Practice cadence and measurement windows are not affected. Default: off. A repository's docs/conventions/planning.yaml time_units key overrides this value. | -| `medium` | string | `"auto"` | `CLAUDE_PLUGIN_OPTION_MEDIUM` | Where the planning views go (plan, brainstorm, design, PRD, the interview's decision table). auto (default): a local file; terminal: no page; file: a local file; artifact: a private Artifact; hosted is read as artifact. A team rendered-views file never publishes. | +| `medium` | string | `"auto"` | `CLAUDE_PLUGIN_OPTION_MEDIUM` | Where planning views go. auto (default): plan, brainstorm and design views go to a private Artifact, kept by the publish gate for a non-public repository or credential-shaped record; PRD and interview views stay local. terminal: no page; file: local; artifact: publish anyway; hosted = artifact. | ### How to set these diff --git a/plugins/planning/lib/publish-gate.mjs b/plugins/planning/lib/publish-gate.mjs new file mode 100755 index 00000000000..555ac2ccd6d --- /dev/null +++ b/plugins/planning/lib/publish-gate.mjs @@ -0,0 +1,525 @@ +#!/usr/bin/env node +// GENERATED from lib/publish-gate.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// Decide whether a rendered view may be published as a claude.ai Artifact or must +// stay on this machine. An explicit `medium: artifact` from a trusted layer +// publishes. Otherwise the view publishes only when its source repository is +// PUBLIC (or it has no repository source) and nothing in it is shaped like a +// credential. A miss proves nothing; a hit keeps the view local. +// +// A `hosted` page goes to a shared page host through `pages-publish`. It runs +// every check whatever the layers say: a credential refuses the host, and a +// repository that is not PUBLIC or a machine path or hostname sends it to the +// private host. The destination is only ever lowered from the one requested. +// +// publish-gate.mjs <PUBLIC|PRIVATE|INTERNAL|UNKNOWN|NONE> [--explicit] [--subject <word>] +// [--medium hosted [--visibility public|private]] < text +// Prints one JSON object. Exit 0 decided, 2 usage. + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * The token shapes every scanner shares: this gate, the read runner's scan job (which loads this + * file alone) and apply-patch's credential check (.github/scripts/apply-patch/apply-patch.mjs) + * import them from here, each the broader of the shapes they once held apart. + * Text shaped like a credential. Conservative: a hit keeps the page local, a miss proves nothing. + * GitHub prefixes from + * https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-authentication-to-github#githubs-token-formats + * and the ghs_ regex GitHub recommends for current and stateless installation tokens from + * https://github.blog/changelog/2026-05-15-github-app-installation-tokens-per-request-override-header/ + * (as of 2026-10-21; recheck when GitHub changes a token format or its recommended regex). + */ +export const CREDENTIAL_SHAPES = Object.freeze([ + ["GitHub token", /\b(?:ghs_[A-Za-z0-9._-]{36,}|gh[pousr]_[0-9A-Za-z]{36,}|github_pat_[0-9A-Za-z_]{22,})/], + ["Anthropic key", /sk-ant-[A-Za-z0-9_-]{20,}/], +]); +export const SECRET_PATTERNS = Object.freeze([ + ["private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/], + ["AWS access key", /\b(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b/], + ...CREDENTIAL_SHAPES, + ["OpenAI key", /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/], + ["Slack token", /\bxox[abposr]-[0-9A-Za-z-]{10,}/], + ["Stripe key", /\b[sr]k_(?:test|live|prod)_[0-9A-Za-z]{10,}/], + ["password or secret assignment", /\b(?:password|passwd|pwd|secret|client_secret|api_?key|token)["']?\s*[:=]\s*["'][^"'\s$<>{}]{8,}["']/i], + // A 32-hex and a 64-hex word on one line, in either order. The lookaheads run once per line + // start (m: after the terminators `.` stops at), so time grows linearly; a `hex.*hex` form + // rescans the line from every hex word, quadratic on one-line JSON full of digests. + [ + "R2 key pair", + /^(?=.*\b[0-9a-f]{32}\b)(?=.*\b[0-9a-f]{64}\b)|\b(?:access_?key_?id|secret_?access_?key)["']?\s*[:=]\s*["']?[0-9A-Za-z/+]{16,}/im, + ], + ["Azure client secret", /(?:^|[^\w~.-])[\w~.]{3}\dQ~[\w~.-]{31,34}(?![\w~.-])/], + ["Cloudflare API token", /\bcf(?:k|ut|at)_[A-Za-z0-9]{40,}/], + ["upload token", /\bpgup_[A-Za-z0-9_-]{20,}/], + ["npm token", /\bnpm_[A-Za-z0-9]{36}/], + ["Google API key", /\bAIza[0-9A-Za-z_-]{35}/], + ["JSON Web Token", /\beyJ[A-Za-z0-9_-]{7,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{20,}/], + ["Docker registry auth", /"auth"\s*:\s*"[A-Za-z0-9+/=]{16,}"/], + // Split literals, so this source line never matches its own pattern. + ["Azure storage account key", new RegExp("Account" + "Key=[A-Za-z0-9+/]{40,}={0,2}")], + ["Pulumi access token", /\bpul-[0-9a-f]{40}/], + ["base64-encoded PEM block", new RegExp("LS0tLS1CRUdJ" + "Ti[A-Za-z0-9+/]{20,}")], + ["netrc password", /\b(?:machine\s+\S+|default)\s+login\s+\S+\s+password\s+[^\s"'\\]{8,}/], + ["netrc password", /^\s*password\s+[^\s"'\\]{8,}\s*$/], +]); + +/** + * Credentials in a URL's userinfo: a password of 6 or more characters that is not a + * placeholder (user:password@, <password>, ${PASSWORD}, ***). Not in SECRET_PATTERNS: + * redaction tests across the tracked tree carry realistic fake connection strings, + * which this shape cannot tell from real ones, and every scanner importing those + * patterns would block a change touching them. Callers whose text has no such + * fixtures (the lane telemetry upserts) check it alongside scanLeak. + */ +export const URL_CREDENTIALS = Object.freeze([ + "URL credentials", + /:\/\/[^/\s:@]+:(?!password@|<[^>]*>@|\$\{[^}]*\}@|\*+@)[^/\s@]{6,}@/i, +]); + +// The secret patterns read on each pair of adjacent lines joined, so a credential a line break +// splits still matches. Left out of every pair pass: the OpenAI key (any sk- and 20 token +// characters), since an sk- word ending a line joins the next line's first word; and the R2 key +// pair (a 32-hex and a 64-hex token), since adjacent md5 and sha256 lines pair. The gate's pair +// pass (findSecret) keeps the assignment, since any label from it keeps a view local or refuses +// the host; the leak scan's split-line pass (straddles) leaves it out too, since it only warns +// on it. publish-gate.test.mjs checks the labels against SECRET_PATTERNS, so a renamed one fails +// loudly there. Loading never throws on a missing label: the leak scan runs a gate whose list +// lacks the assignment (callers.test.mjs). +export const WARN_ONLY = "password or secret assignment"; +export const UNJOINABLE = Object.freeze(["OpenAI key", "R2 key pair"]); +const GATE_JOINABLE = SECRET_PATTERNS.filter(([label]) => !UNJOINABLE.includes(label)); +const JOINABLE = GATE_JOINABLE.filter(([label]) => label !== WARN_ONLY); + +/** Machine paths and hostnames: a hit sends a hosted page to the private host. Only these shapes are caught. */ +export const MACHINE_PATTERNS = Object.freeze([ + ["home path", /\/home\/[a-z_][\w.-]*\/|\/Users\/[^/\s"'<>]+\/|\b[A-Za-z]:\\+Users\\+|\/mnt\/[a-z]\/Users\/|(?<![\w.~-])\/root\//i], + ["WSL path", /\\\\wsl(?:\$|\.localhost)/i], + ["scratchpad slug", /(?<![\w-])-home-[a-z_][\w.]*-/i], + ["fleet hostname", /\bmelo-(?:desk|lap|phone)-\d{3}\b/i], + ["private hostname", /\b[a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:local|internal|lan)\b(?![.-])/i], +]); + +// The builders' page stamps carry a 64-hex digest, which would otherwise pair with any 32-hex token. +// Only their own names match, since what this removes no pattern reads. +const STAMP = /<!-- rv-gen:(?:escape-helper-v\d+|view-builder-interactive) sha256:[0-9a-f]{64} -->/g; + +const ENTITIES = Object.freeze({ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " }); +const codePoint = (n) => (Number.isFinite(n) && n <= 0x10ffff ? String.fromCodePoint(n) : ""); + +// Zero-width space, non-joiner and joiner, word joiner, zero-width no-break space, soft hyphen. +const INVISIBLE = /[\u{200B}-\u{200D}\u{2060}\u{FEFF}\u{AD}]/gu; + +const unescapeOnce = (text) => + text + .normalize("NFKC") + .replace(INVISIBLE, "") + .replace(/\\u([0-9a-f]{4})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16))) + .replace(/<[^<>]*>/g, "") + .replace(/&(?:#x([0-9a-f]+)|#(\d+)|(amp|lt|gt|quot|apos|nbsp));/gi, (_, hex, dec, name) => + name ? ENTITIES[name.toLowerCase()] : codePoint(hex ? Number.parseInt(hex, 16) : Number.parseInt(dec, 10)), + ); + +// The pages host scans a normalized copy beside the raw bytes. The copy here goes further, so a +// split, look-alike, HTML-escaped or JSON-escaped credential or path is caught before upload: +// NFKC, invisible characters stripped, \uXXXX decoded, tags removed and entities decoded until a +// pass changes nothing, then a backslash before punctuation dropped. A backslash before / . " or ' +// stays: folding those turns tracked regex sources and escaped JSON into paths, hostnames and +// assignments, which the tracked-tree test in publish-gate.test.mjs refuses. +export const normalize = (text) => { + let previous; + let folded = text; + while (folded !== previous) { + previous = folded; + folded = unescapeOnce(folded); + } + return folded.replace(/\\([^\sA-Za-z0-9/."'])/g, "$1"); +}; + +const linesOf = (text) => String(text ?? "").split(/\r?\n/).map((line) => line.replace(STAMP, "")); +const firstIn = (patterns, lines, normalized) => { + for (let i = 0; i < lines.length; i += 1) { + for (const [label, re] of patterns) if (re.test(lines[i]) || (normalized[i] !== lines[i] && re.test(normalized[i]))) return [label, i + 1]; + } + return null; +}; +const pairs = (lines) => lines.slice(1).map((line, i) => lines[i] + line); + +/** The first of `patterns` in `text` or its folded copy, as [label, 1-based line], or null. `fold` stands in for normalize. */ +export const firstHit = (patterns, text, fold = normalize) => { + const lines = linesOf(text); + return firstIn(patterns, lines, lines.map(fold)); +}; + +/** + * The first credential-shaped pattern in `text`, as [label, 1-based line], or null. Never the match itself. + * When no line matches alone, the GATE_JOINABLE patterns read each pair of adjacent lines joined, raw + * and normalized, and a hit names the pair's first line. + */ +export const findSecret = (text) => { + const lines = linesOf(text); + const normalized = lines.map(normalize); + return firstIn(SECRET_PATTERNS, lines, normalized) ?? firstIn(GATE_JOINABLE, pairs(lines), pairs(normalized)); +}; + +/** The first machine path or hostname in `text`, as [label, 1-based line], or null. Never the match itself. */ +export const findMachine = (text) => firstHit(MACHINE_PATTERNS, text); + +// ------------------------------------------------------------ leak scan + +// The lanes' leak scan: the read runner's scan job runs it on what a run +// leaves, and work-resolve-issue.yml's run-checks job on a reused branch. +// A credential format blocks and asks for rotation. The one broad label, an +// assignment that may be a test fixture (WARN_ONLY, above), only warns, and only on a plain line. +export const HIDDEN = "assignment hidden by markup or escapes"; +const BLOCKING = SECRET_PATTERNS.filter(([label]) => label !== WARN_ONLY); +const WARN_PATTERN = SECRET_PATTERNS.find(([label]) => label === WARN_ONLY)?.[1]; +const WARN_ONE = WARN_PATTERN && new RegExp(WARN_PATTERN.source, WARN_PATTERN.flags.replace("g", "")); +const WARN_ALL = WARN_PATTERN && new RegExp(WARN_PATTERN.source, `${WARN_PATTERN.flags.replace("g", "")}g`); +// Markup and escapes only. classify reads latin1, so each byte of a UTF-8 character is its own +// code point: findSecret's NFKC and invisible-character passes still run on that text, but on +// code points such as 0xAD, 0xA0 and 0xBD that ordinary UTF-8 continuation bytes produce, so +// counting them here would block a plain assignment on any line of non-ASCII text. A real UTF-8 +// look-alike reaches those passes as latin1 code points they leave alone; scanLeak's UTF-8 pass +// is what folds it. +const folds = (line) => /<[^<>]*>|&#?\w+;|\\u[0-9a-f]{4}|\\[^\sA-Za-z0-9/."']/i.test(line); +const credential = (label) => ({ block: label, credential: true }); + +// Each line is judged on its own: every blocking pattern on the raw line, +// then findSecret (which adds the normalized copy). When findSecret names the +// assignment, its matches are blanked and the line read again, and each match +// is read again with its = and : blanked. A line whose assignment sits beside +// markup or escapes normalize() folds, or whose assignment only the folded +// copy shows, blocks under HIDDEN: blanking cannot be trusted there. +const classifyLine = (line) => { + const raw = BLOCKING.find(([, re]) => re.test(line)); + if (raw) return credential(raw[0]); + const first = findSecret(line); + if (!first) return {}; + if (first[0] !== WARN_ONLY || !WARN_PATTERN) return credential(first[0]); + const again = findSecret(line.replace(WARN_ALL, (match) => " ".repeat(match.length))); + if (again && again[0] !== WARN_ONLY) return credential(again[0]); + for (const [match] of line.matchAll(WARN_ALL)) { + const inside = findSecret(match.replace(/[:=]/g, " ")); + if (inside && inside[0] !== WARN_ONLY) return credential(inside[0]); + } + if (again || folds(line) || !WARN_ONE.test(line)) return { block: HIDDEN, credential: false }; + return { warn: true }; +}; + +// The secret patterns run line by line on the whole text too, and any credential label from them +// blocks. Lines are not joined here: deeperLeak joins them, raw only, with a diff's hunk prefixes +// removed first. +const classify = (text) => { + let warn = false; + for (const line of text.split(/\r?\n/)) { + const result = classifyLine(line); + if (result.block) return result; + warn ||= Boolean(result.warn); + } + const whole = firstHit(SECRET_PATTERNS, text); + if (whole && whole[0] !== WARN_ONLY) return credential(whole[0]); + return { warn }; +}; + +// Two more passes, on credential formats only (owner ruling Q37): +// - UTF-16LE: the text with its NUL bytes removed, so a token written two +// bytes a character reads as one; a space in place of each NUL would split it. +// - Split lines: each pair of adjacent lines joined with no separator, counting +// only a match that straddles the join of a JOINABLE pattern neither line +// matches alone. In a diff the pair is hunk lines with their +, +// - or space prefix removed and the file headers dropped, or a token split +// inside an added hunk would join as "...ab+cd..." and never match. +const straddles = (left, right) => { + const joined = left + right; + for (const [label, re] of JOINABLE) { + if (re.test(left) || re.test(right)) continue; + for (const match of joined.matchAll(new RegExp(re.source, `${re.flags.replace("g", "")}g`))) { + if (match.index < left.length && match.index + match[0].length > left.length) return label; + } + } + return null; +}; +const credentialIn = (text) => { + for (const line of text.split(/\r?\n/)) { + const raw = BLOCKING.find(([, re]) => re.test(line)); + if (raw) return raw[0]; + } + const found = firstHit(SECRET_PATTERNS, text); + return found && found[0] !== WARN_ONLY ? found[0] : null; +}; +const HUNK_HEADER = /^(?:\+\+\+|---) /; + +// JSON writes a line break or tab as \n, \r or \t, which leaves a letter where the break was, so a +// \b-anchored credential right after one has no word boundary. normalize() keeps a backslash +// before a letter (see its comment), so scanLeak reads a copy with these escapes decoded, for the +// credential formats alone. +const BREAK_ESCAPES = Object.freeze({ "\\": "\\", n: "\n", r: "\r", t: "\t" }); +const unescapeBreaks = (text) => text.replace(/\\([\\nrt])/g, (_, c) => BREAK_ESCAPES[c]); + +/** The UTF-16LE and split-line passes over `text`, a diff when `diff`: a credential label, or null. */ +export function deeperLeak(text, diff = false) { + const plain = text.replace(/\0/g, ""); + if (plain !== text) { + const label = credentialIn(plain); + if (label) return label; + } + let lines = plain.split(/\r?\n/); + if (diff) lines = lines.filter((line) => /^[-+ ]/.test(line) && !HUNK_HEADER.test(line)).map((line) => line.slice(1)); + for (let i = 0; i + 1 < lines.length; i += 1) { + const label = straddles(lines[i], lines[i + 1]); + if (label) return label; + } + return null; +} + +// A base64 or base64url run, decoded and read for CREDENTIAL_SHAPES, as gitleaks decodes before +// matching. 36 characters is the shortest run that can hold the shortest shape, the Anthropic key +// (sk-ant- and 20 characters, 27 bytes: 4 * ceil(27 / 3) = 36). Node's base64 decoder reads the +// base64url alphabet too (https://nodejs.org/api/buffer.html#buffers-and-character-encodings). +// Each run decodes at all four offsets, so a stray alphabet character before it (a diff's +, a +// path's /) does not shift every group. Only a decoding at least 90% printable is read or decoded +// again, to ENCODED_DEPTH layers, so image and gzip runs pass. Well-formed UTF-8 (a character cut +// at the end left pending) counts by character, C0 and C1 controls but tab, LF and CR not +// printable, so emoji, accented or CJK text counts; random and compressed bytes are almost never +// well-formed, and those count by byte, printable ASCII only. The first ENCODED_PROBE +// characters (66 bytes) decide that before the rest is decoded, so a binary run costs 66 bytes +// an offset whatever its length. ENCODED_CAP bounds the decoded bytes per call across runs, +// offsets and layers: at most 3 decoded bytes per input character (four offsets of 3/4 each), so +// the 4 MiB cap reads at least 1.4M characters of printable base64 text in full. Past the cap +// nothing is decoded. +// The runs are found by a character loop: a regex over a run of millions of characters overflows +// V8's stack. Padding is left off; the decoder does not need it. +const ENCODED_MIN = 36; +const ENCODED_PROBE = 88; +export const ENCODED_DEPTH = 2; +export const ENCODED_CAP = 1 << 22; +const ALPHABET = new Uint8Array(128); +for (const c of "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/-_") ALPHABET[c.charCodeAt(0)] = 1; +function* encodedRuns(text) { + let start = 0; + for (let i = 0; i <= text.length; i += 1) { + if (i < text.length && ALPHABET[text.charCodeAt(i)]) continue; + if (i - start >= ENCODED_MIN) yield text.slice(start, i); + start = i + 1; + } +} +const textual = (code) => (code >= 0x20 && code < 0x7f) || code > 0x9f || code === 9 || code === 10 || code === 13; +const printable = (bytes) => { + let text; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes, { stream: true }); + } catch { + text = null; + } + let count = 0; + let total = 0; + if (text) { + for (const c of text) { + total += 1; + if (textual(c.codePointAt(0))) count += 1; + } + } else { + total = bytes.length; + for (const byte of bytes) if (byte < 0x80 && textual(byte)) count += 1; + } + return count >= total * 0.9; +}; + +/** The first CREDENTIAL_SHAPES label inside a base64 run of `text`, decoded up to `depth` layers within `cap` decoded bytes, or null. */ +export function encodedLeak(text, { depth = ENCODED_DEPTH, cap = ENCODED_CAP } = {}) { + let budget = cap; + const read = (layer, level) => { + for (const run of encodedRuns(layer)) { + for (let offset = 0; offset < 4; offset += 1) { + if (budget < 3) return null; + const body = run.slice(offset, offset + Math.floor(budget / 3) * 4); + if (body.length < ENCODED_MIN) continue; + let bytes = Buffer.from(body.slice(0, ENCODED_PROBE), "base64"); + if (printable(bytes) && body.length > ENCODED_PROBE) bytes = Buffer.from(body, "base64"); + budget -= bytes.length; + if (!printable(bytes)) continue; + const decoded = bytes.toString("latin1"); + const shape = CREDENTIAL_SHAPES.find(([, re]) => re.test(decoded)); + if (shape) return shape[0]; + const deeper = level < depth ? read(decoded, level + 1) : null; + if (deeper) return deeper; + } + } + return null; + }; + return read(String(text), 1); +} + +/** + * Every pass over `bytes`: `{block, credential}` naming the first blocking + * label (credential true when it asks for rotation), `{warn: true}` for a + * plain assignment alone, else `{}`. Never the match. Latin1 keeps every byte + * for the byte patterns. A UTF-8 reading then gives NFKC and the invisible + * strip real characters, for credential formats only, so a look-alike blocks + * and non-ASCII text beside a plain assignment still only warns. Then that + * reading with JSON's \n, \r and \t escapes decoded, credential formats only, + * so a token after an escaped line break or tab blocks. Last, base64 runs + * decoded (encodedLeak), GitHub and Anthropic shapes only. + */ +export function scanLeak(bytes, { diff = false } = {}) { + const buffer = Buffer.from(bytes); + const text = buffer.toString("latin1"); + const { block, credential: format, warn } = classify(text); + if (block) return { block, credential: format }; + const deep = deeperLeak(text, diff); + if (deep) return credential(deep); + const decoded = buffer.toString("utf8"); + const hidden = decoded !== text && firstHit(BLOCKING, decoded); + if (hidden) return credential(hidden[0]); + const unbroken = unescapeBreaks(decoded); + const escaped = unbroken !== decoded && firstHit(BLOCKING, unbroken); + if (escaped) return credential(escaped[0]); + const encoded = encodedLeak(text); + if (encoded) return credential(encoded); + return warn ? { warn: true } : {}; +} + +// A blocked hit's fingerprint, for a job summary anyone who reads the repository can read: the +// label, the file and the line, never any character of the match. + +/** + * The 1-based line where scanLeak's `block` label sits in `bytes`, read in + * scanLeak's order, as `{line}`; line 0 when no pass places it. + */ +export function locateHit(bytes, block, { diff = false } = {}) { + const patterns = SECRET_PATTERNS.filter(([label]) => label === block).map(([, re]) => re); + const hits = (...candidates) => candidates.some((candidate) => patterns.some((re) => re.test(candidate))); + const buffer = Buffer.from(bytes); + const text = buffer.toString("latin1"); + const at = text.split(/\r?\n/).findIndex((line) => classifyLine(line).block === block); + if (at >= 0) return { line: at + 1 }; + const plain = text.replace(/\0/g, ""); + const decoded = buffer.toString("utf8"); + for (const view of [text, plain, decoded]) { + const index = linesOf(view).findIndex((line) => hits(line, normalize(line))); + if (index >= 0) return { line: index + 1 }; + } + // Escaped breaks decode within one physical line, so each line is decoded on its own and the + // hit keeps the line number of the file it was scanned in. + const escaped = linesOf(decoded).findIndex((line) => linesOf(unescapeBreaks(line)).some((part) => hits(part, normalize(part)))); + if (escaped >= 0) return { line: escaped + 1 }; + let numbered = plain.split(/\r?\n/).map((line, index) => [line, index]); + if (diff) numbered = numbered.filter(([line]) => /^[-+ ]/.test(line) && !HUNK_HEADER.test(line)).map(([line, index]) => [line.slice(1), index]); + for (let i = 0; i + 1 < numbered.length; i += 1) { + if (hits(numbered[i][0] + numbered[i + 1][0])) return { line: numbered[i][1] + 1 }; + } + const encoded = linesOf(text).findIndex((line) => encodedLeak(line) === block); + return { line: encoded + 1 }; +} + +// Model-chosen text as markdown data: every UTF-16 unit outside printable ASCII (controls, C1, +// bidi, line separators, zero-width), and each backtick, pipe, markup character and backslash, +// becomes a \u escape, so a value cannot close its code span, reorder the line or start a command. +const MARKUP = new Set([..."`|<>&\\"]); +const summaryText = (value) => + String(value) + .split("") + .map((c) => { + const code = c.charCodeAt(0); + return code < 0x20 || code > 0x7e || MARKUP.has(c) ? `\\u${code.toString(16).padStart(4, "0")}` : c; + }) + .join(""); + +/** + * One markdown line for a job summary on a block: the rule (scanLeak's + * label), the path and the line (locateHit), never any of the match. A path + * that scanLeak blocks or warns on itself is written as `unshown`. + */ +export function hitFingerprint(bytes, block, path, { diff = false, unshown = "a file name" } = {}) { + const { line } = locateHit(bytes, block, { diff }); + const { block: pathBlock, warn } = scanLeak(Buffer.from(String(path), "utf8")); + const parts = [`rule \`${summaryText(block)}\``, `path \`${summaryText(pathBlock || warn ? unshown : path)}\``]; + if (line) parts.push(`line ${line}`); + return `- ${parts.join(", ")}\n`; +} + +/** + * Each file `to` changes from `from`, both tree-ish: its name, then its bytes + * when it is a regular file or a link (a link's are its target). `git` runs + * git plumbing in the caller's repository and returns stdout as a Buffer. + */ +export function* changedBytes(git, from, to) { + const fields = git(["diff-tree", "-r", "-z", "--no-renames", from, to]).toString("latin1").split("\0"); + for (let i = 0; i + 1 < fields.length; i += 2) { + const [, mode, , sha] = fields[i].replace(/^:/, "").split(" "); + yield Buffer.from(fields[i + 1], "latin1"); + if (/^(100644|100755|120000)$/.test(mode)) yield git(["cat-file", "blob", sha]); + } +} + +// "private" is who sees a new artifact before anyone shares it: +// https://code.claude.com/docs/en/artifacts#share-an-artifact (as of 2026-10-11; +// recheck when that section makes a new artifact visible to anyone but its author). +export const DESTINATION = "a private Artifact on claude.ai"; +export const OPT_IN = "set medium: artifact in the plugin's medium option to publish anyway"; +export const VISIBILITIES = Object.freeze(["PUBLIC", "PRIVATE", "INTERNAL", "UNKNOWN", "NONE"]); + +/** + * Where an `artifact` view actually goes. `visibility` is the source repository's + * (`gh repo view --json visibility`), `NONE` when the view draws on no repository, + * and anything unrecognized is treated as not PUBLIC. + * A `hosted` medium returns `{medium: "hosted", destination: "public"|"private", reason}`, + * or `medium: "file"` when a credential refuses the host; `explicit` skips nothing there. + * @param {{explicit: boolean, visibility: string, text: string, subject?: string, medium?: string, requested?: string}} input + */ +export function publishGate({ explicit, visibility, text, subject = "content", medium = "artifact", requested = "public" }) { + if (medium === "hosted") return hostedGate({ visibility, text, subject, requested }); + if (explicit) return { medium: "artifact", destination: DESTINATION, reason: "a layer sets medium: artifact" }; + if (visibility !== "PUBLIC" && visibility !== "NONE") { + return { medium: "file", reason: `repository visibility is ${visibility || "unknown"}, not PUBLIC`, opt_in: OPT_IN }; + } + const secret = findSecret(text); + if (secret) return { medium: "file", reason: `${subject} line ${secret[1]} looks like a ${secret[0]}`, opt_in: OPT_IN }; + const source = visibility === "NONE" ? "no repository source" : "public repository"; + return { medium: "artifact", destination: DESTINATION, reason: `${source} and nothing credential-shaped in the ${subject}` }; +} + +function hostedGate({ visibility, text, subject, requested }) { + const secret = findSecret(text); + if (secret) return { medium: "file", reason: `${subject} line ${secret[1]} looks like a ${secret[0]}; a hosted page is refused` }; + const to = (destination, reason) => ({ medium: "hosted", destination, reason }); + if (requested !== "public") return to("private", "the private host was requested"); + if (visibility !== "PUBLIC" && visibility !== "NONE") return to("private", `repository visibility is ${visibility || "unknown"}, not PUBLIC`); + const machine = findMachine(text); + if (machine) return to("private", `${subject} line ${machine[1]} names a ${machine[0]}`); + const source = visibility === "NONE" ? "no repository source" : "public repository"; + return to("public", `${source} and nothing credential-, path- or host-shaped in the ${subject}`); +} + +/** CLI: the argument vector after the script path; returns the exit code. */ +export function main(argv, input = () => readFileSync(0, "utf8")) { + const [visibility, ...rest] = argv; + if (!visibility || visibility.startsWith("--")) return usage(); + const opts = { explicit: false, subject: "content", medium: "artifact", requested: "public" }; + let requested = false; + for (let i = 0; i < rest.length; i += 1) { + const next = rest[i + 1] ?? ""; + if (rest[i] === "--explicit") opts.explicit = true; + else if (rest[i] === "--subject" && /^[a-z]{1,20}$/.test(next)) opts.subject = rest[++i]; + else if (rest[i] === "--medium" && next === "hosted") opts.medium = rest[++i]; + else if (rest[i] === "--visibility" && ["public", "private"].includes(next)) [opts.requested, requested] = [rest[++i], true]; + else return usage(); + } + if (requested && opts.medium !== "hosted") return usage(); + const result = publishGate({ ...opts, visibility: visibility.toUpperCase(), text: input() }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return 0; +} + +function usage() { + process.stderr.write( + "usage: publish-gate.mjs <PUBLIC|PRIVATE|INTERNAL|UNKNOWN|NONE> [--explicit] [--subject <word>] [--medium hosted [--visibility public|private]] < text\n", + ); + return 2; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/plugins/planning/lib/view-builder.mjs b/plugins/planning/lib/view-builder.mjs index d9df8de727e..8202be9f20c 100644 --- a/plugins/planning/lib/view-builder.mjs +++ b/plugins/planning/lib/view-builder.mjs @@ -17,6 +17,11 @@ // exception, in both profiles, is the optional page title (rule 5): the builder // writes it as one escaped, capped line of text into the head's <title>. // +// View kinds: VIEW_KINDS holds one entry per kind, { id, parse(record) -> data, template, tier }, +// the template path relative to the root of the plugin carrying this file. render(laneId, kind, +// record) builds a kind the lane registers (lib/view-registry.mjs). Adding a kind is an addition +// here and in the lane's registration, never a skill edit. +// // CLI: // node view-builder.mjs --profile report|interactive --template <file> --data <file.json> --out <file> // [--connect http://127.0.0.1:<port>] (interactive: the session-bridge origin) @@ -26,8 +31,10 @@ import { createHash } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { escapeHtml, pageDigest, stampPage, validateRenderedPage } from "./html-escape.mjs"; +import { LANES } from "./view-registry.mjs"; export const INTERACTIVE_MARKER = "rv-gen:view-builder-interactive"; const MARKER_RE = /<!-- rv-gen:view-builder-interactive sha256:([0-9a-f]{64}) -->/g; @@ -188,6 +195,115 @@ export function validateView(html, runtime) { : validateRenderedPage(html); } +// ------------------------------------------------------------ view kinds + +const isText = (value) => typeof value === "string"; +const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value); + +/** + * A record that is one JSON object (or its text): top-level text fields, then one list of rows + * whose fields are text, plus `rowList`, a list of text in each row, when the shape names one. + */ +const jsonRecord = (shape) => (record) => { + const data = isText(record) ? JSON.parse(record) : record; + if (!isObject(data)) throw new ViewBuildError(["data is not an object"]); + const failures = shape.text.filter((key) => !isText(data[key])).map((key) => `${key} must be text`); + if (!Array.isArray(data[shape.list])) throw new ViewBuildError([...failures, `${shape.list} must be a list`]); + for (const [index, row] of data[shape.list].entries()) { + const at = `${shape.list}[${index}]`; + if (!isObject(row)) { + failures.push(`${at} must be an object`); + continue; + } + for (const key of shape.row) if (!isText(row[key])) failures.push(`${at}.${key} must be text`); + if (shape.rowList && !(Array.isArray(row[shape.rowList]) && row[shape.rowList].every(isText))) { + failures.push(`${at}.${shape.rowList} must be a list of text`); + } + } + if (failures.length) throw new ViewBuildError(failures); + return data; +}; + +const trimBlank = (lines) => { + let [start, end] = [0, lines.length]; + while (start < end && !lines[start].trim()) start += 1; + while (end > start && !lines[end - 1].trim()) end -= 1; + return lines.slice(start, end); +}; + +/** + * A markdown record's `## Design` section: the title from the record's first `# ` heading, the + * text before the first `### ` subsection, and each subsection's lines as written. A heading + * inside a code fence is text; a fence closes only on a bare run of its own character at least as + * long as the opening run (CommonMark). + */ +function parseDesign(record) { + if (!isText(record)) throw new ViewBuildError(["the design record must be markdown text"]); + let [fence, inDesign, found, title] = [null, false, false, null]; + const intro = []; + const sections = []; + for (const line of lf(record).split("\n")) { + const heading = fence ? null : /^(#{1,6})[ \t]+(.+?)[ \t]*$/.exec(line); + const run = /^[ \t]*(`{3,}(?=[^`]*$)|~{3,})(.*)$/.exec(line); + if (!fence && run) fence = run[1]; + else if (fence && run && run[1][0] === fence[0] && run[1].length >= fence.length && !run[2].trim()) fence = null; + const level = heading?.[1].length; + if (level === 1) title ??= heading[2]; + if (level <= 2) { + inDesign = level === 2 && heading[2] === "Design"; + found ||= inDesign; + } else if (inDesign && level === 3) { + sections.push({ name: heading[2], lines: [] }); + } else if (inDesign) { + (sections.at(-1)?.lines ?? intro).push(line); + } + } + if (!found) throw new ViewBuildError(["the record has no ## Design section"]); + return { title: title ?? "Design", intro: trimBlank(intro).join("\n"), sections: sections.map(({ name, lines }) => ({ name, lines: trimBlank(lines) })) }; +} + +const kindEntry = (id, parse, template, tier) => Object.freeze({ id, parse, template, tier }); + +/** One entry per view kind: `tier` is the rendered-views view tier; `static` builds the report profile. */ +export const VIEW_KINDS = Object.freeze({ + plan: kindEntry( + "plan", + jsonRecord({ text: ["title", "goal", "blast"], list: "phases", row: ["status", "name", "what", "needs"], rowList: "criteria" }), + "skills/plan/templates/plan-view.html", + "client-interactive", + ), + brainstorm: kindEntry( + "brainstorm", + jsonRecord({ text: ["title", "problem"], list: "candidates", row: ["effort", "name", "what", "where", "impact"] }), + "skills/brainstorm/templates/brainstorm-view.html", + "client-interactive", + ), + design: kindEntry("design", parseDesign, "skills/design/templates/design-view.html", "client-interactive"), +}); + +const PLUGIN_ROOT = fileURLToPath(new URL("../", import.meta.url)); + +/** + * Build the `kind` view of `record` for the lane `laneId`, which must register that kind. + * A record the kind cannot read throws ViewBuildError; an unknown lane or kind throws Error. + * @param {{lanes?: object, kinds?: object, root?: string, connect?: string | null, title?: string | null}} [options] + * lanes and kinds replace the registries; root is the directory the kind's template path is under + */ +export function render(laneId, kind, record, { lanes = LANES, kinds = VIEW_KINDS, root = PLUGIN_ROOT, connect = null, title = null } = {}) { + const lane = Object.hasOwn(lanes, laneId) ? lanes[laneId] : undefined; + if (!lane) throw new Error(`view-builder: unknown lane ${JSON.stringify(laneId)}`); + if (!lane.kinds.includes(kind)) throw new Error(`view-builder: lane ${laneId} does not register the view kind ${JSON.stringify(kind)}`); + const entry = Object.hasOwn(kinds, kind) ? kinds[kind] : undefined; + if (!entry) throw new Error(`view-builder: no view kind ${JSON.stringify(kind)}`); + return buildView({ + profile: entry.tier === "static" ? "report" : "interactive", + template: readFileSync(join(root, entry.template), "utf8"), + data: entry.parse(record), + connect, + title, + }); +} + // ---------------------------------------------------------------- report function parseSlots(template, failures) { diff --git a/plugins/planning/lib/view-registry.mjs b/plugins/planning/lib/view-registry.mjs index 5b33fb87a25..1dd99753c9c 100644 --- a/plugins/planning/lib/view-registry.mjs +++ b/plugins/planning/lib/view-registry.mjs @@ -29,6 +29,9 @@ export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS /** The one fallback chain: a view that cannot be served on a medium steps down this list. */ export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); +/** The chain's last step builds no page: the record stands on its own. */ +export const NO_PAGE = FALLBACK_CHAIN.at(-1); + /** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ export const NO_PREFERENCE = "auto"; @@ -47,7 +50,7 @@ export const LANES = Object.freeze( lane("review:explain-change", ["digest"], true, "artifact"), lane("visualization:present", ["deck"], false, "artifact"), lane("visualization:visualize", [], false, null), - lane("planning", ["plan", "brainstorm"], false, "file"), + lane("planning", ["plan", "brainstorm", "design"], false, "artifact"), lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), lane("discovery:blindspot", ["blindspot"], false, "file"), lane("debugging:debug", ["post-mortem"], false, "file"), diff --git a/plugins/planning/lib/view-settings.mjs b/plugins/planning/lib/view-settings.mjs index c37c0127da9..d268b37da9a 100755 --- a/plugins/planning/lib/view-settings.mjs +++ b/plugins/planning/lib/view-settings.mjs @@ -19,7 +19,8 @@ // medium then steps down the fallback chain (lib/view-registry.mjs) until reachable. // // view-settings.mjs resolve --lane <id> --option <user_config.medium> [--argument <m>] [--team-ref <ref>] -// Prints the resolution as one JSON line, then the report line and one line per notice. +// Prints the resolution as one JSON line, then the report line and one line per notice. With a +// medium resolved, the JSON adds `page` (the medium shows a page) and `publishes`. // Exit 0 resolved, 2 usage. import { execFileSync, spawnSync } from "node:child_process"; @@ -29,7 +30,7 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { configBlockBody, readLayers } from "./config-cascade.mjs"; -import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; +import { NO_PAGE, NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; const CONCERN = "rendered-views"; const HOME = "docs/conventions"; @@ -303,7 +304,10 @@ export function main(argv, env = process.env) { if (request.argument !== null && !mediumOf(request.argument)) return usage(); const ci = Boolean(env.CI) && env.CI !== "false"; const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); - process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + // So a skill can act on the medium without naming its value: does it show a page, does it publish. + const entry = mediumOf(resolution.medium); + const shown = entry ? { ...resolution, page: entry.id !== NO_PAGE, publishes: entry.publishes } : resolution; + process.stdout.write(`${JSON.stringify(shown)}\n${reportLines(resolution).join("\n")}\n`); return 0; } diff --git a/plugins/planning/reference/rendered-view.md b/plugins/planning/reference/rendered-view.md index 666e608b274..a534e1d953b 100644 --- a/plugins/planning/reference/rendered-view.md +++ b/plugins/planning/reference/rendered-view.md @@ -4,7 +4,7 @@ edit the canonical source, then rerun the script. --> # Rendered views of a skill's record How a view lane offers an interactive view of the record its skill wrote. The lanes that carry this file: -planning (`/planning:plan`, `/planning:brainstorm`), architecture (the `map-*` skills), discovery +planning (`/planning:plan`, `/planning:brainstorm`, `/planning:design`), architecture (the `map-*` skills), discovery (`/discovery:blindspot`) and debugging (`/debugging:debug`). Each lane's section below names its record, build command and data shape. `<plugin-root>` is the plugin's root directory, the one that holds `.claude-plugin/`: the path the calling skill gave for this file, cut before its first `skills/` or @@ -28,17 +28,9 @@ script written in the session. resolver, not on whether the resolver registers the lane. Only `planning` has migrated today; architecture, discovery and debugging are registered but read the cascade themselves until their own change migrates them. - - **Resolver lanes** (`planning`). Run the resolver. `--option` takes this plugin's `medium` option as the - calling skill states it; add `--argument` only when the reader named a medium in the request: - - ```bash - node "<plugin-root>/lib/view-settings.mjs" resolve --lane <lane> --option "<medium option>" [--argument terminal|file|artifact] - ``` - - It prints one JSON line, then the report line naming the medium and what decided it, then any notice; - print those lines as they stand. The JSON's `medium` is the one to use: `terminal`, `file` or `artifact`. - Do not read the rendered-views convention files yourself. When `node` is missing, the builder cannot run - either: deliver the record. + - **Resolver lanes** (`planning`). The lane's build command runs the resolver, the publish gate and + delivery in one call: follow its section in place of steps 3 and 5, and step 4's rule for a missing + `node` or a non-zero exit still applies. Do not read the rendered-views convention files yourself. - **Other lanes.** Read the `rendered-views` cascade surface: `~/.claude/rendered-views.md`, then `<repo root>/.claude/rendered-views.md`, then `<repo root>/.claude/rendered-views.local.md`, whichever exist. The last layer that states `medium:` wins (`auto`, `terminal`, `file`, `artifact`). A team layer @@ -69,31 +61,54 @@ script written in the session. ## Planning -`/planning:plan` records `PLAN.md`; `/planning:brainstorm` records the conversation list or `brainstorm.md`. -Genre: plans presented for approval, and option spreads. Resolver lane: `--lane planning`. +`/planning:plan` records `PLAN.md`; `/planning:brainstorm` records the conversation list or `brainstorm.md`; +`/planning:design` records `PLAN.md`'s `## Design` section. Genre: plans presented for approval, option +spreads, and designs handed to the plan. Resolver lane: `--lane planning`. -Build: pipe one JSON object (shapes under "Data shapes") to the builder; use `brainstorm` in place of `plan` -for the brainstorm view: +Resolve, build and deliver in one call. The builder runs the shared medium resolver +(`lib/view-settings.mjs`, the same resolution as its `resolve` command), runs the publish gate over a +publishing medium, builds the page unless the medium shows none, and hands it to the medium's entry in the +registry (`lib/view-registry.mjs`). Pipe one JSON object (shapes under "Data shapes") for the plan or +brainstorm view: ```bash -node "<plugin-root>/scripts/build-view.mjs" plan <<'JSON' +node "<plugin-root>/scripts/build-view.mjs" plan --option "<medium option>" --visibility "<visibility>" <<'JSON' {"title": "...", "goal": "...", "blast": "...", "phases": [{"status": "TODO", "name": "...", "what": "...", "needs": "none", "criteria": ["..."]}]} JSON ``` -A plan or brainstorm session reads repository files, issue text and fetched pages, which is why the builder -is the only way to the page. +- `plan` names the view: `brainstorm` for the brainstorm view; `design --record "<PLAN.md path>"` for the + design view, which reads the record's `## Design` section and takes no stdin. +- `--option` takes this plugin's `medium` option as the calling skill states it. Add + `--argument "<medium>"` only when the reader named a medium in the request. +- `--visibility` is the visibility of the repository the record draws on + (`gh repo view --json visibility -q .visibility`), or `NONE` when it draws on none. Left out, the + shipped default stays local. + +A planning session reads repository files, issue text and fetched pages, which is why the builder is the +only way to the page. + +The builder prints one JSON line, then the report line naming the medium and what decided it, then one line +per notice. Print the report and notice lines as they stand, then deliver what the JSON names: + +- `publish` set: publish that file with the Artifact tool and give the link, as step 5 says for `artifact`. + When the Artifact tool is unavailable, hand back `page` and say why in one line. +- Otherwise `page` set: hand back that path. +- Neither: no page was built, and the record stands. -Reply ids: `phases-2` is the second phase in `PLAN.md`; `candidates-3` is the third candidate in the list. -Approval of a plan is stated in the conversation, at the approval gate. +Reply ids: `phases-2` is the second phase in `PLAN.md`; `candidates-3` is the third candidate in the list; +`sections-2` is the second `###` subsection of `## Design`. Approval of a plan is stated in the conversation, +at the approval gate. ### Connect a view to the session -Planning only: it ships the view server. Either view's reply can reach this session instead of being -pasted. In the plan view the reader ticks phases, picks a verdict (`change`, `question`, `approve`), adds a -note, and sends. In the brainstorm view the reader ticks the candidates that resonate, adds a note, and -sends. Only for `file` (or `auto`) with the reader at this machine: the page is served from `127.0.0.1`. -Skip it for `artifact`, and when python3 or curl is missing; the copied reply still closes the loop. +Planning only: it ships the view server. A view's reply can reach this session instead of being pasted. In +the plan view the reader ticks phases, picks a verdict (`change`, `question`, `approve`), adds a note, and +sends. In the brainstorm view the reader ticks the candidates that resonate, adds a note, and sends. In the +design view the reader ticks the sections they question, adds a note, and sends. Only when the delivery was +a local page (`page` set, no `publish`) with the reader at this machine: the page is served from +`127.0.0.1`. Skip it for a published view, and when python3 or curl is missing; the copied reply still +closes the loop. 1. Start the view server on a new data dir under the OS temp directory, never beside the record (`ensure-running` creates it private): @@ -103,7 +118,8 @@ Skip it for `artifact`, and when python3 or curl is missing; the copied reply st ``` It prints one JSON line: `url`, `origin`, `page` and `watch`. -2. Build the page into `page`, naming `origin` (`brainstorm` in place of `plan` for the brainstorm view): +2. Build the page into `page`, naming `origin` (`brainstorm`, or `design --record "<PLAN.md path>"`, in + place of `plan` for the other views): ```bash node "<plugin-root>/scripts/build-view.mjs" plan --connect "<origin>" --out "<page>" <<'JSON' @@ -124,6 +140,8 @@ Skip it for `artifact`, and when python3 or curl is missing; the copied reply st - Brainstorm view: resolve `candidates-N` against the candidate list. The ticks are the reader's reaction, not a locked scope: reply with the scope you would propose and its route (brainstorm step 5), and lock nothing until the reader confirms it in the conversation. + - Design view: resolve `sections-N` against `## Design`. A change to a contract, boundary or verdict + routes back through `/planning:design`; the view edits nothing. 6. Write `<data_dir>/ops.json` with the Write tool, `{"replies": [{"seq": 1, "text": "..."}], "handled": [2]}`, a reply of at most 4000 characters per event you answer and `handled` for the rest, then run the event line's `next` as a background Bash task. It @@ -140,18 +158,22 @@ With no session listening, the page says so and its copy and save controls still ### Other optional views The interview's read-only decision table, the PRD pitch view and the design topology view are written -by the session, not the builder, and are only ever local files. Before writing one, resolve `medium` as -step 3 above does, passing a medium the reader names in the request as `--argument`. Then: +by the session, not the builder, and are only ever local files. Before writing one, run the shared medium +resolver, passing a medium the reader names in the request as `--argument`, and print its notice lines: -- `terminal`: write no file. The interview renders the same table inline as a fenced markdown table; - the PRD and design steps skip the view. -- `file`, `artifact` or `hosted`: write the local file. These views are never published, so say that a - publishing value was read as `file`. -- `auto`, an empty value or no value: the skill's shipped default. Any other value is reported and read - as `auto`. +```bash +node "<plugin-root>/lib/view-settings.mjs" resolve --lane planning --option "<medium option>" [--argument "<medium>"] +``` + +Its JSON line then decides: -Name the source of `medium` with the view or its absence: the request, the layer's path, or "shipped -default". The interview's `page` surface follows the `surface` option, not `medium`. +- `page` false: write no file, and print the report line as it stands. The interview renders the same + table inline as a fenced markdown table; the PRD and design steps skip the view. +- `page` true: write the local file. These views are never published, so when `publishes` is true, print + the report line with `file` in place of the medium and keep what decided it, as in + `view: file (plugin default)`; otherwise print it as it stands. + +The interview's `page` surface follows the `surface` option, not `medium`. ### Data shapes @@ -164,6 +186,10 @@ Every field is plain text. List order is the numbering in the record. `brainstorm`: `title`, `problem`, and `candidates` ordered cheapest first, each with `effort`, `name`, `what`, `where` (the files or mechanisms it touches), and `impact`. +`design`: no JSON. The builder reads the `PLAN.md` that `--record` names: its level-one heading as the title, +the text before the first `###` subsection of `## Design`, and each subsection's lines as written. A record +with no `## Design` section exits 1. + ## Architecture The `map-*` skills record a markdown file and a JSON record; both stay authoritative. A record holds diff --git a/plugins/planning/scripts/build-view.mjs b/plugins/planning/scripts/build-view.mjs index 081b481be6e..f4a62958812 100755 --- a/plugins/planning/scripts/build-view.mjs +++ b/plugins/planning/scripts/build-view.mjs @@ -1,63 +1,33 @@ #!/usr/bin/env node -// Build the plan or brainstorm view: a checked-in template plus the JSON data -// object on stdin, through the shared view builder (interactive profile). -// Writes the page to one deterministic path under the OS temp directory, -// never beside the record, and prints that path. +// Build a planning view (a kind the planning lane registers in lib/view-registry.mjs) from its +// record through render(laneId, kind, record) in the shared view builder. The record is a file +// (--record) or stdin: JSON for plan and brainstorm, a PLAN.md holding `## Design` for design. +// A page goes to one deterministic path under the OS temp directory, never beside the record. // -// node build-view.mjs plan|brainstorm < data.json -// node build-view.mjs plan|brainstorm --connect http://127.0.0.1:<port> --out <data_dir>/page.html < data.json +// node build-view.mjs <kind> [--record <file>] +// builds the page and prints its path +// node build-view.mjs <kind> --option <medium option> [--argument <medium>] [--visibility <V>] [--record <file>] +// resolves the medium (lib/view-settings.mjs), runs the publish gate (lib/publish-gate.mjs) over a +// publishing medium, builds the page unless the medium builds none, and hands it to the medium's +// registry entry. Prints one JSON line, the delivery plus `page` when a page was built, then the +// report line and one line per notice. <V> is the repository's visibility +// (PUBLIC|PRIVATE|INTERNAL|UNKNOWN|NONE); left out, the shipped default never publishes. +// node build-view.mjs <kind> --connect http://127.0.0.1:<port> --out <data_dir>/page.html [--record <file>] // the Claude-interactive page that view-bridge (../view-bridge/) serves; <data_dir> must be // a private view-bridge data dir outside any working tree, or the build is refused (exit 2) // -// Exit 0 built, 1 the data or the page fails its checks, 2 usage or environment. +// Exit 0 built or delivered, 1 the record or the page fails its checks, 2 usage or environment. import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { buildView, ViewBuildError } from "../lib/view-builder.mjs"; +import { publishGate } from "../lib/publish-gate.mjs"; +import { render, ViewBuildError } from "../lib/view-builder.mjs"; +import { laneOf, MEDIUMS, mediumOf, NO_PAGE, NO_PREFERENCE, reportLines } from "../lib/view-registry.mjs"; +import { findRoot, gateExplicit, resolveMedium } from "../lib/view-settings.mjs"; -const TEMPLATES = { - plan: "../skills/plan/templates/plan-view.html", - brainstorm: "../skills/brainstorm/templates/brainstorm-view.html", -}; - -// Data shapes from reference/rendered-view.md: top-level text fields, then one -// list of rows whose fields are text, plus `criteria` (a list of text) for plan. -const SHAPES = { - plan: { - text: ["title", "goal", "blast"], - list: "phases", - row: ["status", "name", "what", "needs"], - rowList: "criteria", - }, - brainstorm: { - text: ["title", "problem"], - list: "candidates", - row: ["effort", "name", "what", "where", "impact"], - }, -}; - -const isText = (value) => typeof value === "string"; -const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value); - -const shapeFailures = (shape, data) => { - if (!isObject(data)) return ["data is not an object"]; - const failures = shape.text.filter((key) => !isText(data[key])).map((key) => `${key} must be text`); - if (!Array.isArray(data[shape.list])) return [...failures, `${shape.list} must be a list`]; - for (const [index, row] of data[shape.list].entries()) { - const at = `${shape.list}[${index}]`; - if (!isObject(row)) { - failures.push(`${at} must be an object`); - continue; - } - for (const key of shape.row) if (!isText(row[key])) failures.push(`${at}.${key} must be text`); - if (shape.rowList && !(Array.isArray(row[shape.rowList]) && row[shape.rowList].every(isText))) { - failures.push(`${at}.${shape.rowList} must be a list of text`); - } - } - return failures; -}; +export const LANE = "planning"; const insideWorkingTree = (dir) => { for (let at = dir; ; at = dirname(at)) { @@ -94,37 +64,93 @@ const bridgeOutProblem = (out, origin) => { return null; }; -const [kind, ...rest] = process.argv.slice(2); -const flags = {}; -for (let i = 0; i < rest.length; i += 2) flags[rest[i]] = rest[i + 1]; -const flagsOk = - Object.keys(flags).every((key) => ["--connect", "--out"].includes(key)) && - ("--connect" in flags) === ("--out" in flags) && - Object.values(flags).every((value) => typeof value === "string" && value !== ""); -if (!Object.hasOwn(TEMPLATES, kind) || !flagsOk) { - console.error("usage: build-view.mjs plan|brainstorm [--connect <session-bridge origin> --out <data_dir>/page.html] < data.json"); - process.exit(2); +/** Write the page to <tmp>/planning-views/<kind>.html; the temp root is shared, so the directory is private and no link is written through. */ +export function writePrivate(page, kind) { + const dir = join(tmpdir(), "planning-views"); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + if (!lstatSync(dir).isDirectory()) throw new Error("build-view: refusing a symlinked or non-directory output path"); + chmodSync(dir, 0o700); + const out = join(dir, `${kind}.html`); + rmSync(out, { force: true }); + writeFileSync(out, page, { mode: 0o600 }); + return out; } -if (flags["--out"]) { - const problem = bridgeOutProblem(flags["--out"], flags["--connect"]); - if (problem) { - console.error(`build-view: refused: ${problem}`); - process.exit(2); +/** + * Deliver the `kind` view of `record` on the resolved medium. A publishing medium first passes the + * publish gate, which keeps the page on the chain's local step for a non-public repository or a + * credential-shaped record unless the reader chose to publish; the report then names the gate. + * @param {{kind: string, record: unknown, resolution: {preferred: string|null, medium: string, decidedBy: string, notices: {text: string}[]}, visibility?: string, mediums?: object, write?: (page: string, kind: string) => string, context?: object}} input + * mediums replaces the medium registry; write stores the page and returns its path + * @returns {{delivery: object, report: string[]}} delivery: the medium entry's deliver result, plus `page` when a page was built + */ +export function deliverView({ kind, record, resolution, visibility = "UNKNOWN", mediums = MEDIUMS, write = writePrivate, context = {} }) { + const entryOf = (id) => (Object.hasOwn(mediums, id) ? mediums[id] : undefined); + let entry = entryOf(resolution.medium); + if (!entry) throw new Error(`build-view: no medium entry for ${JSON.stringify(resolution.medium)}`); + let shown = resolution; + if (entry.publishes) { + const text = typeof record === "string" ? record : JSON.stringify(record); + const gate = publishGate({ explicit: gateExplicit(resolution), visibility, text, subject: "record" }); + if (!mediumOf(gate.medium).publishes) { + entry = entryOf(gate.medium) ?? mediumOf(gate.medium); + const notices = [...resolution.notices, ...(gate.opt_in ? [{ kind: "gate", text: gate.opt_in }] : [])]; + shown = { ...resolution, medium: gate.medium, decidedBy: `publish gate: ${gate.reason}`, notices }; + } } + const path = entry.id === NO_PAGE ? null : write(render(LANE, kind, record), kind); + const delivery = entry.deliver({ kind, path }, context); + return { delivery: path ? { ...delivery, page: path } : delivery, report: reportLines(shown) }; } -try { - const data = JSON.parse(readFileSync(0, "utf8")); - const failures = shapeFailures(SHAPES[kind], data); - if (failures.length > 0) throw new ViewBuildError(failures); - const page = buildView({ - profile: "interactive", - template: readFileSync(fileURLToPath(new URL(TEMPLATES[kind], import.meta.url)), "utf8"), - data, - connect: flags["--connect"] ?? null, - }); - if (flags["--out"]) { +const USAGE = + "usage: build-view.mjs <kind> [--record <file>]\n" + + " build-view.mjs <kind> --option <medium option> [--argument <medium>] [--visibility <V>] [--record <file>]\n" + + " build-view.mjs <kind> --connect <session-bridge origin> --out <data_dir>/page.html [--record <file>]\n"; +const FLAGS = ["--record", "--option", "--argument", "--visibility", "--connect", "--out"]; + +function main(argv, env = process.env) { + const [kind, ...rest] = argv; + const flags = {}; + for (let i = 0; i < rest.length; i += 2) flags[rest[i]] = rest[i + 1]; + const has = (key) => key in flags; + const flagsOk = + Object.keys(flags).every((key) => FLAGS.includes(key)) && + Object.entries(flags).every(([key, value]) => typeof value === "string" && (value !== "" || key === "--option")) && + has("--connect") === has("--out") && + !(has("--option") && has("--connect")) && + (has("--option") || !(has("--argument") || has("--visibility"))) && + (!has("--argument") || flags["--argument"] === NO_PREFERENCE || Boolean(mediumOf(flags["--argument"]))); + if (!laneOf(LANE).kinds.includes(kind) || !flagsOk) { + process.stderr.write(USAGE); + return 2; + } + if (has("--out")) { + const problem = bridgeOutProblem(flags["--out"], flags["--connect"]); + if (problem) { + console.error(`build-view: refused: ${problem}`); + return 2; + } + } + try { + const record = readFileSync(flags["--record"] ?? 0, "utf8"); + if (has("--option")) { + const argument = flags["--argument"] === NO_PREFERENCE ? null : (flags["--argument"] ?? null); + const interactive = !(Boolean(env.CI) && env.CI !== "false"); + const resolution = resolveMedium( + { lane: LANE, argument, userConfig: flags["--option"], projectRoot: findRoot() }, + { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive }, + ); + const visibility = (flags["--visibility"] ?? "UNKNOWN").toUpperCase(); + const { delivery, report } = deliverView({ kind, record, resolution, visibility, context: { interactive } }); + process.stdout.write(`${JSON.stringify(delivery)}\n${report.join("\n")}\n`); + return 0; + } + const page = render(LANE, kind, record, { connect: flags["--connect"] ?? null }); + if (!has("--out")) { + console.log(writePrivate(page, kind)); + return 0; + } // Replace a planted link or file rather than write through it. if (existsSync(flags["--out"]) && lstatSync(flags["--out"]).isDirectory()) { throw new Error(`build-view: refused: ${flags["--out"]} is a directory`); @@ -132,19 +158,13 @@ try { rmSync(flags["--out"], { force: true }); writeFileSync(flags["--out"], page, { flag: "wx", mode: 0o600 }); console.log(flags["--out"]); - process.exit(0); + return 0; + } catch (err) { + console.error(err instanceof SyntaxError ? "build-view: the record is not JSON" : err.message); + return err instanceof ViewBuildError ? 1 : 2; } - // The temp root is shared: keep the directory private and never write through a link. - const dir = join(tmpdir(), "planning-views"); - mkdirSync(dir, { recursive: true, mode: 0o700 }); - if (!lstatSync(dir).isDirectory()) - throw new Error("build-view: refusing a symlinked or non-directory output path"); - chmodSync(dir, 0o700); - const out = join(dir, `${kind}.html`); - rmSync(out, { force: true }); - writeFileSync(out, page, { mode: 0o600 }); - console.log(out); -} catch (err) { - console.error(err instanceof SyntaxError ? "build-view: stdin is not JSON" : err.message); - process.exit(err instanceof ViewBuildError ? 1 : 2); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exitCode = main(process.argv.slice(2)); } diff --git a/plugins/planning/scripts/build-view.test.sh b/plugins/planning/scripts/build-view.test.sh index b1e52cac185..48bb6fc4d60 100755 --- a/plugins/planning/scripts/build-view.test.sh +++ b/plugins/planning/scripts/build-view.test.sh @@ -140,6 +140,24 @@ for (const [kind, fixture] of Object.entries(fixtures)) { check(`${kind}: browser keeps hostile data as text`, shownBad.includes('class="rv-ready"') && !/<img|<svg/i.test(shownBad) && shownBad.includes("<img src=x")); } } +// The design view reads a PLAN.md, not JSON: its Design section's subsections become rows. +{ + const record = ["# Plan: Cache", "", "## Design", "", "Gate: PASS.", "", "### Module layout", "", "| Module | Owns |", "", "### Contracts", "", `get(key) ${hostile}`, "", "## Plan", ""].join("\n"); + const built = build("design", record); + const path = `${work}/design-good.html`; + check("design: builds from a PLAN.md on stdin", built.status === 0, built.stderr); + copyFileSync(built.stdout.trim(), path); + check("design: the page passes the interactive profile", verify(path).status === 0, verify(path).stdout); + check("design: hostile record text stays inside the JSON block", !readFileSync(path, "utf8").includes("<img src=x")); + check("design: a record with no Design section exits 1", build("design", "# Plan\n\n## Plan\n").status === 1); + if (chrome) { + const shown = dump(path); + check("design: browser renders from file://", shown.includes('class="rv-ready"'), shown.slice(0, 160)); + check("design: browser rows carry builder ids", ["sections-2", "sections-2-lines-1"].every((id) => shown.includes(`id="${id}"`))); + check("design: browser keeps hostile record text as text", !/<img|<svg/i.test(shown) && shown.includes("<img src=x")); + } +} + for (const kind of Object.keys(fixtures)) { const html = readFileSync(`${dir}/${fixtures[kind].template}`, "utf8"); check(`${kind}: the template carries the send control and the session replies`, ["data-rv-send=", "data-rv-session=", "data-rv-replies="].every((marker) => html.includes(marker))); diff --git a/plugins/planning/scripts/deliver-view.test.mjs b/plugins/planning/scripts/deliver-view.test.mjs new file mode 100644 index 00000000000..914a032bfcf --- /dev/null +++ b/plugins/planning/scripts/deliver-view.test.mjs @@ -0,0 +1,231 @@ +// Planning views on the view registries: each kind renders through render(laneId, kind, record), +// the shipped default is a private Artifact that the publish gate keeps local for a non-public +// repository or a credential-shaped record, and a medium added only to a registry delivers a plan +// view with no edit to the planning skills. +import { strict as assert } from "node:assert"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { describe, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { render, validateView, VIEW_KINDS, ViewBuildError } from "../lib/view-builder.mjs"; +import { MEDIUMS } from "../lib/view-registry.mjs"; +import { resolveMedium } from "../lib/view-settings.mjs"; +import { deliverView } from "./build-view.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI = join(HERE, "build-view.mjs"); +const dirs = []; +process.on("exit", () => dirs.forEach((dir) => rmSync(dir, { recursive: true, force: true }))); +const tempDir = () => { + const dir = mkdtempSync(join(tmpdir(), "deliver-view-")); + dirs.push(dir); + return dir; +}; + +const DESIGN_RECORD = [ + "# Plan: Cache query results", + "", + "## Brief", + "", + "Cache repeated reads.", + "", + "## Design", + "", + "Gate: PASS (design handoff).", + "", + "### Module layout", + "", + "| Module | Owns |", + "|---|---|", + "| Cache | entries |", + "", + "### Contracts", + "", + "```text", + "get(key) -> value?", + "## inside a fence, not a heading", + "```", + "", + "## Plan", + "", + "### Phase 1: Cache layer", + "", +].join("\n"); + +const PLAN = { + title: "Add caching to query handlers", + goal: "Cache repeated query results.", + blast: "MEDIUM", + phases: [{ status: "TODO", name: "Phase 1: Cache layer", what: "Add the cache.", needs: "none", criteria: ["Tests pass."] }], +}; +const PLAN_RECORD = JSON.stringify(PLAN); +const dataOf = (page) => JSON.parse(/<script type="application\/json" id="rv-data">([\s\S]*?)<\/script>/.exec(page)[1]); +const resolution = (medium, decidedBy, preferred = medium) => ({ preferred, medium, decidedBy, notices: [] }); + +/** A write step that keeps each page in memory and hands back a made-up path. */ +const memory = () => { + const pages = []; + return { pages, write: (page, kind) => (pages.push(page), `/views/${kind}-${pages.length}.html`) }; +}; + +describe("the design kind", () => { + test("reads the record's ## Design section: the title, the lead text, and each subsection's lines", () => { + assert.deepEqual(VIEW_KINDS.design.parse(DESIGN_RECORD), { + title: "Plan: Cache query results", + intro: "Gate: PASS (design handoff).", + sections: [ + { name: "Module layout", lines: ["| Module | Owns |", "|---|---|", "| Cache | entries |"] }, + { name: "Contracts", lines: ["```text", "get(key) -> value?", "## inside a fence, not a heading", "```"] }, + ], + }); + }); + + test("a record with no ## Design section is refused as data", () => { + assert.throws(() => VIEW_KINDS.design.parse("# Plan\n\n## Plan\n\n### Phase 1\n"), ViewBuildError); + }); + + test("renders a page that passes the interactive profile", () => { + const page = render("planning", "design", DESIGN_RECORD); + assert.equal(validateView(page).ok, true, validateView(page).failures?.join(", ")); + assert.deepEqual(dataOf(page).sections.map((s) => s.name), ["Module layout", "Contracts"]); + }); +}); + +describe("render", () => { + test("renders the plan kind from its JSON record", () => { + assert.equal(dataOf(render("planning", "plan", PLAN_RECORD)).title, "Add caching to query handlers"); + }); + + test("refuses a kind the lane does not register", () => { + assert.throws(() => render("review:explain-change", "plan", PLAN_RECORD), /does not register/); + }); + + test("refuses an unknown lane", () => { + assert.throws(() => render("nope", "plan", PLAN_RECORD), /unknown lane/); + }); +}); + +describe("deliverView", () => { + test("a medium registered only in a fixture registry delivers a plan view", () => { + const delivered = []; + const fake = Object.freeze({ + id: "fake", + publishes: false, + reportName: "a fake medium", + reachable: () => ({ ok: true }), + deliver: (view) => (delivered.push(view), { location: `fake:${view.path}` }), + }); + const { pages, write } = memory(); + const out = deliverView({ kind: "plan", record: PLAN_RECORD, resolution: resolution("fake", "argument"), mediums: { ...MEDIUMS, fake }, write }); + assert.equal(delivered.length, 1); + assert.equal(delivered[0].path, "/views/plan-1.html"); + assert.equal(out.delivery.location, "fake:/views/plan-1.html"); + assert.equal(dataOf(pages[0]).title, "Add caching to query handlers"); + assert.equal(validateView(pages[0]).ok, true); + assert.deepEqual(out.report, ["view: fake (argument)"]); + }); + + test("the shipped default publishes a public repository's clean record", () => { + const { write } = memory(); + const out = deliverView({ kind: "plan", record: PLAN_RECORD, resolution: resolution("artifact", "plugin default"), visibility: "PUBLIC", write }); + assert.equal(out.delivery.publish, "/views/plan-1.html"); + assert.deepEqual(out.report, ["view: artifact (plugin default)"]); + }); + + test("the publish gate keeps the shipped default local for a private repository", () => { + const { write } = memory(); + const out = deliverView({ kind: "plan", record: PLAN_RECORD, resolution: resolution("artifact", "plugin default"), visibility: "PRIVATE", write }); + assert.equal(out.delivery.publish, undefined); + assert.equal(out.delivery.location, "/views/plan-1.html"); + assert.match(out.report[0], /^view: file \(publish gate: .*PRIVATE/); + assert.equal(out.report.length, 2, "the gate's opt-in line follows the report line"); + }); + + test("the publish gate keeps the shipped default local when the visibility is not given", () => { + const out = deliverView({ kind: "plan", record: PLAN_RECORD, resolution: resolution("artifact", "plugin default"), write: memory().write }); + assert.equal(out.delivery.publish, undefined); + }); + + test("the publish gate keeps a credential-shaped record local in a public repository", () => { + const record = JSON.stringify({ ...PLAN, goal: `token ghp_${"a".repeat(36)}` }); + const out = deliverView({ kind: "plan", record, resolution: resolution("artifact", "plugin default"), visibility: "PUBLIC", write: memory().write }); + assert.equal(out.delivery.publish, undefined); + assert.match(out.report[0], /^view: file \(publish gate: .*GitHub token/); + }); + + test("an artifact the reader chose in userConfig publishes from a private repository", () => { + const out = deliverView({ kind: "plan", record: PLAN_RECORD, resolution: resolution("artifact", "userConfig"), visibility: "PRIVATE", write: memory().write }); + assert.equal(out.delivery.publish, "/views/plan-1.html"); + }); + + test("the terminal builds no page", () => { + const { pages, write } = memory(); + const out = deliverView({ kind: "design", record: "no design here", resolution: resolution("terminal", "userConfig"), write }); + assert.equal(pages.length, 0); + assert.deepEqual(out.delivery, { location: "terminal" }); + }); +}); + +describe("the planning lane", () => { + test("ships a private Artifact as its default when no scope decides", () => { + const home = tempDir(); + const got = resolveMedium({ lane: "planning", userConfig: "auto" }, { userHome: home, dataDir: null }); + assert.deepEqual([got.preferred, got.medium, got.decidedBy], ["artifact", "artifact", "plugin default"]); + }); +}); + +describe("the build-view CLI", () => { + const env = (dir, extra = {}) => ({ ...process.env, HOME: dir, USERPROFILE: dir, CLAUDE_PROJECT_DIR: dir, TMPDIR: dir, TEMP: dir, TMP: dir, CI: "", ...extra }); + + test("design --record writes one HTML page", () => { + const dir = tempDir(); + const record = join(dir, "PLAN.md"); + writeFileSync(record, DESIGN_RECORD); + const run = spawnSync("node", [CLI, "design", "--record", record], { encoding: "utf8", env: env(dir) }); + assert.equal(run.status, 0, run.stderr); + const path = run.stdout.trim(); + assert.equal(path, join(dir, "planning-views", "design.html")); + assert.deepEqual(readdirSync(join(dir, "planning-views")), ["design.html"]); + assert.equal(validateView(readFileSync(path, "utf8")).ok, true); + }); + + test("--option resolves, gates and delivers, then prints the report line", () => { + const dir = tempDir(); + const record = join(dir, "PLAN.md"); + writeFileSync(record, DESIGN_RECORD); + const run = spawnSync("node", [CLI, "design", "--record", record, "--option", "auto"], { encoding: "utf8", env: env(dir) }); + assert.equal(run.status, 0, run.stderr); + const [json, report] = run.stdout.trim().split("\n"); + const delivery = JSON.parse(json); + assert.equal(delivery.page, join(dir, "planning-views", "design.html")); + assert.equal(delivery.publish, undefined); + assert.match(report, /^view: file \(publish gate: /); + }); + + test("a non-interactive run builds no page", () => { + const dir = tempDir(); + const run = spawnSync("node", [CLI, "plan", "--option", "artifact"], { input: PLAN_RECORD, encoding: "utf8", env: env(dir, { CI: "true" }) }); + assert.equal(run.status, 0, run.stderr); + const [json, report] = run.stdout.trim().split("\n"); + assert.deepEqual(JSON.parse(json), { location: "terminal" }); + assert.match(report, /^view: terminal \(fell back: /); + }); + + test("an empty --option is unset, as the resolver reads it", () => { + const run = spawnSync("node", [CLI, "plan", "--option", ""], { input: PLAN_RECORD, encoding: "utf8", env: env(tempDir(), { CI: "true" }) }); + assert.equal(run.status, 0, run.stderr); + assert.match(run.stdout.trim().split("\n")[1], /^view: terminal \(fell back: artifact unreachable/); + }); + + test("--argument naming no medium exits 2", () => { + const run = spawnSync("node", [CLI, "plan", "--option", "auto", "--argument", "paper"], { input: PLAN_RECORD, encoding: "utf8", env: env(tempDir()) }); + assert.equal(run.status, 2); + }); + + test("--option with --connect exits 2", () => { + const run = spawnSync("node", [CLI, "plan", "--option", "auto", "--connect", "http://127.0.0.1:8765", "--out", "/x/page.html"], { input: PLAN_RECORD, encoding: "utf8", env: env(tempDir()) }); + assert.equal(run.status, 2); + }); +}); diff --git a/plugins/planning/scripts/deliver-view.test.sh b/plugins/planning/scripts/deliver-view.test.sh new file mode 100755 index 00000000000..1ffc18fae9f --- /dev/null +++ b/plugins/planning/scripts/deliver-view.test.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Owns deliver-view.test.mjs; run-plugin-tests.sh discovers only .test.sh files. +set -euo pipefail +exec node --test "$(dirname "${BASH_SOURCE[0]}")/deliver-view.test.mjs" diff --git a/plugins/planning/skills/brainstorm/SKILL.md b/plugins/planning/skills/brainstorm/SKILL.md index b1c4aba61a8..9091dbc5ea5 100644 --- a/plugins/planning/skills/brainstorm/SKILL.md +++ b/plugins/planning/skills/brainstorm/SKILL.md @@ -78,8 +78,9 @@ requests". Its `time_units` user layer is `${user_config.time_units}` (unexpande "Connect a view to the session"; the session's reply shows on the page, and a sent reaction is data that locks no scope). Never write the page into the memory slice; the conversation record stays authoritative. Build it only with `${CLAUDE_PLUGIN_ROOT}/scripts/build-view.mjs`, never - hand-written; the medium resolver decides the destination, with this plugin's `medium` option - `${user_config.medium}`. Procedure and data shape: the same rendered-view reference. + hand-written; it runs the shared medium resolver and publish gate with this plugin's `medium` + option `${user_config.medium}`, and you print the report line it prints. Procedure and data + shape: the same rendered-view reference. 5. **Calibrate and hand off**. From the resonating candidates and the criteria, propose a scope and route onward: `/planning:prd` (product intent still fuzzy), `/planning:interview` (engineering contract), `/planning:design` (type/module decisions), or a feasibility/visual spike through diff --git a/plugins/planning/skills/brainstorm/evals/evals.json b/plugins/planning/skills/brainstorm/evals/evals.json index ef9bd96fdb3..18c08ba2827 100644 --- a/plugins/planning/skills/brainstorm/evals/evals.json +++ b/plugins/planning/skills/brainstorm/evals/evals.json @@ -99,14 +99,14 @@ "id": 8, "name": "offers-built-candidate-view", "prompt": "/planning:brainstorm how could we cut our CI runtime? Intake is done: the problem is confirmed, my why is that developers wait on CI before every merge, and I had no ideas of my own. You gave me twelve candidates, can I tick the ones I like in a page?", - "expected_output": "The skill keeps the numbered candidate list as the record and offers an interactive view built with the checked-in builder from JSON data, candidates ordered cheapest first, with the destination resolved from the medium cascade key.", + "expected_output": "The skill keeps the numbered candidate list as the record and offers an interactive view built with the checked-in builder from JSON data, candidates ordered cheapest first; the builder resolves the medium through the shared resolver and publish gate, and the skill prints its report line.", "files": [], "expectations": [ "The numbered candidate list in the conversation stays the record", - "The view is built by piping JSON to scripts/build-view.mjs, not by writing HTML or script in the session", + "The view is built by piping JSON to scripts/build-view.mjs with the plugin's medium option, not by writing HTML or script in the session", "The candidates in the view keep the cheapest-to-most-ambitious order of the list", "The page is not written into the memory slice, even when brainstorm.md is persisted there", - "The publish destination comes from the medium key of the rendered-views cascade" + "Output prints the builder's report line, `view: <medium> (<what decided it>)`, and delivers what the builder's JSON names without choosing a medium itself" ] } ] diff --git a/plugins/planning/skills/design/SKILL.md b/plugins/planning/skills/design/SKILL.md index b3ddb2b4a72..d545ffb4d02 100644 --- a/plugins/planning/skills/design/SKILL.md +++ b/plugins/planning/skills/design/SKILL.md @@ -268,7 +268,8 @@ Rules: [`${CLAUDE_PLUGIN_ROOT}/reference/artifact-protocol.md`](${CLAUDE_PLUGIN_ROOT}/reference/artifact-protocol.md). Resolve `medium` first per [`${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md`](${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md) -"Other optional views", with this plugin's `medium` option `${user_config.medium}`: `terminal` skips the view. +"Other optional views", with this plugin's `medium` option `${user_config.medium}`, and follow its +result, which names the report line to print. ### Phase 5: Discussion Rounds @@ -314,6 +315,13 @@ Invoke `/planning:design-handoff` via the Skill tool. It is the only gate implem plan-ready summary and resume prompt. This skill carries no gate criteria of its own; criteria changes land in `/planning:design-handoff` only. +On PASS, offer an interactive design view of PLAN.md's `## Design` (its sections, a tick for each one +the reader questions, a copy-out of the reply, sent to this session when connected). PLAN.md stays the +record. Build it only with `${CLAUDE_PLUGIN_ROOT}/scripts/build-view.mjs` from that record, never +hand-written; it runs the shared medium resolver and publish gate with this plugin's `medium` option +`${user_config.medium}`, and you print the report line it prints. Procedure: +[`${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md`](${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md). + **Stub files (`scaffold_stubs`)**. Before invoking the gate, resolve `scaffold_stubs`, lowest layer first: the default `false`; the plugin's user config (`${user_config.scaffold_stubs}`, where a literal unexpanded value means unset); then the `scaffold_stubs` key of the repository's diff --git a/plugins/planning/skills/design/evals/evals.json b/plugins/planning/skills/design/evals/evals.json index 02d528046b4..c8486bfe1d7 100644 --- a/plugins/planning/skills/design/evals/evals.json +++ b/plugins/planning/skills/design/evals/evals.json @@ -248,6 +248,18 @@ "The variation thread is recorded resolved (contain, with its rationale), so the design-handoff gate does not bounce it back as unresolved", "Output continues the design rather than blocking on the unanswered question" ] + }, + { + "id": 20, + "name": "offers-built-design-view", + "prompt": "/planning:design handoff. The design-handoff gate passed and wrote PLAN.md's Design section for the cache topic. Can I see the design as a page?", + "expected_output": "The skill keeps PLAN.md's Design section as the record and builds the design view from it with the checked-in builder; the builder resolves the medium through the shared resolver and publish gate, and the skill prints its report line and never writes HTML or script itself.", + "files": [], + "expectations": [ + "PLAN.md's Design section stays the record; the page is not written beside it", + "The view is built with scripts/build-view.mjs design --record naming PLAN.md, with the plugin's medium option, not by writing HTML or script in the session", + "Output prints the builder's report line, `view: <medium> (<what decided it>)`, and delivers what the builder's JSON names without choosing a medium itself" + ] } ] } diff --git a/plugins/planning/skills/design/templates/design-view.html b/plugins/planning/skills/design/templates/design-view.html new file mode 100644 index 00000000000..9c249ff4825 --- /dev/null +++ b/plugins/planning/skills/design/templates/design-view.html @@ -0,0 +1,112 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> +<title>Design View + + + +
+
+

Design view. The Design section of PLAN.md is the record.

+

Design

+

+

0 design sections.

+
+
+ + +
+
    +
  1. + +
    +

    +
    +
    +
  2. +
+
+

Send your reply back

+

Tick the sections you have a question or change for and say what it is. The ids name sections by their position in the Design section.

+ + +
+ + + +
+

+

+

Session replies

+
    + +
    +
    + + diff --git a/plugins/planning/skills/plan/SKILL.md b/plugins/planning/skills/plan/SKILL.md index ef57d051e14..b5c16b00b9f 100644 --- a/plugins/planning/skills/plan/SKILL.md +++ b/plugins/planning/skills/plan/SKILL.md @@ -108,7 +108,7 @@ The plan is a proposal, not a commitment. The user approves, modifies, or reject 7. **Explicit approval request**: "Approve this plan to proceed to execution, or provide feedback to revise. Anything tagged `[EXEC-SHAPE]` or `[FALLBACK]` above is /planning:plan's discretion. Flag any you want changed." Then name each Brief deferred question still open with **arbiter: USER-RESERVED**, with the plan's proposal, as needing its own reply 8. **Highest-leverage replies**: close with 2-4 pre-drafted one-line revision replies, one per flagged close call or gate-passed decision, each a copyable sentence that flips exactly that decision (e.g. "Switch phase 2 to the queue-based alternative"). The user's cheapest possible reaction is pasting one back; a presentation whose flagged decisions have no pre-drafted flip line makes the user compose the revision themselves -**Presentation order: tweak-likelihood first.** Lead with what the user is most likely to change: data-model/schema choices, type interfaces and public contracts, and user-facing surfaces (flag close calls with their alternatives); mechanical, low-judgment work goes last. Phase EXECUTION order stays as `phase_order` set it in Step 2. After the presentation, offer an interactive plan view (phases, dependencies, acceptance criteria, a reply that names the phases to change, sent to this session through `session-bridge` when connected). `PLAN.md` stays the record. Build it only with `${CLAUDE_PLUGIN_ROOT}/scripts/build-view.mjs`, never hand-written; the medium resolver decides the destination, with this plugin's `medium` option `${user_config.medium}`. Procedure and data shape: [`${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md`](${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md). +**Presentation order: tweak-likelihood first.** Lead with what the user is most likely to change: data-model/schema choices, type interfaces and public contracts, and user-facing surfaces (flag close calls with their alternatives); mechanical, low-judgment work goes last. Phase EXECUTION order stays as `phase_order` set it in Step 2. After the presentation, offer an interactive plan view (phases, dependencies, acceptance criteria, a reply that names the phases to change, sent to this session through `session-bridge` when connected). `PLAN.md` stays the record. Build it only with `${CLAUDE_PLUGIN_ROOT}/scripts/build-view.mjs`, never hand-written; it runs the shared medium resolver and publish gate with this plugin's `medium` option `${user_config.medium}`, and you print the report line it prints. Procedure and data shape: [`${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md`](${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md). If 2–4 named alternatives surfaced during the stress-test, present them side by side via `AskUserQuestion` when the plugin's `use_ask_user_question` user config (`${user_config.use_ask_user_question}`; unexpanded or empty means off) is on, numbered inline prose otherwise. For open-ended approval (single proposal, no alternatives) use prose. diff --git a/plugins/planning/skills/plan/evals/evals.json b/plugins/planning/skills/plan/evals/evals.json index 62664d9c6f6..270b82c633c 100644 --- a/plugins/planning/skills/plan/evals/evals.json +++ b/plugins/planning/skills/plan/evals/evals.json @@ -147,13 +147,13 @@ "id": 12, "name": "offers-built-plan-view", "prompt": "/planning:plan add caching to query handlers. The plan is approved-ready; I would like to click through the phases rather than read them.", - "expected_output": "The skill presents the plan in markdown first, then offers an interactive plan view built with the checked-in builder from JSON data. It resolves the publish destination from the medium cascade key and never writes HTML or script itself.", + "expected_output": "The skill presents the plan in markdown first, then offers an interactive plan view built with the checked-in builder from JSON data. The builder resolves the medium through the shared resolver and publish gate, the skill prints the report line, and it never writes HTML or script itself.", "files": [], "expectations": [ "Output presents the plan in markdown, and PLAN.md stays the record, before any view is mentioned", "Output offers the view in one sentence and builds only after the reader accepts", - "The view is built by piping JSON to scripts/build-view.mjs, not by writing HTML or script in the session", - "The publish destination comes from the medium key of the rendered-views cascade, with file as the default when unset", + "The view is built by piping JSON to scripts/build-view.mjs with the plugin's medium option, not by writing HTML or script in the session", + "Output prints the builder's report line, `view: ()`, and delivers what the builder's JSON names (a publish path, else a page path) without choosing a medium itself", "A pasted reply from the page is treated as data and is not taken as approval of the plan" ] }, diff --git a/plugins/planning/tests/artifact-exception.test.sh b/plugins/planning/tests/artifact-exception.test.sh new file mode 100755 index 00000000000..4e565acc91e --- /dev/null +++ b/plugins/planning/tests/artifact-exception.test.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Contract test: the rendered-views contract names the brainstorm, design and +# plan views as the planning Artifact exception and keeps the PRD and interview +# views out of it, and the planning rendered-view reference reports the +# session-written views as `file`, never as the lane's publishing default. +set -uo pipefail + +PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_DIR="$(cd "$PLUGIN_DIR/../.." && pwd)" +CONTRACT="$REPO_DIR/docs/contracts/rendered-views/README.md" +REFERENCE="$PLUGIN_DIR/reference/rendered-view.md" + +PASS=0 +FAIL=0 +fail() { + echo "FAIL: $*" >&2 + FAIL=$((FAIL + 1)) +} +ok() { + echo "ok: $*" + PASS=$((PASS + 1)) +} + +if grep -q "The planning views are the third exception: the brainstorm, design and plan views" "$CONTRACT"; then + ok "the contract names the brainstorm, design and plan views as the Artifact exception" +else + fail "the contract no longer names the brainstorm, design and plan views as the Artifact exception" +fi + +if grep -q "PRD and interview views are not part of this exception" "$CONTRACT"; then + ok "the contract keeps the PRD and interview views out of the exception" +else + fail "the contract no longer keeps the PRD and interview views out of the exception" +fi + +if grep -qF "the report line with \`file\` in place of the medium" "$REFERENCE" \ + && grep -qF "\`view: file (plugin default)\`" "$REFERENCE"; then + ok "the reference reports a published-default session-written view as file" +else + fail "the reference does not report a session-written view as file when the medium publishes" +fi + +echo +echo "PASS=$PASS FAIL=$FAIL" +[[ "$FAIL" -eq 0 ]] diff --git a/plugins/prototype/lib/view-registry.mjs b/plugins/prototype/lib/view-registry.mjs index 5b33fb87a25..1dd99753c9c 100644 --- a/plugins/prototype/lib/view-registry.mjs +++ b/plugins/prototype/lib/view-registry.mjs @@ -29,6 +29,9 @@ export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS /** The one fallback chain: a view that cannot be served on a medium steps down this list. */ export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); +/** The chain's last step builds no page: the record stands on its own. */ +export const NO_PAGE = FALLBACK_CHAIN.at(-1); + /** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ export const NO_PREFERENCE = "auto"; @@ -47,7 +50,7 @@ export const LANES = Object.freeze( lane("review:explain-change", ["digest"], true, "artifact"), lane("visualization:present", ["deck"], false, "artifact"), lane("visualization:visualize", [], false, null), - lane("planning", ["plan", "brainstorm"], false, "file"), + lane("planning", ["plan", "brainstorm", "design"], false, "artifact"), lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), lane("discovery:blindspot", ["blindspot"], false, "file"), lane("debugging:debug", ["post-mortem"], false, "file"), diff --git a/plugins/prototype/lib/view-settings.mjs b/plugins/prototype/lib/view-settings.mjs index c37c0127da9..d268b37da9a 100755 --- a/plugins/prototype/lib/view-settings.mjs +++ b/plugins/prototype/lib/view-settings.mjs @@ -19,7 +19,8 @@ // medium then steps down the fallback chain (lib/view-registry.mjs) until reachable. // // view-settings.mjs resolve --lane --option [--argument ] [--team-ref ] -// Prints the resolution as one JSON line, then the report line and one line per notice. +// Prints the resolution as one JSON line, then the report line and one line per notice. With a +// medium resolved, the JSON adds `page` (the medium shows a page) and `publishes`. // Exit 0 resolved, 2 usage. import { execFileSync, spawnSync } from "node:child_process"; @@ -29,7 +30,7 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { configBlockBody, readLayers } from "./config-cascade.mjs"; -import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; +import { NO_PAGE, NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; const CONCERN = "rendered-views"; const HOME = "docs/conventions"; @@ -303,7 +304,10 @@ export function main(argv, env = process.env) { if (request.argument !== null && !mediumOf(request.argument)) return usage(); const ci = Boolean(env.CI) && env.CI !== "false"; const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); - process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + // So a skill can act on the medium without naming its value: does it show a page, does it publish. + const entry = mediumOf(resolution.medium); + const shown = entry ? { ...resolution, page: entry.id !== NO_PAGE, publishes: entry.publishes } : resolution; + process.stdout.write(`${JSON.stringify(shown)}\n${reportLines(resolution).join("\n")}\n`); return 0; } diff --git a/plugins/review/lib/view-builder.mjs b/plugins/review/lib/view-builder.mjs index d9df8de727e..8202be9f20c 100644 --- a/plugins/review/lib/view-builder.mjs +++ b/plugins/review/lib/view-builder.mjs @@ -17,6 +17,11 @@ // exception, in both profiles, is the optional page title (rule 5): the builder // writes it as one escaped, capped line of text into the head's . // +// View kinds: VIEW_KINDS holds one entry per kind, { id, parse(record) -> data, template, tier }, +// the template path relative to the root of the plugin carrying this file. render(laneId, kind, +// record) builds a kind the lane registers (lib/view-registry.mjs). Adding a kind is an addition +// here and in the lane's registration, never a skill edit. +// // CLI: // node view-builder.mjs --profile report|interactive --template <file> --data <file.json> --out <file> // [--connect http://127.0.0.1:<port>] (interactive: the session-bridge origin) @@ -26,8 +31,10 @@ import { createHash } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { escapeHtml, pageDigest, stampPage, validateRenderedPage } from "./html-escape.mjs"; +import { LANES } from "./view-registry.mjs"; export const INTERACTIVE_MARKER = "rv-gen:view-builder-interactive"; const MARKER_RE = /<!-- rv-gen:view-builder-interactive sha256:([0-9a-f]{64}) -->/g; @@ -188,6 +195,115 @@ export function validateView(html, runtime) { : validateRenderedPage(html); } +// ------------------------------------------------------------ view kinds + +const isText = (value) => typeof value === "string"; +const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value); + +/** + * A record that is one JSON object (or its text): top-level text fields, then one list of rows + * whose fields are text, plus `rowList`, a list of text in each row, when the shape names one. + */ +const jsonRecord = (shape) => (record) => { + const data = isText(record) ? JSON.parse(record) : record; + if (!isObject(data)) throw new ViewBuildError(["data is not an object"]); + const failures = shape.text.filter((key) => !isText(data[key])).map((key) => `${key} must be text`); + if (!Array.isArray(data[shape.list])) throw new ViewBuildError([...failures, `${shape.list} must be a list`]); + for (const [index, row] of data[shape.list].entries()) { + const at = `${shape.list}[${index}]`; + if (!isObject(row)) { + failures.push(`${at} must be an object`); + continue; + } + for (const key of shape.row) if (!isText(row[key])) failures.push(`${at}.${key} must be text`); + if (shape.rowList && !(Array.isArray(row[shape.rowList]) && row[shape.rowList].every(isText))) { + failures.push(`${at}.${shape.rowList} must be a list of text`); + } + } + if (failures.length) throw new ViewBuildError(failures); + return data; +}; + +const trimBlank = (lines) => { + let [start, end] = [0, lines.length]; + while (start < end && !lines[start].trim()) start += 1; + while (end > start && !lines[end - 1].trim()) end -= 1; + return lines.slice(start, end); +}; + +/** + * A markdown record's `## Design` section: the title from the record's first `# ` heading, the + * text before the first `### ` subsection, and each subsection's lines as written. A heading + * inside a code fence is text; a fence closes only on a bare run of its own character at least as + * long as the opening run (CommonMark). + */ +function parseDesign(record) { + if (!isText(record)) throw new ViewBuildError(["the design record must be markdown text"]); + let [fence, inDesign, found, title] = [null, false, false, null]; + const intro = []; + const sections = []; + for (const line of lf(record).split("\n")) { + const heading = fence ? null : /^(#{1,6})[ \t]+(.+?)[ \t]*$/.exec(line); + const run = /^[ \t]*(`{3,}(?=[^`]*$)|~{3,})(.*)$/.exec(line); + if (!fence && run) fence = run[1]; + else if (fence && run && run[1][0] === fence[0] && run[1].length >= fence.length && !run[2].trim()) fence = null; + const level = heading?.[1].length; + if (level === 1) title ??= heading[2]; + if (level <= 2) { + inDesign = level === 2 && heading[2] === "Design"; + found ||= inDesign; + } else if (inDesign && level === 3) { + sections.push({ name: heading[2], lines: [] }); + } else if (inDesign) { + (sections.at(-1)?.lines ?? intro).push(line); + } + } + if (!found) throw new ViewBuildError(["the record has no ## Design section"]); + return { title: title ?? "Design", intro: trimBlank(intro).join("\n"), sections: sections.map(({ name, lines }) => ({ name, lines: trimBlank(lines) })) }; +} + +const kindEntry = (id, parse, template, tier) => Object.freeze({ id, parse, template, tier }); + +/** One entry per view kind: `tier` is the rendered-views view tier; `static` builds the report profile. */ +export const VIEW_KINDS = Object.freeze({ + plan: kindEntry( + "plan", + jsonRecord({ text: ["title", "goal", "blast"], list: "phases", row: ["status", "name", "what", "needs"], rowList: "criteria" }), + "skills/plan/templates/plan-view.html", + "client-interactive", + ), + brainstorm: kindEntry( + "brainstorm", + jsonRecord({ text: ["title", "problem"], list: "candidates", row: ["effort", "name", "what", "where", "impact"] }), + "skills/brainstorm/templates/brainstorm-view.html", + "client-interactive", + ), + design: kindEntry("design", parseDesign, "skills/design/templates/design-view.html", "client-interactive"), +}); + +const PLUGIN_ROOT = fileURLToPath(new URL("../", import.meta.url)); + +/** + * Build the `kind` view of `record` for the lane `laneId`, which must register that kind. + * A record the kind cannot read throws ViewBuildError; an unknown lane or kind throws Error. + * @param {{lanes?: object, kinds?: object, root?: string, connect?: string | null, title?: string | null}} [options] + * lanes and kinds replace the registries; root is the directory the kind's template path is under + */ +export function render(laneId, kind, record, { lanes = LANES, kinds = VIEW_KINDS, root = PLUGIN_ROOT, connect = null, title = null } = {}) { + const lane = Object.hasOwn(lanes, laneId) ? lanes[laneId] : undefined; + if (!lane) throw new Error(`view-builder: unknown lane ${JSON.stringify(laneId)}`); + if (!lane.kinds.includes(kind)) throw new Error(`view-builder: lane ${laneId} does not register the view kind ${JSON.stringify(kind)}`); + const entry = Object.hasOwn(kinds, kind) ? kinds[kind] : undefined; + if (!entry) throw new Error(`view-builder: no view kind ${JSON.stringify(kind)}`); + return buildView({ + profile: entry.tier === "static" ? "report" : "interactive", + template: readFileSync(join(root, entry.template), "utf8"), + data: entry.parse(record), + connect, + title, + }); +} + // ---------------------------------------------------------------- report function parseSlots(template, failures) { diff --git a/plugins/review/lib/view-registry.mjs b/plugins/review/lib/view-registry.mjs index 5b33fb87a25..1dd99753c9c 100644 --- a/plugins/review/lib/view-registry.mjs +++ b/plugins/review/lib/view-registry.mjs @@ -29,6 +29,9 @@ export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS /** The one fallback chain: a view that cannot be served on a medium steps down this list. */ export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); +/** The chain's last step builds no page: the record stands on its own. */ +export const NO_PAGE = FALLBACK_CHAIN.at(-1); + /** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ export const NO_PREFERENCE = "auto"; @@ -47,7 +50,7 @@ export const LANES = Object.freeze( lane("review:explain-change", ["digest"], true, "artifact"), lane("visualization:present", ["deck"], false, "artifact"), lane("visualization:visualize", [], false, null), - lane("planning", ["plan", "brainstorm"], false, "file"), + lane("planning", ["plan", "brainstorm", "design"], false, "artifact"), lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), lane("discovery:blindspot", ["blindspot"], false, "file"), lane("debugging:debug", ["post-mortem"], false, "file"), diff --git a/plugins/review/lib/view-settings.mjs b/plugins/review/lib/view-settings.mjs index c37c0127da9..d268b37da9a 100755 --- a/plugins/review/lib/view-settings.mjs +++ b/plugins/review/lib/view-settings.mjs @@ -19,7 +19,8 @@ // medium then steps down the fallback chain (lib/view-registry.mjs) until reachable. // // view-settings.mjs resolve --lane <id> --option <user_config.medium> [--argument <m>] [--team-ref <ref>] -// Prints the resolution as one JSON line, then the report line and one line per notice. +// Prints the resolution as one JSON line, then the report line and one line per notice. With a +// medium resolved, the JSON adds `page` (the medium shows a page) and `publishes`. // Exit 0 resolved, 2 usage. import { execFileSync, spawnSync } from "node:child_process"; @@ -29,7 +30,7 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { configBlockBody, readLayers } from "./config-cascade.mjs"; -import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; +import { NO_PAGE, NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; const CONCERN = "rendered-views"; const HOME = "docs/conventions"; @@ -303,7 +304,10 @@ export function main(argv, env = process.env) { if (request.argument !== null && !mediumOf(request.argument)) return usage(); const ci = Boolean(env.CI) && env.CI !== "false"; const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); - process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + // So a skill can act on the medium without naming its value: does it show a page, does it publish. + const entry = mediumOf(resolution.medium); + const shown = entry ? { ...resolution, page: entry.id !== NO_PAGE, publishes: entry.publishes } : resolution; + process.stdout.write(`${JSON.stringify(shown)}\n${reportLines(resolution).join("\n")}\n`); return 0; } diff --git a/plugins/source-control/lib/view-registry.mjs b/plugins/source-control/lib/view-registry.mjs index 5b33fb87a25..1dd99753c9c 100644 --- a/plugins/source-control/lib/view-registry.mjs +++ b/plugins/source-control/lib/view-registry.mjs @@ -29,6 +29,9 @@ export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS /** The one fallback chain: a view that cannot be served on a medium steps down this list. */ export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); +/** The chain's last step builds no page: the record stands on its own. */ +export const NO_PAGE = FALLBACK_CHAIN.at(-1); + /** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ export const NO_PREFERENCE = "auto"; @@ -47,7 +50,7 @@ export const LANES = Object.freeze( lane("review:explain-change", ["digest"], true, "artifact"), lane("visualization:present", ["deck"], false, "artifact"), lane("visualization:visualize", [], false, null), - lane("planning", ["plan", "brainstorm"], false, "file"), + lane("planning", ["plan", "brainstorm", "design"], false, "artifact"), lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), lane("discovery:blindspot", ["blindspot"], false, "file"), lane("debugging:debug", ["post-mortem"], false, "file"), diff --git a/plugins/source-control/lib/view-settings.mjs b/plugins/source-control/lib/view-settings.mjs index c37c0127da9..d268b37da9a 100755 --- a/plugins/source-control/lib/view-settings.mjs +++ b/plugins/source-control/lib/view-settings.mjs @@ -19,7 +19,8 @@ // medium then steps down the fallback chain (lib/view-registry.mjs) until reachable. // // view-settings.mjs resolve --lane <id> --option <user_config.medium> [--argument <m>] [--team-ref <ref>] -// Prints the resolution as one JSON line, then the report line and one line per notice. +// Prints the resolution as one JSON line, then the report line and one line per notice. With a +// medium resolved, the JSON adds `page` (the medium shows a page) and `publishes`. // Exit 0 resolved, 2 usage. import { execFileSync, spawnSync } from "node:child_process"; @@ -29,7 +30,7 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { configBlockBody, readLayers } from "./config-cascade.mjs"; -import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; +import { NO_PAGE, NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; const CONCERN = "rendered-views"; const HOME = "docs/conventions"; @@ -303,7 +304,10 @@ export function main(argv, env = process.env) { if (request.argument !== null && !mediumOf(request.argument)) return usage(); const ci = Boolean(env.CI) && env.CI !== "false"; const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); - process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + // So a skill can act on the medium without naming its value: does it show a page, does it publish. + const entry = mediumOf(resolution.medium); + const shown = entry ? { ...resolution, page: entry.id !== NO_PAGE, publishes: entry.publishes } : resolution; + process.stdout.write(`${JSON.stringify(shown)}\n${reportLines(resolution).join("\n")}\n`); return 0; } diff --git a/plugins/visualization/lib/view-registry.mjs b/plugins/visualization/lib/view-registry.mjs index 5b33fb87a25..1dd99753c9c 100644 --- a/plugins/visualization/lib/view-registry.mjs +++ b/plugins/visualization/lib/view-registry.mjs @@ -29,6 +29,9 @@ export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS /** The one fallback chain: a view that cannot be served on a medium steps down this list. */ export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); +/** The chain's last step builds no page: the record stands on its own. */ +export const NO_PAGE = FALLBACK_CHAIN.at(-1); + /** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ export const NO_PREFERENCE = "auto"; @@ -47,7 +50,7 @@ export const LANES = Object.freeze( lane("review:explain-change", ["digest"], true, "artifact"), lane("visualization:present", ["deck"], false, "artifact"), lane("visualization:visualize", [], false, null), - lane("planning", ["plan", "brainstorm"], false, "file"), + lane("planning", ["plan", "brainstorm", "design"], false, "artifact"), lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), lane("discovery:blindspot", ["blindspot"], false, "file"), lane("debugging:debug", ["post-mortem"], false, "file"), diff --git a/plugins/visualization/lib/view-settings.mjs b/plugins/visualization/lib/view-settings.mjs index c37c0127da9..d268b37da9a 100755 --- a/plugins/visualization/lib/view-settings.mjs +++ b/plugins/visualization/lib/view-settings.mjs @@ -19,7 +19,8 @@ // medium then steps down the fallback chain (lib/view-registry.mjs) until reachable. // // view-settings.mjs resolve --lane <id> --option <user_config.medium> [--argument <m>] [--team-ref <ref>] -// Prints the resolution as one JSON line, then the report line and one line per notice. +// Prints the resolution as one JSON line, then the report line and one line per notice. With a +// medium resolved, the JSON adds `page` (the medium shows a page) and `publishes`. // Exit 0 resolved, 2 usage. import { execFileSync, spawnSync } from "node:child_process"; @@ -29,7 +30,7 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { configBlockBody, readLayers } from "./config-cascade.mjs"; -import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; +import { NO_PAGE, NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; const CONCERN = "rendered-views"; const HOME = "docs/conventions"; @@ -303,7 +304,10 @@ export function main(argv, env = process.env) { if (request.argument !== null && !mediumOf(request.argument)) return usage(); const ci = Boolean(env.CI) && env.CI !== "false"; const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); - process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + // So a skill can act on the medium without naming its value: does it show a page, does it publish. + const entry = mediumOf(resolution.medium); + const shown = entry ? { ...resolution, page: entry.id !== NO_PAGE, publishes: entry.publishes } : resolution; + process.stdout.write(`${JSON.stringify(shown)}\n${reportLines(resolution).join("\n")}\n`); return 0; } diff --git a/plugins/work-items/lib/view-builder.mjs b/plugins/work-items/lib/view-builder.mjs index d9df8de727e..8202be9f20c 100644 --- a/plugins/work-items/lib/view-builder.mjs +++ b/plugins/work-items/lib/view-builder.mjs @@ -17,6 +17,11 @@ // exception, in both profiles, is the optional page title (rule 5): the builder // writes it as one escaped, capped line of text into the head's <title>. // +// View kinds: VIEW_KINDS holds one entry per kind, { id, parse(record) -> data, template, tier }, +// the template path relative to the root of the plugin carrying this file. render(laneId, kind, +// record) builds a kind the lane registers (lib/view-registry.mjs). Adding a kind is an addition +// here and in the lane's registration, never a skill edit. +// // CLI: // node view-builder.mjs --profile report|interactive --template <file> --data <file.json> --out <file> // [--connect http://127.0.0.1:<port>] (interactive: the session-bridge origin) @@ -26,8 +31,10 @@ import { createHash } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { escapeHtml, pageDigest, stampPage, validateRenderedPage } from "./html-escape.mjs"; +import { LANES } from "./view-registry.mjs"; export const INTERACTIVE_MARKER = "rv-gen:view-builder-interactive"; const MARKER_RE = /<!-- rv-gen:view-builder-interactive sha256:([0-9a-f]{64}) -->/g; @@ -188,6 +195,115 @@ export function validateView(html, runtime) { : validateRenderedPage(html); } +// ------------------------------------------------------------ view kinds + +const isText = (value) => typeof value === "string"; +const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value); + +/** + * A record that is one JSON object (or its text): top-level text fields, then one list of rows + * whose fields are text, plus `rowList`, a list of text in each row, when the shape names one. + */ +const jsonRecord = (shape) => (record) => { + const data = isText(record) ? JSON.parse(record) : record; + if (!isObject(data)) throw new ViewBuildError(["data is not an object"]); + const failures = shape.text.filter((key) => !isText(data[key])).map((key) => `${key} must be text`); + if (!Array.isArray(data[shape.list])) throw new ViewBuildError([...failures, `${shape.list} must be a list`]); + for (const [index, row] of data[shape.list].entries()) { + const at = `${shape.list}[${index}]`; + if (!isObject(row)) { + failures.push(`${at} must be an object`); + continue; + } + for (const key of shape.row) if (!isText(row[key])) failures.push(`${at}.${key} must be text`); + if (shape.rowList && !(Array.isArray(row[shape.rowList]) && row[shape.rowList].every(isText))) { + failures.push(`${at}.${shape.rowList} must be a list of text`); + } + } + if (failures.length) throw new ViewBuildError(failures); + return data; +}; + +const trimBlank = (lines) => { + let [start, end] = [0, lines.length]; + while (start < end && !lines[start].trim()) start += 1; + while (end > start && !lines[end - 1].trim()) end -= 1; + return lines.slice(start, end); +}; + +/** + * A markdown record's `## Design` section: the title from the record's first `# ` heading, the + * text before the first `### ` subsection, and each subsection's lines as written. A heading + * inside a code fence is text; a fence closes only on a bare run of its own character at least as + * long as the opening run (CommonMark). + */ +function parseDesign(record) { + if (!isText(record)) throw new ViewBuildError(["the design record must be markdown text"]); + let [fence, inDesign, found, title] = [null, false, false, null]; + const intro = []; + const sections = []; + for (const line of lf(record).split("\n")) { + const heading = fence ? null : /^(#{1,6})[ \t]+(.+?)[ \t]*$/.exec(line); + const run = /^[ \t]*(`{3,}(?=[^`]*$)|~{3,})(.*)$/.exec(line); + if (!fence && run) fence = run[1]; + else if (fence && run && run[1][0] === fence[0] && run[1].length >= fence.length && !run[2].trim()) fence = null; + const level = heading?.[1].length; + if (level === 1) title ??= heading[2]; + if (level <= 2) { + inDesign = level === 2 && heading[2] === "Design"; + found ||= inDesign; + } else if (inDesign && level === 3) { + sections.push({ name: heading[2], lines: [] }); + } else if (inDesign) { + (sections.at(-1)?.lines ?? intro).push(line); + } + } + if (!found) throw new ViewBuildError(["the record has no ## Design section"]); + return { title: title ?? "Design", intro: trimBlank(intro).join("\n"), sections: sections.map(({ name, lines }) => ({ name, lines: trimBlank(lines) })) }; +} + +const kindEntry = (id, parse, template, tier) => Object.freeze({ id, parse, template, tier }); + +/** One entry per view kind: `tier` is the rendered-views view tier; `static` builds the report profile. */ +export const VIEW_KINDS = Object.freeze({ + plan: kindEntry( + "plan", + jsonRecord({ text: ["title", "goal", "blast"], list: "phases", row: ["status", "name", "what", "needs"], rowList: "criteria" }), + "skills/plan/templates/plan-view.html", + "client-interactive", + ), + brainstorm: kindEntry( + "brainstorm", + jsonRecord({ text: ["title", "problem"], list: "candidates", row: ["effort", "name", "what", "where", "impact"] }), + "skills/brainstorm/templates/brainstorm-view.html", + "client-interactive", + ), + design: kindEntry("design", parseDesign, "skills/design/templates/design-view.html", "client-interactive"), +}); + +const PLUGIN_ROOT = fileURLToPath(new URL("../", import.meta.url)); + +/** + * Build the `kind` view of `record` for the lane `laneId`, which must register that kind. + * A record the kind cannot read throws ViewBuildError; an unknown lane or kind throws Error. + * @param {{lanes?: object, kinds?: object, root?: string, connect?: string | null, title?: string | null}} [options] + * lanes and kinds replace the registries; root is the directory the kind's template path is under + */ +export function render(laneId, kind, record, { lanes = LANES, kinds = VIEW_KINDS, root = PLUGIN_ROOT, connect = null, title = null } = {}) { + const lane = Object.hasOwn(lanes, laneId) ? lanes[laneId] : undefined; + if (!lane) throw new Error(`view-builder: unknown lane ${JSON.stringify(laneId)}`); + if (!lane.kinds.includes(kind)) throw new Error(`view-builder: lane ${laneId} does not register the view kind ${JSON.stringify(kind)}`); + const entry = Object.hasOwn(kinds, kind) ? kinds[kind] : undefined; + if (!entry) throw new Error(`view-builder: no view kind ${JSON.stringify(kind)}`); + return buildView({ + profile: entry.tier === "static" ? "report" : "interactive", + template: readFileSync(join(root, entry.template), "utf8"), + data: entry.parse(record), + connect, + title, + }); +} + // ---------------------------------------------------------------- report function parseSlots(template, failures) { diff --git a/plugins/work-items/lib/view-registry.mjs b/plugins/work-items/lib/view-registry.mjs index 5b33fb87a25..1dd99753c9c 100644 --- a/plugins/work-items/lib/view-registry.mjs +++ b/plugins/work-items/lib/view-registry.mjs @@ -29,6 +29,9 @@ export const mediumOf = (id) => (typeof id === "string" && Object.hasOwn(MEDIUMS /** The one fallback chain: a view that cannot be served on a medium steps down this list. */ export const FALLBACK_CHAIN = Object.freeze(["artifact", "file", "terminal"]); +/** The chain's last step builds no page: the record stands on its own. */ +export const NO_PAGE = FALLBACK_CHAIN.at(-1); + /** A scope may say `auto`, which is no preference: it resolves exactly as unset. */ export const NO_PREFERENCE = "auto"; @@ -47,7 +50,7 @@ export const LANES = Object.freeze( lane("review:explain-change", ["digest"], true, "artifact"), lane("visualization:present", ["deck"], false, "artifact"), lane("visualization:visualize", [], false, null), - lane("planning", ["plan", "brainstorm"], false, "file"), + lane("planning", ["plan", "brainstorm", "design"], false, "artifact"), lane("architecture", ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"], false, "file"), lane("discovery:blindspot", ["blindspot"], false, "file"), lane("debugging:debug", ["post-mortem"], false, "file"), diff --git a/plugins/work-items/lib/view-settings.mjs b/plugins/work-items/lib/view-settings.mjs index c37c0127da9..d268b37da9a 100755 --- a/plugins/work-items/lib/view-settings.mjs +++ b/plugins/work-items/lib/view-settings.mjs @@ -19,7 +19,8 @@ // medium then steps down the fallback chain (lib/view-registry.mjs) until reachable. // // view-settings.mjs resolve --lane <id> --option <user_config.medium> [--argument <m>] [--team-ref <ref>] -// Prints the resolution as one JSON line, then the report line and one line per notice. +// Prints the resolution as one JSON line, then the report line and one line per notice. With a +// medium resolved, the JSON adds `page` (the medium shows a page) and `publishes`. // Exit 0 resolved, 2 usage. import { execFileSync, spawnSync } from "node:child_process"; @@ -29,7 +30,7 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { configBlockBody, readLayers } from "./config-cascade.mjs"; -import { NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; +import { NO_PAGE, NO_PREFERENCE, SCHEMA, fallBack, laneOf, mediumOf, reportLines } from "./view-registry.mjs"; const CONCERN = "rendered-views"; const HOME = "docs/conventions"; @@ -303,7 +304,10 @@ export function main(argv, env = process.env) { if (request.argument !== null && !mediumOf(request.argument)) return usage(); const ci = Boolean(env.CI) && env.CI !== "false"; const resolution = resolveMedium({ ...request, projectRoot: findRoot() }, { dataDir: env.CLAUDE_PLUGIN_DATA || null, interactive: !ci }); - process.stdout.write(`${JSON.stringify(resolution)}\n${reportLines(resolution).join("\n")}\n`); + // So a skill can act on the medium without naming its value: does it show a page, does it publish. + const entry = mediumOf(resolution.medium); + const shown = entry ? { ...resolution, page: entry.id !== NO_PAGE, publishes: entry.publishes } : resolution; + process.stdout.write(`${JSON.stringify(shown)}\n${reportLines(resolution).join("\n")}\n`); return 0; } diff --git a/scripts/shared-copies.txt b/scripts/shared-copies.txt index ea59e4245a7..de55321e982 100644 --- a/scripts/shared-copies.txt +++ b/scripts/shared-copies.txt @@ -470,6 +470,7 @@ lib/publish-gate.mjs plugins/visualization/lib/publish-gate.mjs lib/publish-gate.mjs plugins/work-items/lib/publish-gate.mjs lib/publish-gate.mjs plugins/harness-ops/lib/publish-gate.mjs lib/publish-gate.mjs plugins/source-control/lib/publish-gate.mjs +lib/publish-gate.mjs plugins/planning/lib/publish-gate.mjs lib/view-settings.mjs plugins/review/lib/view-settings.mjs lib/view-settings.mjs plugins/visualization/lib/view-settings.mjs lib/view-settings.mjs plugins/work-items/lib/view-settings.mjs @@ -482,6 +483,7 @@ lib/view-registry.mjs plugins/work-items/lib/view-registry.mjs lib/view-registry.mjs plugins/harness-ops/lib/view-registry.mjs lib/view-registry.mjs plugins/source-control/lib/view-registry.mjs lib/view-registry.mjs plugins/planning/lib/view-registry.mjs +lib/view-registry.mjs plugins/machine-health/lib/view-registry.mjs lib/config-cascade.mjs plugins/review/lib/config-cascade.mjs lib/config-cascade.mjs plugins/visualization/lib/config-cascade.mjs lib/config-cascade.mjs plugins/work-items/lib/config-cascade.mjs