diff --git a/.gitignore b/.gitignore index 65c573f..76bcae7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ playwright-report/ playwright/.cache/ .DS_Store *.log +__pycache__/ +*.pyc diff --git a/README.md b/README.md index c1aa1c0..0847340 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,22 @@ not published automatically. Use `--output-root` to select another private location. After publishing the associated LinkedIn post, add its HTTPS URL as `discussionUrl` so the article invites readers to join that conversation. +Create or refresh a public-but-unlisted review page from a private package: + +```bash +npm run insight:review -- --package /path/to/private-package +npm run insight:review -- --package /path/to/private-package --replace +npm run sync +``` + +The review URL is `/review/the-working-title.html`. Only the `CWCW insight +draft` section and publication metadata are copied into this public repository; +research notes, raw material and social copy stay in the private package. Review +pages are visibly marked as drafts, carry `noindex,nofollow`, and are excluded +from Insights, RSS, the sitemap, `llms.txt` and social-card generation. They are +unlisted, not confidential: anyone with the URL or repository access can read +them. Publishing remains a separate, deliberate edit to `content/blog.json`. + `content/blog.json` is the insight source of truth. `npm run sync` renders static `docs/notes/*.html`, `feed.xml`, `sitemap.xml` and `llms.txt`; GitHub Pages serves `docs/` from `main`. Review the PR and local Lighthouse results before diff --git a/content/review-drafts/stop-watching-the-build.json b/content/review-drafts/stop-watching-the-build.json new file mode 100644 index 0000000..f1a5e46 --- /dev/null +++ b/content/review-drafts/stop-watching-the-build.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "status": "review", + "id": "stop-watching-the-build", + "created": "2026-09-20", + "title": "Stop Watching the Build", + "seoTitle": "Stop Watching the Build: An Event-Driven Agent Harness", + "summary": "How costly CI polling led us from constant checking, through slower scheduled heartbeats, to signed webhooks and exact agent continuation.", + "section": "Working practice", + "tags": [ + "agent harnesses", + "CI", + "event-driven systems", + "token cost", + "embedded Linux", + "Android", + "Jev", + "Preloop" + ], + "bodyMarkdown": "On 16th August, one of our CI and bench chats generated 92 Task workers. When we\nadded the parent conversation and those workers together, the cost-equivalent\nusage came to about $183.\n\nCall it roughly $200 for one chat.\n\nThat needs a qualification. It was a combined usage estimate, not necessarily\na $183 charge landing on a card. Some of the work sat within bundled product\nusage. Nor was every dollar caused by CI polling alone. The thread mixed build\nmonitoring, bench work, Task fan-out and large tool results.\n\nBut the shape of the waste was clear. We had made a reasoning system behave\nlike a sensor.\n\nThe agent would ask GitHub whether a workflow had finished. It had not. A later\nturn would ask again. The same thing happened with embedded Linux builds that\ncould run for hours. Each check looked small in isolation, but the chat kept\ngrowing. Tool results were added to context and could be carried into later\nturns. Worker conversations accumulated outside the parent total. We were\npaying an increasingly capable model to discover that nothing had happened.\n\nThe build was doing useful work. The agent was mostly watching it do that work.\n\n### First response: poll less often\n\nOur first improvement was straightforward. We stopped continuous checking and\nmoved long waits to scheduled heartbeats.\n\nInstead of keeping an agent running, a heartbeat woke at a slower interval,\nmade one bounded observation and went quiet again when the state had not\nchanged. That was materially better. It removed frantic status loops and made\nthe cost visible as a deliberate schedule rather than an accidental habit.\n\nIt also helped us establish some important disciplines:\n\n- one bounded status read per scheduled run;\n- no full CI logs unless a failure required them;\n- no `gh run watch` inside an agent chat;\n- no repeated progress narration when the external state was unchanged.\n\nThis was a useful intermediate design. It was not the final one.\n\nA fifteen-minute heartbeat still wakes up every fifteen minutes. Most of those\nwake-ups may say nothing more interesting than \"still running\". Slower polling\nreduces waste, but it does not remove the underlying mistake. Time is still\ndriving the reasoning system when an event should be driving it.\n\n### The change in question\n\nThe better question was not \"how often should the agent check?\"\n\nIt was \"who already knows that the state changed?\"\n\nGitHub knows when a workflow completes. Foundries knows when an embedded Linux\nbuild succeeds or fails. Those systems should emit an event. The harness should\nretain it durably, correlate it with the exact waiting task and wake that task\nonce.\n\nThat led us towards a genuinely event-driven continuation path:\n\n1. The task launching a build registers the immutable provider and build ID,\n its own task ID and an expiry.\n2. The task stops. There is no model waiting in the background.\n3. CI sends a signed success or failure webhook.\n4. A small gateway stores the event before trying to deliver it.\n5. A private tunnel carries it back to the workstation when available.\n6. A local dispatcher matches the exact correlation and resumes only the task\n that launched that build.\n7. The task receives bounded evidence and continues with the next useful test\n or a targeted diagnosis.\n\nEither side can arrive first. A very short build may complete before its wait\nregistration reaches the dispatcher. A laptop may be asleep when the webhook\narrives. A task may still be active when its event is delivered. Those are\ntransport and concurrency problems, not reasons to make an agent poll. The\nevent is retained and reconciled when the other side becomes available.\n\nSuccess events matter as much as failures. A successful image build often\nunblocks the next physical-board or runtime test. If success does not wake the\ntask, someone still has to watch the build.\n\n### Do not wake the model with a whole log\n\nEvent-driven delivery solved when to wake the agent. It did not by itself solve\nwhat to put into context.\n\nA raw BitBake, Soong or Ninja log can contain megabytes of routine progress.\nPulling all of it into a chat recreates much of the cost in a different form.\nIt can also bury the useful failure line.\n\nThe harness now reduces logs programmatically before model reasoning begins.\nFor BitBake, the first `ERROR:` record is significant even when unrelated\ntasks continue afterwards. For Android, we look for bounded, actionable Ninja,\nSoong, compiler, `lpmake` or `avbtool` failures. The extractor sanitises the\nfirst useful signal and leaves the ordinary log outside model context.\n\nWhere we control the build process, the same rules can fail fast. There is\nlittle value in allowing hours of dependent work to continue after a decisive\nfailure if the harness can stop safely, wake the task and begin a repair.\n\n### Where Jev and Preloop fit\n\nWe are also testing Jev through a Preloop adapter as an observe-only semantic\nsensor. It can help classify a failure, judge whether the first error appears\nactionable and suggest the cheapest next proof.\n\nIt is deliberately not in the critical wake path.\n\nThe deterministic event and bounded failure envelope are stored and routed\nfirst. Jev is supplemental. It cannot mark CI green, authorise a change, retry\na build or weaken compiler, test, hash or human approval gates. If Jev or\nPreloop is unavailable, the task still wakes. A later explicit event may make\none deferred advisory attempt; there is no watcher checking when the watcher is\navailable.\n\nThis distinction matters. Event-driven should not mean handing control to a\nprobabilistic component. It means using deterministic events to decide when\nreasoning is worth paying for.\n\n### What this should change about cost\n\nThe expected saving is not mysterious:\n\n- unchanged external state should cost zero model turns;\n- success should carry a small typed event, not a log or an LLM summary;\n- failure should carry the earliest bounded evidence needed for diagnosis;\n- disconnected infrastructure should queue events rather than provoke retries\n from an agent;\n- duplicate delivery should be absorbed by idempotent transport;\n- one task should wake once for the build it actually owns.\n\nThis does not make CI free. Builds still consume runner time, storage and\nnetwork traffic. Webhooks, durable storage and private delivery have an\nengineering cost. A difficult failure may still justify substantial model\nwork.\n\nThe aim is narrower and more defensible: do not spend reasoning tokens on\nwaiting.\n\n### When we can call it truly event-driven\n\nAt the time of this draft, the Foundries kiosk and Android FRDM lanes provide\nthe reference implementation. A real Foundries failure has already traversed\nthe webhook, durable outbox, private tunnel and exact-task continuation path.\nThat is useful evidence, but it is not yet a claim that every external wait in\nthe harness is event-driven.\n\nBefore publishing this as a completed journey, I want evidence that:\n\n- every material CI and long-running build lane uses a provider callback or an\n equivalent completion event;\n- superseded scheduled status heartbeats remain disabled;\n- success and failure both resume the correct task;\n- offline delivery and restart recovery have been exercised in practice;\n- duplicate, late and event-first delivery do not create a second turn;\n- before-and-after usage data shows fewer model turns and lower charged or\n cost-equivalent usage for comparable waits.\n\nThe most important design principle is already clear, though.\n\nThe agent should reason when there is something to reason about. The transport\nshould do the waiting.\n\nChop wood. Carry water.\n" +} diff --git a/docs/review/stop-watching-the-build.html b/docs/review/stop-watching-the-build.html new file mode 100644 index 0000000..c47ccdc --- /dev/null +++ b/docs/review/stop-watching-the-build.html @@ -0,0 +1,113 @@ + + + + + + Review draft: Stop Watching the Build: An Event-Driven Agent Harness — Chop Wood Carry Water + + + + + + + + +
+ + + Chop Wood Carry WaterDurable Agent Harness + + +
+
+
+ +
+

