diff --git a/README.md b/README.md index a07209d..1793c06 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ skills/prd, next, review-page skills/*-artifact/ the document contracts workflows/ panel, brief, draft (PRD and architecture), team hooks/hooks.json usage log on SubagentStart; old-tool check on SessionStart -scripts/ generate.sh, usage-log.sh, check-prereqs.sh +scripts/ generate.sh, render-review.py, usage-log.sh, check-prereqs.sh tests/ run.sh and two fixture projects ``` diff --git a/scripts/render-review.py b/scripts/render-review.py new file mode 100755 index 0000000..fe02afe --- /dev/null +++ b/scripts/render-review.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""render-review.py: turn a markdown document into a self-contained review page. + +Usage: + render-review.py --in docs/PRD.md --out .ck/runs//review.html --title "Ardennes Hour PRD" \ + [--question "Imagine this shipped and did not move the number. What went wrong?"] [--meta "Revision 1"] + +Standard library only. The page carries the five comment steps in its banner, a sticky +table of contents from the ## and ### headings, tables and code that scroll inside their +own container, and light and dark themes. Publish the output with the Artifact tool. +""" +import argparse, html, re, sys, pathlib + +def slug(text): + s = re.sub(r'[^a-z0-9]+', '-', text.lower()).strip('-') + return s or 'section' + +def inline(text): + parts = re.split(r'(`[^`\n]*`)', text) + out = [] + for p in parts: + if p.startswith('`') and p.endswith('`') and len(p) >= 2: + out.append('' + html.escape(p[1:-1]) + '') + continue + p = html.escape(p, quote=False) + p = re.sub(r'\[([^\]]+)\]\((https?://[^)\s]+)\)', r'\1', p) + p = re.sub(r'\*\*(.+?)\*\*', r'\1', p) + p = re.sub(r'(?\1', p) + out.append(p) + return ''.join(out) + +def render(md): + lines = md.split('\n') + out, toc = [], [] + i, n = 0, len(lines) + para = [] + def flush_para(): + if para: + out.append('

' + inline(' '.join(s.strip() for s in para)) + '

') + para.clear() + list_stack = [] # (indent, tag) + def close_lists(to_indent=-1): + while list_stack and list_stack[-1][0] > to_indent: + out.append('') + while i < n: + line = lines[i] + stripped = line.strip() + # fenced code + if stripped.startswith('```'): + flush_para(); close_lists() + fence = stripped[:3] + buf = [] + i += 1 + while i < n and not lines[i].strip().startswith(fence): + buf.append(lines[i]); i += 1 + out.append('
' + html.escape('\n'.join(buf)) + '
') + i += 1 + continue + if stripped.startswith('````'): + i += 1; continue + # heading + m = re.match(r'^(#{1,4})\s+(.*)$', line) + if m: + flush_para(); close_lists() + level = len(m.group(1)); text = m.group(2).strip() + if level == 1: + i += 1; continue # the banner carries the title + sid = slug(re.sub(r'`', '', text)) + if level in (2, 3): toc.append((level, sid, re.sub(r'`', '', text))) + out.append(f'{inline(text)} §') + i += 1; continue + # hr + if re.match(r'^\s*(-{3,}|\*{3,})\s*$', line): + flush_para(); close_lists(); out.append('
'); i += 1; continue + # table + if stripped.startswith('|') and i + 1 < n and re.match(r'^\s*\|?\s*:?-{2,}', lines[i + 1]): + flush_para(); close_lists() + header = [c.strip() for c in stripped.strip('|').split('|')] + i += 2 + rows = [] + while i < n and lines[i].strip().startswith('|'): + rows.append([c.strip() for c in lines[i].strip().strip('|').split('|')]); i += 1 + out.append('
' + ''.join('' for c in header) + '') + for r in rows: + out.append('' + ''.join('' for c in r) + '') + out.append('
' + inline(c) + '
' + inline(c) + '
') + continue + # blockquote + if stripped.startswith('>'): + flush_para(); close_lists() + buf = [] + while i < n and lines[i].strip().startswith('>'): + buf.append(lines[i].strip()[1:].strip()); i += 1 + out.append('
' + ''.join('

' + inline(b) + '

' for b in buf if b) + '
') + continue + # list item + m = re.match(r'^(\s*)([-*]|\d+\.)\s+(.*)$', line) + if m: + flush_para() + indent = len(m.group(1)); tag = 'ol' if m.group(2)[0].isdigit() else 'ul' + text = m.group(3) + cls = '' + if text.startswith('[ ] '): text = text[4:]; cls = ' class="task"' + elif text.startswith('[x] '): text = text[4:]; cls = ' class="task done"' + if list_stack and indent > list_stack[-1][0]: + out.append('<' + tag + '>' + inline(text)); list_stack.append((indent, tag)) + else: + close_lists(indent) + if list_stack and list_stack[-1][0] == indent: + out.append('' + inline(text)) + else: + out.append('<' + tag + '>' + inline(text)); list_stack.append((indent, tag)) + i += 1; continue + # blank + if not stripped: + flush_para() + if list_stack and (i + 1 >= n or not re.match(r'^\s*([-*]|\d+\.)\s+', lines[i + 1])): + close_lists() + i += 1; continue + # continuation of a list item + if list_stack and line.startswith(' '): + out.append(' ' + inline(stripped)); i += 1; continue + para.append(line); i += 1 + flush_para(); close_lists() + return '\n'.join(out), toc + +def toc_html(toc): + if not toc: return '' + h = ['
    ']; depth = 2 + for level, sid, text in toc: + if level > depth: h.append('
      '); depth = level + while level < depth: h.append('
    '); depth -= 1 + h.append(f'
  • {html.escape(text)}
  • ') + while depth > 2: h.append('
'); depth -= 1 + h.append('') + return '\n'.join(h) + +CSS = """ +:root{--bg:#faf9f5;--ink:#141413;--muted:#5f5d55;--rule:#e8e6dc;--rule-strong:#b0aea5;--surface:#f2f0e8;--accent:#d97757;--link:#a3492a;--banner:#141413;--banner-ink:#faf9f5;--banner-muted:#b0aea5} +@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#141413;--ink:#faf9f5;--muted:#b0aea5;--rule:#2b2a26;--rule-strong:#4a4842;--surface:#1e1d1a;--link:#e69a74;--banner:#1e1d1a}} +:root[data-theme="dark"]{--bg:#141413;--ink:#faf9f5;--muted:#b0aea5;--rule:#2b2a26;--rule-strong:#4a4842;--surface:#1e1d1a;--link:#e69a74;--banner:#1e1d1a} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:400 17px/1.65 Georgia,'Times New Roman',serif} +a{color:var(--link)}.banner{background:var(--banner);color:var(--banner-ink);border-bottom:3px solid var(--accent)} +.banner-inner{max-width:1180px;margin:0 auto;padding:32px 24px 28px;display:grid;gap:18px} +.eyebrow{font:500 12px/1 system-ui,sans-serif;letter-spacing:.12em;text-transform:uppercase;color:var(--accent);margin:0 0 10px} +.banner h1{font:600 clamp(26px,4vw,38px)/1.15 system-ui,sans-serif;margin:0 0 8px}.banner .meta{margin:0;color:var(--banner-muted);font-size:15px} +.banner code{background:rgba(250,249,245,.1);color:var(--banner-ink)} +.howto{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}.howto>div{border:1px solid var(--rule-strong);border-radius:6px;padding:14px 16px} +.howto h2{font:600 13px/1 system-ui,sans-serif;letter-spacing:.1em;text-transform:uppercase;margin:0 0 10px;color:var(--banner-muted)} +.howto p,.howto li{font-size:15.5px;line-height:1.5}.howto p{margin:0 0 8px}.howto ol{margin:0 0 8px;padding-left:22px}.howto .q{font-style:italic} +.page{max-width:1180px;margin:0 auto;padding:28px 24px 90px;display:grid;grid-template-columns:240px minmax(0,1fr);gap:48px;align-items:start} +nav.toc{position:sticky;top:20px;max-height:calc(100vh - 40px);overflow:auto;font:13px/1.4 system-ui,sans-serif}nav.toc ul{list-style:none;margin:0;padding:0} +nav.toc ul ul{margin:4px 0 6px 12px;border-left:1px solid var(--rule);padding-left:10px}nav.toc li{margin:0 0 6px}nav.toc a{color:var(--ink);text-decoration:none} +article{max-width:74ch;min-width:0}h2{font:600 26px/1.25 system-ui,sans-serif;margin:52px 0 14px;padding-top:24px;border-top:1px solid var(--rule-strong)} +article>h2:first-of-type{margin-top:6px;padding-top:0;border-top:0}h3{font:600 19px/1.3 system-ui,sans-serif;margin:32px 0 10px}h4{font:600 15px/1.3 system-ui,sans-serif;margin:22px 0 8px} +.anchor{font:400 14px/1 system-ui,sans-serif;color:var(--rule-strong);text-decoration:none;margin-left:6px}p{margin:0 0 15px}ul,ol{padding-left:26px;margin:0 0 15px}li{margin:0 0 5px} +blockquote{margin:0 0 18px;padding:12px 18px;border-left:3px solid var(--accent);background:var(--surface)}blockquote p{margin:0 0 6px} +code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:.86em;background:var(--surface);padding:1px 5px;border-radius:3px} +pre{background:var(--surface);border:1px solid var(--rule);border-radius:6px;padding:14px 16px;overflow-x:auto;margin:0 0 18px;font-size:13px;line-height:1.55}pre code{background:none;padding:0;font-size:inherit} +.table-wrap{overflow-x:auto;margin:0 0 22px;border:1px solid var(--rule);border-radius:6px}table{border-collapse:collapse;width:100%;font-size:14.5px;line-height:1.45} +th{text-align:left;font:600 11.5px/1.3 system-ui,sans-serif;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:10px 12px;border-bottom:1px solid var(--rule-strong);background:var(--surface);white-space:nowrap} +td{padding:10px 12px;border-bottom:1px solid var(--rule);vertical-align:top}tr:last-child td{border-bottom:0}li.task{list-style:'\\2610 '}li.task.done{list-style:'\\2611 '} +@media (max-width:900px){.page{grid-template-columns:minmax(0,1fr);gap:20px;padding:22px 18px 70px}nav.toc{position:static;max-height:none}body{font-size:16px}} +""" + +STEPS = """
    +
  1. Open this link signed in to your Claude account.
  2. +
  3. Switch the page to comment mode from the bar at the top.
  4. +
  5. Click the passage you want to comment on and type.
  6. +
  7. Put @claude in the comment so Claude can reply to it and resolve it.
  8. +
  9. Say done in the chat when you have finished.
  10. +
+

Claude then works through every comment, changes the document, republishes this same page, and resolves each comment with one line saying what changed.

""" + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--in', dest='src', required=True) + ap.add_argument('--out', dest='out', required=True) + ap.add_argument('--title', required=True) + ap.add_argument('--question', default='') + ap.add_argument('--meta', default='') + a = ap.parse_args() + md = pathlib.Path(a.src).read_text() + body, toc = render(md) + q = '' + if a.question: + q = ('

One question first

' + html.escape(a.question) + + '

Answer it as a comment on this box.

') + meta = ('

' + html.escape(a.meta) + '

') if a.meta else '' + page = (f'{html.escape(a.title)}\n\n' + f'\n' + f'
\n{body}\n
\n') + pathlib.Path(a.out).parent.mkdir(parents=True, exist_ok=True) + pathlib.Path(a.out).write_text(page) + print(f'wrote {a.out} ({len(page)} bytes, {len(toc)} headings)') + +if __name__ == '__main__': + main() diff --git a/skills/architecture-artifact/SKILL.md b/skills/architecture-artifact/SKILL.md index a6e448b..f4438bf 100644 --- a/skills/architecture-artifact/SKILL.md +++ b/skills/architecture-artifact/SKILL.md @@ -41,3 +41,4 @@ Every claim not taken directly from the PRD or the roadmap carries an inline tag 7. Every claim not from the PRD or roadmap carries a `[C]` tag, and every tag appears in Appendix A or is marked unchallenged. 8. Assumptions exposed by the premortem appear in Risks or Context and constraints. 9. No em-dashes in prose. +10. The document is under 3,000 words unless the author asked for more. diff --git a/skills/prd-artifact/SKILL.md b/skills/prd-artifact/SKILL.md index 2f47a53..8fea19a 100644 --- a/skills/prd-artifact/SKILL.md +++ b/skills/prd-artifact/SKILL.md @@ -38,6 +38,7 @@ Every claim not taken directly from the brief carries an inline tag `[C1]`, `[C2 8. Assumptions includes the assumption the premortem exposed (or, before the premortem exists, says the premortem is pending). 9. Open questions lists every decision left to the author. 10. No em-dashes in prose. Em-dashes are acceptable only as separators in structured lists. +11. The PRD, before its appendices, is under 3,000 words unless the author asked for more. Requirements are numbered statements with acceptance criteria, not essays. The appendices carry the panel's record verbatim and are not counted. ## File paths diff --git a/skills/prd/SKILL.md b/skills/prd/SKILL.md index a3bbfc5..717a91f 100644 --- a/skills/prd/SKILL.md +++ b/skills/prd/SKILL.md @@ -44,11 +44,15 @@ Finally: "Default reviewers, or name them?" (record as a lens list or nothing) a ## 3. Resume check -Read the latest `.ck/runs/*/run.json` with `command: "prd"` for this project, if any. +Read the latest `.ck/runs/*/run.json` with `command: "prd"` for this project, if any. `run.json` can be stale: a workflow cannot write it, and a session can end before the notification arrives. So decide the stage from what is on disk, in this order: -- If `docs/PRD.md` exists and `status` names a stopped stage, offer: "Your PRD stopped at [stage]. Everything before it is saved in `docs/PRD.md`. Continue from there, or start over?" Continue means `startAt` = that stage. -- If `status` is `review`, go to step 7: the author has reviewed. -- If `status` is `final`, ask: "The PRD is finished. Re-run the reviewers on some sections, or start over?" +- `status` is `final`: ask "The PRD is finished. Re-run the reviewers on some sections, or start over?" +- `status` is `review`: go to step 7; the author has reviewed. +- The run directory holds `panel/*.json` and `docs/decisions/-prd-review.md` exists, but `docs/PRD.md` still has placeholder rows in Appendix A: the panel finished and the rewrite did not. `startAt` is `synthesize`. +- `docs/PRD.md` exists and no panel files do: the draft finished. `startAt` is `validate`. +- Otherwise `startAt` is `draft`. + +When `startAt` is later than `draft`, say: "Your PRD stopped after the [stage] step. Everything so far is saved in `docs/PRD.md`. Continuing from there." Reuse the existing run directory, run id, and timestamp, and go to step 5. Offer "start over" only when the author asks for it; in a session that cannot ask, continue. ## 4. Mint the run @@ -86,6 +90,8 @@ Immediately write the returned run id into `run.json` as `harnessRunId`, with `w If the notification reports a stop or a failure: record the failed stage in `run.json` and say: "I couldn't finish the [stage] step. Everything up to it is saved in `docs/PRD.md`. Run `/ck:prd` again to continue from there." Within the same session you may instead offer to relaunch with `resumeFromRunId`. +If the notification reports success but says the panel did not run, relaunch with `startAt: "panel"` and wait again. Never edit `docs/PRD.md` yourself in this step or the next: the workflow and the finalize agent write it, and the main session only launches, waits, reads, and reports. + ## 6. The review Set `status` to `review`. Read `docs/PRD.md`. Review it per `${CLAUDE_PLUGIN_ROOT}/skills/review-page/SKILL.md`, with the premortem question from Appendix B at the top of the page. That skill publishes, waits for "done", applies every comment to `docs/PRD.md` (recording each in `/review.md`), republishes, and resolves; or, when publishing is unavailable, prints the file-edit message and stops until the next run. @@ -104,7 +110,7 @@ One agent, inline: Agent({ subagent_type: "ck:river", description: "Finalize PRD", - prompt: "Read /docs/PRD.md and /review.md (the review comments and how each was applied, or the note that the file was edited directly). Fold the premortem answer into Assumptions and Risks, resolve each open decision as answered, keep Appendix A intact, and check the result against ${CLAUDE_PLUGIN_ROOT}/skills/prd-artifact/SKILL.md. Write docs/PRD.md. Set status 'final' in /run.json. Return the path and a five-line summary." + prompt: "Read /docs/PRD.md and /review.md (the review comments and how each was applied, or the note that the file was edited directly). Fold the premortem answer into Assumptions and Risks, resolve each open decision as answered, keep Appendix A intact, and check the result against ${CLAUDE_PLUGIN_ROOT}/skills/prd-artifact/SKILL.md. If there is no review.md and the author left no answer to the premortem question, do not invent one: leave the question open under Appendix B and say so in the summary. Write docs/PRD.md with one Write call. Set status 'final' in /run.json. Return the path and a five-line summary." }) ``` diff --git a/skills/review-page/SKILL.md b/skills/review-page/SKILL.md index c56b74d..ba7bb42 100644 --- a/skills/review-page/SKILL.md +++ b/skills/review-page/SKILL.md @@ -10,8 +10,8 @@ Every document or gallery that needs a decision is reviewed on a page in Claude' ## 1. Publish -1. Build one self-contained HTML page from the file on disk. For a document: render the markdown with a sticky table of contents so a comment can point at a section; tables and code blocks scroll inside their own container; light and dark themes. For a gallery: the labeled variants side by side, each with its rendering, rationale, trade-off, what it satisfies, and states where applicable, per the gallery contract. -2. The banner at the top carries, in this order: the product name and what is being reviewed; the round or revision; the one question the gate asks, when it has one (the PRD's premortem question, for example); and these five steps, verbatim: +1. Build the page with one command, never by writing HTML yourself: `python3 "${CLAUDE_PLUGIN_ROOT}/scripts/render-review.py" --in --out /review.html --title ": " --question "" --meta ""`. It renders the markdown with the banner below, a sticky table of contents so a comment can point at a section, tables and code that scroll inside their own container, and light and dark themes. For a gallery, the workflow has already written the page to the gallery contract; publish that file. +2. The banner carries, in this order: the product name and what is being reviewed; the round or revision; the one question the gate asks, when it has one (the PRD's premortem question, for example); and these five steps, verbatim (the renderer writes them; a gallery page must carry them too): 1. Open this link signed in to your Claude account. 2. Switch the page to comment mode from the bar at the top. 3. Click the passage or the variant you want to comment on and type. diff --git a/tests/drill/2026-09-09.md b/tests/drill/2026-09-09.md index fe377b9..5ede6bc 100644 --- a/tests/drill/2026-09-09.md +++ b/tests/drill/2026-09-09.md @@ -13,4 +13,55 @@ Findings: 2. **The workflow ran without a skill in front of it.** The session minted the run arguments from the workflow's description and passed them; the document landed at `docs/brief.md` in the project. The cache directory was not created under the project's `.ck/runs/`, because nothing tells a skill-less workflow where the cache goes; harmless for the brief, which keeps nothing in the cache that later steps need. 3. **Search requests are billed under Haiku** in the session's usage summary even though Toni ran the pass on Opus 5. Not a defect; noted so the cost model reads the numbers right. -Next drills, in order: `/ck:panel` on the fixture PRD; `/ck:prd` with the review page and a `startAt` resume; `/ck:team`. +## Drill 3, the same day: direct invocation + +Will typed `/ck:brief a simple lemonade stand sign` on his machine and the first launch failed: "args.runId, args.runDir, args.projectRoot, args.pluginRoot, and args.timestamp are required". A slash command hands the workflow its typed text as a plain string, and the session gets no chance to compose arguments first; the drills above had run through the non-interactive harness, where the model composed them. A spike also showed `${CLAUDE_PLUGIN_ROOT}` does not expand inside a workflow script, so a script cannot find its contracts by path on its own. + +Fix, in every script: accept a string, default the project root to the session's directory and the cache to `.ck/runs/-latest`, have the first agent add `.ck/` to `.git/info/exclude`, and load the contract and the roster by skill name (`ck:brief-artifact`, `ck:roster`, and so on) when no plugin path was passed. The generator now emits `skills/roster/SKILL.md` for that. + +Re-run, nested, with the bare string `/ck:brief a simple lemonade stand sign`: + +| Check | Result | +|---|---| +| Launch | No error; the workflow ran on the string | +| Document | `docs/brief.md`, 1,195 words, all eight sections in order, validated first pass | +| Cache | `.ck/runs/brief-latest/market.json` in the project; `.ck/` appended to `.git/info/exclude` | +| Contract | Three agent transcripts show `Skill ck:brief-artifact` loaded by name | +| Cost | $2.09: River 8.1k output tokens on Fable 5.1, Toni 9.8k on Opus 5, for a 1,195-word document. The return schema repeated the whole brief as structured output; it now returns counts and the open questions only | + +## Drill 4, the same day: `/ck:panel` by direct invocation + +On the game fixture with its seeded brief and PRD: `/ck:panel Should Ardennes Hour ship its first version with a standard 52-card deck instead of a custom printed deck?` + +| Check | Result | +|---|---| +| Launch | The bare question launched the workflow; no arguments composed | +| Lenses | River on Fable 5.1, Toni on Opus 5, Kai on Sonnet 5, confirmed in the per-agent transcripts; the synthesis on the session model | +| Evidence | River read the PRD and skipped the two absent files, saying so; Toni read the brief, the PRD, and the idea; Kai's file lacks `evidenceRead` (the schema names it, so the harness should have retried; check on the next run) | +| Disagreement | River no, Toni no, Kai yes-if; two kill conditions already met; the memo names three real splits and a third option nobody was asked about | +| Memo | `docs/decisions/should-ardennes-hour-ship-its-first-vers.md`, all ten sections in order, 4,743 words | +| Cost | $2.68 against $0.80 estimated: the synthesis wrote 27.7k output tokens (a 4,700-word memo, then the same memo again as structured output); River 10.2k on Fable, Toni 13.9k on Opus | + +Fixed the same day, the brief's pattern again: the memo contract caps the memo at 1,500 words and forbids restating a lens's reasoning; the lens prompt caps reasoning at 200 words and every other field at 100; the synthesis returns counts and one-line topics instead of the memo; the file name cuts at a word boundary. + +## Drill 5, the same day: `/ck:prd`, two passes, not finished + +On the game fixture with its seeded brief and the seeded PRD removed. Nested, non-interactive. + +| Pass | What happened | Cost | +|---|---|---| +| 5a | The skill minted the run, wrote `run.json` with the harness run id and `workflow: "ck:draft"`, launched by name, and waited. The draft (3,189 words, all thirteen sections, claims tagged), the validator, and the nested panel (three lenses, a memo of 1,839 words) all finished. The harness's ten-minute wait ceiling then killed the session during River's rewrite. Every lens answered yes-if: that panel failed to disagree | $4.25 | +| 5b | With the wait ceiling lifted, `/ck:prd` again. The rewritten resume check chose `startAt: synthesize` from what was on disk. Two defects showed: the script, resumed at the rewrite, declared "the panel did not run" instead of reading the earlier run's memo and lens files; and the main session, seeing that, improvised a second launch and did heavy work itself (84k output tokens on the session model). Killed at the 25-minute ceiling with the rewrite still running | $7.62 | + +Fixed the same day: `draft.js` resumed at the rewrite reads the earlier memo and lens files and returns the memo path; the `prd` skill relaunches with `startAt: panel` when a result says the panel did not run, and never edits the PRD in the main session; the PRD and architecture contracts cap the document at 3,000 words, and the prompts say so. Two facts about the test harness, not the plugin: non-interactive Claude Code kills background workflows after ten minutes unless `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0` is set, and a `/ck:prd` run on this fixture takes longer than that. The next pass runs from scratch with the ceiling lifted. + +### Passes 5c and 5d: finished + +| Pass | What happened | Cost | +|---|---|---| +| 5c | From scratch on a fresh copy, wait ceiling lifted, 37 minutes. Draft, validator, nested panel, rewrite, then the review step: the skill published a review page from the nested session (`https://claude.ai/code/artifact/f9547723-...`) and set `status: review`. The PRD: 2,995 words, all thirteen sections, Appendix A with real rows (two blocking, several major, one open), Appendix B premortem; the memo 1,498 words. The journal: River's rewrite took 117 turns and 182k output tokens editing the document in pieces; the memo synthesis 234 turns; the main session wrote the review page's HTML by hand (102k output tokens) | $13.93 | +| 5d | `/ck:prd` again in a new session. The resume check saw `review`, found no `review.md`, treated it as a file-edit review, and finalized with one River agent: premortem folded into Assumptions and Risks, two claims added, `status: final`. It invented the author's premortem answer because none existed; the skill now leaves the question open in that case. The finished PRD is 3,407 words, 407 over the cap, all in Appendix A's verbatim panel record; the cap now excludes the appendices | $2.40 | + +Drill 5 in total: $28.20 across four passes; one complete PRD from scratch (5c plus 5d): $16.33, against $2.65 estimated. Fixed after 5c: `scripts/render-review.py` builds the review page in one command; every author prompt says to write the file once and not re-read it; the draft, rewrite, and memo stages run at medium effort. Re-measure a full `/ck:prd` after these before any further tuning. + +Next drills, in order: `/ck:prd` once more for the cost after the fixes; `/ck:brief` and `/ck:panel` once more for the same reason; `/ck:team`. diff --git a/tests/run.sh b/tests/run.sh index ce869be..4b33467 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -232,6 +232,19 @@ for f in skills/*-artifact/SKILL.md; do if printf '%s' "$prose" | grep -q '—'; then fail "$f has an em-dash in prose"; else ok "$f has no em-dash in prose"; fi done +# ─── 12. Review page renderer ──────────────────────────────────────────────── +section "12. Review page renderer" +if python3 scripts/render-review.py --in tests/fixtures/game/docs/PRD.md --out "$TMP/review.html" --title "Fixture PRD" --question "What went wrong?" >/dev/null 2>"$TMP/rr.err"; then + ok "render-review.py renders the fixture PRD" + h_src=$(grep -cE '^##+ ' tests/fixtures/game/docs/PRD.md); h_out=$(grep -o '' "$TMP/review.html" && ok "tables scroll in their own container" || fail "table wrapper missing" +else + fail "render-review.py failed: $(cat "$TMP/rr.err")" +fi + # ─── Summary ───────────────────────────────────────────────────────────────── printf "\n%d passed, %d failed\n" "$PASS" "$FAIL" if (( FAIL > 0 )); then diff --git a/workflows/brief.js b/workflows/brief.js index ce14a38..c41b4fe 100644 --- a/workflows/brief.js +++ b/workflows/brief.js @@ -104,7 +104,7 @@ let brief = await agent( ? `Comparable products, from Toni's market pass (attribute the section to it and cite its sources):\n` + JSON.stringify(market, null, 1) + '\n' : `The market pass returned nothing; write the Comparable products section as "pending" and say why.\n`) + - `Write ${briefPath} to that contract (create the directory if needed). Apply your Required Behaviors in ` + + `Write ${briefPath} to that contract (create the directory if needed). Write the whole file with one Write call; do not build it with piecemeal edits, and do not re-read it after writing. Apply your Required Behaviors in ` + `subagent form. Three Whys: do not accept the idea as the problem; write the chain (idea, why, why, why), ` + `each step more specific, until the user pain is exposed or the idea is shown to address a symptom, and say ` + `which. V0 Challenge: propose a first version that cuts at least half the scope, say what it cuts, and give ` + @@ -116,7 +116,7 @@ let brief = await agent( `Return only the brief object: briefPath must be '${briefPath}'; chainSteps, comparables, and nonGoals are ` + `counts of what you wrote; openQuestions is the list of open questions, one line each. Do not repeat the ` + `document in the return value.`, - { label: 'river:draft', phase: 'Draft', agentType: 'ck:river', schema: BRIEF_SCHEMA }, + { label: 'river:draft', phase: 'Draft', agentType: 'ck:river', effort: 'medium', schema: BRIEF_SCHEMA }, ) if (!brief) throw new Error('brief: River returned nothing') log(`brief: ${brief.chainSteps} step(s) in the root-cause chain, ${brief.comparables} comparable(s), ${brief.openQuestions.length} open question(s)`) diff --git a/workflows/draft.js b/workflows/draft.js index ba3ccab..dcf2e83 100644 --- a/workflows/draft.js +++ b/workflows/draft.js @@ -20,6 +20,7 @@ const ARTIFACTS = { question: "Is this PRD ready for the author's review, and what would you change before it ships?", premortem: 'this shipped on time and did not move the success metric', memoSlug: 'prd-review', + maxWords: 3000, lenses: [ { persona: 'river', lens: 'product', model: 'claude-fable-5-1', reads: ['docs/brief.md', 'docs/opportunity.md', 'ROADMAP.md'] }, { persona: 'toni', lens: 'marketing', model: 'claude-opus-5', reads: ['docs/market-research.md', 'docs/opportunity.md'] }, @@ -35,6 +36,7 @@ const ARTIFACTS = { question: 'Would you build it this way, and what would you change before the first line of code?', premortem: 'this shipped and fell over in production in its first month', memoSlug: 'architecture-review', + maxWords: 3000, lenses: [ { persona: 'morgan', lens: 'security', model: 'claude-fable-5-1', reads: ['docs/PRD.md', 'SECURITY.md'] }, { persona: 'alex', lens: 'platform', model: 'claude-sonnet-5', reads: ['infra/', 'Dockerfile', '.github/workflows/'] }, @@ -148,15 +150,15 @@ if (runs('draft')) { `author already decided; do not re-ask any of it.\n` + `${contractStep} It gives the section order, required fields, and the checklist your draft will be ` + `validated against.\n` + - `Write ${outPath} to that contract, all sections in this order (create the directory if needed): ` + + `Write ${outPath} to that contract, all sections in this order (create the directory if needed). Write the whole file with one Write call; do not build it with piecemeal edits, and do not re-read it after writing. ` + SECTIONS.map(s => '"' + s + '"').join(', ') + `.\n` + `Apply your Required Behaviors in subagent form. Leave Appendix B (the premortem) for the pass after the ` + `panel, and say so under its heading.\n` + `Tag every claim that is not taken directly from the inputs with an inline marker [C1], [C2], ... so the ` + `panel can address it, and list those claims with their section. Put anything you would have asked the ` + - `author under Open questions, with your assumption.\n` + + `author under Open questions, with your assumption. Keep the document under ${A.maxWords} words before the appendices.\n` + `Return the draft object; path must be '${outPath}'.`, - { label: `${A.author}:draft`, phase: 'Draft', agentType: author, schema: DRAFT_SCHEMA }, + { label: `${A.author}:draft`, phase: 'Draft', agentType: author, effort: 'medium', schema: DRAFT_SCHEMA }, ) if (!draft) throw new Error(`draft: ${A.author} returned nothing for the draft`) log(`draft: ${draft.claims.length} tagged claim(s), ${draft.assumptions.length} assumption(s), ${draft.questions.length} open question(s)`) @@ -223,15 +225,19 @@ if (runs('panel')) { // ---- Synthesize ---- phase('Synthesize') +// A resumed run starts here with the panel's files already on disk from the earlier run. +const earlierMemo = projectRoot + '/docs/decisions/' + stamp + '-' + A.memoSlug + '.md' const panelInputs = panel && panel.memoPath ? `${panel.memoPath} and every file under ${runDir}/panel/` : (panel ? `every file under ${runDir}/panel/ (the memo was not written)` - : 'nothing else: the panel did not run, and the document header must say so') + : (startAt === 'synthesize' + ? `${earlierMemo} and every file under ${runDir}/panel/, written by the earlier run of this workflow (if neither exists, say in the document header that the panel did not run)` + : 'nothing else: the panel did not run, and the document header must say so')) const final = await agent( `${contractStep} Read the inputs (${inputs.join(', ')}), ${outPath}, and ${panelInputs}.\n` + `Rewrite ${outPath}: the same sections, in contract order, revised where the panel showed a claim wrong or ` + - `unsupported, followed by two appendices.\n` + + `unsupported, followed by two appendices. Write the whole file with one Write call; do not build it with piecemeal edits, and do not re-read it after writing. \n` + `Appendix A, Challenged claims: one row per point a lens raised against a [C] claim or against something ` + `untagged: claim | challenged by (persona and lens) | severity (blocking, major, minor: your call from the ` + `memo) | status | resolution. Status is upheld (you kept it; say why), revised (you changed it; quote the ` + @@ -240,9 +246,9 @@ const final = await agent( `Appendix B, Premortem: write the 2-3 sentence scenario in which ${A.premortem}; name the hidden assumption ` + `it exposes; add that assumption to the Assumptions section; leave the question "What went wrong?" ` + `verbatim for the author. The review asks it.\n` + - `Check your own output against the contract's checklist before returning. List every decision you left ` + - `open under openDecisions. Generated ${stamp}, run ${runId}. Return the object; path must be '${outPath}'.`, - { label: `${A.author}:synthesize`, phase: 'Synthesize', agentType: author, schema: FINAL_SCHEMA }, + `Keep the document under ${A.maxWords} words before the appendices. Check your own output against the contract's checklist before ` + + `returning. List every decision you left open under openDecisions. Generated ${stamp}, run ${runId}. Return the object; path must be '${outPath}'.`, + { label: `${A.author}:synthesize`, phase: 'Synthesize', agentType: author, effort: 'medium', schema: FINAL_SCHEMA }, ) if (!final) throw new Error(`draft: ${A.author} returned nothing for the synthesis; the draft is at ` + outPath) @@ -251,7 +257,7 @@ return { artifact: a.artifact, startedAt: startAt, path: final.path, - memoPath: panel ? panel.memoPath : null, + memoPath: panel ? panel.memoPath : (startAt === 'synthesize' ? earlierMemo : null), lenses: panel ? panel.lenses : [], validation, challengedClaims: final.challengedClaims, diff --git a/workflows/panel.js b/workflows/panel.js index 68330fe..256016b 100644 --- a/workflows/panel.js +++ b/workflows/panel.js @@ -139,8 +139,8 @@ const results = (await parallel(lenses.map(l => () => agent( `state your assumption, and proceed.\n` + `handoffBrief: decisions you want recorded, open risks in your domain, one direct question to a named lens.\n` + `Length: reasoning at most 200 words; every other text field at most 100 words. Findings, not prose.\n` + - `Write the same object as JSON to ${runDir}/panel/${l.persona}.json (create the directory if needed) and ` + - `return it with persona '${l.persona}' and lens '${l.lens}'.`, + `Write the same object as JSON to ${runDir}/panel/${l.persona}.json (create the directory if needed), with one ` + + `Write call, and return it with persona '${l.persona}' and lens '${l.lens}'.`, { label: `${l.lens}:${l.persona}`, phase: 'Lenses', agentType: 'ck:' + l.persona, model: l.model, schema: LENS_SCHEMA }, )))).filter(Boolean) @@ -178,8 +178,8 @@ const memo = await agent( `quote verbatim only what the sections require and summarize the rest; do not restate a lens's reasoning ` + `in your own words. Then return only the memo object: memoPath must be '${memoPath}'; disagreementCount ` + `and disagreementTopics (one line each) and killConditionsMet are counts of what you wrote; summary is at ` + - `most 80 words. Do not repeat the memo in the return value.`, - { label: 'synthesis', phase: 'Synthesis', schema: MEMO_SCHEMA }, + `most 80 words. Do not repeat the memo in the return value. Write the whole file with one Write call; do not build it with piecemeal edits, and do not re-read it after writing. `, + { label: 'synthesis', phase: 'Synthesis', effort: 'medium', schema: MEMO_SCHEMA }, ) if (!memo) { diff --git a/workflows/team.js b/workflows/team.js index f3f5301..ef15654 100644 --- a/workflows/team.js +++ b/workflows/team.js @@ -168,7 +168,7 @@ let team = await agent( `Your nominations: ${runDir}/nominations.json. The confirmations: every file under ${runDir}/confirmations/ ` + (silent.length ? `(${silent.join(', ')} did not answer; treat their nominations as accepted and say so). ` : '') + `The product documents: ${inputs.join(', ')}. ${rosterStep}\n` + - `Write ${teamPath} (create the directory if needed): the Cast table (persona, role, tier, why on this ` + + `Write ${teamPath} (create the directory if needed). Write the whole file with one Write call; do not build it with piecemeal edits, and do not re-read it after writing. The Cast table (persona, role, tier, why on this ` + `product); the Roles and responsibilities matrix (one row per pipeline document and stage, and per PRD ` + `requirement area when a PRD exists; columns owner, contributors, reviewers; exactly one owner per row); ` + `the Hand-off order (who hands to whom, in pipeline order, and what each hand-off carries); Needs (per ` +