Working practice · draft 2026-09-20

+

Stop Watching the Build

+ +

How costly CI polling led us from constant checking, through slower scheduled heartbeats, to signed webhooks and exact agent continuation.

+
+
+

On 16th August, one of our CI and bench chats generated 92 Task workers. When we added the parent conversation and those workers together, the cost-equivalent usage came to about $183.

+

Call it roughly $200 for one chat.

+

That needs a qualification. It was a combined usage estimate, not necessarily a $183 charge landing on a card. Some of the work sat within bundled product usage. Nor was every dollar caused by CI polling alone. The thread mixed build monitoring, bench work, Task fan-out and large tool results.

+

But the shape of the waste was clear. We had made a reasoning system behave like a sensor.

+

The agent would ask GitHub whether a workflow had finished. It had not. A later turn would ask again. The same thing happened with embedded Linux builds that could run for hours. Each check looked small in isolation, but the chat kept growing. Tool results were added to context and could be carried into later turns. Worker conversations accumulated outside the parent total. We were paying an increasingly capable model to discover that nothing had happened.

+

The build was doing useful work. The agent was mostly watching it do that work.

+

First response: poll less often

+

Our first improvement was straightforward. We stopped continuous checking and moved long waits to scheduled heartbeats.

+

Instead of keeping an agent running, a heartbeat woke at a slower interval, made one bounded observation and went quiet again when the state had not changed. That was materially better. It removed frantic status loops and made the cost visible as a deliberate schedule rather than an accidental habit.

+

It also helped us establish some important disciplines:

+
    +
  • one bounded status read per scheduled run;
  • +
  • no full CI logs unless a failure required them;
  • +
  • no gh run watch inside an agent chat;
  • +
  • no repeated progress narration when the external state was unchanged.
  • +
+

This was a useful intermediate design. It was not the final one.

+

A fifteen-minute heartbeat still wakes up every fifteen minutes. Most of those wake-ups may say nothing more interesting than "still running". Slower polling reduces waste, but it does not remove the underlying mistake. Time is still driving the reasoning system when an event should be driving it.

+

The change in question

+

The better question was not "how often should the agent check?"

+

It was "who already knows that the state changed?"

+

GitHub knows when a workflow completes. Foundries knows when an embedded Linux build succeeds or fails. Those systems should emit an event. The harness should retain it durably, correlate it with the exact waiting task and wake that task once.

+

That led us towards a genuinely event-driven continuation path:

+
    +
  1. The task launching a build registers the immutable provider and build ID, its own task ID and an expiry.
  2. +
  3. The task stops. There is no model waiting in the background.
  4. +
  5. CI sends a signed success or failure webhook.
  6. +
  7. A small gateway stores the event before trying to deliver it.
  8. +
  9. A private tunnel carries it back to the workstation when available.
  10. +
  11. A local dispatcher matches the exact correlation and resumes only the task that launched that build.
  12. +
  13. The task receives bounded evidence and continues with the next useful test or a targeted diagnosis.
  14. +
+

Either side can arrive first. A very short build may complete before its wait registration reaches the dispatcher. A laptop may be asleep when the webhook arrives. A task may still be active when its event is delivered. Those are transport and concurrency problems, not reasons to make an agent poll. The event is retained and reconciled when the other side becomes available.

+

Success events matter as much as failures. A successful image build often unblocks the next physical-board or runtime test. If success does not wake the task, someone still has to watch the build.

+

Do not wake the model with a whole log

+

Event-driven delivery solved when to wake the agent. It did not by itself solve what to put into context.

+

A raw BitBake, Soong or Ninja log can contain megabytes of routine progress. Pulling all of it into a chat recreates much of the cost in a different form. It can also bury the useful failure line.

+

The harness now reduces logs programmatically before model reasoning begins. For BitBake, the first ERROR: record is significant even when unrelated tasks continue afterwards. For Android, we look for bounded, actionable Ninja, Soong, compiler, lpmake or avbtool failures. The extractor sanitises the first useful signal and leaves the ordinary log outside model context.

+

Where we control the build process, the same rules can fail fast. There is little value in allowing hours of dependent work to continue after a decisive failure if the harness can stop safely, wake the task and begin a repair.

+

Where Jev and Preloop fit

+

We are also testing Jev through a Preloop adapter as an observe-only semantic sensor. It can help classify a failure, judge whether the first error appears actionable and suggest the cheapest next proof.

+

It is deliberately not in the critical wake path.

+

The deterministic event and bounded failure envelope are stored and routed first. Jev is supplemental. It cannot mark CI green, authorise a change, retry a build or weaken compiler, test, hash or human approval gates. If Jev or Preloop is unavailable, the task still wakes. A later explicit event may make one deferred advisory attempt; there is no watcher checking when the watcher is available.

+

This distinction matters. Event-driven should not mean handing control to a probabilistic component. It means using deterministic events to decide when reasoning is worth paying for.

+

What this should change about cost

+

The expected saving is not mysterious:

+
    +
  • unchanged external state should cost zero model turns;
  • +
  • success should carry a small typed event, not a log or an LLM summary;
  • +
  • failure should carry the earliest bounded evidence needed for diagnosis;
  • +
  • disconnected infrastructure should queue events rather than provoke retries from an agent;
  • +
  • duplicate delivery should be absorbed by idempotent transport;
  • +
  • one task should wake once for the build it actually owns.
  • +
+

This does not make CI free. Builds still consume runner time, storage and network traffic. Webhooks, durable storage and private delivery have an engineering cost. A difficult failure may still justify substantial model work.

+

The aim is narrower and more defensible: do not spend reasoning tokens on waiting.

+

When we can call it truly event-driven

+

At the time of this draft, the Foundries kiosk and Android FRDM lanes provide the reference implementation. A real Foundries failure has already traversed the webhook, durable outbox, private tunnel and exact-task continuation path. That is useful evidence, but it is not yet a claim that every external wait in the harness is event-driven.

+

Before publishing this as a completed journey, I want evidence that:

+
    +
  • every material CI and long-running build lane uses a provider callback or an equivalent completion event;
  • +
  • superseded scheduled status heartbeats remain disabled;
  • +
  • success and failure both resume the correct task;
  • +
  • offline delivery and restart recovery have been exercised in practice;
  • +
  • duplicate, late and event-first delivery do not create a second turn;
  • +
  • before-and-after usage data shows fewer model turns and lower charged or cost-equivalent usage for comparable waits.
  • +
+

The most important design principle is already clear, though.

+

The agent should reason when there is something to reason about. The transport should do the waiting.

+

Chop wood. Carry water.

+
+ +
+
+ + + diff --git a/docs/styles.css b/docs/styles.css index 3ad4a9a..d45d0cd 100644 --- a/docs/styles.css +++ b/docs/styles.css @@ -1182,6 +1182,47 @@ section h2 { margin: 0 0 1.35rem; } +.note-body h2 { + margin: 2.4rem 0 0.8rem; + color: var(--carbon); + font-family: var(--font-display); + font-size: clamp(1.45rem, 3vw, 2rem); + line-height: 1.15; +} + +.note-body ul, +.note-body ol { + margin: 0 0 1.35rem; + padding-left: 1.5rem; +} + +.note-body li { + margin-bottom: 0.55rem; +} + +.review-banner { + display: grid; + gap: 0.35rem; + margin-bottom: 2rem; + padding: 1rem 1.1rem; + border: 2px solid var(--timber); + border-radius: var(--radius); + background: color-mix(in srgb, var(--timber) 14%, white); + color: var(--carbon); + font-family: var(--font-label); +} + +.review-banner strong { + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.review-footer { + padding-top: 1.25rem; + border-top: 1px solid var(--line); + color: var(--graphite); +} + .note-sources { max-width: 46rem; margin-top: 2.5rem; diff --git a/package.json b/package.json index 3c93519..64c2eac 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "description": "Public notebook on building a durable AI agent harness", "scripts": { "insight:new": "python3 scripts/new_insight.py", + "insight:review": "python3 scripts/publish_insight_review.py", "sync": "bash scripts/sync-content.sh", "cards": "python3 scripts/render_home_social_card.py && python3 scripts/render_note_social_cards.py", "privacy": "bash scripts/privacy-check.sh", diff --git a/scripts/publish_insight_review.py b/scripts/publish_insight_review.py new file mode 100755 index 0000000..39a4008 --- /dev/null +++ b/scripts/publish_insight_review.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Copy only an insight article into the public-but-unlisted review lane.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +REVIEW_SOURCE = ROOT / "content" / "review-drafts" +ARTICLE_HEADING = "## CWCW insight draft" + + +def validate_slug(value: str) -> str: + if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", value): + raise ValueError(f"Invalid insight id: {value}") + return value + + +def extract_article(markdown: str) -> str: + lines = markdown.splitlines() + try: + start = lines.index(ARTICLE_HEADING) + 1 + except ValueError as error: + raise ValueError(f"Draft is missing {ARTICLE_HEADING!r}") from error + + end = len(lines) + for index in range(start, len(lines)): + if lines[index].startswith("## "): + end = index + break + article = "\n".join(lines[start:end]).strip() + if not article: + raise ValueError("CWCW insight draft section is empty") + return article + "\n" + + +def prepare_review(package: Path, output_root: Path, replace: bool = False) -> Path: + package = package.expanduser().resolve() + manifest = json.loads((package / "manifest.json").read_text()) + article = extract_article((package / "draft-package.md").read_text()) + slug = validate_slug(manifest["id"]) + + required = ("title", "summary", "section", "created") + missing = [key for key in required if not str(manifest.get(key, "")).strip()] + if missing: + raise ValueError(f"Draft manifest is missing: {', '.join(missing)}") + + review = { + "schemaVersion": 1, + "status": "review", + "id": slug, + "created": manifest["created"], + "title": manifest["title"], + "seoTitle": manifest.get("seoTitle") or manifest["title"], + "summary": manifest["summary"], + "section": manifest["section"], + "tags": manifest.get("tags", []), + "bodyMarkdown": article, + } + output_root.mkdir(parents=True, exist_ok=True) + target = output_root / f"{slug}.json" + if target.exists() and not replace: + raise FileExistsError(f"Review already exists: {target}; pass --replace to update it") + target.write_text(json.dumps(review, indent=2, ensure_ascii=False) + "\n") + return target + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--package", required=True, type=Path) + parser.add_argument("--output-root", type=Path, default=REVIEW_SOURCE) + parser.add_argument("--replace", action="store_true") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + print(prepare_review(args.package, args.output_root, args.replace)) + + +if __name__ == "__main__": + main() diff --git a/scripts/render_review_drafts.py b/scripts/render_review_drafts.py new file mode 100755 index 0000000..971a6ef --- /dev/null +++ b/scripts/render_review_drafts.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Render public-but-unlisted CWCW review drafts without discovery metadata.""" + +from __future__ import annotations + +import html +import json +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "content" / "review-drafts" +OUTPUT = ROOT / "docs" / "review" + + +def inline_markdown(value: str) -> str: + parts = value.split("`") + rendered = [] + for index, part in enumerate(parts): + escaped = html.escape(part) + rendered.append(f"{escaped}" if index % 2 else escaped) + return "".join(rendered) + + +def render_markdown(markdown: str) -> str: + blocks: list[str] = [] + paragraph: list[str] = [] + list_kind: str | None = None + list_items: list[str] = [] + + def flush_paragraph() -> None: + if paragraph: + blocks.append(f"

{inline_markdown(' '.join(paragraph))}

") + paragraph.clear() + + def flush_list() -> None: + nonlocal list_kind + if list_kind: + items = "\n".join(f"
  • {inline_markdown(item)}
  • " for item in list_items) + blocks.append(f" <{list_kind}>\n{items}\n ") + list_items.clear() + list_kind = None + + for raw_line in markdown.splitlines(): + line = raw_line.strip() + if not line: + flush_paragraph() + flush_list() + continue + if line.startswith("### "): + flush_paragraph() + flush_list() + blocks.append(f"

    {inline_markdown(line[4:])}

    ") + continue + unordered = re.match(r"^-\s+(.+)$", line) + ordered = re.match(r"^\d+\.\s+(.+)$", line) + if unordered or ordered: + flush_paragraph() + desired = "ul" if unordered else "ol" + if list_kind and list_kind != desired: + flush_list() + list_kind = desired + list_items.append((unordered or ordered).group(1)) + continue + if list_kind: + list_items[-1] += " " + line + continue + flush_list() + paragraph.append(line) + + flush_paragraph() + flush_list() + return "\n".join(blocks) + + +def render_review(draft: dict) -> str: + if draft.get("status") != "review": + raise ValueError(f"Review draft {draft.get('id', '')} has invalid status") + title = html.escape(draft["title"]) + seo_title = html.escape(draft.get("seoTitle") or draft["title"]) + summary = html.escape(draft["summary"]) + section = html.escape(draft["section"]) + created = html.escape(draft["created"]) + body = render_markdown(draft["bodyMarkdown"]) + return f""" + + + + + Review draft: {seo_title} — Chop Wood Carry Water + + + + + + + + +
    + + + Chop Wood Carry WaterDurable Agent Harness + + +
    +
    +
    + +
    +

    {section} · draft {created}

    +

    {title}

    + +

    {summary}

    +
    +
    +{body} +
    +
    +

    End of review draft. This page is deliberately absent from Insights, RSS, the sitemap and llms.txt.

    +
    +
    +
    + + + +""" + + +def main() -> None: + OUTPUT.mkdir(parents=True, exist_ok=True) + expected: set[str] = set() + if SOURCE.exists(): + for source in sorted(SOURCE.glob("*.json")): + draft = json.loads(source.read_text()) + target = OUTPUT / f"{draft['id']}.html" + target.write_text(render_review(draft)) + expected.add(target.name) + for stale in OUTPUT.glob("*.html"): + if stale.name not in expected: + stale.unlink() + print(f"Rendered {len(expected)} unlisted review draft(s)") + + +if __name__ == "__main__": + main() diff --git a/scripts/sync-content.sh b/scripts/sync-content.sh index 75533c6..e57e4d2 100755 --- a/scripts/sync-content.sh +++ b/scripts/sync-content.sh @@ -7,11 +7,13 @@ rsync -a --delete \ --exclude '.gitkeep' \ --exclude 'denylist.txt' \ --exclude 'site.json' \ + --exclude 'review-drafts/' \ "$ROOT/content/" "$ROOT/docs/content/" rsync -a --delete "$ROOT/starters/" "$ROOT/docs/starters/" rsync -a --delete "$ROOT/packs/cursor-hour/" "$ROOT/docs/packs/cursor-hour/" rsync -a --delete "$ROOT/packs/codex-hour/" "$ROOT/docs/packs/codex-hour/" "$ROOT/scripts/render_notes.py" +"$ROOT/scripts/render_review_drafts.py" ( cd "$ROOT/packs" rm -f "$ROOT/docs/packs/cursor-hour-starter.zip" @@ -19,4 +21,4 @@ rsync -a --delete "$ROOT/packs/codex-hour/" "$ROOT/docs/packs/codex-hour/" zip -qr "$ROOT/docs/packs/cursor-hour-starter.zip" cursor-hour zip -qr "$ROOT/docs/packs/codex-hour-starter.zip" codex-hour ) -echo "Synced content + rendered notes/feed/sitemap; synced starters and Codex/Cursor packs (+ zip)" +echo "Synced content + rendered notes/reviews/feed/sitemap; synced starters and Codex/Cursor packs (+ zip)" diff --git a/tests/review.spec.js b/tests/review.spec.js new file mode 100644 index 0000000..c4b6480 --- /dev/null +++ b/tests/review.spec.js @@ -0,0 +1,39 @@ +const { test, expect } = require("@playwright/test"); +const fs = require("node:fs"); +const path = require("node:path"); + +const ROOT = path.resolve(__dirname, ".."); +const reviews = fs + .readdirSync(path.join(ROOT, "content", "review-drafts")) + .filter((name) => name.endsWith(".json")) + .map((name) => JSON.parse(fs.readFileSync(path.join(ROOT, "content", "review-drafts", name)))); + +test.describe("unlisted insight reviews", () => { + for (const review of reviews) { + test(`${review.id} is visibly draft-only and absent from discovery`, async ({ page, request }) => { + const localPath = `/review/${review.id}.html`; + await page.goto(localPath); + + await expect(page.locator("main h1")).toHaveText(review.title); + await expect(page.getByText("Review draft — not published", { exact: true })).toBeVisible(); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute( + "content", + "noindex,nofollow,noarchive,nosnippet,noimageindex", + ); + await expect(page.locator('link[rel="canonical"]')).toHaveCount(0); + await expect(page.locator('meta[property^="og:"]')).toHaveCount(0); + await expect(page.locator('script[type="application/ld+json"]')).toHaveCount(0); + await expect(page.getByText("Share this insight", { exact: true })).toHaveCount(0); + + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, + ); + expect(overflow).toBeFalsy(); + + for (const discoveryPath of ["/", "/feed.xml", "/sitemap.xml", "/llms.txt"]) { + const source = await (await request.get(discoveryPath)).text(); + expect(source).not.toContain(review.id); + } + }); + } +}); diff --git a/tests/test_insight_tools.py b/tests/test_insight_tools.py index 5aaedd3..f8fec4a 100644 --- a/tests/test_insight_tools.py +++ b/tests/test_insight_tools.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import importlib.util +import json import tempfile import unittest from pathlib import Path @@ -18,6 +19,12 @@ def load_module(name: str, path: Path): new_insight = load_module("new_insight", ROOT / "scripts" / "new_insight.py") render_notes = load_module("render_notes", ROOT / "scripts" / "render_notes.py") +publish_review = load_module( + "publish_review", ROOT / "scripts" / "publish_insight_review.py" +) +render_reviews = load_module( + "render_reviews", ROOT / "scripts" / "render_review_drafts.py" +) class InsightToolsTest(unittest.TestCase): @@ -64,6 +71,73 @@ def test_non_linkedin_discussion_url_is_rejected(self): {"discussionUrl": "https://example.com/not-linkedin"} ) + def test_review_import_copies_only_article(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + package = root / "private-package" + package.mkdir() + (package / "manifest.json").write_text( + json.dumps( + { + "id": "safe-review", + "created": "2026-09-20", + "title": "Safe review", + "summary": "A review summary.", + "section": "Working practice", + } + ) + ) + (package / "draft-package.md").write_text( + """# Safe review + +## CWCW insight draft + +The public article with `code`. + +## Research and caveats + +PRIVATE CORRESPONDENCE + +## Raw material + +PRIVATE RAW MATERIAL +""" + ) + target = publish_review.prepare_review(package, root / "reviews") + review = json.loads(target.read_text()) + self.assertEqual(review["status"], "review") + self.assertIn("The public article", review["bodyMarkdown"]) + self.assertNotIn("PRIVATE CORRESPONDENCE", target.read_text()) + self.assertNotIn("PRIVATE RAW MATERIAL", target.read_text()) + + def test_review_page_is_unlisted_and_escapes_html(self): + rendered = render_reviews.render_review( + { + "status": "review", + "id": "safe-review", + "created": "2026-09-20", + "title": "Safe review", + "summary": "A review summary.", + "section": "Working practice", + "bodyMarkdown": "### Heading\n\n", + } + ) + self.assertIn("noindex,nofollow,noarchive,nosnippet,noimageindex", rendered) + self.assertIn("Review draft — not published", rendered) + self.assertIn("<script>alert(1)</script>", rendered) + self.assertNotIn('", rendered) + self.assertIn("Register the build, its task and expiry.", rendered) + self.assertEqual(rendered.count("
  • "), 2) + if __name__ == "__main__": unittest.main()