ulw-research
Maximum-saturation research orchestration: ALWAYS proposes the final materials first (PDF+DOCX default), then parallel explore+librarian swarms across codebase, web, official docs, and OSS repos — max-roster teammode when the harness has it — with live journaling, a recursive EXP
Install
npx skills add https://github.com/code-yeongyu/oh-my-openagent/tree/dev/packages/shared-skills/skills/ulw-research
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install code-yeongyu-oh-my-openagent@llmmart
git clone https://github.com/code-yeongyu/oh-my-openagent.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole code-yeongyu/oh-my-openagent collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Codex Harness Tool Compatibility
This skill may include examples copied from the OpenCode harness. In Codex, do not call OpenCode-only tools such as call_omo_agent(...), task(...), background_output(...), or team_*(...) literally. Translate those examples to Codex native tools:
| OpenCode example | Codex tool to use |
|---|---|
call_omo_agent(subagent_type="explore", ...) |
multi_agent_v1.spawn_agent({"message":"TASK: act as an explorer. ...","agent_type":"explorer","fork_context":false}) |
call_omo_agent(subagent_type="librarian", ...) |
multi_agent_v1.spawn_agent({"message":"TASK: act as a librarian. ...","agent_type":"librarian","fork_context":false}) |
task(subagent_type="plan", ...) |
multi_agent_v1.spawn_agent({"message":"TASK: act as a planning agent. ...","agent_type":"plan","fork_context":false}) |
task(subagent_type="oracle", ...) for final verification |
multi_agent_v1.spawn_agent({"message":"TASK: act as a rigorous reviewer. ...","agent_type":"lazycodex-gate-reviewer","fork_context":false}) |
task(category="...", ...) for implementation or QA |
multi_agent_v1.spawn_agent({"message":"TASK: act as an implementation or QA worker. ...","fork_context":false}) |
background_output(task_id="...") |
multi_agent_v1.wait_agent(...) for mailbox signals |
team_*(...) |
Use Codex native subagents via multi_agent_v1.spawn_agent and multi_agent_v1.wait_agent; use multi_agent_v1.send_input and multi_agent_v1.close_agent only when exposed in the active tools list |
Role-specific behavior must be described in a self-contained message. Use fork_context: false to start the child with only the initial prompt (no parent history); use fork_context: true only when full parent history is truly required. Include any required conversation context, files, diffs, constraints, and requested skill names directly in the spawned agent's message. OMO installs these selectable agent roles into ~/.codex/agents/: explorer, librarian, plan, momus, metis, lazycodex-code-reviewer, lazycodex-qa-executor, and lazycodex-gate-reviewer — pass the matching name as agent_type so the child gets that role's model and instructions. If the spawn tool exposes no agent_type parameter, omit it and describe the role inside message. If a code block below conflicts with this section, this section wins.
Codex exposes ONE of two subagent tool surfaces per session; check your own tool list and route accordingly. If multi_agent_v1.* tools exist, use the table above as written. If instead a flat spawn_agent with a required task_name exists (multi_agent_v2), rewrite every multi_agent_v1.* example: multi_agent_v1.spawn_agent({...,"fork_context":false}) becomes spawn_agent({"task_name":"<lowercase_digits_underscores>","message":...,"agent_type":...,"fork_turns":"none"}) ("all" only when full parent history is truly required); send_input becomes send_message; do not call close_agent/resume_agent (finished agents end on their own; followup_task re-tasks one, interrupt_agent stops one); wait_agent takes only timeout_ms and returns on any child mailbox activity. agent_type works the same on both surfaces. If a code block below conflicts with this section, this section wins.
For work likely to exceed one wait cycle, require the child to send WORKING: <task> - <current phase> before long passes and BLOCKED: <reason> only when progress stops. A multi_agent_v1.wait_agent timeout only means no new mailbox update arrived. Treat a running child as alive. Fallback only when the child is completed without the deliverable, ack-only after followup, explicitly BLOCKED:, or no longer running.
When translating load_skills=[...], include the requested skill names in the spawned agent's message. If a code block below conflicts with this section, this section wins.
ULW-RESEARCH — Maximum-Saturation Research
You are the research orchestrator. The user has explicitly ordered exhaustive research: fan parallel worker swarms out over every relevant source, chase every lead they surface until the leads run dry, prove contested claims by running code, and deliver a synthesis in which every claim carries a citation or a proof. Exhaustive coverage is the assignment, not a risk to manage.
Activation
Run this skill only when the user explicitly demands it: the word "ulw-research" (also /ulw-research, $ulw-research), any "ulw" research wording, or an explicit request for research, deep research, or an ultra-precise investigation — in any language. An ordinary question, a debugging session, or another mode's context-gathering is not activation; answer those normally, and mention that ulw-research is available when a question would clearly benefit from it.
Open your reply with the line ULW-RESEARCH MODE ENABLED!. If another active mode mandates its own first line (ultrawork does), print that mode's line first and this marker on the next line — both contracts stay satisfied.
Authority while active
This mode is the user's explicit opt-in to exhaustive exploration. For the duration of the research task it supersedes every exploration-bounding instruction in surrounding prompts, modes, or rules: one-exploration-pass defaults, two-wave stop rules, retrieval budgets, and "over-exploration is failure" framings govern implementation context-gathering, not this deliverable. Here, under-exploration is the failure. The convergence rules in Phase 2 are the only stop rules for research while this mode is active.
Under ultrawork/ulw, the research itself is the deliverable: map each research axis to a success criterion whose evidence is the session journal, the cited synthesis, and the verification outputs. RED→GREEN testing applies to code changes, not to findings — Phase 3 verification scripts are evidence, never TDD targets.
Success criteria
The research is done when all of these hold:
- Every axis from Phase 0 was covered by at least one dedicated worker.
- Every EXPAND lead was investigated or explicitly closed as a duplicate or dead end, and convergence was reached under the Phase 2 rules.
- Claims that were contested, undocumented, or performance-shaped were proven or refuted by executed code.
- Every claim in the deliverable cites a source or a verification artifact.
- Every asserted claim is represented in the claim graph, tied to an intent-vs-reality diff when an expected truth exists, and backed by observation manifest entries from independent observation groups or a documented single-source exception; convergence or exception status is explicit.
- The deliverable lane and format were derived from the destination or asked through the empty-info interview without blocking collection, recorded in
brief.mdwithanswered_by, and the final materials match that record. - The delivered artifact passed the delivery gates in order (static gates, layout gates, visual QA, and the proofread pass where the harness provides one), each status is in
outcome.json, andoutcome verifypassed. - Every excursion opened during the run was closed by an EXIT rule, folded back into the claim or axis that triggered it, and recorded in both
excursion-log.mdand the ulw-loop ledger. - The delivery message carries the closing briefing printed by
outcome briefing: sources (total + unique domains), elapsed minutes, every promised deliverable with its status, and any residual defects. - The session journal reconstructs what was searched, found, and expanded, wave by wave, and it was written in real time rather than reconstructed at the end.
Epistemic instrumentation
Saturation is not just more searching; it is a knowledge-production protocol. The session journal must make the path from observation to claim to verdict auditable. The orchestrator owns these artifacts:
intent-diff.md— one row per expected truth derived from the user intent, design/spec text, branch history, or authoritative docs. Required fields:intent_id, expected truth, observed reality, diff, violated invariant, intent source, supporting observations, status (true,violated, orunknown), and linked claim ids.claim-graph.md— the single claim store; one node per claim. Required fields:claim_id, statement, claim type, risk tier, scope, intent ids, supporting observations, contradicting observations, independent observation groups, convergence status, counter-search result, primary source backing, dependencies, status (supported,partial,refuted, orunresolved), and final synthesis location. High-risk non-code nodes that clear the Phase 3b gate are mirrored into averified-claimsdigest section at the top of the file — the sole allowlist the synthesis draws non-code claims from.observation-manifest.md— one row per observation. Required fields:observation_id, source path or URL, evidence layer, observer group, independence basis, observer,observed_at,valid_atorclaim_valid_at, artifact path, quote or line anchor, and contamination notes.verification-economics.md— one row per proof decision. Required fields: claim, risk, error cost, verification cost/time, chosen verification path, defer/verify decision, outcome, and residual risk.cause-disappearance.md— one row per causal finding. Required fields: cause id, expected truth, previous observation,last_seen, disconfirming observation, replacement cause if any, current status, and whether the violation is no longer observed.
Observation candidates and claim candidates travel back from workers as message text. The orchestrator writes the instrumentation artifacts, links candidates into the intent diff and claim graph, and records where each observation entered the synthesis. A conclusion is not ready for final materials until its expected truth/reality diff is closed or marked unknown, its claim node exists, and its independent-observation convergence status is supported or explicitly excepted.
Run the swarm as a cooperating team
Saturation research defaults to teammode, not isolated fire-and-forget workers: a lead one worker surfaces almost always reshapes what another should search next. When your harness gives you real cooperating members — Codex: the teammode skill (MultiAgentV2 agents, or codex_app threads as its fallback); OpenCode: team_mode — run this swarm as a team. Fall back to the background-worker swarm below only when team mode is unavailable, or the axes are genuinely independent with no cross-pollination expected.
- One member per axis — by part, ownership, or perspective, never a job title. Each Phase 0 axis is one member owning one concrete slice: a codebase part, a source territory, or a question lens. No two members share an angle. "Backend researcher" or "the web person" gives no real boundary and invites overlap — name what the member owns.
- Always the maximum roster. The team is not sized by taste: fill every member slot your harness allows (OpenCode
team_modecaps members at 8; Codex teammode has its own ceiling) on every run. If you can only name five axes, split the broadest one — by source territory, by time window, by perspective — until the roster is full. A half-empty team is a half-covered topic. - Compose deliberately across everything you can spawn. Before writing the roster, enumerate what this session actually has — every delegation tier the harness or config defines and every spawnable role — and give each slot the cheapest tier that can do ITS job: broad recon on fast tiers, contested analysis on deep tiers, attack lanes on the strongest reasoning tier. Mixed tiers by design, never one tier across the whole board.
- Routing words from the user are literal. "quick", "fast", "deep", "all quick", "max parallel" — in any language — are hard instructions, not mood. Route exactly as asked and journal
requested tier -> spawned worker -> fallback reasonfor every slot. Silently promoting a "quick" roster to a heavier tier is a defect, and so is dropping to a cheaper one without saying why. - Debate members are mandatory for ultradebate/hyperdebate, default otherwise. At least one skeptic or red-team perspective attacks claims, evidence quality, source independence, synthesis structure, and report choices before they reach the deliverable. When the user says ultradebate or hyperdebate, run at least two attacking perspectives and give every contested claim a full round.
One team, or a sequence — decided by scale and precision. One team is the floor, not the ceiling. When the brief shows 6+ axes, several source territories, or a long final document — or a wrong claim is expensive (legal, medical, financial, procurement, public-facing) — run the research team to convergence, disband it, and stand up a REFINEMENT team from your strongest reasoning tiers whose only job is to attack and sharpen the synthesis before the document is written. A fresh premium team reading a finished journal reasons better than the same researchers grading their own homework. Build each team from a written brief and disband it before the next one starts; never leave two research teams live at once.
- The raise law — broadcast every lead the instant it surfaces. Members over-communicate relentlessly: every new lead, finding, contradiction, and dead end is raised to you the moment it surfaces, never hoarded for a final dump. Through long passes they send
WORKING: <axis> - <phase>, andBLOCKED: <reason>the moment progress stops, so you always know a member is alive. Too many small updates is correct here; going quiet is the only failure. - You lead; expand on each raised lead. Members raise via message text, never write session files. Journal each lead and spawn its expansion the instant it lands (Phase 2), not only when a member's final reply arrives.
Worker ground rules
Research workers (explore, librarian, browsing) differ by harness, but assume:
- Read-only. Most research workers cannot write files. Never ask a worker to write the journal or any session file — every journal write is yours.
- No recursion — workers AND members. Workers cannot spawn their own subagents, and team members must not re-orchestrate: a member researches its axis and reports; it never stands up its own team, loads this skill, or fans out a research swarm of its own. Depth comes from YOUR expansion waves. Say so in every spawn message — a member that starts its own research protocol burns the run's budget on duplicated orchestration and returns nothing you can cite.
- Built-in brakes. Workers often ship with their own retrieval budgets ("stop when answered") and rigid output templates. Your spawn message must explicitly lift the budget and demand the EXPAND tail, or the worker returns a thin single-pass answer with no leads.
- Capability routing. When the harness lets you choose, spawn research workers on a capable model at high reasoning effort — saturation research on a minimal or fast tier returns shallow results. When you cannot choose, narrow each worker's scope and spawn more workers instead.
The spawn-message contract
Every research spawn message contains, in order:
TASK:— one imperative line naming the role and the axis.- The budget lift: "This is an explicit exhaustive-research assignment. Your default retrieval budget and stop-when-answered rules do not apply — run the full protocol below and report every lead."
- Scope — the axis, the sources to hit, and what a complete answer contains.
- The role protocol (Phase 1).
- The reply tail. EXPAND markers, observation candidates, and claim candidates travel back as message text, never as files. Every worker ends the reply with:
## EXPAND
- LEAD: <discovery not yet investigated> — WHY: <why it matters> — ANGLE: <suggested search>
- DEAD END: <lead explored to exhaustion>
A worker with nothing to expand writes ## EXPAND followed by none — <one-line reason>. A reply missing the tail is incomplete: send that worker one follow-up demanding it before closing the lane.
Phase 0 — Decompose and open the journal
Before spawning anything, decompose the query. Start from "what must be true if the user's intent/spec is true?", not "what looks broken?" Seed intent-diff.md with those expected truths before treating code, current docs, or web results as the source of truth:
<analysis>
Core question: <the actual information need>
Axes (3+ orthogonal): <axis — what to search, where, why> ...
Codebase relevant: <yes/no> · External: <yes/no> · Browsing: <yes/no> · Verification likely: <yes/no>
Scale: <axis count, source territories, target document length> · Precision demand: <what a wrong claim costs here> → lifecycle: <single team | research team then refinement team>
</analysis>
Then create the session directory:
mkdir -p .omo/ulw-research/$(date +%Y%m%d-%H%M%S)
This is $SESSION_DIR. The orchestrator owns the journal: you write every file in it; workers never do. Maintain:
brief.md— the analysis block, the axis list, the expected truths, and the## Deliverablerecord from the interview below (lane, state, formats, each field withanswered_by).sources-ledger.md— one line per source the moment it is read:[S<n>] <url> — <what it is>. The closing briefing counts sources and domains from this file.wave-<N>-<kind>-<axis>.md— your digest of each worker return: key findings, sources with URLs, and the worker's EXPAND markers verbatim.expansion-log.md— per wave: workers spawned, markers gained, leads opened and closed.excursion-log.md— one ENTER row and one EXIT row per excursion:excursion_id, parent claim or axis, ENTER trigger, depth, workers spent, the EXIT rule that closed it, what it changed in the top-level answer (noneis a valid, required answer), and the ulw-loop steer/evidence id it was mirrored into.intent-diff.md— orchestrator-owned expected-truth ledger comparing intent/spec/history to observed reality.claim-graph.md— orchestrator-owned claim graph linking every final assertion to observations, counterevidence, dependencies, and verdict.observation-manifest.md— orchestrator-owned observation manifest withobserved_at, temporal validity, artifact paths, and contamination notes.verification-economics.md— proof-cost ledger mapping claim risk to verification path, deferral decisions, and residual risk.cause-disappearance.md— cause ledger tracking expected truth, previous observation,last_seen, disconfirming observation, and whether the violation is no longer observed.verify-<slug>.md,SYNTHESIS.md,REPORT.*from later phases.
Append each digest the moment its worker returns, not in a batch at the end — the journal is your recovery point after context loss and the user's audit trail.
Run it as a loop, and journal in real time
ulw-loop is ON by default for this mode: when the ulw-loop skill is available, register the research axes as loop goals so the run has durable state and survives a compaction. The session directory's timestamp is the run's start clock — the closing briefing is computed from it, so create it once and never rename it. From that point every finding, source, quote, number, and lead is written into $SESSION_DIR the instant it lands — never held in the conversation for an end-of-run dump. After any context loss, re-read the brief and the journal before doing anything else, then resume from the open wave.
Deliverable lane, format, and the empty-info interview
Never block collection on the shape of the deliverable, and never guess it either. Read references/deliverable-phase.md sections 1-6 before writing the brief, then:
- Derive first. Name the lane (
template-strict,template-vibe,no-format,edit-existing) and the promised formats from the request and its destination (the reference's section 3). When the request refers to an existing deliverable, decide its state withnode "$SKILL_DIR/scripts/report-tools.mjs" outcome state --deliverable <path> --session-dir "$SESSION_DIR":partialresumes the skeleton on disk,completemeans edit-existing and never a regenerated report.$SKILL_DIRis this skill's own directory, the folder containing this SKILL.md. - Read the requester's format memory with the memory tool when the harness exposes one: the pointer
system/human/report-style.md, thenreference/human-report-style.md; take the choice recorded for the most similar context (same destination kind and audience) as the first option. - Ask only what is still missing: at most the reference's three questions (destination and format, audience and length, template lineage) in one call to the harness question tool (
ask_user_questionorrequest_user_input), non-blocking where the harness supports it, each with its default first, a free-text "describe the format" path, and "don't care, you decide". Collection starts without waiting; a late answer is folded in until the assembly worker starts, and after that it becomes a re-render request. - Record it. Write
## Deliverableintobrief.md(lane, state, formats, destination, audience, template, format description, each withanswered_by: user|default|request) and open the manifest in the same step:node "$SKILL_DIR/scripts/report-tools.mjs" outcome init --promised <formats> --lane <lane> --session-dir "$SESSION_DIR".
Phase 5 opens by turning the recorded fields into design-spec.md.
Phase 1 — Saturation wave
When the user asked for MASS research, the wave is sized by the topic's angles, not by the roster ceiling. "mass ulw research", "mulw research", "ulw mass research" — in any language — order over-collection that a team of 8 cannot produce. Where the harness has a dependency-graph surface (the mass-ulw skill and its references/planning.md), run collection through it instead: a 60+ node opening wave covering every angle the topic has, routed across the whole difficulty ladder in one graph — mechanical sweeps and per-item harvest batches on the cheapest tier, judgment-shaped angles a step up, cross-territory angles a step above that, and the deepest tier reserved for genuinely hairy contradictions. Each wave's EXPAND leads define the next wave's nodes, and the synthesis reduces through several parallel architect-tier nodes into ONE architect-tier reducer (the strongest reasoning tier substitutes wherever no architect tier exists). Absent that surface, hold the same shape with background workers in batched waves. Everything else in this skill still binds: the deliverable interview, the journal, the claim graph, the convergence rules, and the delivery gates.
Otherwise launch the entire first wave in one turn — every axis at once, as team members if you formed a team, else as background workers. Sequential launches and "start with one and see" defeat the mode.
Scaling floor — more angles always justify more workers:
| Query scope | explore | librarian | browsing | repo-dive | floor |
|---|---|---|---|---|---|
| Single topic, codebase only | 3 | 0 | 0 | 0 | 3 |
| Single topic, web only | 0 | 4 | 1 | 1 | 6 |
| Single topic, both | 2 | 3 | 1 | 1 | 7 |
| Multi-faceted | 4 | 6 | 2 | 2 | 14 |
| Full due diligence | 4 | 6 | 3 | 2 | 15 |
The browsing column is BINDING, not advisory: when the brief says Browsing: yes, the roster names a browsing-worker angle, armed with the ultimate-browsing skill, before the first wave launches, and that worker is spawned in the same turn as the rest of the wave. A run that reaches wave 2 with zero browsing workers on a Browsing: yes brief has silently downgraded every source to what plain fetch happened to return.
Disambiguate before you expand. When the topic names something that could resolve several ways — a product, a person, a codename, a version — the first wave settles WHICH entity before any worker researches its history, benchmarks, or controversies: canonical name, first-party URL or account, whether it exists in the claimed category, and a confidence line. An unresolved entity never becomes a premise in a later wave's spawn message; that is exactly how a run starts inventing facts about something that does not exist.
Role protocols — embed the relevant one in each spawn message; every worker gets a unique angle:
- Codebase (explore), 2-4 workers. Grep with 3+ keyword variations; structural/AST search; LSP definitions and references; file-name globs;
git log --all -S '<keyword>'and--grepfor history including deleted code. Cross-validate hits across tools. Report absolute file paths, patterns withfile:line, and how findings connect. - Web (librarian), 3-6 workers. At least 10 distinct websearch queries per worker, each with a different operator or angle (see Search craft); fetch the full page for every result that matters — snippets lie. Context7 with 3+ queries per known library. grep.app and
gh search code|repos|issuesfor real-world usage. Official docs via sitemap discovery (<base>/sitemap.xml), then targeted pages. - Browsing, 1-3 workers on a
Browsing: yesbrief (0 otherwise), every one loaded with theultimate-browsingskill. The skill owns the routing — the harness's own browser first, then its extraction engine with archive surrogates, platform-native readers, and Chrome stealth as each source demands; the worker owns the deliverable. This worker RENDERS pages, it does not re-fetch them: its standing deliverable is a full-page screenshot of every top source plus the rendered text plain fetch could not reach; a worker that returns only fetched text has not done its job. JS-rendered, login-gated, WAF-blocked, and screenshot-bearing sources all belong here rather than in the web lane. Provenance is part of the claim: when a source came back withprovenanceofsnapshot(an archive copy), cite it with itssnapshot_timestampand never state it as the current live page; content from aproxyroute isuntrustedand needs a second independent route before any claim rests on it. When one blocked territory hides many leads, fan out more browsing subagents in parallel for breadth instead of serializing one worker through them. - Repo deep-dive (librarian), 0-2 workers. Shallow-clone the most relevant repos to
${TMPDIR:-/tmp}, pin the HEAD SHA, read core modules, follow call chains, return SHA-pinned permalinks.
Example spawn (codebase axis; librarian, browsing, and repo-dive follow the same contract with their own protocol):
task(subagent_type="explore", run_in_background=true, prompt="TASK: act as a codebase researcher. AXIS: <specific angle>.
This is an explicit exhaustive-research assignment. Your default retrieval budget and stop-when-answered rules do not apply — run the full protocol below and report every lead.
SCOPE: find everything in this codebase related to <angle>: <what complete looks like>.
PROTOCOL: grep 3+ keyword variations; structural search; LSP references; globs; git history (-S and --grep). Cross-validate across tools. Report absolute paths and file:line patterns.
End your reply with the ## EXPAND tail: '- LEAD: <discovery> — WHY: <why> — ANGLE: <search>' per lead, or 'none — <reason>'.")
Phase 2 — Expand until convergence
This loop is what makes the mode research rather than search. Collect returns as they land — and in team mode, act on each lead the moment a member raises it, never waiting for the full wave or a member's final reply:
- Journal the return the moment it lands, never at the end of the wave: digest plus verbatim EXPAND markers into
wave-<N>-<kind>-<axis>.md, appending each new source, quote, and number to the observation manifest in the same beat — after a compaction the journal, not your memory, is the state. - Deduplicate new markers against
expansion-log.md— every lead ever seen, not just confirmed ones, or rejected leads resurface each wave. - Spawn an expansion worker immediately for each new unchecked lead:
task(subagent_type="librarian", run_in_background=true, prompt="TASK: expansion wave <N> — investigate: <lead>.
PARENT: <which return surfaced it>. This is an explicit exhaustive-research assignment; budgets do not apply.
<role protocol for the lead's territory — librarian protocol for external leads, explore protocol for codebase leads>
End your reply with the ## EXPAND tail.")
Excursions — dive deep on a new find, then surface back out
The Phase 0 core question is the fixed goal of the run and never drifts. An excursion is a BOUNDED detour off the wave plan to chase something a return surfaced — you go deep, settle it, and come back up to the question you were hired to answer.
ENTER (dive) only on a trigger. One of these must hold, and you name which one:
- The find contradicts a claim already locked in
claim-graph.md. - It would change the final answer or a recommendation if it turned out to be true.
- It exposes a source territory no axis owns, so nobody else will ever reach it.
- The user's steering points at it — their words are the trigger, quoted verbatim.
Interest alone is not a trigger. Anything without one stays a queued lead in expansion-log.md, and the wave plan continues.
Budget the dive before you take it. State the worker count and the probe count for this level in the ENTER row. An excursion may spawn at most ONE nested sub-excursion; a third level means the thing has become its own research question — surface immediately and either promote it to a real axis with its own worker or record it as an out-of-scope gap in SYNTHESIS.md. When the ulw-loop skill is driving the run, a promotion is omo-agent-toolkit ulw-loop steer --kind add_subgoal --title "<axis>" --objective "<what it must answer>" --evidence "<what surfaced it>" --rationale "<why the plan changes>".
EXIT (surface) the moment any of these holds — you do not need all of them:
- The ENTER trigger is resolved: the claim is confirmed, refuted, or its dependency is closed.
- Two consecutive probes changed nothing in the parent answer.
- The finding stops moving any claim's status — diminishing return is an exit, not a reason to push harder.
- The level's stated budget is spent.
Fold back on the way out. Every EXIT writes one line saying what the excursion changed in the top-level answer, and none — <reason> is a legitimate, required outcome; an excursion whose result is silently dropped is a lost run. Update the parent claim node or axis digest with the result, then mirror the whole excursion into the loop ledger — omo-agent-toolkit ulw-loop steer --kind annotate_ledger --evidence "<what the excursion observed>" --rationale "<what it changed, or none>", and omo-agent-toolkit ulw-loop record-evidence --goal-id <id> --criterion-id <id> --status pass|fail|blocked --evidence "<artifact>" when it settled a criterion. After a compaction, omo-agent-toolkit ulw-loop status --json plus excursion-log.md tell you which excursions are still open.
Anti-drift. After every EXIT, re-read the core question in the journal and confirm the run still answers it. Three consecutive excursions that changed nothing end excursions for the run: converge on what you have.
- Record the wave in
expansion-log.md: spawned, markers gained, leads opened/closed. - Relay the user's steering to everyone. When the user changes scope, cadence, target sources, language, or format mid-run, broadcast it to every live worker and member immediately and record the exact wording in
expansion-log.md. Steering only you saw silently splits the swarm's assignment from the user's actual ask.
Convergence — the only stop rules while this mode is active. Run at least 2 expansion waves on any multi-faceted query before claiming convergence; then stop only when one holds:
- Zero unchecked leads remain — each investigated or closed as duplicate/dead end.
- 3 consecutive waves produced no new actionable leads.
- Expansion depth reached 5 waves — pause, show the open leads, and ask the user whether to extend.
Never end the run on a worker's completion. Workers finishing is not the deliverable; your synthesis is. Reserve the last fifth of the run's context and time for Phases 4-5 and stop opening waves the moment that reserve is threatened. A converged answer with two open leads beats nine finished workers and no report.
Phase 3 — Verify contested claims by running code
Settle with executed code, not judgment, whenever sources disagree, a behavior is undocumented, a claim is performance- or compatibility-shaped, or the honest answer is "it should work". Spawn one verification worker per claim:
task(category="deep-low", run_in_background=true, prompt="TASK: verify by execution: <claim>.
SOURCE: <where it came from>; CONTRADICTION: <opposing source, if any>.
Write a minimal self-contained script that tests the claim; run it (uv run --with <deps> python / bun / direct compile); capture full stdout+stderr; pin versions.
Reply with: the exact code, the full output, environment (OS, runtime, dependency versions), and a verdict — CONFIRMED / REFUTED / PARTIAL — grounded in the output.")
Journal each verdict to verify-<slug>.md.
Phase 3b — Lock non-code claims through the claim graph
Code settles code-shaped claims (Phase 3). Numeric, market-share, legal, dated, causal, and financial claims cannot be run — so they pass through a data-flow-lock instead (the verification idea adapted from fivetaku/insane-research): the synthesis may assert a high-risk non-code claim only if it cleared this gate, and the gate's output is the sole allowlist the synthesis draws from. Skip the gate and there is nothing to synthesize — the lock is self-enforcing.
The claim graph is orchestrator-owned. Workers only return verified-claim markers, observation candidates, and claim candidates as message text, the same channel as EXPAND markers — never a file. As leads resolve, you record one node per asserted claim in claim-graph.md and compute its status; workers report claim candidates in their replies, and you decide. The graph is the single claim store: final synthesis may not draw from free-form claims that skipped it.
A high-risk claim clears the gate to verified-claims only when all hold:
- >= 2 independent source domains corroborate it (two pages on the same domain count once).
- >= 2 independent observation groups converge on it, unless the graph records why a primary-only source is the correct single-source exception.
- One counter-search actively looked for a refutation and did not find a stronger one.
- A primary source (the standard, filing, dataset, or first-party doc) backs it, not only secondary commentary.
- Temporal evidence is explicit: each supporting observation records
observed_atand eithervalid_atorclaim_valid_at, so branch-only, historical, release, and current-runtime claims cannot be conflated.
Anything that fails goes to an Unresolved (insufficient evidence) or Refuted (counter-search won) annex — abstention is a correct outcome, not a gap to paper over. Record each gate outcome on the claim node itself — risk tier, independent source domains, counter-search result, primary source backing, and status — and mirror the cleared nodes into the verified-claims digest section at the top of claim-graph.md. Worker reply marker (message text, same channel as EXPAND):
## CLAIMS
- CLAIM: <non-code assertion> — RISK: high|normal — SOURCES: <domain1, domain2> — COUNTER: <refutation search result> — PRIMARY: <primary source or none>
Phase 4 — Synthesize
After convergence and all verifications, re-read the whole journal, start from intent-diff.md, claim-graph.md, and observation-manifest.md, then write SYNTHESIS.md:
# ULW-Research Synthesis: <query>
Workers: <total> · Waves: <count> · Excursions: <count> · Sources: <count> (<unique domains> domains) · Verifications: <count> · Elapsed: <minutes> min
## Executive summary — 2-3 paragraphs answering the core question
## Findings by theme — per theme: consensus, evidence links, key quote (<20 words, attributed), verified yes/no
## Codebase findings — absolute paths with line references
## Sources (ranked) — URL, what it contains, reliability, access date
## Verified claims — code: claim | verdict | verify-<slug>.md · non-code: only rows cleared into verified-claims
## Epistemic instrumentation — intent-vs-reality diff closure, claim graph coverage, observation manifest coverage, independent-observation convergence, verification economics summary, cause-disappearance records
## Contradictions — source A vs source B, resolution with evidence
## Gaps — what saturation could not answer · unresolved/refuted claim-graph nodes
## Expansion trace — per wave: workers → markers; convergence reason
SYNTHESIS.md is the citation source of truth for final materials: every claim carries inline [Source N] citations, and every high-risk non-code claim you assert must be a verified-claims row from Phase 3b. Assert nothing the gate left in the unresolved/refuted annex.
Write the skeleton early and fill it as claims lock. The moment the brief records the deliverable, create the deliverable file with its section headings and a STATUS: draft — <n> sections open line as line 2 (an HTML comment in HTML); that line is the partial-state marker outcome state reads, removed only when the deliverable is complete. An interrupted run must leave a partial report on disk, never an empty directory and a lost conversation.
Keep sourced numbers, assumptions, and derived results visibly apart. Every quantitative claim carries its lineage: MEASURED (a number a source states, cited), ASSUMED (a coefficient, distribution, or scope you chose — say why), DERIVED (computed from those, showing the formula), plus a sensitivity line whenever the assumption moves the answer. Presenting a derived estimate with the confidence of a measured one is the most damaging thing this mode can ship.
Search in English, deliver in the user's language. Retrieval stays English-first (Search craft), but the synthesis and every final material are written in the language the user wrote to you in unless they ask otherwise — and a translated report still quotes its original-language sources verbatim.
Phase 5 — Final materials
The promised formats recorded in brief.md and outcome.json are binding; pdf + docx is the default pair only when a document was asked for with no format word and no destination:
| Target | How |
|---|---|
| PDF (default) | Author the report as one self-contained HTML file, then print it headless: chrome --headless --disable-gpu --no-pdf-header-footer --print-to-pdf=<out.pdf> file://<report.html>. Embed the design-spec.md fonts as real webfonts (CJK included) instead of trusting system fallbacks. uv run --with weasyprint python is the fallback renderer. |
| DOCX (default) | pandoc <report.md> -o <out.docx>, adding --reference-doc=<template.docx> when the user has a house style; uv run --with python-docx python when pandoc is unavailable. Charts and Mermaid renders go in as images. |
| Slides / deck | uv run --with python-pptx python — one claim per slide, a chart or diagram per claim. |
| Standalone HTML / Markdown | The authored source itself. |
Write design-spec.md the moment the deliverable is recorded — before any asset worker spawns. When a document was pointed at, extract it instead of retyping its stylesheet: node "$SKILL_DIR/scripts/report-tools.mjs" format-extract <reference> --out "$SESSION_DIR/design-spec.md" (--from-url for a URL), then fill every TODO: ask from the interview answers, never by guessing. It is the one design contract every asset and assembly worker receives: template family (absent a reference, the clean analyst-report register — one accent over neutral tones, generous margins, styled section headings, no emoji, no clipart), palette tokens, body/heading fonts (a real gothic CJK webfont — Pretendard, Noto Sans KR — when the report language needs one, with word-break: keep-all for Korean), responsive breakpoints and HTML/CSS charts for web deliverables, the figure standard below, and the lineage mode on its own line: Lineage: inline (analyst default: every unit-bearing number tagged MEASURED / ASSUMED / DERIVED or cited in the same element) or Lineage: section (web and blog register: every section cited, lineage in the figure captions). The reference's section 6 lists the full defaults. One font family and one palette govern prose, charts, Mermaid, and generated images alike; a diagram rendering in a random default font inside a styled report is a defect, not a style choice.
The figure standard — binding for every image, chart, and diagram. Each figure sits in a fixed-size container styled from the spec (border, background, caption); the image scales to fit entirely inside it with its original aspect ratio preserved — object-fit: contain semantics — never stretched, never cropped, never spilling out. Every chart carries a title, axis labels, units, and value labels in the report's language; a bare number the reader cannot name is a defect.
Asset workers (background, parallel, each fed design-spec.md) — visuals are the DEFAULT deliverable of this phase, not garnish the user must ask for; a delivered report without figures is an incomplete run:
- Charts for every quantitative finding, computed from real data. Pull the numbers into an actual table first (CSV/JSON under
$SESSION_DIR), then plot from that table, never from prose. Follow the data-scientist tool doctrine — numpy always, Polars for filtering/sorting/transforms, DuckDB for joins/aggregations/window functions, never pandas — and load thedata-scientistskill when this session has it:uv run --with numpy --with polars --with duckdb --with pyarrow --with matplotlib python. Keeppyarrowin that set — the DuckDB-to-Polars handoff (.pl()) fails without it, and.df()fails without pandas, so hand data across through.pl(), never.df(). Save to$SESSION_DIR/assets/. - Mermaid graphs for process, architecture, argument, timeline, and evidence-flow structure, themed to the spec's fonts and palette. Render each to SVG and confirm the file exists before the document references it.
- Generated visuals through the imagegen skill whenever the session has it: a cover plus a concept illustration per major theme, prompted from the spec's style, palette, and mood — document-styled illustration, never generic stock art dropped into a designed page.
- Full-page screenshots of the top 5-10 sources (browsing worker) as provenance you can show.
Verify the asset manifest before rendering. List every asset the document references, assert each file exists and is non-empty on disk, and re-render whatever is missing. A document that renders with three broken diagrams is a document you will publish twice.
Assembly worker — task(category="deep-low", load_skills=["frontend", "visual-qa", "open-design", "data-scientist", "imagegen", "ulw-loop"], run_in_background=true, ...): before writing, read every available design and visualization skill and apply it — the report is a designed artifact, not a text dump; the worker's prompt carries design-spec.md. Use the template the user approved; absent a stronger house style the default skeleton is executive summary → key findings by theme → detailed analysis (quotes under 20 words with attribution, charts, Mermaid graphs, generated visuals, SHA-pinned permalinks, verification results) → comparative analysis when options compete → numbered sources with access dates → methodology appendix (workers, waves, searches, verifications, debate rounds) → correction log naming what verification overturned. Write it long and specific: every claim cites [Source N], and the sources section lists every source the run actually used rather than a curated few.
The delivery gates — every gate for the QA tier must PASS, in order
Nothing reaches the user until the gates pass. Record each result with node "$SKILL_DIR/scripts/report-tools.mjs" outcome gate <static|layout|visual|proofread> <pass|fail|not_run> --session-dir "$SESSION_DIR"; a gate this harness does not provide is recorded not_run. A chat or post answer (light tier) runs the static gates and one render; every paginated or web deliverable runs them all (the reference's sections 3 and 7).
- Static gates (always, before anyone looks at pixels).
node "$SKILL_DIR/scripts/report-tools.mjs" check "$SESSION_DIR/report.html" --design-spec "$SESSION_DIR/design-spec.md" > "$SESSION_DIR/defects.json": keep-all, palette tokens, emoji, em dashes, heading length, unsourced numbers, figure containers, chart text, gothic-only fonts, citations, the closing and sources sections, broken assets. Codes and fixes: references/report-gates.md. - Layout gates. Print the probe with
node "$SKILL_DIR/scripts/report-tools.mjs" layout-probe --json, evaluate itssourcein the rendered page through thebrowserskill's owned headless engine, save the result as$SESSION_DIR/boxes.json, and re-runcheckwith--layout "$SESSION_DIR/boxes.json"(overflow, clipped text, distorted images, overlapping siblings). - Visual QA. Render the produced artifact back to images — PDF pages to PNG, the HTML through the browser skill at desktop and phone widths — and look at them: missing or broken figures, images stretched or spilling their containers, diagram or chart text rendered off the spec's font or palette, clipped tables, overflowing CJK text, blank pages, unlabeled chart values, wrong page breaks. Reading the source markup is not visual QA; inspect the pixels.
- Proofread gate —
task(category="writing", ...). Hand the final text to a dedicatedwritingworker whose only job is language: grammar, spelling, punctuation, terminology consistency, and whether the prose reads NATIVELY in the report's own language. It proofreads only and never composes. It returns a defect list; fix every item and re-run the gate on the delta. Deliver only on a clean pass — this gate runs BEFORE the first delivery, not after the user finds the typo.
Repair is bounded. After every gate run that found defects, ask node "$SKILL_DIR/scripts/report-tools.mjs" repair decide --state "$SESSION_DIR/repair-state.json" --defects "$SESSION_DIR/defects.json" --artifact-bytes <bytes> --renders <pages> --session-dir "$SESSION_DIR" and obey it: repair means fix and re-run; deliver means stop and ship with the residual defects the manifest now lists; block means a content-integrity defect remains (or there is no usable artifact), so do not deliver: tell the user what blocks it and record the row as failed with the reason. Never repair past a stop decision (3 attempts, a 2-attempt plateau, an oscillation, or 15 minutes; the reference's section 8).
Then resolve every manifest row (outcome set <format> delivered --path <file> or blocked_capability / skipped / failed with --reason) and deliver: the artifact plus a compact chat-readable summary of what it says — the answer in a few sentences, the numbers that matter, and what to look at first. The document is the deliverable; the summary is what gets it read.
The closing briefing — every run ends with it
The last thing the user reads states, in one compact block, what the answer is made of:
- The printed block.
node "$SKILL_DIR/scripts/report-tools.mjs" outcome verify --session-dir "$SESSION_DIR" && node "$SKILL_DIR/scripts/report-tools.mjs" outcome finish --session-dir "$SESSION_DIR" && node "$SKILL_DIR/scripts/report-tools.mjs" outcome briefing --session-dir "$SESSION_DIR": sources and distinct domains counted fromsources-ledger.md, elapsed minutes from the session directory's own timestamp, every promised deliverable with its status, the gate results, the residual defects, and the repair summary. A failingverifymeans the run is not done. - Effort. Workers, waves, excursions, and verifications — the same counters as the
SYNTHESIS.mdheader — plus how many sources were primary and how many claims went to the unresolved/refuted annex.
Never ship the artifact without this block, and never fill it from memory — paste the printed block and read the rest off the journal.
Record the format choice. After delivery, when the harness exposes a memory tool and the run qualifies (the user declared a preference, or made the same unsolicited choice on 2+ runs), append one episode line to reference/human-report-style.md in the grammar of the reference's section 5, and update the system/human/report-style.md pointer when a generalization changed.
If you stood up a team, disband it and confirm every worker is terminal before the final answer.
Search craft
English first: run every search in English by default — it is the largest, most authoritative corpus on every engine, GitHub, and documentation site. Add a secondary local-language sweep (1-2 librarians) only after the English sweep, when the topic is inherently local, or when the user asks for sources in a specific language.
Vary operators on every query — same query twice wastes a worker:
| Operator | Example | Use |
|---|---|---|
site: |
site:github.com <topic> |
Restrict to a domain |
filetype: |
filetype:pdf <topic> survey |
Papers, specs |
intitle: / inurl: |
intitle:benchmark <topic> |
Targeted pages |
"exact" / -term |
"<exact phrase>" -tutorial |
Precision, exclusion |
OR |
<a> OR <b> <topic> |
Coverage |
before: / after: |
<topic> after:2025-06-01 |
Recency control |
High-yield combinations: official docs (site:<docs domain>), GitHub implementations (site:github.com), recent discussion (site:reddit.com OR site:news.ycombinator.com after:<date>), academic (site:arxiv.org OR filetype:pdf survey), changelog hunting (changelog OR "release notes" <version>), alternatives (vs OR alternative OR comparison).
Failure modes
| Failure | Correction |
|---|---|
| Sequential spawning, or trimming the first wave | All first-wave workers in one turn, background, scaling floor respected |
| A team member hoards leads for one final dump | Raise law — every lead, finding, and dead end broadcast the moment it surfaces |
| Worker reply without the EXPAND tail | One follow-up demanding it; the lane stays open until it lands |
| Stopping after wave 1 because "enough was found" | Convergence rules only: 2+ expansion waves, leads run dry |
| Obeying a surrounding "stop exploring" rule mid-research | Authority section — those rules do not bind this mode |
| Asking a worker to write journal or session files | Workers are read-only; you journal every return |
| Two workers given the same angle | One unique angle per worker, always |
A Browsing: yes run whose roster carries no browsing worker, or a browsing worker spawned without ultimate-browsing |
The browsing column is binding — name the angle in the brief and spawn it, armed with the skill, in the first wave before any lead is chased |
| Contested claim settled by judgment | Phase 3 — run code, capture output, verdict |
| Deliverable claims without citations | Every claim cites a source or a verification artifact |
| Guessing the deliverable format | Derive it from the request and destination; ask the empty-info interview only for what is still missing, and record answered_by for every field |
| Blocking the collection wave on the format question | Ask non-blocking and start collecting; unanswered fields run on the shown defaults |
| A roster smaller than the harness ceiling | Fill every member slot; split the broadest axis until the team is full |
| One tier across the whole roster | Mixed tiers by design — cheap breadth, premium attack |
| Silently re-routing a "quick"/"fast" instruction | Routing words are literal; journal requested -> spawned -> fallback per slot |
| A worker or member that starts its own research swarm | Members research one axis and report; orchestration is yours alone |
| Expanding on an entity the first wave never disambiguated | Settle canonical identity and first-party source before any later spawn asserts it |
| Batching findings into an end-of-run journal dump | Journal each return as it lands; the journal is what survives a compaction |
| Ending the run because every worker finished | Reserve the final fifth of the run for synthesis and materials |
| A derived estimate presented as a measured number | MEASURED / ASSUMED / DERIVED lineage on every quantitative claim, plus a sensitivity line |
| Delivering before the delivery gates pass | Static and layout gates, visual QA on rendered pages, plus the harness's proofread gate — a typo the user finds means a gate did not run |
| Skipping the static gates | report-tools check runs before any pixel review; its defects are cheaper than a visual pass |
| Delivering with a pending manifest row | outcome verify must pass; every promised format ends delivered, blocked_capability, skipped, or failed with a reason |
| Repairing past the tracker's stop decision | repair decide owns the budget; deliver ships with the residual list, block stops delivery |
Guessing a design-spec value the extractor marked TODO: ask |
Fill it from the interview answers or the reference's defaults, never by guessing |
| Referencing an asset that is not on disk | Verify the asset manifest before rendering; re-render whatever is missing |
| A figure stretched, cropped, or styled off the report's design language | design-spec.md binds every asset: fixed containers, contain-fit with aspect preserved, spec fonts and palette in charts and Mermaid |
| Chasing an interesting find with no ENTER trigger | Excursions need a named trigger; everything else stays a queued lead |
| An excursion that never came back, or drifted into a new mission | EXIT rules are unconditional; depth 3 means promote it to an axis or record it as a gap |
| An excursion whose result was never folded back | Every EXIT writes what it changed in the top-level answer, none included, and mirrors into the loop ledger |
| Delivering without the closing briefing | Paste the block outcome briefing prints; never compute or recall its numbers by hand |
Files (oh-my-openagent)
-
references
-
deliverable-phase.md 17.8 KB
# ulw-research - deliverable phase contract Read this before the brief is written and again before Phase 6. It replaces the old blocking format question with a contract that never stalls collection: the deliverable is derived from the request and its destination, the requester is asked only what is genuinely missing, and every promise made about the output is tracked in a manifest until it is delivered, skipped, or blocked with a reason. Every helper named here is one command of the dispatcher: ```bash node "$SKILL_DIR/scripts/report-tools.mjs" <command> ... ``` `$SKILL_DIR` is the directory of the skill's `SKILL.md`. The helpers are plain `.mjs` files with no dependencies and run under `node` or `bun` on macOS, Linux, and Windows. They never launch a browser. Exit codes: `0` pass, `1` semantic failure (blockers found, a failed verify, a blocked repair), `2` usage or IO error. JSON goes to stdout and a one-line summary to stderr. Always pass `--session-dir "$SESSION_DIR"` explicitly; `SESSION_DIR` in the environment is only a fallback. ## 1. Lanes The brief names exactly one lane. The lane decides how much the requester is asked, what the design spec starts from, and how strict the gates are. | Lane | Definition | How to say it to the requester | | --- | --- | --- | | `template-strict` | The requester pointed at a document (a prior report, a page, a house template). Its structure, tokens, citation style, and figure standard are copied mechanically and not reinterpreted. | "I will match the document you pointed at: same structure, colors, fonts, and citation style." | | `template-vibe` | The requester named a register or feeling ("like a consulting deck", "a clean blog post") without a document to copy. The design spec is written from the defaults in section 6 and the register. | "I will write it in that style using the default report design; point me at an example if you want an exact match." | | `no-format` | Nothing about the shape was said and the destination implies it (a chat answer, a thread post, a quick note). No interview about format. | "I will answer in the shape this destination expects." | | `edit-existing` | A complete deliverable for this question already exists (section 2). The run edits it in place. It never regenerates it. | "I will update the existing report rather than write a new one." | ## 2. Deliverable state Before the brief is written, decide the state of any deliverable the request refers to: ```bash node "$SKILL_DIR/scripts/report-tools.mjs" outcome state --deliverable <path> --session-dir "$SESSION_DIR" ``` | State | Meaning | What it opens | | --- | --- | --- | | `none` | No file, or an empty one. | A fresh run in the lane the request implies. | | `partial` | The file carries a `STATUS: draft` line in its first 5 lines, or its manifest still has a `pending` row. | Resume the on-disk skeleton: keep the sections that exist, fill the missing ones, and never start over. | | `complete` | The manifest says every promised row is resolved and the file is present and non-empty. | `edit-existing` only. A complete report is never regenerated from scratch. | ## 3. Destination-derived defaults The request and its destination usually answer the format question already. Derive the defaults first; the interview (section 4) only covers what this table leaves open. | Request or destination signal | Default format set | Lane | QA tier | | --- | --- | --- | --- | | A question answered in chat, with no document asked for | `post` (the answer itself, Markdown) | `no-format` | light | | A thread, channel, or messenger post | `post` | `no-format` | light | | A web page URL as the destination, or "blog", "page" | `html` | `template-vibe` | full | | The words "PDF", "DOCX", "slides", or "LaTeX" in the request | exactly the named formats (`pdf`, `docx`, `slides`, `latex`) | `template-vibe` | full | | "Report", "document", "write-up" with no format word and no destination | `pdf` + `docx` | `template-vibe` | full | | A pointed-at document (a file, a URL, a prior report) | the reference's own format | `template-strict` | full | | A prior report of the same kind in this workspace, not pointed at | that report's format, offered as the Q1 default | `template-vibe` | full | | A complete existing deliverable for this question (section 2) | its existing format | `edit-existing` | the original's tier | QA tiers: **light** runs the static gates and one render check of the answer. **Full** runs the static gates, the layout gates, visual QA of every page, and the proofread gate (section 7). ## 4. The empty-info interview The interview has exactly three questions, asked together through the harness question tool without waiting for the answer. A question is **skipped** when the request, the destination, a pointed-at document, or a declared memory episode already answers it; when all three are answered, nothing is asked. - **Q1 - destination and format.** Options in this order: the memory hit (the choice recorded for the most similar prior context, section 5) when there is one; the derived default from section 3; "describe the format in your own words" (free text, which becomes the brief for the design spec); "don't care, you decide" (resolves to the derived default). - **Q2 - audience and length.** Who reads it, and a length band: `short` (a page or a post), `standard` (a report of about 5 to 12 pages), or `deep` (a full dossier). The derived default is first. - **Q3 - template lineage.** The pointed-at document, the last report of the same kind, or the clean analyst register (section 6). The derived default is first. Non-blocking rule: ask, do not wait, and start collection. Write `brief.md` at once with every field and who answered it; the unanswered fields carry their defaults. A late answer is folded in until the assembly lane starts; after that it becomes a re-render request, handled like any other repair. Recorded fields, under `## Deliverable` in `brief.md`: ```text lane: template-vibe state: none formats: pdf, docx (answered_by: request) destination: internal review document (answered_by: default) audience: engineering leads, standard (answered_by: user) template: clean analyst register (answered_by: default) format description: none ``` `answered_by` is one of `user`, `default`, `request`. Run `outcome init` (section 9) in the same step, with the formats as the promised list. ## 5. Report-format memory episodes When the memory tool is present, the requester's report-format choices are kept as an append-only episode log, so a repeat requester is not asked the same question twice. When no memory tool is present, skip this section silently. - Pointer: `system/human/report-style.md`, projected every session. One line naming the log, plus the generalizations currently in force. - Episode log: `reference/human-report-style.md`, one line per episode, newest last: ```text - [YYYY-MM-DD] <destination kind> · <audience> - <format and template chosen, one line> <!-- src: <session-id>; pattern: observed|declared; confidence: low|medium|high --> ``` Qualification (write after delivery, only when one of these holds): `declared` - the requester stated the preference outright ("always PDF only"); `observed` - the requester made the same unsolicited choice on 2 or more runs (read the log first). Record format and template choices only, never report content, subjects, names, or findings. Append; never rewrite an existing line. Read-time generalization (before the interview): a similar context is one with the same destination kind and audience. Consistent similar episodes supply Q1's first option; a `declared` similar episode skips the question it answers. One session never flips a default: a single `observed` episode that disagrees with the rest changes nothing. Pruning: keep the log near 15 lines. When it grows past that, prune the oldest episodes that agree with the majority for their context first. Never prune a `declared` episode or a minority episode (the only one recording a different choice for its context). Update the pointer in the same memory commit when a generalization changes. ## 6. design-spec.md Write `$SESSION_DIR/design-spec.md` the moment the deliverable fields are known, before any asset lane starts. Every asset and assembly lane receives it. 1. When a document was pointed at, extract it first. Do not retype its stylesheet: ```bash node "$SKILL_DIR/scripts/report-tools.mjs" format-extract <reference.html|reference.md> --out "$SESSION_DIR/design-spec.md" node "$SKILL_DIR/scripts/report-tools.mjs" format-extract <https://...> --from-url --out "$SESSION_DIR/design-spec.md" ``` The extractor copies the CSS custom properties (light and dark), font stacks by role, the most-used colors, px breakpoints, the section skeleton, the heading numbering, the citation marker style, the lineage mode, and the figure containers, under the fixed sections `Extracted from`, `Tokens`, `Typography`, `Layout`, `Structure`, `Figures`, `Citations`, `Open questions`. A field it cannot determine reads `TODO: ask`. A PDF reference is unsupported: ask for its HTML or Markdown source. 2. Fill every `TODO: ask` from the interview answers, then from the clean analyst register below. Never guess a value. 3. Apply the binding figure standard and the defaults: - Every figure sits in a fixed-size container with a caption; the image scales inside it with contain-fit, its aspect ratio preserved, never stretched, cropped, or spilling out. Every chart carries a title, axis labels, units, and value labels as real text, with deterministic ticks (round values computed from the data range, never left to a renderer's auto-scaling). - One accent color over neutral background, text, and rule tones. Every color in prose, charts, and diagrams is a spec token. - For web deliverables, HTML/CSS bars and inline SVG are preferred over image charts, and the page has responsive breakpoints so it reads on a phone, a tablet, and a desktop. - Gothic (sans) type only, as a real embedded webfont for the report language; Korean text uses `word-break: keep-all; overflow-wrap: anywhere`. - A closing section on how the report was made (method, sources, members and waves, the gates it passed, what verification overturned) and a numbered sources section with access dates. 4. Declare the lineage mode on its own line in the spec: - `Lineage: inline` (the analyst default): every unit-bearing number carries `MEASURED`, `ASSUMED`, or `DERIVED` inline or in a `data-lineage` attribute, or a `[S<n>]` citation in the same element. The gate reports an untagged number as a major defect. - `Lineage: section` (web and blog register): sources are bracketed source-label links at sentence end (the report language's word for "source"), lineage distinctions live in the figure captions, and every section is cited. The gate reports an untagged number as an advisory minor defect; the section-citation gate carries the weight. ## 7. Delivery gates, in order Nothing reaches the requester until the gates for its QA tier pass, in this order. Each gate writes its status into the manifest with `outcome gate <gate> <pass|fail|not_run> --session-dir "$SESSION_DIR"`. 1. **Static gates.** `check` on the rendered HTML: ```bash node "$SKILL_DIR/scripts/report-tools.mjs" check "$SESSION_DIR/report.html" --design-spec "$SESSION_DIR/design-spec.md" > "$SESSION_DIR/defects.json" ``` It checks keep-all, palette tokens, emoji, em dashes in prose (en dashes outside numeric ranges are advisory), heading length and generic headings, unsourced numbers (section 6), figure containers and captions, chart SVG text, Korean serif fonts, sections without citations, the closing section, the sources section, broken local assets, and any `--require-section <regex>` the brief requires. Every code, severity, and fix is in [report-gates.md](report-gates.md). 2. **Layout gates.** Print the probe, evaluate it in the rendered page through the browser skill's owned headless engine, save the returned object as `$SESSION_DIR/boxes.json`, and re-run `check` with `--layout "$SESSION_DIR/boxes.json"`. It adds overflow past the container, clipped text, scroll containers, distorted images, and overlapping siblings. ```bash node "$SKILL_DIR/scripts/report-tools.mjs" layout-probe --json # .source is one async expression ``` 3. **Visual QA.** Render the artifact to images (PDF pages to PNG, the HTML in the browser skill's engine at desktop and phone widths) and look at the pixels for what a machine cannot see: composition, hierarchy, page breaks, figure legibility. 4. **Proofread.** A dedicated writing lane proofreads the final text only (grammar, spelling, punctuation, terminology, native register); it never composes or rewrites content. The light tier runs gate 1 and one render (recorded as `visual`) and records `layout` and `proofread` as `not_run`. The full tier runs all four. ## 8. Bounded repair loop After every gate run that produced defects, ask the tracker what to do next: ```bash node "$SKILL_DIR/scripts/report-tools.mjs" repair decide --state "$SESSION_DIR/repair-state.json" --defects "$SESSION_DIR/defects.json" --artifact-bytes <report bytes> --renders <rendered page count> --session-dir "$SESSION_DIR" ``` - `action: repair`: fix the listed defects, re-render, re-run the gate, and ask again. - `action: deliver`: stop repairing and deliver. `reason` is `clean` or the stop condition that fired; the residual defects are written into the manifest and listed in the closing briefing. - `action: block` (exit `1`): do not deliver. Either a content-integrity defect remains (`missing_closing_section`, `missing_citation_section`, `broken_asset_reference`, `missing_required_section`, `missing_promised_deliverable`), or a stop fired without proof of a usable artifact (`--artifact-bytes` and `--renders`). Tell the requester what blocks delivery and record the affected rows as `failed` with the reason. Defaults: 3 repair attempts, a plateau of 2 consecutive attempts that do not improve the (blocker, major, minor) counts compared in that order, and 15 minutes of wall clock from the first decision. A defect set that repeats an earlier attempt exactly stops the loop as an oscillation. When several conditions hold at once the reported reason follows `clean`, `blocking`, `oscillation`, `plateau`, `max_attempts`, `wall_clock`. ## 9. Outcome manifest and closing briefing `$SESSION_DIR/outcome.json` tracks every promise made about the output, so a promised format is never silently dropped. ```bash node "$SKILL_DIR/scripts/report-tools.mjs" outcome init --promised pdf,docx --lane template-vibe --session-dir "$SESSION_DIR" node "$SKILL_DIR/scripts/report-tools.mjs" outcome set pdf delivered --path "$SESSION_DIR/report.pdf" --pages 12 --session-dir "$SESSION_DIR" node "$SKILL_DIR/scripts/report-tools.mjs" outcome set docx blocked_capability --reason "no document converter in this session" --session-dir "$SESSION_DIR" node "$SKILL_DIR/scripts/report-tools.mjs" outcome render "$SESSION_DIR/renders/page-01.png" --page 1 --session-dir "$SESSION_DIR" node "$SKILL_DIR/scripts/report-tools.mjs" outcome verify --session-dir "$SESSION_DIR" node "$SKILL_DIR/scripts/report-tools.mjs" outcome finish --session-dir "$SESSION_DIR" node "$SKILL_DIR/scripts/report-tools.mjs" outcome briefing --session-dir "$SESSION_DIR" ``` - `init` runs when the brief is written (section 4): one `pending` row per promised format (`pdf`, `docx`, `html`, `md`, `latex`, `slides`, `post`). - `set` resolves a row: `delivered` (absolute path; bytes are read from disk; pages when paginated), `blocked_capability` (this session cannot produce it), `skipped` (the requester or the lane dropped it), or `failed`. Every status except `delivered` needs `--reason`. - `verify` runs before delivery and exits `1` while a row is `pending`, a status lacks its reason, or a delivered file is missing or empty. - `finish` stamps the finish time, the elapsed minutes from the session directory's start stamp, and the source and domain counts from `sources-ledger.md`. - `briefing` prints the closing block from the manifest and the ledger: sources and distinct domains, elapsed minutes, every deliverable with its status, the gate results, the residual defects, and the repair summary. That text is the closing briefing, pasted as printed, never filled in from memory. ## 10. Command reference Invoke every command as `node "$SKILL_DIR/scripts/report-tools.mjs" <command>`. Exit codes for all of them: `0` pass, `1` semantic failure, `2` usage or IO error. | Command | Flags | Exit `1` when | | --- | --- | --- | | `check <report.html>` | `--design-spec <spec>` (required), `--layout <boxes.json>`, `--require-section <regex>` (repeatable), `--max-defects N` | any blocker defect | | `layout-probe` | `--cap N` (default 400), `--root <selector>` (default `main, article`), `--json` | never | | `repair decide` | `--state <file>` and `--defects <json>` (required), `--artifact-bytes N`, `--renders N`, `--session-dir <dir>`, `--max-attempts N`, `--plateau-limit N`, `--wall-clock-ms N`, `--now <ISO>` | the decision is `block` | | `outcome init` | `--promised <list>` (required), `--lane <lane>`, `--session-dir <dir>`, `--now <ISO>` | never | | `outcome set <format> <status>` | `--path <file>`, `--pages N`, `--reason <text>`, `--session-dir <dir>`, `--now <ISO>` | never | | `outcome gate <gate> <status>` | `--session-dir <dir>` | never | | `outcome render <png>` | `--page N` (required), `--session-dir <dir>` | never | | `outcome state` | `--deliverable <path>` (required), `--session-dir <dir>` | never | | `outcome verify` | `--session-dir <dir>` | a promise is unresolved or a delivered file is missing or empty | | `outcome finish` | `--ledger <file>`, `--session-dir <dir>`, `--now <ISO>` | never | | `outcome briefing` | `--json`, `--ledger <file>`, `--session-dir <dir>`, `--now <ISO>` | never | | `format-extract <reference>` | `--out <design-spec.md>` (required), `--from-url` | never (an unsupported reference exits `2`) | | `--help` | `--json` | never | -
report-gates.md 7.5 KB
# Report-Gates Defect Glossary This reference catalogs every defect code the report gates (`static`, `layout`, `visual`, `proofread`) can raise, their severity and impact, and how to repair them. ## Defect Codes by Category ### Static Gate (Content & Structure) | Code | Severity | Integrity | What It Detects | How to Fix | |------|----------|-----------|-----------------|-----------| | `korean_no_keep_all` | major | false | Text containing Hangul uses default word breaking, causing character-level wrapping and orphaned characters on mobile or narrow containers. | Add "word-break: keep-all; overflow-wrap: anywhere" to the body or the slide text container. | | `palette_off_token` | minor | false | Color values are hardcoded literals (hex, rgb) instead of design-system tokens or are missing from the design spec palette. | Replace the literal color with a design-spec token (var(--...)) or add the color to the spec palette. | | `emoji_in_prose` | major | false | Emoji characters appear in body text, headings, or figure captions. Emoji render at inconsistent sizes and lack semantic meaning. | Remove the emoji; use an inline SVG icon or plain text. | | `em_dash_in_prose` | major | false | Em dashes (—) are used in body text for sentence breaks or parenthetical asides. Readability and accessibility suffer without a rewrite. | Rewrite the sentence with a comma, colon, or period instead of an em dash. | | `en_dash_in_prose` | minor | false | En dashes (–) are used incorrectly outside of numeric ranges. Only numeric ranges (e.g., "pages 5–10") should use en dashes. | Use a hyphen for compounds; keep the en dash only between numbers. | | `heading_too_long` | minor | false | A heading exceeds one visual line: 26 Korean characters (52 display width units) or 12 English words. | Shorten the heading to one line (26 Korean characters or 12 English words). | | `heading_generic` | minor | false | A heading uses generic labels (e.g., "Introduction", "Background") without conveying the section's specific claim or finding. | Make the heading carry the section's claim instead of a generic label. | | `unsourced_number` | major | false | A numeric value (percentage, count, duration, ratio) appears without a source attribution, measurement tag, or derivation chain. | Tag the number MEASURED / ASSUMED / DERIVED or cite its source in the same paragraph. | | `chart_without_figure` | major | false | An image or chart lacks a figure wrapper with a caption. Uncaptioned charts isolate visuals from narrative flow. | Wrap the image or chart in a figure container with a caption. | | `chart_svg_no_text` | major | false | An SVG chart or diagram lacks a title, axis labels, units, or value labels. Visuals cannot stand alone without text guidance. | Give the chart a title, axis labels, units, and value labels as SVG text. | | `chart_svg_missing_labels` | minor | false | An SVG chart has some labels (e.g., title) but is missing others (e.g., axis ticks or unit). | Add the missing title, axis ticks, or unit label to the chart. | | `korean_serif_font` | major | false | A serif or brush Korean font family is applied to Korean text. The design spec mandates gothic (sans) Korean typefaces. | Use a gothic (sans) Korean family; serif and brush families are banned by the design spec. | | `section_without_citation` | major | false | A major section or subsection contains no in-text citations or reference marks. Source attribution is missing. | Cite at least one source inside this section or merge it into a cited one. | | `missing_closing_section` | blocker | true | The report lacks a closing section explaining the methodology, sources, and gate outcomes. Delivery is incomplete without narrative closure. | Add the closing section that explains how the report was made (method, sources, gates). | | `missing_citation_section` | blocker | true | The report has no sources, references, or citation definitions (e.g., [S1], [S2]). Attribution is impossible. | Add a sources or references section, or [S<n>] definition lines. | | `broken_asset_reference` | blocker | true | An asset file (image, diagram, spreadsheet) is missing or unreadable. The deliverable cannot be rendered or is incomplete. | Re-render or restore the missing asset file before delivery. | | `missing_required_section` | blocker | true | A section explicitly required by the brief (e.g., via `--require-section`) is absent. | Add the section the brief requires (matched by --require-section). | | `missing_promised_deliverable` | blocker | true | A deliverable (e.g., a slide deck, a chart, a data file) is promised in the brief but not delivered, not marked blocked_capability, and not skipped with a reason. | Produce the promised deliverable or record it as blocked_capability or skipped with a reason. | ### Layout Gate (Rendering & Presentation) | Code | Severity | Integrity | What It Detects | How to Fix | |------|----------|-----------|-----------------|-----------| | `layout_overflow` | major | false | An element exceeds its container width or height, causing horizontal scroll or content spillage in PDF, DOCX, or slide renders. | Constrain the element to its container (max-width: 100%, overflow-wrap: anywhere, or a narrower measure). | | `layout_text_clipped` | major | false | Text is clipped or hidden by `overflow: hidden` or a fixed container height. Content is lost or unreadable. | Remove the overflow: hidden clip or give the text room to wrap. | | `layout_scroll_container` | minor | false | A table or list is wrapped in a scrollable container for HTML deliverables. Scrolling content prints clipped in PDF or DOCX. | Scrolling content prints clipped; make the content fit or wrap the table in a scroll wrapper only for HTML deliverables. | | `layout_image_distorted` | major | false | An image is distorted, stretched, or squeezed (aspect ratio is not preserved). The visual information is misrepresented. | Preserve the image aspect ratio (object-fit: contain inside a fixed container). | | `layout_sibling_overlap` | major | false | Two adjacent block elements overlap (via negative margins, absolute positioning, or grid collision). Layout is broken. | Separate the overlapping elements (margins, grid, or a slide split). | ## Exit Codes Gates emit a manifest-level outcome with one of three statuses: | Status | Meaning | |--------|---------| | `pass` | All checks passed; no defects raised. | | `fail` | One or more defects raised (blocker, major, or minor severity). Repair is required before delivery. | | `not_run` | The gate was not invoked (e.g., no probe file provided for layout, no visual inspection conducted). | ### Layout Gate Probe File Requirement The `layout` gate only runs when a probe file (a rendering artifact: PDF, PNG, or HTML snapshot) is provided alongside the manifest. If no probe file is available, the manifest records `layout: not_run` and the gate does not emit defects. ## Lineage and Citation Markers Defect codes and their hints are delivered via the outcome manifest under `residualDefects` (if repair did not exhaust). Each defect entry includes: - **code**: A string key from the table above (e.g., `"em_dash_in_prose"`) - **message**: A human-readable narrative or location hint describing where the defect was found (e.g., "Line 42: em dash found in paragraph 2") Citations within reports follow one of two lineage modes: - **inline**: Source marks appear as `[Sn]` or `[Rn]` hyperlinks inline with the cited text. - **section**: Sources are listed in a dedicated section with numbered entries ([S1], [S2], etc.). Both modes are valid; choose based on the report's narrative flow.
-
-
scripts
-
tests
-
fixtures
-
assets
-
figure-1.svg 141 B · in bundle
-
-
bad-report.html 2 KB · in bundle
-
boxes.json 3.2 KB
{ "probe_version": 1, "viewport": { "w": 1280, "h": 900 }, "document": { "w": 1280, "h": 9240 }, "count": 7, "truncated": false, "boxes": [ { "selector": "body > main > section:nth-of-type(1) > h2", "tag": "h2", "rect": { "x": 353, "y": 420, "w": 574, "h": 38.4 }, "parentSelector": "body > main > section:nth-of-type(1)", "parentRect": { "x": 353, "y": 400, "w": 574, "h": 1400 }, "scroll": { "sw": 574, "cw": 574, "sh": 38, "ch": 38 }, "overflow": { "x": "visible", "y": "visible" }, "position": "static", "textLen": 31 }, { "selector": "body > main > section:nth-of-type(1) > p:nth-of-type(1)", "tag": "p", "rect": { "x": 353, "y": 474.4, "w": 574, "h": 115.6 }, "parentSelector": "body > main > section:nth-of-type(1)", "parentRect": { "x": 353, "y": 400, "w": 574, "h": 1400 }, "scroll": { "sw": 574, "cw": 574, "sh": 116, "ch": 116 }, "overflow": { "x": "visible", "y": "visible" }, "position": "static", "textLen": 212 }, { "selector": "body > main > section:nth-of-type(1) > figure:nth-of-type(1)", "tag": "figure", "rect": { "x": 353, "y": 614, "w": 574, "h": 761 }, "parentSelector": "body > main > section:nth-of-type(1)", "parentRect": { "x": 353, "y": 400, "w": 574, "h": 1400 }, "scroll": { "sw": 574, "cw": 574, "sh": 761, "ch": 761 }, "overflow": { "x": "visible", "y": "visible" }, "position": "static", "textLen": 48 }, { "selector": "body > main > section:nth-of-type(1) > figure:nth-of-type(1) > img", "tag": "img", "rect": { "x": 353, "y": 614, "w": 574, "h": 717 }, "parentSelector": "body > main > section:nth-of-type(1) > figure:nth-of-type(1)", "parentRect": { "x": 353, "y": 614, "w": 574, "h": 761 }, "scroll": { "sw": 574, "cw": 574, "sh": 717, "ch": 717 }, "overflow": { "x": "visible", "y": "visible" }, "position": "static", "textLen": 0, "objectFit": "fill", "natural": { "w": 1280, "h": 1600 } }, { "selector": "body > main > section:nth-of-type(1) > figure:nth-of-type(2)", "tag": "figure", "rect": { "x": 353, "y": 1399, "w": 574, "h": 368.4 }, "parentSelector": "body > main > section:nth-of-type(1)", "parentRect": { "x": 353, "y": 400, "w": 574, "h": 1400 }, "scroll": { "sw": 574, "cw": 574, "sh": 368, "ch": 368 }, "overflow": { "x": "visible", "y": "visible" }, "position": "static", "textLen": 52 }, { "selector": "body > main > section:nth-of-type(1) > figure:nth-of-type(2) > img", "tag": "img", "rect": { "x": 353, "y": 1399, "w": 574, "h": 324.4 }, "parentSelector": "body > main > section:nth-of-type(1) > figure:nth-of-type(2)", "parentRect": { "x": 353, "y": 1399, "w": 574, "h": 368.4 }, "scroll": { "sw": 574, "cw": 574, "sh": 324, "ch": 324 }, "overflow": { "x": "visible", "y": "visible" }, "position": "static", "textLen": 0, "objectFit": "fill", "natural": { "w": 1100, "h": 620 } }, { "selector": "#method > pre", "tag": "pre", "rect": { "x": 353, "y": 8120, "w": 574, "h": 180 }, "parentSelector": "#method", "parentRect": { "x": 353, "y": 8040, "w": 574, "h": 420 }, "scroll": { "sw": 812, "cw": 550, "sh": 156, "ch": 156 }, "overflow": { "x": "auto", "y": "auto" }, "position": "static", "textLen": 640 } ] } -
design-spec.md 458 B
# Design spec ## Extracted from - Source: fixture written by hand for the static gate tests ## Tokens ```css :root { --bg: #f7f5f0; --fg: #1f1d1a; --accent: #2f6f4f; --rule: #d9d4c7; --mute: #6b665c; } @media (prefers-color-scheme: dark) { :root { --bg: #121110; --fg: #e8e4dc; --accent: #7fbf9f; } } ``` ## Typography - Body font: Pretendard, "Apple SD Gothic Neo", sans-serif ## Citations - Style: bracket-S - Lineage: inline -
good-report.html 2.7 KB · in bundle
-
reference-report.html 2.5 KB · in bundle
-
reference.md 513 B
# 작은 팀은 어떻게 매주 배포하는가 기준일은 2026년 9월 16일입니다 [S1]. ## 01 배포 주기 제품 공지는 147건이었어요 [S1]. 월평균은 8.75건입니다 [S2]. ```md ## 99 not a heading inside a fence ``` ### Details below the section level ## 02 리뷰 없이 일하는 방식 작업은 리뷰 대기열 없이 main 에 들어갑니다 [S3]. ## 03 출처 - [S1] Changelog, accessed 2026-09-16. - [S2] Derived monthly mean. - [S3] Engineering note, accessed 2026-09-16.
-
-
-
cli-support.mjs 3.5 KB · in bundle
-
cli-support.test.ts 3.5 KB
import { describe, expect, test } from "bun:test" import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { pathToFileURL } from "node:url" import { CliError, exitCodeFor, parseArgs, readJsonFile, writeJsonAtomic } from "./cli-support.mjs" import { isCliEntry } from "./entry-guard.mjs" function tempDir() { return mkdtempSync(join(tmpdir(), "report-tools-")) } describe("parseArgs", () => { test("#given spaced, equals, repeated, and boolean flags #when parsed #then positionals and flag values are separated", () => { // given const argv = ["a.html", "--design-spec", "spec.md", "--max-defects=5", "--require-section", "^Methods", "--require-section", "^Sources", "--json", "b"] // when const parsed = parseArgs(argv, { repeatable: ["require-section"], booleans: ["json"] }) // then expect(parsed.positionals).toEqual(["a.html", "b"]) expect(parsed.flags["design-spec"]).toBe("spec.md") expect(parsed.flags["max-defects"]).toBe("5") expect(parsed.flags["require-section"]).toEqual(["^Methods", "^Sources"]) expect(parsed.flags.json).toBe(true) }) test("#given a value flag with no value #when parsed #then a CliError names the flag", () => { expect(() => parseArgs(["--design-spec"], {})).toThrow(CliError) expect(() => parseArgs(["--design-spec"], {})).toThrow("--design-spec") }) test("#given an integer flag helper #when the value is not a positive integer #then a CliError is thrown", () => { const parsed = parseArgs(["--cap", "0"], {}) expect(() => parsed.int("cap")).toThrow(CliError) expect(parseArgs(["--cap", "7"], {}).int("cap")).toBe(7) expect(parseArgs([], {}).int("cap", 400)).toBe(400) }) }) describe("exitCodeFor", () => { test("#given results and errors #when mapped #then pass is 0, semantic failure is 1, usage and IO errors are 2", () => { expect(exitCodeFor({ exitCode: 0 })).toBe(0) expect(exitCodeFor({ exitCode: 1 })).toBe(1) expect(exitCodeFor(new CliError("usage"))).toBe(2) const io = Object.assign(new Error("ENOENT"), { code: "ENOENT" }) expect(exitCodeFor(io)).toBe(2) }) }) describe("JSON files", () => { test("#given an existing file #when written atomically #then the new content replaces it and no temp file is left", () => { // given const dir = tempDir() const file = join(dir, "state.json") writeFileSync(file, '{"old":true}') // when writeJsonAtomic(file, { fresh: 1 }) // then expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ fresh: 1 }) expect(readJsonFile(file)).toEqual({ fresh: 1 }) const leftovers = readdirSync(dir).filter((name) => name !== "state.json") expect(leftovers).toEqual([]) }) test("#given a missing or malformed file #when read #then a CliError names the path", () => { const dir = tempDir() expect(() => readJsonFile(join(dir, "missing.json"))).toThrow(CliError) const bad = join(dir, "bad.json") writeFileSync(bad, "{nope") expect(() => readJsonFile(bad)).toThrow("bad.json") }) }) describe("isCliEntry", () => { test("#given argv[1] pointing at this module #when checked #then it is an entry, and another path is not", () => { const original = process.argv[1] try { const self = join(import.meta.dir, "entry-guard.mjs") process.argv[1] = self expect(isCliEntry(pathToFileURL(self).href)).toBe(true) process.argv[1] = join(import.meta.dir, "contracts.mjs") expect(isCliEntry(pathToFileURL(self).href)).toBe(false) } finally { process.argv[1] = original } }) }) -
contracts.mjs 11.3 KB · in bundle
-
contracts.test.ts 6.4 KB
import { describe, expect, test } from "bun:test" import { DEFECT_CODES, DELIVERABLE_FORMATS, DELIVERABLE_STATUSES, DESIGN_SPEC_SECTIONS, GATE_NAMES, GATE_STATUSES, INTEGRITY_CODES, LANES, LAYOUT_CODES, LINEAGE_MODES, MANIFEST_CODES, REPAIR_DEFAULTS, SCHEMA_VERSION, STATES, STATIC_CODES, validateOutcomeManifest, validateRepairState, } from "./contracts.mjs" const SEVERITIES = new Set(["blocker", "major", "minor"]) const STATIC_CODES_EXPECTED = [ "korean_no_keep_all", "palette_off_token", "emoji_in_prose", "em_dash_in_prose", "en_dash_in_prose", "heading_too_long", "heading_generic", "unsourced_number", "chart_without_figure", "chart_svg_no_text", "chart_svg_missing_labels", "korean_serif_font", "section_without_citation", "missing_closing_section", "missing_citation_section", "broken_asset_reference", "missing_required_section", ] const LAYOUT_CODES_EXPECTED = [ "layout_overflow", "layout_text_clipped", "layout_scroll_container", "layout_image_distorted", "layout_sibling_overlap", ] function sampleManifest() { return { schemaVersion: SCHEMA_VERSION, sessionDir: "/tmp/session/20260101-120000", startedAt: "2026-01-01T12:00:00.000Z", finishedAt: null, elapsedMinutes: null, lane: "no-format", interview: null, deliverables: [ { format: "pdf", status: "pending", promised: true, updatedAt: "2026-01-01T12:00:00.000Z" }, ], renders: [], gates: { static: "not_run", layout: "not_run", visual: "not_run", proofread: "not_run" }, residualDefects: [], repair: null, sources: null, } } describe("DEFECT_CODES", () => { test("#given the code table #when every code is inspected #then it has a severity, a hint, and an integrity flag", () => { expect(Object.keys(DEFECT_CODES).sort()).toEqual([...STATIC_CODES_EXPECTED, "missing_promised_deliverable", ...LAYOUT_CODES_EXPECTED].sort()) for (const [code, entry] of Object.entries(DEFECT_CODES)) { expect(SEVERITIES.has(entry.severity), `${code} severity`).toBe(true) expect(typeof entry.hint).toBe("string") expect(entry.hint.length).toBeGreaterThan(10) expect(typeof entry.integrity).toBe("boolean") } }) test("#given the code table #when integrity codes are derived #then they are exactly the five blocker codes", () => { expect([...INTEGRITY_CODES].sort()).toEqual([ "broken_asset_reference", "missing_citation_section", "missing_closing_section", "missing_promised_deliverable", "missing_required_section", ]) for (const code of INTEGRITY_CODES) expect(DEFECT_CODES[code].severity).toBe("blocker") for (const [code, entry] of Object.entries(DEFECT_CODES)) { if (entry.severity === "blocker") expect(INTEGRITY_CODES.has(code), `${code} must be integrity`).toBe(true) } }) test("#given the frozen table #when a severity write is attempted #then it throws and the value is unchanged", () => { expect(Object.isFrozen(DEFECT_CODES)).toBe(true) expect(Object.isFrozen(DEFECT_CODES.korean_no_keep_all)).toBe(true) expect(() => { "use strict" DEFECT_CODES.korean_no_keep_all.severity = "minor" }).toThrow() expect(DEFECT_CODES.korean_no_keep_all.severity).toBe("major") }) }) describe("code groups", () => { test("#given the code groups #when unioned #then they partition the code table exactly", () => { expect([...STATIC_CODES].sort()).toEqual(STATIC_CODES_EXPECTED.slice().sort()) expect([...LAYOUT_CODES].sort()).toEqual(LAYOUT_CODES_EXPECTED.slice().sort()) expect([...MANIFEST_CODES]).toEqual(["missing_promised_deliverable"]) const union = [...STATIC_CODES, ...LAYOUT_CODES, ...MANIFEST_CODES].sort() expect(union).toEqual(Object.keys(DEFECT_CODES).sort()) expect(new Set(union).size).toBe(union.length) expect(Object.isFrozen(STATIC_CODES)).toBe(true) }) }) describe("enums", () => { test("#given the vocabulary #when read #then lanes, states, formats, statuses, gates, lineage modes are fixed lists", () => { expect(LANES).toEqual(["template-strict", "template-vibe", "no-format", "edit-existing"]) expect(STATES).toEqual(["none", "partial", "complete"]) expect(DELIVERABLE_FORMATS).toEqual(["pdf", "docx", "html", "md", "latex", "slides", "post"]) expect(DELIVERABLE_STATUSES).toEqual(["pending", "delivered", "blocked_capability", "skipped", "failed"]) expect(GATE_NAMES).toEqual(["static", "layout", "visual", "proofread"]) expect(GATE_STATUSES).toEqual(["pass", "fail", "not_run"]) expect(LINEAGE_MODES).toEqual(["inline", "section"]) expect(DESIGN_SPEC_SECTIONS).toEqual([ "Extracted from", "Tokens", "Typography", "Layout", "Structure", "Figures", "Citations", "Open questions", ]) expect(REPAIR_DEFAULTS).toEqual({ maxAttempts: 3, plateauLimit: 2, wallClockMs: 900_000 }) expect(Object.isFrozen(LANES)).toBe(true) expect(Object.isFrozen(REPAIR_DEFAULTS)).toBe(true) }) }) describe("validateOutcomeManifest", () => { test("#given an empty object #when validated #then it fails naming schemaVersion", () => { const result = validateOutcomeManifest({}) expect(result.ok).toBe(false) expect(result.errors.some((e) => e.includes("schemaVersion"))).toBe(true) }) test("#given a well-formed manifest #when validated #then it passes", () => { expect(validateOutcomeManifest(sampleManifest())).toEqual({ ok: true, errors: [] }) }) test("#given a deliverable with an unknown status #when validated #then the error names the row", () => { const m = sampleManifest() m.deliverables[0].status = "done" const result = validateOutcomeManifest(m) expect(result.ok).toBe(false) expect(result.errors.join("\n")).toMatch(/deliverables\[0\]\.status/) }) test("#given a gate with an unknown value #when validated #then it fails", () => { const m = sampleManifest() m.gates.static = "ok" expect(validateOutcomeManifest(m).ok).toBe(false) }) }) describe("validateRepairState", () => { test("#given a well-formed state #when validated #then it passes, and a bad measure fails", () => { const good = { schemaVersion: SCHEMA_VERSION, startedAt: "2026-01-01T12:00:00.000Z", budget: { maxAttempts: 3, plateauLimit: 2, wallClockMs: 900_000 }, attempts: [{ at: "2026-01-01T12:01:00.000Z", measure: [1, 2, 0], signature: "em_dash_in_prose|unsourced_number" }], consecutiveNonProgress: 0, seenSignatures: ["em_dash_in_prose|unsourced_number"], } expect(validateRepairState(good)).toEqual({ ok: true, errors: [] }) const bad = { ...good, attempts: [{ at: "x", measure: [1, 2], signature: "" }] } expect(validateRepairState(bad).ok).toBe(false) }) }) -
css-lite.mjs 7.2 KB · in bundle
-
css-lite.test.ts 6.2 KB
import { describe, expect, test } from "bun:test" import { customProperties, fontFamilies, hexColors, mediaBreakpoints, normalizeHex, resolveVar, scanRules } from "./css-lite.mjs" const THEMED_CSS = ` /* tokens */ :root { --bg: #FAF6EF; --fg: #24211b; --accent: #c2410c; --ff: -apple-system, "Noto Sans KR", sans-serif; } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --bg: #0c0b0a; --fg: #fbf0df; } } html[data-theme="dark"] { --bg: #0c0b0a; --fg: #fbf0df; --accent: #f8b878; } html[data-theme="light"] { --bg: #faf6ef; } body { font-family: var(--ff); color: var(--fg); } ` describe("scanRules", () => { test("#given a minified one-line stylesheet with nested @media #when scanned #then every style rule is returned in source order", () => { // given const css = "a{color:#fff}@media screen{@media (max-width:640px){.b{margin:0;padding:1px}.c{top:0}}}.d{left:0}" // when const rules = scanRules(css) // then expect(rules.map((rule) => rule.selector)).toEqual(["a", ".b", ".c", ".d"]) expect(rules[1].atRule).toBe("@media screen @media (max-width:640px)") expect(rules[1].declarations).toEqual([ { prop: "margin", value: "0" }, { prop: "padding", value: "1px" }, ]) expect(rules[0].atRule).toBeUndefined() }) test("#given an unbalanced brace #when scanned #then the rules parsed so far are returned without throwing", () => { // given const css = ".a{color:red}.b{color:blue}@media (min-width:1px){.c{top:0}.d{left:0" // when const rules = scanRules(css) // then expect(rules.map((rule) => rule.selector)).toEqual([".a", ".b", ".c"]) }) test("#given a stray closing brace and a top-level @import #when scanned #then both are ignored", () => { // given const css = '@import url("x.css");}.a{color:red}' // when const rules = scanRules(css) // then expect(rules).toEqual([{ selector: ".a", declarations: [{ prop: "color", value: "red" }] }]) }) test("#given braces and semicolons inside strings, url() and comments #when scanned #then declarations stay intact", () => { // given const css = '/* .x{} */.q::before{content:"};{";background:url(data:image/svg+xml;base64,AAA=)}' // when const rules = scanRules(css) // then expect(rules).toHaveLength(1) expect(rules[0].declarations).toEqual([ { prop: "content", value: '"};{"' }, { prop: "background", value: "url(data:image/svg+xml;base64,AAA=)" }, ]) }) test("#given @font-face and @keyframes #when scanned #then font-face is a rule and keyframe steps carry the at-rule", () => { // given const css = "@font-face{font-family:Pretendard;src:url(p.woff2)}@keyframes spin{from{opacity:0}to{opacity:1}}" // when const rules = scanRules(css) // then expect(rules.map((rule) => rule.selector)).toEqual(["@font-face", "from", "to"]) expect(rules[1].atRule).toBe("@keyframes spin") }) test("#given non-string input #when scanned #then an empty list is returned", () => { expect(scanRules(undefined)).toEqual([]) }) }) describe("customProperties", () => { test("#given a light and dark themed sample #when read #then both maps are returned", () => { // when const props = customProperties(THEMED_CSS) // then expect(props.light.get("--bg")).toBe("#faf6ef") expect(props.light.get("--accent")).toBe("#c2410c") expect(props.light.get("--ff")).toBe('-apple-system, "Noto Sans KR", sans-serif') expect(props.dark.get("--bg")).toBe("#0c0b0a") expect(props.dark.get("--fg")).toBe("#fbf0df") expect(props.dark.get("--accent")).toBe("#f8b878") expect(props.light.size).toBe(4) expect(props.dark.size).toBe(3) }) test("#given custom properties on a component selector #when read #then they are not global tokens", () => { expect(customProperties(".card{--pad:4px}").light.size).toBe(0) }) }) describe("fontFamilies", () => { test("#given font-family, var() and font shorthand #when read #then families are split, unquoted and resolved", () => { // given const css = `${THEMED_CSS} code { font: 14px/1.5 ui-monospace, 'SFMono-Regular', monospace; } h1 { font-weight: 700; }` // when const fonts = fontFamilies(css) // then expect(fonts).toEqual([ { selector: "body", families: ["-apple-system", "Noto Sans KR", "sans-serif"] }, { selector: "code", families: ["ui-monospace", "SFMono-Regular", "monospace"] }, ]) }) }) describe("hexColors / normalizeHex", () => { test("#given short, alpha and uppercase hex #when normalized #then six lowercase digits are returned", () => { expect(normalizeHex("#ABC")).toBe("#aabbcc") expect(normalizeHex("#abcd")).toBe("#aabbcc") expect(normalizeHex("#AABBCC80")).toBe("#aabbcc") expect(normalizeHex("#12345")).toBeNull() }) test("#given text with hex colors, entities and ids #when scanned #then only colors are returned with repeats", () => { // given const text = "color:#FFF; fill: #fff; stroke:#c2410c80; { #fade-in #s1 #abcdefg" // when const colors = hexColors(text) // then expect(colors).toEqual(["#ffffff", "#ffffff", "#c2410c"]) }) }) describe("mediaBreakpoints", () => { test("#given max-width and min-width queries #when read #then px values are returned sorted", () => { expect(mediaBreakpoints("@media (max-width:640px){} @media (min-width: 1024px){}")).toEqual([640, 1024]) }) test("#given duplicates, range syntax and non-width queries #when read #then unique widths only", () => { const css = "@media (width <= 560px){} @media print{} @media (max-width:640px) and (min-width:560px){}" expect(mediaBreakpoints(css)).toEqual([560, 640]) }) }) describe("resolveVar", () => { test("#given nested var() with fallbacks #when resolved #then known names resolve and unknown ones keep their text", () => { // given const props = new Map([ ["--a", "var(--b)"], ["--b", "#123456"], ]) // then expect(resolveVar("1px solid var(--a)", props)).toBe("1px solid #123456") expect(resolveVar("var(--missing, var(--b))", props)).toBe("#123456") expect(resolveVar("var(--missing)", props)).toBe("var(--missing)") expect(resolveVar("var(--x)", { "--x": "red" })).toBe("red") }) test("#given a self-referencing variable #when resolved #then it terminates", () => { const props = new Map([["--loop", "var(--loop)"]]) expect(typeof resolveVar("var(--loop)", props)).toBe("string") }) }) -
design-spec.mjs 6.8 KB · in bundle
-
design-spec.test.ts 6.3 KB
import { describe, expect, test } from "bun:test" import { DESIGN_SPEC_SECTIONS } from "./contracts.mjs" import { parseLineageMode, parsePalette, renderDesignSpec } from "./design-spec.mjs" function sectionBody(markdown: string, heading: string): string { const start = markdown.indexOf(`## ${heading}\n`) const rest = markdown.slice(start + heading.length + 4) const next = rest.search(/^## /m) return next === -1 ? rest : rest.slice(0, next) } describe("parsePalette", () => { test("#given a css fence and a Palette bullet list #when parsed #then the union is returned", () => { // given const spec = [ "# Spec", "```css", ":root { --bg: #FAF6EF; --accent: #c2410c; }", "```", "## Palette", "- accent #C2410C", "- good #15803d", "## Layout", "- divider #999999 is prose, not a token", ].join("\n") // when const palette = parsePalette(spec) // then expect([...palette].sort()).toEqual(["#15803d", "#c2410c", "#faf6ef"]) }) test("#given Tokens and Colours headings with nested subheadings #when parsed #then hex under them counts", () => { // given const spec = [ "## Tokens (copied from the reference)", "- Light: --bg #faf6ef, --fg #24211b", "### Dark", "- --bg #0c0b0a", "## Colours", "good #abc", "## Structure", "#def is not a color heading", ].join("\n") // then expect([...parsePalette(spec)].sort()).toEqual(["#0c0b0a", "#24211b", "#aabbcc", "#faf6ef"]) }) test("#given a fence line starting with a hash #when parsed #then it is not read as a heading", () => { // given const spec = ["## Palette", "```", "# not a heading", "--x: #111111", "```", "- #222222"].join("\n") // then expect([...parsePalette(spec)].sort()).toEqual(["#111111", "#222222"]) }) test("#given a spec with no tokens #when parsed #then the palette is empty", () => { expect(parsePalette("# Spec\n\nPlain prose with #fff outside a palette.").size).toBe(0) }) }) describe("parseLineageMode", () => { test("#given a Lineage line under Citations #when parsed #then that mode is returned", () => { expect(parseLineageMode("## Citations\n- Style: bracket-S\n- Lineage: section\n")).toBe("section") expect(parseLineageMode("## Citations\nLineage: inline")).toBe("inline") }) test("#given no Citations section or a Lineage line elsewhere #when parsed #then inline is the default", () => { expect(parseLineageMode("# Spec")).toBe("inline") expect(parseLineageMode("## Layout\nLineage: section\n## Citations\n- Style: footnote")).toBe("inline") expect(parseLineageMode("## Citations\nLineage: TODO: ask")).toBe("inline") }) }) describe("renderDesignSpec", () => { test("#given an empty extract #when rendered #then every section heading appears in order with TODO: ask under each", () => { // when const markdown = renderDesignSpec({}) // then let cursor = -1 for (const heading of DESIGN_SPEC_SECTIONS) { const index = markdown.indexOf(`## ${heading}\n`) expect(index, heading).toBeGreaterThan(cursor) cursor = index expect(sectionBody(markdown, heading), heading).toContain("TODO: ask") } }) test("#given a full extract #when rendered #then values, provenance and a round-trippable palette and lineage appear", () => { // given const extract = { origin: "tests/fixtures/reference-report.html", bytes: 16500, date: "2026-09-24", tokens: { light: new Map([ ["--bg", "#faf6ef"], ["--accent", "#c2410c"], ]), dark: new Map([["--bg", "#0c0b0a"]]), }, fonts: { body: "Pretendard, sans-serif", heading: "Pretendard, sans-serif", mono: "ui-monospace, monospace" }, colors: [{ hex: "#24211b", count: 12, roles: ["body"] }], breakpoints: [560, 640], headingNumbering: "double-digit", citationStyle: "bracket-S", lineageMode: "section", figureContainers: ["fig"], skeleton: [ { level: 1, text: "Title" }, { level: 2, text: "01 Findings" }, ], measure: { fontSize: "17px", lineHeight: "1.75", maxWidth: "720px" }, notes: ["skipped external stylesheet: theme.css"], } // when const markdown = renderDesignSpec(extract) // then const provenance = sectionBody(markdown, "Extracted from") expect(provenance).toContain("tests/fixtures/reference-report.html") expect(provenance).toContain("16500") expect(provenance).toContain("2026-09-24") expect(provenance).toContain("skipped external stylesheet: theme.css") expect([...parsePalette(markdown)].sort()).toEqual(["#0c0b0a", "#24211b", "#c2410c", "#faf6ef"]) expect(parseLineageMode(markdown)).toBe("section") expect(sectionBody(markdown, "Layout")).toContain("560px, 640px") expect(sectionBody(markdown, "Structure")).toContain(" - 01 Findings") expect(sectionBody(markdown, "Figures")).toContain("fig") expect(sectionBody(markdown, "Open questions")).not.toContain("TODO: ask") for (const heading of ["Tokens", "Typography", "Layout", "Structure", "Figures", "Citations"]) { expect(sectionBody(markdown, heading), heading).not.toContain("TODO: ask") } }) test("#given unknown enum values and plain-object tokens #when rendered #then unknowns are TODO: ask and listed as open questions", () => { // given const extract = { tokens: { light: { "--bg": "#ffffff" }, dark: {} }, lineageMode: "unknown", citationStyle: "unknown" } // when const markdown = renderDesignSpec(extract) // then expect(sectionBody(markdown, "Tokens")).toContain("--bg: #ffffff;") expect(sectionBody(markdown, "Tokens")).toContain("Dark tokens: TODO: ask") expect(sectionBody(markdown, "Citations")).toContain("Lineage: TODO: ask") expect(parseLineageMode(markdown)).toBe("inline") expect(sectionBody(markdown, "Open questions")).toContain("Lineage mode") }) }) describe("parsePalette with Korean headings", () => { test("#given a spec whose token section is headed in Korean #when parsed #then the bullet hexes under it are the palette", () => { // given const md = ["# design-spec", "## 토큰 (변경 금지)", "- 라이트: bg #faf6ef · fg #24211b · accent #c2410c", "## 그림 표준", "- 테두리 #ffffff"].join("\n") // when const palette = parsePalette(md) // then expect([...palette].sort()).toEqual(["#24211b", "#c2410c", "#faf6ef"]) }) test("#given Korean color and palette headings #when parsed #then each is recognized", () => { expect([...parsePalette("## 팔레트\n- #112233")]).toEqual(["#112233"]) expect([...parsePalette("## 색상\n- #445566")]).toEqual(["#445566"]) }) }) -
entities.mjs 1.5 KB · in bundle
-
entry-guard.mjs 784 B · in bundle
-
format-extract-css.mjs 5 KB · in bundle
-
format-extract.mjs 10 KB · in bundle
-
format-extract.test.ts 10.9 KB
import { afterAll, beforeAll, describe, expect, test } from "bun:test" import { readFileSync } from "node:fs" import { createServer, type Server } from "node:http" import type { AddressInfo } from "node:net" import { once } from "node:events" import { join } from "node:path" import { DESIGN_SPEC_SECTIONS } from "./contracts.mjs" import { renderDesignSpec } from "./design-spec.mjs" import { UnsupportedFormatError, extractFromHtml, extractFromMarkdown, extractFromPath, fetchHtml, } from "./format-extract.mjs" const FIXTURES = join(import.meta.dir, "tests", "fixtures") const HTML_PATH = join(FIXTURES, "reference-report.html") const MD_PATH = join(FIXTURES, "reference.md") const html = readFileSync(HTML_PATH, "utf8") const markdown = readFileSync(MD_PATH, "utf8") const UNSUPPORTED = "unsupported: point at the HTML or Markdown source" function sectionBody(spec: string, heading: string): string { const start = spec.indexOf(`## ${heading}\n`) const rest = spec.slice(start + heading.length + 4) const next = rest.search(/^## /m) return next === -1 ? rest : rest.slice(0, next) } function bulletLines(body: string): string[] { return body.split("\n").filter((line) => line.startsWith("- ")) } describe("extractFromHtml on the reference report", () => { const extract = extractFromHtml(html, { origin: HTML_PATH }) test("#given light and dark token blocks #when extracted #then both maps carry at least 8 tokens", () => { expect(extract.tokens.light.size).toBeGreaterThanOrEqual(8) expect(extract.tokens.dark.size).toBeGreaterThanOrEqual(8) expect(extract.tokens.light.get("--bg")).toBe("#faf6ef") expect(extract.tokens.dark.get("--accent")).toBe("#f8b878") }) test("#given body, heading, and code font rules #when extracted #then the three stacks resolve through var()", () => { expect(extract.fonts).toEqual({ body: '"Inter Display", "Noto Sans KR", sans-serif', heading: "Fraunces, Georgia, serif", mono: '"Iosevka Term", ui-monospace, monospace', }) }) test("#given two min-width media queries #when extracted #then breakpoints are [560, 640]", () => { expect(extract.breakpoints).toEqual([560, 640]) }) test("#given 01-style h2 numbering and [S1] markers #when extracted #then numbering, citation, and lineage are detected", () => { expect(extract.headingNumbering).toBe("double-digit") expect(extract.citationStyle).toBe("bracket-S") expect(extract.lineageMode).toBe("inline") }) test("#given img and svg inside .fig containers #when extracted #then figureContainers contains fig", () => { expect(extract.figureContainers).toContain("fig") }) test("#given the fixture headings #when extracted #then the skeleton has one entry per h1 and h2", () => { const count = (html.match(/<h[12][\s>]/g) ?? []).length expect(extract.skeleton).toHaveLength(count) expect(extract.skeleton[0]).toEqual({ level: 1, text: "How a small team ships every week" }) expect(extract.skeleton[1]).toEqual({ level: 2, text: "01 The release cadence" }) }) test("#given body size, line height, and a measure token #when extracted #then measure is resolved", () => { expect(extract.measure).toEqual({ fontSize: "17px", lineHeight: "1.7", maxWidth: "720px" }) }) test("#given repeated hex colors #when extracted #then at most 8 colors are ranked by count with selector roles", () => { expect(extract.colors.length).toBeLessThanOrEqual(8) const counts = extract.colors.map((color: { count: number }) => color.count) expect(counts).toEqual([...counts].sort((a, b) => b - a)) const accent = extract.colors.find((color: { hex: string }) => color.hex === "#c2410c") expect(accent?.roles).toEqual(expect.arrayContaining(["accent", "link"])) const background = extract.colors.find((color: { hex: string }) => color.hex === "#faf6ef") expect(background?.roles).toContain("background") }) test("#given the source #when extracted #then origin and utf-8 byte count are recorded", () => { expect(extract.origin).toBe(HTML_PATH) expect(extract.bytes).toBe(Buffer.byteLength(html, "utf8")) }) test("#given the extract #when rendered #then the 8 design-spec sections appear in contract order", () => { const spec = renderDesignSpec(extract) const positions = DESIGN_SPEC_SECTIONS.map((heading: string) => spec.indexOf(`\n## ${heading}\n`)) expect(positions.every((at: number) => at > 0)).toBe(true) expect(positions).toEqual([...positions].sort((a, b) => a - b)) expect(sectionBody(spec, "Tokens")).toContain("--bg: #faf6ef;") expect(sectionBody(spec, "Tokens")).not.toContain("TODO: ask") }) }) describe("extractFromMarkdown on the reference markdown", () => { const extract = extractFromMarkdown(markdown, { origin: MD_PATH }) test("#given h1, h2, h3, and a fenced pseudo-heading #when extracted #then the skeleton keeps h1 and h2 outside fences", () => { expect(extract.skeleton).toEqual([ { level: 1, text: "작은 팀은 어떻게 매주 배포하는가" }, { level: 2, text: "01 배포 주기" }, { level: 2, text: "02 리뷰 없이 일하는 방식" }, { level: 2, text: "03 출처" }, ]) }) test("#given [S1] markers and 01-style headings #when extracted #then citation style and numbering are detected", () => { expect(extract.citationStyle).toBe("bracket-S") expect(extract.headingNumbering).toBe("double-digit") }) test("#given markdown input #when rendered #then every CSS-derived line reads TODO: ask", () => { const spec = renderDesignSpec(extract) for (const heading of ["Tokens", "Typography", "Layout"]) { const lines = bulletLines(sectionBody(spec, heading)) expect(lines.length).toBeGreaterThan(0) for (const line of lines) expect(line).toEndWith("TODO: ask") } expect(sectionBody(spec, "Figures")).toContain("- Containers: TODO: ask") expect(extract.tokens.light.size + extract.tokens.dark.size).toBe(0) }) }) describe("unsupported inputs", () => { test("#given a .pdf path #when extracted from the path #then the typed unsupported error is thrown", () => { const attempt = () => extractFromPath(join(FIXTURES, "reference.pdf")) expect(attempt).toThrow(UnsupportedFormatError) expect(attempt).toThrow(UNSUPPORTED) }) test("#given PDF bytes passed as HTML #when extracted #then the typed unsupported error is thrown", () => { expect(() => extractFromHtml("%PDF-1.7\n1 0 obj", { origin: "report" })).toThrow(UnsupportedFormatError) }) test("#given an html path #when extracted from the path #then it dispatches to the HTML extractor", () => { const extract = extractFromPath(HTML_PATH) expect(extract.origin).toBe(HTML_PATH) expect(extract.breakpoints).toEqual([560, 640]) }) }) describe("extractFromHtml edge cases", () => { test("#given only external stylesheets #when rendered #then Tokens reads TODO: ask and a note names the skipped href", () => { const source = '<html><head><link rel="stylesheet" href="css/site.css"></head><body><h1>Only links</h1></body></html>' const extract = extractFromHtml(source, { origin: "external.html" }) const spec = renderDesignSpec(extract) const tokens = bulletLines(sectionBody(spec, "Tokens")) expect(tokens.length).toBeGreaterThan(0) for (const line of tokens) expect(line).toEndWith("TODO: ask") expect(extract.notes.some((note: string) => note.includes("css/site.css"))).toBe(true) expect(sectionBody(spec, "Extracted from")).toContain("css/site.css") }) test("#given a numeric eyebrow sibling before each h2 #when extracted #then numbering is double-digit", () => { const source = '<body><h1>T</h1><div><span class="sec-no">01</span><h2>First</h2></div><div><span class="sec-no">02</span><h2>Second</h2></div></body>' expect(extractFromHtml(source, { origin: "x.html" }).headingNumbering).toBe("double-digit") }) test("#given decimal, bare, and absent h2 headings #when extracted #then numbering is decimal, none, and unknown", () => { const numbering = (body: string) => extractFromHtml(`<body>${body}</body>`, { origin: "x.html" }).headingNumbering expect(numbering("<h2>1. Intro</h2><h2>2. Method</h2>")).toBe("decimal") expect(numbering("<h2>Intro</h2><h2>Method</h2>")).toBe("none") expect(numbering("<h1>Only a title</h1>")).toBe("unknown") }) test("#given [출처] anchors and no lineage tokens #when extracted #then citation is bracket-source and lineage is section", () => { const source = '<body><h2>A</h2><p>Fact one. <a class="src" href="https://example.org/a">[출처]</a></p><p>Fact two <a href="https://example.org/b">[Source 2]</a></p></body>' const extract = extractFromHtml(source, { origin: "x.html" }) expect(extract.citationStyle).toBe("bracket-source") expect(extract.lineageMode).toBe("section") }) test("#given a MEASURED token with source anchors #when extracted #then lineage is inline", () => { const source = '<body><p>Latency 42 ms MEASURED <a href="https://example.org/a">[출처]</a></p></body>' expect(extractFromHtml(source, { origin: "x.html" }).lineageMode).toBe("inline") }) test("#given sup footnote references #when extracted #then citation style is footnote", () => { const source = '<body><p>Claim<sup><a href="#fn1">1</a></sup></p><ol><li id="fn1">Note</li></ol></body>' expect(extractFromHtml(source, { origin: "x.html" }).citationStyle).toBe("footnote") }) test("#given no citations, fonts, or headings #when rendered #then unknown fields read TODO: ask", () => { const extract = extractFromHtml("<body><p>Plain text only.</p></body>", { origin: "x.html" }) expect(extract.citationStyle).toBe("unknown") expect(extract.lineageMode).toBe("unknown") expect(extract.fonts).toEqual({}) const spec = renderDesignSpec(extract) expect(sectionBody(spec, "Citations")).toContain("- Style: TODO: ask") expect(sectionBody(spec, "Citations")).toContain("- Lineage: TODO: ask") expect(sectionBody(spec, "Typography")).toContain("- Heading font: TODO: ask") }) }) describe("fetchHtml", () => { let server: Server let base = "" beforeAll(async () => { server = createServer((request, response) => { if (request.url === "/report.html") { response.writeHead(200, { "content-type": "text/html; charset=utf-8" }) response.end(html) } else if (request.url === "/paper") { response.writeHead(200, { "content-type": "application/pdf" }) response.end("%PDF-1.7") } else { response.writeHead(404, { "content-type": "text/plain" }) response.end("missing") } }) server.listen(0, "127.0.0.1") await once(server, "listening") base = `http://127.0.0.1:${(server.address() as AddressInfo).port}` }) afterAll(async () => { server.close() server.closeAllConnections() await once(server, "close") }) test("#given an html url #when fetched #then the body text is returned", async () => { expect(await fetchHtml(`${base}/report.html`)).toBe(html) }) test("#given a pdf response #when fetched #then the typed unsupported error is thrown", async () => { await expect(fetchHtml(`${base}/paper`)).rejects.toThrow(UnsupportedFormatError) }) test("#given a 404 response #when fetched #then the error names the status", async () => { await expect(fetchHtml(`${base}/missing`)).rejects.toThrow("404") }) }) -
gates-figures.mjs 7.2 KB · in bundle
-
gates-layout.mjs 6.2 KB · in bundle
-
gates-layout.test.ts 11.8 KB
import { describe, expect, test } from "bun:test" import { readFileSync } from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" import { DEFECT_CODES, LAYOUT_CODES } from "./contracts.mjs" import { checkLayout } from "./gates-layout.mjs" import { ProbeError } from "./layout-probe.mjs" const FIXTURE_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "tests", "fixtures", "boxes.json") const PARENT = "body > main > section" const PARENT_RECT = { x: 0, y: 0, w: 600, h: 400 } type Size = { w: number; h: number } type Box = ReturnType<typeof box> function box(overrides: Record<string, unknown> = {}) { return { selector: `${PARENT} > p`, tag: "p", rect: { x: 10, y: 10, w: 100, h: 20 }, parentSelector: PARENT, parentRect: PARENT_RECT, scroll: { sw: 100, cw: 100, sh: 20, ch: 20 }, overflow: { x: "visible", y: "visible" }, position: "static", textLen: 10, ...overrides, } } function img(rect: Size, natural: Size, overrides: Record<string, unknown> = {}) { return box({ selector: `${PARENT} > figure > img`, tag: "img", rect: { x: 0, y: 0, ...rect }, textLen: 0, objectFit: "fill", natural, ...overrides }) } function probe(boxes: Box[], overrides: Record<string, unknown> = {}) { return { probe_version: 1, viewport: { w: 1280, h: 900 }, document: { w: 1280, h: 2000 }, count: boxes.length, truncated: false, boxes, ...overrides } } function codes(result: ReturnType<typeof checkLayout>) { return result.defects.map((defect) => defect.code) } describe("checkLayout L1 overflow", () => { test("#given a child spilling 3px past the right edge of its parent #when checked #then layout_overflow is raised with the contract severity", () => { // given const child = box({ rect: { x: 500, y: 10, w: 103, h: 20 } }) // when const result = checkLayout(probe([child])) // then expect(codes(result)).toEqual(["layout_overflow"]) expect(result.defects[0].severity).toBe(DEFECT_CODES.layout_overflow.severity) expect(result.defects[0].selector).toBe(child.selector) expect(result.defects[0].hint).toBe(DEFECT_CODES.layout_overflow.hint) expect(result.defects[0].message).toContain("3px") }) test("#given a child spilling exactly 2px #when checked #then no defect is raised", () => { expect(checkLayout(probe([box({ rect: { x: 500, y: 10, w: 102, h: 20 } })])).defects).toEqual([]) }) test("#given a 2px spill that float arithmetic on 0.1px probe values turns into 2.000000000000001 #when checked #then no defect is raised", () => { // given const child = box({ rect: { x: 0.1, y: 0, w: 9.8, h: 5 }, parentRect: { x: 0.1, y: 0, w: 7.8, h: 20 } }) expect(child.rect.x + child.rect.w - (child.parentRect.x + child.parentRect.w)).toBeGreaterThan(2) // when / then expect(checkLayout(probe([child])).defects).toEqual([]) }) test("#given a child spilling 3px past the top edge #when checked #then layout_overflow is raised", () => { expect(codes(checkLayout(probe([box({ rect: { x: 10, y: -3, w: 100, h: 20 } })])))).toEqual(["layout_overflow"]) }) test("#given absolute and fixed children spilling 50px #when checked #then they are exempt", () => { const boxes = ["absolute", "fixed"].map((position, i) => box({ selector: `${PARENT} > div:nth-of-type(${i + 1}) > p`, parentSelector: `${PARENT} > div:nth-of-type(${i + 1})`, rect: { x: 580, y: 10, w: 70, h: 20 }, position })) expect(checkLayout(probe(boxes)).defects).toEqual([]) }) test("#given a box with no parent rect #when checked #then L1 is skipped without throwing", () => { expect(checkLayout(probe([box({ parentSelector: null, parentRect: null })])).defects).toEqual([]) }) }) describe("checkLayout L2 and L3 scroll inequality", () => { test("#given text wider than its box by 3px under overflow hidden #when checked #then layout_text_clipped is raised", () => { const clipped = box({ scroll: { sw: 103, cw: 100, sh: 20, ch: 20 }, overflow: { x: "hidden", y: "visible" } }) const result = checkLayout(probe([clipped])) expect(codes(result)).toEqual(["layout_text_clipped"]) expect(result.defects[0].severity).toBe(DEFECT_CODES.layout_text_clipped.severity) }) test("#given text taller than its box by 3px under overflow clip #when checked #then layout_text_clipped is raised", () => { const clipped = box({ scroll: { sw: 100, cw: 100, sh: 23, ch: 20 }, overflow: { x: "visible", y: "clip" } }) expect(codes(checkLayout(probe([clipped])))).toEqual(["layout_text_clipped"]) }) test("#given a 2px excess under overflow hidden #when checked #then no defect is raised", () => { const edge = box({ scroll: { sw: 102, cw: 100, sh: 22, ch: 20 }, overflow: { x: "hidden", y: "hidden" } }) expect(checkLayout(probe([edge])).defects).toEqual([]) }) test("#given a pre that scrolls horizontally under overflow auto #when checked #then minor layout_scroll_container is raised", () => { const scroller = box({ selector: `${PARENT} > pre`, tag: "pre", scroll: { sw: 812, cw: 550, sh: 156, ch: 156 }, overflow: { x: "auto", y: "auto" } }) const result = checkLayout(probe([scroller])) expect(codes(result)).toEqual(["layout_scroll_container"]) expect(result.defects[0].severity).toBe(DEFECT_CODES.layout_scroll_container.severity) }) test("#given content overflowing a visible box #when checked #then neither L2 nor L3 fires", () => { const visible = box({ scroll: { sw: 300, cw: 100, sh: 90, ch: 20 } }) expect(checkLayout(probe([visible])).defects).toEqual([]) }) }) describe("checkLayout L4 image distortion", () => { test("#given an img rendered 300x150 with natural 300x160 #when checked #then layout_image_distorted names 6.7%", () => { const result = checkLayout(probe([img({ w: 300, h: 150 }, { w: 300, h: 160 })])) expect(codes(result)).toEqual(["layout_image_distorted"]) expect(result.defects[0].severity).toBe(DEFECT_CODES.layout_image_distorted.severity) expect(result.defects[0].message).toContain("6.7%") }) test("#given an img rendered 300x150 with natural 300x154 (under 3% drift) #when checked #then no defect is raised", () => { expect(checkLayout(probe([img({ w: 300, h: 150 }, { w: 300, h: 154 })])).defects).toEqual([]) }) test("#given a distorted img under object-fit cover, contain, or scale-down #when checked #then it is exempt", () => { const boxes = ["cover", "contain", "scale-down"].map((objectFit, i) => img({ w: 300, h: 150 }, { w: 300, h: 300 }, { objectFit, selector: `${PARENT} > figure:nth-of-type(${i + 1}) > img`, parentSelector: `${PARENT} > figure:nth-of-type(${i + 1})` })) expect(checkLayout(probe(boxes)).defects).toEqual([]) }) test("#given an img whose natural size is 0x0 (not loaded) #when checked #then it is skipped without throwing", () => { expect(() => checkLayout(probe([img({ w: 300, h: 150 }, { w: 0, h: 0 })]))).not.toThrow() expect(checkLayout(probe([img({ w: 300, h: 150 }, { w: 0, h: 0 })])).defects).toEqual([]) }) test("#given an img with a zero rendered height #when checked #then it is skipped", () => { expect(checkLayout(probe([img({ w: 300, h: 0 }, { w: 300, h: 160 })])).defects).toEqual([]) }) }) describe("checkLayout L5 sibling overlap", () => { const first = box({ selector: `${PARENT} > p:nth-of-type(1)`, rect: { x: 0, y: 0, w: 100, h: 100 } }) test("#given two static siblings intersecting 13x13 #when checked #then layout_sibling_overlap names both selectors", () => { const second = box({ selector: `${PARENT} > p:nth-of-type(2)`, rect: { x: 87, y: 87, w: 100, h: 100 }, position: "relative" }) const result = checkLayout(probe([first, second])) expect(codes(result)).toEqual(["layout_sibling_overlap"]) expect(result.defects[0].severity).toBe(DEFECT_CODES.layout_sibling_overlap.severity) expect(result.defects[0].message).toContain(first.selector) expect(result.defects[0].message).toContain(second.selector) }) test("#given two siblings intersecting 12x40 #when checked #then no defect is raised", () => { const second = box({ selector: `${PARENT} > p:nth-of-type(2)`, rect: { x: 88, y: 60, w: 100, h: 100 } }) expect(checkLayout(probe([first, second])).defects).toEqual([]) }) test("#given a 13x13 intersection where one box is absolute #when checked #then it is exempt", () => { const badge = box({ selector: `${PARENT} > p:nth-of-type(2)`, rect: { x: 87, y: 87, w: 100, h: 100 }, position: "absolute" }) expect(checkLayout(probe([first, badge])).defects).toEqual([]) }) test("#given a 13x13 intersection between boxes of different parents #when checked #then it is not a sibling overlap", () => { const cousin = box({ selector: "body > main > div > p", parentSelector: "body > main > div", rect: { x: 87, y: 87, w: 100, h: 100 }, parentRect: { x: 0, y: 0, w: 600, h: 400 } }) expect(checkLayout(probe([first, cousin])).defects).toEqual([]) }) }) describe("checkLayout probe validation and summary", () => { test("#given probe_version 2 #when checked #then a typed ProbeError is thrown", () => { expect(() => checkLayout(probe([], { probe_version: 2 }))).toThrow(ProbeError) try { checkLayout(probe([], { probe_version: 2 })) } catch (error) { expect((error as ProbeError).code).toBe("unsupported_probe_version") } }) test("#given a probe without a boxes array #when checked #then a typed ProbeError is thrown", () => { const malformed = JSON.parse('{"probe_version": 1}') expect(() => checkLayout(malformed)).toThrow(ProbeError) }) test("#given a truncated probe #when checked #then the summary ends with the truncation note", () => { const result = checkLayout(probe([box()], { count: 500, truncated: true })) expect(result.summary.endsWith("layout sample truncated")).toBe(true) }) test("#given a complete probe #when checked #then the summary carries no truncation note", () => { expect(checkLayout(probe([box()])).summary).not.toContain("truncated") }) test("#given every defect raised across the rules #when inspected #then each code is a LAYOUT_CODES entry", () => { const boxes = [ box({ rect: { x: 590, y: 10, w: 20, h: 20 } }), box({ selector: `${PARENT} > h2`, scroll: { sw: 130, cw: 100, sh: 20, ch: 20 }, overflow: { x: "hidden", y: "hidden" } }), img({ w: 300, h: 150 }, { w: 300, h: 300 }), ] const raised = codes(checkLayout(probe(boxes))) expect(raised.length).toBeGreaterThan(0) expect(raised.every((code: string) => LAYOUT_CODES.includes(code))).toBe(true) }) }) describe("checkLayout on the fixture sample", () => { test("#given the boxes.json sample with the two measured real-sample images #when checked #then only the pre scroll container is reported", () => { // given const sample = JSON.parse(readFileSync(FIXTURE_PATH, "utf8")) // when const result = checkLayout(sample) // then expect(result.defects.map((defect) => [defect.code, defect.selector])).toEqual([["layout_scroll_container", "#method > pre"]]) expect(result.summary).not.toContain("truncated") }) }) describe("checkLayout L1 inside a scrolling or clipping parent", () => { const wideTable = (parentOverflow?: { x: string; y: string }) => box({ selector: `${PARENT} > div > table`, tag: "table", rect: { x: 0, y: 10, w: 816, h: 200 }, parentSelector: `${PARENT} > div`, ...(parentOverflow ? { parentOverflow } : {}) }) test("#given a table spilling 216px inside a parent with overflow-x auto #when checked #then it is the advisory scroll container, not an overflow", () => { const result = checkLayout(probe([wideTable({ x: "auto", y: "visible" })])) expect(codes(result)).toEqual(["layout_scroll_container"]) expect(result.defects[0]?.severity).toBe(DEFECT_CODES.layout_scroll_container.severity) }) test("#given the same spill inside a parent with overflow-x hidden #when checked #then the content is reported clipped", () => { expect(codes(checkLayout(probe([wideTable({ x: "hidden", y: "visible" })])))).toEqual(["layout_text_clipped"]) }) test("#given the same spill with no parentOverflow recorded #when checked #then it stays a layout_overflow", () => { expect(codes(checkLayout(probe([wideTable()])))).toEqual(["layout_overflow"]) }) }) -
gates-static.mjs 6.8 KB · in bundle
-
gates-static.test.ts 10.3 KB
import { describe, expect, test } from "bun:test" import { readFileSync } from "node:fs" import { join } from "node:path" import { DEFECT_CODES, STATIC_CODES } from "./contracts.mjs" import { parseLineageMode, parsePalette } from "./design-spec.mjs" import { checkStatic } from "./gates-static.mjs" import { parseHtml } from "./html-lite.mjs" type Defect = { code: string; severity: string; selector: string; line: number; message: string; hint: string } type Result = { defects: Defect[]; gatesRun: string[]; gatesSkipped: string[] } type FakeStat = { size: number } const FIXTURES = join(import.meta.dir, "tests", "fixtures") const GOOD = readFileSync(join(FIXTURES, "good-report.html"), "utf8") const BAD = readFileSync(join(FIXTURES, "bad-report.html"), "utf8") const SPEC = readFileSync(join(FIXTURES, "design-spec.md"), "utf8") const PALETTE = parsePalette(SPEC) function run(html: string, options: Record<string, unknown> = {}): Result { return checkStatic(parseHtml(html), { palette: PALETTE, lineageMode: parseLineageMode(SPEC), requireSections: [], baseDir: FIXTURES, ...options, }) as Result } const codes = (result: Result) => result.defects.map((defect) => defect.code) const has = (html: string, code: string, options: Record<string, unknown> = {}) => codes(run(html, options)).includes(code) describe("checkStatic fixtures", () => { test("#given the passing fixture report #when every gate runs #then no defect is raised", () => { // given const options = { requireSections: [/sources/i, /review/i] } // when const result = run(GOOD, options) // then expect(codes(result)).toEqual([]) expect(result.gatesSkipped).toEqual([]) expect([...result.gatesRun].sort()).toEqual([...STATIC_CODES].sort()) }) test("#given the failing fixture report #when every gate runs #then each static code is raised exactly once", () => { // given const options = { requireSections: [/risks/i] } // when const result = run(BAD, options) // then expect([...codes(result)].sort()).toEqual([...STATIC_CODES].sort()) expect(STATIC_CODES).toHaveLength(17) expect(codes(result)).not.toContain("missing_promised_deliverable") }) test("#given the failing fixture report #when defects are read #then each carries contract severity, selector, line, token message and hint", () => { // given const result = run(BAD, { requireSections: [/risks/i] }) // when const byCode = new Map(result.defects.map((defect) => [defect.code, defect])) // then for (const defect of result.defects) { const contract = DEFECT_CODES[defect.code as keyof typeof DEFECT_CODES] expect(defect.severity).toBe(contract.severity) expect(defect.hint).toBe(contract.hint) expect(typeof defect.selector).toBe("string") expect(defect.selector.length).toBeGreaterThan(0) expect(Number.isInteger(defect.line)).toBe(true) } expect(byCode.get("palette_off_token")?.message).toContain("#123456") expect(byCode.get("unsourced_number")?.message).toContain("12.5%") expect(byCode.get("broken_asset_reference")?.message).toContain("missing-figure.png") expect(byCode.get("korean_serif_font")?.message).toContain("Nanum Myeongjo") expect(byCode.get("emoji_in_prose")?.message).toContain("\u{1F680}") }) }) describe("mutation proofs on the passing fixture", () => { test("#given keep-all removed #when checked #then only korean_no_keep_all is raised", () => { // given const mutated = GOOD.replace("word-break: keep-all;", "") // when const result = run(mutated, { requireSections: [/sources/i] }) // then expect(codes(result)).toEqual(["korean_no_keep_all"]) }) test("#given the closing heading renamed #when checked #then the missing_closing_section blocker is raised", () => { // given const mutated = GOOD.replace("<h2>How this report was made</h2>", "<h2>Notes on the data</h2>") // when const result = run(mutated) // then const defect = result.defects.find((entry) => entry.code === "missing_closing_section") expect(defect?.severity).toBe("blocker") }) test("#given the sources heading renamed #when checked #then the missing_citation_section blocker is raised", () => { // given const mutated = GOOD.replace("<h2>Sources</h2>", "<h2>Further reading</h2>") // when const result = run(mutated) // then const defect = result.defects.find((entry) => entry.code === "missing_citation_section") expect(defect?.severity).toBe("blocker") }) test("#given no sources heading but three [S<n>] definition lines #when checked #then no citation-section blocker", () => { // given const html = "<h2>Notes</h2><p>[S1] First source title</p><p>[S2] Second source title</p><p>[Source 3] Third source title</p>" // when const found = has(html, "missing_citation_section") // then expect(found).toBe(false) }) }) describe("unsourced_number", () => { const claim = "\uB9E4\uCD9C\uC774 12.5% \uC99D\uAC00\uD588\uB2E4" test("#given a bare revenue claim #when checked #then unsourced_number is raised", () => { // given const html = `<p>${claim}</p>` // when const found = has(html, "unsourced_number") // then expect(found).toBe(true) }) test.each([ ["a [S3] marker", `<p>${claim} [S3]</p>`], ["a MEASURED token", `<p>${claim} MEASURED</p>`], ["a data-lineage attribute", `<p data-lineage="DERIVED">${claim}</p>`], ["a #s3 anchor", `<p>${claim} <a href="#s3">3</a></p>`], ])("#given the claim with %s #when checked #then it is not flagged", (_label, html) => { // given / when const found = has(html, "unsourced_number") // then expect(found).toBe(false) }) test("#given dates, versions, figure refs and bare years only #when checked #then no claim is found", () => { // given const html = "<p>Released 2024-03-01 as v2.3.1 in 2019, see Figure 3 and Table 2.</p>" // when const found = has(html, "unsourced_number") // then expect(found).toBe(false) }) test("#given inline and section lineage modes #when the same claim is checked #then severity is major then minor", () => { // given const html = `<p>${claim}</p>` // when const inline = run(html, { lineageMode: "inline" }).defects.find((d) => d.code === "unsourced_number") const section = run(html, { lineageMode: "section" }).defects.find((d) => d.code === "unsourced_number") // then expect(inline?.severity).toBe(DEFECT_CODES.unsourced_number.severity) expect(section?.severity).toBe("minor") }) test("#given a claim inside a figure, a table and the closing section #when checked #then none is flagged", () => { // given const html = `<figure><p>${claim}</p><figcaption>x</figcaption></figure><table><tr><td><p>${claim}</p></td></tr></table><h2>How it was made</h2><p>${claim}</p>` // when const found = has(html, "unsourced_number") // then expect(found).toBe(false) }) }) describe("dashes and emoji", () => { test("#given an en dash between years #when checked #then it is allowed", () => { // given / when const found = has("<p>2019\u20132024</p>", "en_dash_in_prose") // then expect(found).toBe(false) }) test("#given an en dash between letters #when checked #then en_dash_in_prose is raised", () => { // given / when const found = has("<p>a\u2013b</p>", "en_dash_in_prose") // then expect(found).toBe(true) }) test("#given em dashes in a td and in the sources section #when checked #then they are out of prose scope", () => { // given const html = "<table><tr><td>\u2014</td></tr></table><h2>Sources</h2><ol><li>Title \u2014 subtitle</li></ol>" // when const found = has(html, "em_dash_in_prose") // then expect(found).toBe(false) }) test("#given a check-mark emoji in prose #when checked #then emoji_in_prose is raised", () => { // given / when const found = has("<p>\u2705 done</p>", "emoji_in_prose") // then expect(found).toBe(true) }) test("#given a plain check mark in a td #when checked #then no emoji is raised", () => { // given / when const found = has("<table><tr><td>\u2713</td></tr></table>", "emoji_in_prose") // then expect(found).toBe(false) }) }) describe("figures and charts", () => { test("#given a 16px svg inside an h2 #when checked #then it is not treated as a chart", () => { // given const html = '<h2>Title <svg width="16" height="16" viewBox="0 0 16 16"><path d="M0 0h16"/></svg></h2>' // when const found = codes(run(html)).filter((code) => code.startsWith("chart_")) // then expect(found).toEqual([]) }) test("#given a figure svg with title, three ticks and a label #when checked #then no chart defect", () => { // given const svg = '<svg width="400" height="200"><title>Deploys</title><text>0</text><text>5</text><text>10</text><text>Weeks</text></svg>' // when const found = codes(run(`<figure>${svg}<figcaption>Deploys per week</figcaption></figure>`)).filter((code) => code.startsWith("chart_")) // then expect(found).toEqual([]) }) }) describe("gate bookkeeping", () => { test("#given a spec with an empty palette #when checked #then palette_off_token is skipped, not run", () => { // given const palette = parsePalette("# Design spec\n\n## Citations\n- Lineage: inline\n") // when const result = run('<p style="color: #123456">x</p>', { palette }) // then expect(palette.size).toBe(0) expect(result.gatesSkipped).toContain("palette_off_token") expect(result.gatesRun).not.toContain("palette_off_token") expect(codes(result)).not.toContain("palette_off_token") }) test("#given an injected fs with a zero-byte and a missing asset #when checked #then both are broken and remote ones are ignored", () => { // given const sizes = new Map<string, number>([[join("base", "empty.png"), 0], [join("base", "ok.css"), 10]]) const fs = { statSync(file: string): FakeStat { const size = sizes.get(file) if (size === undefined) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) return { size } }, } const html = '<link rel="stylesheet" href="ok.css"><img src="empty.png"><script src="gone.js"></script><img src="https://example.org/a.png">' // when const broken = run(html, { baseDir: "base", fs }).defects.filter((d) => d.code === "broken_asset_reference") // then expect(broken.map((d) => d.message.includes("empty.png") || d.message.includes("gone.js"))).toEqual([true, true]) }) test("#given an unknown lineage mode #when checked #then it throws instead of guessing a severity", () => { // given / when / then expect(() => run("<p>x</p>", { lineageMode: "loose" })).toThrow(TypeError) }) }) -
gates-structure.mjs 4.3 KB · in bundle
-
gates-text.mjs 7.9 KB · in bundle
-
glossary-vocabulary.test.ts 1.7 KB
import { describe, expect, test } from "bun:test" import { readFileSync } from "node:fs" import { join } from "node:path" import { DEFECT_CODES, GATE_STATUSES, LINEAGE_MODES } from "./contracts.mjs" describe("report-gates glossary vocabulary parity", () => { test("#given the defect codes #when the glossary is loaded #then every exported code appears as a backticked table cell", () => { const glossaryPath = join(import.meta.dir, "../references/report-gates.md") const glossary = readFileSync(glossaryPath, "utf8") const missing: string[] = [] for (const code of Object.keys(DEFECT_CODES)) { if (!glossary.includes(`\`${code}\``)) { missing.push(code) } } expect(missing, `Missing codes: ${missing.join(", ")}`).toEqual([]) }) test("#given the glossary #when scanned for backticked tokens #then no extra defect code appears that is not exported", () => { const glossaryPath = join(import.meta.dir, "../references/report-gates.md") const glossary = readFileSync(glossaryPath, "utf8") const defectCodeSet = new Set(Object.keys(DEFECT_CODES)) const validTokens = new Set([...defectCodeSet, ...GATE_STATUSES, ...LINEAGE_MODES]) const backtickRegex = /`([a-z_][a-z0-9_]*)`/g const extras: string[] = [] let match while ((match = backtickRegex.exec(glossary)) !== null) { const token = match[1] // Only flag tokens that look like defect codes (underscore-separated) // and aren't already exported from contracts if (!validTokens.has(token) && token.includes("_") && token.length > 3) { if (!extras.includes(token)) extras.push(token) } } expect(extras, `Extra codes not in contracts: ${extras.join(", ")}`).toEqual([]) }) }) -
html-lite.mjs 8.7 KB · in bundle
-
html-lite.test.ts 6.7 KB
import { describe, expect, test } from "bun:test" import { decodeEntities, parseHtml, proseText, PROSE_TAGS, selectorPath } from "./html-lite.mjs" type LiteNode = { tag: string attrs: Record<string, string> children: LiteNode[] parent: LiteNode | null line: number text: string raw: string | null } function elements(root: LiteNode): LiteNode[] { const out: LiteNode[] = [] const stack = [...root.children].reverse() while (stack.length > 0) { const node = stack.pop() as LiteNode if (node.tag === "#text") continue out.push(node) for (let i = node.children.length - 1; i >= 0; i--) stack.push(node.children[i] as LiteNode) } return out } function byTag(root: LiteNode, tag: string): LiteNode[] { return elements(root).filter((node) => node.tag === tag) } function childTags(node: LiteNode): string[] { return node.children.filter((child) => child.tag !== "#text").map((child) => child.tag) } describe("selectorPath", () => { test("#given the 3rd p in the 2nd section under main #when a path is built #then it uses nth-of-type segments under body", () => { // given const doc = parseHtml( "<html><body><main><section><p>x</p></section><section><p>1</p><p>2</p><p>3</p></section></main></body></html>", ) const third = byTag(doc, "p")[3] as LiteNode // when const path = selectorPath(third) // then expect(path).toBe("body > main > section:nth-of-type(2) > p:nth-of-type(3)") }) test("#given an ancestor with a unique id #when a path is built #then it anchors at that id", () => { // given const doc = parseHtml( '<body><main><section id="results"><p>1</p><p>2</p><p>3</p></section></main></body>', ) const third = byTag(doc, "p")[2] as LiteNode // when const path = selectorPath(third) // then expect(path).toBe("#results > p:nth-of-type(3)") }) test("#given a duplicated id #when a path is built #then the id is not used as an anchor", () => { // given const doc = parseHtml('<body><div id="dup"><p>a</p></div><div id="dup"><p>b</p></div></body>') const second = byTag(doc, "p")[1] as LiteNode // when const path = selectorPath(second) // then expect(path).toBe("body > div:nth-of-type(2) > p") }) }) describe("parseHtml", () => { test("#given a p followed by an h2 #when parsed #then the p auto-closes and h2 is its sibling", () => { // given const source = "<body><p>a<h2>b</h2></body>" // when const doc = parseHtml(source) // then const body = byTag(doc, "body")[0] as LiteNode expect(childTags(body)).toEqual(["p", "h2"]) expect((byTag(doc, "h2")[0] as LiteNode).parent).toBe(body) }) test("#given consecutive li without close tags #when parsed #then each li is a sibling", () => { // given const source = "<body><ul><li>one<li>two<li>three</ul></body>" // when const doc = parseHtml(source) // then const list = byTag(doc, "ul")[0] as LiteNode expect(childTags(list)).toEqual(["li", "li", "li"]) }) test("#given an element on source line 7 #when parsed #then its line is 7", () => { // given const source = ["<html>", "<body>", "<main>", "<section>", "<p>one</p>", "<p>two</p>", '<p id="seven">x</p>', "</section>"].join("\n") // when const doc = parseHtml(source) // then const seven = byTag(doc, "p").find((node) => node.attrs.id === "seven") as LiteNode expect(seven.line).toBe(7) }) test("#given comments, void tags and an unmatched close tag #when parsed #then comments vanish, voids stay leaves, the stray close is ignored", () => { // given const source = "<body><!-- <p>hidden</p> --><div>a<br>b<img src=x.png></span> c</div></body>" // when const doc = parseHtml(source) // then expect(byTag(doc, "p")).toHaveLength(0) const div = byTag(doc, "div")[0] as LiteNode expect(childTags(div)).toEqual(["br", "img"]) expect((byTag(doc, "br")[0] as LiteNode).children).toHaveLength(0) expect((byTag(doc, "img")[0] as LiteNode).attrs.src).toBe("x.png") expect(proseText(div)).toBe("a b c") }) test("#given style, script and svg elements #when parsed #then their inner source is captured raw", () => { // given const source = '<head><style>p > a { color: red }</style></head><body><script>if (a < b) {}</script><svg viewBox="0 0 1 1"><svg><text>t</text></svg></svg><p>after</p></body>' // when const doc = parseHtml(source) // then expect((byTag(doc, "style")[0] as LiteNode).raw).toBe("p > a { color: red }") expect((byTag(doc, "script")[0] as LiteNode).raw).toBe("if (a < b) {}") const svg = byTag(doc, "svg")[0] as LiteNode expect(svg.raw).toBe("<svg><text>t</text></svg>") expect(svg.attrs.viewbox).toBe("0 0 1 1") expect(childTags(svg.parent as LiteNode)).toEqual(["script", "svg", "p"]) }) test("#given an unclosed fragment without body #when parsed #then the tree still has body > div > p", () => { // given const source = "<div><p>unclosed" // when const doc = parseHtml(source) // then const p = byTag(doc, "p")[0] as LiteNode expect(p.parent?.tag).toBe("div") expect(p.parent?.parent?.tag).toBe("body") expect(selectorPath(p)).toBe("body > div > p") expect(proseText(p)).toBe("unclosed") }) test("#given attributes in every quoting style #when parsed #then values are decoded", () => { // given const source = `<body><a href="#s1" data-x='y & z' hidden title=bare>k</a></body>` // when const doc = parseHtml(source) // then const anchor = byTag(doc, "a")[0] as LiteNode expect(anchor.attrs).toEqual({ href: "#s1", "data-x": "y & z", hidden: "", title: "bare" }) }) }) describe("decodeEntities", () => { test("#given — #when decoded #then it is U+2014", () => { expect(decodeEntities("a—b")).toBe("a\u2014b") }) test("#given named, decimal and hex entities #when decoded #then each maps to its code point", () => { // given const source = "&<>"' –…©®×·,😀" // when const decoded = decodeEntities(source) // then expect(decoded).toBe("&<>\"'\u00a0\u2013\u2026\u00a9\u00ae\u00d7\u00b7,\u{1F600}") }) test("#given unknown or malformed entities #when decoded #then they are left verbatim", () => { expect(decodeEntities("&bogus; & &#xZZ; AT&T")).toBe("&bogus; & &#xZZ; AT&T") }) }) describe("proseText", () => { test("#given a paragraph with inline code #when prose is extracted #then code is skipped and whitespace collapsed", () => { // given const doc = parseHtml("<body><p>Run <code>npm i</code>\n then <kbd>Enter</kbd> now</p></body>") const p = byTag(doc, "p")[0] as LiteNode // when const prose = proseText(p) // then expect(prose).toBe("Run then now") }) test("#given PROSE_TAGS #when read #then it holds the prose-bearing tags", () => { expect([...PROSE_TAGS].sort()).toEqual( ["blockquote", "dd", "dt", "figcaption", "h1", "h2", "h3", "h4", "h5", "h6", "li", "p", "td", "th"].sort(), ) }) }) -
layout-probe.mjs 4.6 KB · in bundle
-
layout-probe.test.ts 4 KB
import { describe, expect, test } from "bun:test" import { parseHtml, selectorPath } from "./html-lite.mjs" import { buildProbeSource, PATH_FUNCTION_SOURCE, PROBE_VERSION, ProbeError } from "./layout-probe.mjs" // A minimal element adapter over the html-lite tree: exactly the DOM surface the path rule reads. function adapt(node, parentAdapter, cache) { if (cache.has(node)) return cache.get(node) const el = { tagName: node.tag.toUpperCase(), id: node.attrs.id ?? "", parentElement: parentAdapter, children: [] } cache.set(node, el) el.children = node.children.filter((c) => c.tag !== "#text").map((c) => adapt(c, el, cache)) return el } function pathRule() { return new Function(`${PATH_FUNCTION_SOURCE}; return ulwSelectorPath;`)() } const FIXTURE = `<!doctype html><html><head><style>p{}</style></head><body> <main id="top"><section><h2>A</h2><p>one</p><p>two</p><figure class="fig"><img src="a.png"><figcaption>c</figcaption></figure></section> <section id="results"><h2>B</h2><p>x</p><figure><svg width="300"><text>t</text></svg></figure><figure><img src="b.png"></figure></section> <section><div id="dup"></div><div id="dup"><p>y</p></div></section></main></body></html>` describe("PATH_FUNCTION_SOURCE", () => { test("#given the html-lite tree of a fixture #when the in-page path rule runs on an adapter of every element #then it equals html-lite selectorPath", () => { // given const doc = parseHtml(FIXTURE) const ids = new Map() const cache = new Map() const elements = [] const walk = (node, parentAdapter) => { if (node.tag === "#text") return const el = adapt(node, parentAdapter, cache) if (node.tag !== "#root" && node.parent) elements.push([node, el]) if (el.id) ids.set(el.id, (ids.get(el.id) ?? 0) + 1) for (const child of node.children) walk(child, el) } walk(doc, null) const rule = pathRule() // when const mismatches = elements .filter(([node]) => node.tag !== "html" && node.tag !== "head" && node.tag !== "#root") .map(([node, el]) => [selectorPath(node), rule(el, (id) => ids.get(id) ?? 0)]) .filter(([expected, actual]) => expected !== actual) // then expect(elements.length).toBeGreaterThan(10) expect(mismatches).toEqual([]) }) test("#given a duplicated id #when a descendant path is built #then the duplicate is not used as an anchor", () => { const doc = parseHtml(FIXTURE) const p = [] const find = (n) => { if (n.tag === "p" && n.parent?.attrs.id === "dup") p.push(n); n.children.forEach(find) } find(doc) expect(selectorPath(p[0])).toBe("#top > section:nth-of-type(3) > div:nth-of-type(2) > p") }) }) describe("buildProbeSource", () => { test("#given defaults #when built #then it is one async IIFE expression that parses and carries the version, cap, and image wait", () => { const src = buildProbeSource() expect(src).toMatch(/^\(async \(\) => \{[\s\S]*\}\)\(\)\s*$/) expect(src.includes('img.loading = "eager"')).toBe(true) expect(() => new Function(`return ${src}`)).not.toThrow() expect(src.includes("const CAP = 400;")).toBe(true) expect(src.includes(`probe_version: ${PROBE_VERSION}`)).toBe(true) }) test("#given cap 50 and a custom root #when built #then both are embedded as literals", () => { const src = buildProbeSource({ cap: 50, root: "article.report" }) expect(src.includes("const CAP = 50;")).toBe(true) expect(src.includes('document.querySelector("article.report")')).toBe(true) }) test("#given cap 0 or a fractional cap #when built #then a typed ProbeError is thrown", () => { expect(() => buildProbeSource({ cap: 0 })).toThrow(ProbeError) expect(() => buildProbeSource({ cap: 1.5 })).toThrow(ProbeError) }) test("#given an empty root #when built #then a typed ProbeError is thrown", () => { expect(() => buildProbeSource({ root: "" })).toThrow(ProbeError) expect(() => buildProbeSource({ root: " " })).toThrow(ProbeError) }) test("#given a root containing a double quote #when built #then the source still parses", () => { const src = buildProbeSource({ root: 'main[data-x="1"]' }) expect(() => new Function(`return ${src}`)).not.toThrow() }) }) -
outcome.mjs 10.1 KB · in bundle
-
outcome.test.ts 11.1 KB
import { describe, expect, test } from "bun:test" import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { DELIVERABLE_FORMATS, GATE_NAMES, validateOutcomeManifest } from "./contracts.mjs" import { MANIFEST_FILENAME, OutcomeError, addRender, briefingFacts, buildBriefing, deliverableState, initManifest, readManifest, setDeliverable, setGate, verifyManifest, writeManifest, } from "./outcome.mjs" const STAMP = "20260101-120000" const START_LOCAL = new Date(2026, 0, 1, 12, 0, 0) function sessionDir(): string { const dir = join(mkdtempSync(join(tmpdir(), "ulw-outcome-")), STAMP) mkdirSync(dir) return dir } function oneByteFile(dir: string, name: string): string { const file = join(dir, name) writeFileSync(file, "x") return file } function codes(problems: { code: string }[]): string[] { return problems.map((problem) => problem.code) } describe("initManifest", () => { test("#given promised pdf and docx #when initialised #then there is one pending row per format and the schema validates", () => { // given const dir = sessionDir() // when const manifest = initManifest({ sessionDir: dir, promised: ["pdf", "docx"], lane: "no-format" }) // then expect(manifest.deliverables.map((row) => [row.format, row.status, row.promised])).toEqual([ ["pdf", "pending", true], ["docx", "pending", true], ]) expect(manifest.startedAt).toBe(START_LOCAL.toISOString()) expect(Object.keys(manifest.gates)).toEqual([...GATE_NAMES]) expect(Object.values(manifest.gates).every((status) => status === "not_run")).toBe(true) expect(validateOutcomeManifest(manifest)).toEqual({ ok: true, errors: [] }) }) test("#given a session dir without a YYYYMMDD-HHMMSS basename #when initialised #then it throws a typed error", () => { // given const dir = mkdtempSync(join(tmpdir(), "ulw-outcome-")) // when / then expect(() => initManifest({ sessionDir: dir, promised: ["pdf"], lane: null })).toThrow(OutcomeError) }) }) describe("setDeliverable and verifyManifest", () => { test("#given a fresh pdf+docx manifest #when verified #then both pending rows are missing_promised_deliverable", () => { // given const manifest = initManifest({ sessionDir: sessionDir(), promised: ["pdf", "docx"], lane: "no-format" }) // when const result = verifyManifest(manifest) // then expect(result.ok).toBe(false) expect(codes(result.problems)).toEqual(["missing_promised_deliverable", "missing_promised_deliverable"]) }) test("#given pdf delivered as a 1-byte file and docx blocked with a reason #when verified #then it passes", () => { // given const dir = sessionDir() const initial = initManifest({ sessionDir: dir, promised: ["pdf", "docx"], lane: "no-format" }) // when const afterPdf = setDeliverable(initial, "pdf", "delivered", { path: oneByteFile(dir, "report.pdf") }) const afterDocx = setDeliverable(afterPdf, "docx", "blocked_capability", { reason: "no docx renderer" }) // then expect(afterPdf.deliverables.find((row) => row.format === "docx")?.status).toBe("pending") expect(codes(verifyManifest(afterPdf).problems)).toEqual(["missing_promised_deliverable"]) expect(verifyManifest(afterDocx)).toEqual({ ok: true, problems: [] }) expect(initial.deliverables.every((row) => row.status === "pending")).toBe(true) }) test("#given a delivered row whose file is missing or empty #when verified #then each path is named in problems", () => { // given const dir = sessionDir() const missing = join(dir, "gone.pdf") const empty = join(dir, "empty.html") writeFileSync(empty, "") let manifest = initManifest({ sessionDir: dir, promised: ["pdf", "html"], lane: "no-format" }) manifest = setDeliverable(manifest, "pdf", "delivered", { path: missing }) manifest = setDeliverable(manifest, "html", "delivered", { path: empty }) // when const result = verifyManifest(manifest) // then expect(result.ok).toBe(false) expect(result.problems.map((problem) => problem.path)).toEqual([missing, empty]) }) test("#given a skipped row without a reason #when verified #then it is a problem", () => { // given const base = initManifest({ sessionDir: sessionDir(), promised: ["md"], lane: null }) // when const result = verifyManifest(setDeliverable(base, "md", "skipped", {})) // then expect(codes(result.problems)).toEqual(["missing_promised_deliverable"]) }) test("#given an unpromised format #when set #then a non-promised row is appended and the promised rows survive", () => { // given const dir = sessionDir() const base = initManifest({ sessionDir: dir, promised: ["pdf"], lane: null }) // when const next = setDeliverable(base, "html", "delivered", { path: oneByteFile(dir, "r.html"), bytes: 1, pages: 1 }) // then expect(next.deliverables.map((row) => [row.format, row.status, row.promised])).toEqual([ ["pdf", "pending", true], ["html", "delivered", false], ]) expect(validateOutcomeManifest(next).ok).toBe(true) }) test("#given an unknown format #when set #then it throws a typed error naming the allowed list", () => { // given const base = initManifest({ sessionDir: sessionDir(), promised: ["pdf"], lane: null }) // when let caught: unknown try { setDeliverable(base, "epub", "delivered", { path: "x" }) } catch (error) { caught = error } // then expect(caught).toBeInstanceOf(OutcomeError) const error = caught as OutcomeError expect(error.code).toBe("unknown_format") expect(error.allowed).toEqual([...DELIVERABLE_FORMATS]) for (const format of DELIVERABLE_FORMATS) expect(error.message).toContain(format) }) }) describe("setGate and addRender", () => { test("#given a manifest #when a gate and renders are recorded #then they land without mutating the input", () => { // given const base = initManifest({ sessionDir: sessionDir(), promised: ["pdf"], lane: null }) // when const gated = setGate(base, "static", "pass") const rendered = addRender(addRender(addRender(gated, "p1.png", 1), "p2.png", 2), "p1.png", 1) // then expect(base.gates.static).toBe("not_run") expect(rendered.gates.static).toBe("pass") expect(rendered.renders).toEqual([ { path: "p1.png", page: 1 }, { path: "p2.png", page: 2 }, ]) expect(() => setGate(base, "spelling", "pass")).toThrow(OutcomeError) expect(() => setGate(base, "static", "ok")).toThrow(OutcomeError) expect(() => addRender(base, "p0.png", 0)).toThrow(OutcomeError) }) }) describe("deliverableState", () => { test("#given the draft marker, no marker, and a sibling manifest #when read #then state follows the file and the manifest", () => { // given const dir = sessionDir() const report = join(dir, "report.md") // when / then expect(deliverableState(report, null)).toBe("none") writeFileSync(report, "") expect(deliverableState(report, null)).toBe("none") writeFileSync(report, "# Report\nSTATUS: draft \u2014 3 sections open\n\nbody\n") expect(deliverableState(report, null)).toBe("partial") writeFileSync(report, "# Report\n\nbody\n") expect(deliverableState(report, null)).toBe("complete") const pending = initManifest({ sessionDir: dir, promised: ["md"], lane: null }) writeManifest(join(dir, MANIFEST_FILENAME), pending) expect(deliverableState(report, null)).toBe("partial") const done = setDeliverable(pending, "md", "delivered", { path: report }) expect(deliverableState(report, done)).toBe("complete") writeManifest(join(dir, MANIFEST_FILENAME), done) expect(deliverableState(report, null)).toBe("complete") }) }) describe("briefingFacts and buildBriefing", () => { test("#given 2 [S<n>] lines and 1 [Source 3] line on 2 hosts and now = start + 61 min #when briefed #then sources 3, domains 2, minutes 61", () => { // given const dir = sessionDir() const manifest = setDeliverable( initManifest({ sessionDir: dir, promised: ["pdf", "docx"], lane: "no-format" }), "docx", "skipped", { reason: "chat delivery" }, ) const ledger = [ "# Sources ledger", "[S1] https://alpha.example/one - primary", "[S2] https://www.beta.example/two", "[Source 3] https://alpha.example/three", "note: https://gamma.example/not-a-source", "", ].join("\n") const now = new Date(2026, 0, 1, 13, 1, 0) // when const facts = briefingFacts(manifest, ledger, now) const text = buildBriefing(manifest, ledger, now) // then expect(facts.sources).toBe(3) expect(facts.domains).toBe(2) expect(facts.minutes).toBe(61) expect(facts.deliverables).toEqual([ { format: "pdf", status: "pending", reason: null, path: null }, { format: "docx", status: "skipped", reason: "chat delivery", path: null }, ]) expect(facts.gates).toEqual(manifest.gates) expect(facts.residualDefects).toBe(0) expect(facts.repair).toBeNull() expect(typeof text).toBe("string") for (const token of ["pdf", "docx", "chat delivery"]) expect(text).toContain(token) }) }) describe("readManifest and writeManifest", () => { test("#given an existing manifest file #when overwritten #then it round-trips and leaves no temp file", () => { // given const dir = sessionDir() const file = join(dir, MANIFEST_FILENAME) const first = initManifest({ sessionDir: dir, promised: ["pdf"], lane: null }) writeManifest(file, first) // when const second = setGate(first, "visual", "fail") writeManifest(file, second) // then expect(readManifest(file)).toEqual(second) expect(readdirSync(dir)).toEqual([MANIFEST_FILENAME]) }) test("#given an invalid manifest #when written or read #then it throws a typed error and writes nothing", () => { // given const dir = sessionDir() const file = join(dir, MANIFEST_FILENAME) // when / then expect(() => writeManifest(file, { schemaVersion: 99 })).toThrow(OutcomeError) expect(existsSync(file)).toBe(false) writeFileSync(file, "{not json") expect(() => readManifest(file)).toThrow(OutcomeError) }) }) describe("round trip in a stamped session dir", () => { test("#given session dir 20260101-120000 #when init, set, gate, render, verify, brief through the file #then verify passes and the briefing is computed", () => { // given const dir = sessionDir() const file = join(dir, MANIFEST_FILENAME) writeManifest(file, initManifest({ sessionDir: dir, promised: ["html", "post"], lane: "no-format" })) expect(verifyManifest(readManifest(file)).ok).toBe(false) // when let manifest = readManifest(file) manifest = setDeliverable(manifest, "html", "delivered", { path: oneByteFile(dir, "report.html"), bytes: 1 }) manifest = setDeliverable(manifest, "post", "skipped", { reason: "chat delivery" }) manifest = addRender(setGate(manifest, "static", "pass"), join(dir, "page-1.png"), 1) writeManifest(file, manifest) const reread = readManifest(file) const ledger = join(dir, "sources-ledger.md") writeFileSync(ledger, "[S1] https://a.example/\n") const facts = briefingFacts(reread, readFileSync(ledger, "utf8"), new Date(2026, 0, 1, 12, 30, 0)) // then expect(verifyManifest(reread)).toEqual({ ok: true, problems: [] }) expect(facts.sources).toBe(1) expect(facts.minutes).toBe(30) expect(facts.gates.static).toBe("pass") expect(deliverableState(join(dir, "report.html"), null)).toBe("complete") }) }) -
repair-tracker.mjs 7.3 KB · in bundle
-
repair-tracker.test.ts 10.4 KB
import { describe, expect, test } from "bun:test" import { REPAIR_DEFAULTS, validateRepairState } from "./contracts.mjs" import { RepairTrackerError, createTracker, decide, deserializeState, isArtifactOk, serializeState, } from "./repair-tracker.mjs" const START = "2026-09-24T00:00:00.000Z" const MINUTE = 60 * 1000 const OK = { artifactOk: true } function at(minutes: number): string { return new Date(Date.parse(START) + minutes * MINUTE).toISOString() } function defects(...codes: string[]) { return codes.map((code) => ({ code, message: `${code} found` })) } type Step = { codes: string[]; minutes?: number } function runSequence(options: Record<string, unknown>, steps: Step[], extra: Record<string, unknown> = OK) { let state = createTracker({ startedAt: START, ...options }) const decisions = [] for (const [index, step] of steps.entries()) { const result = decide(state, defects(...step.codes), at(step.minutes ?? index), extra) state = result.state decisions.push(result.decision) } return { state, decisions } } describe("createTracker", () => { test("#given no budget #when a tracker is created #then it uses REPAIR_DEFAULTS and validates", () => { const state = createTracker({ startedAt: START }) expect(state.budget).toEqual({ ...REPAIR_DEFAULTS }) expect(state.attempts).toEqual([]) expect(state.consecutiveNonProgress).toBe(0) expect(validateRepairState(state)).toEqual({ ok: true, errors: [] }) }) test("#given no startedAt #when a tracker is created #then it throws instead of reading the clock", () => { // @ts-expect-error startedAt is deliberately omitted to prove the runtime guard expect(() => createTracker({})).toThrow(RepairTrackerError) }) test("#given an empty integrityCodes list #when a blocker remains at stop #then the integrity code is still blocking", () => { const { decisions } = runSequence({ maxAttempts: 1, integrityCodes: [] }, [ { codes: ["missing_citation_section"] }, { codes: ["missing_citation_section", "heading_generic"] }, ]) expect(decisions[1]).toMatchObject({ action: "block", reason: "blocking" }) }) test("#given an extra integrity code #when it remains at stop #then delivery is blocked on it", () => { const { decisions } = runSequence({ maxAttempts: 1, integrityCodes: ["em_dash_in_prose"] }, [ { codes: ["em_dash_in_prose"] }, { codes: ["em_dash_in_prose", "heading_generic"] }, ]) expect(decisions[1].action).toBe("block") expect(decisions[1].blocking.map((d: { code: string }) => d.code)).toEqual(["em_dash_in_prose"]) }) test("#given an unknown integrity code #when a tracker is created #then it throws a typed error", () => { expect(() => createTracker({ startedAt: START, integrityCodes: ["not_a_code"] })).toThrow(RepairTrackerError) }) }) describe("decide stop conditions", () => { test("#given plateauLimit 2 #when the measure stops improving twice #then the fourth decide stops with plateau", () => { // [0,3,1] -> [0,2,1] -> [0,2,1] -> [0,2,1], every signature distinct const { decisions, state } = runSequence({ maxAttempts: 5, plateauLimit: 2 }, [ { codes: ["emoji_in_prose", "em_dash_in_prose", "unsourced_number", "heading_generic"] }, { codes: ["chart_without_figure", "chart_svg_no_text", "heading_too_long"] }, { codes: ["korean_no_keep_all", "korean_serif_font", "palette_off_token"] }, { codes: ["layout_overflow", "layout_text_clipped", "en_dash_in_prose"] }, ]) expect(decisions.slice(0, 3).map((d) => d.action)).toEqual(["repair", "repair", "repair"]) expect(decisions[3]).toMatchObject({ action: "deliver", reason: "plateau" }) expect(decisions[3].residual.map((d: { code: string }) => d.code)).toEqual([ "layout_overflow", "layout_text_clipped", "en_dash_in_prose", ]) expect(state.consecutiveNonProgress).toBe(2) }) test("#given the literal blocker plateau sequence #when it stops #then the integrity codes block delivery", () => { // [2,1,0] -> [1,1,0] -> [1,1,0] -> [1,1,0] const { decisions } = runSequence({ maxAttempts: 5, plateauLimit: 2 }, [ { codes: ["missing_citation_section", "missing_closing_section", "emoji_in_prose"] }, { codes: ["missing_citation_section", "em_dash_in_prose"] }, { codes: ["missing_closing_section", "unsourced_number"] }, { codes: ["broken_asset_reference", "layout_overflow"] }, ]) expect(decisions.slice(0, 3).map((d) => d.action)).toEqual(["repair", "repair", "repair"]) expect(decisions[3]).toMatchObject({ action: "block", reason: "blocking" }) expect(decisions[3].blocking.map((d: { code: string }) => d.code)).toEqual(["broken_asset_reference"]) }) test("#given signatures A, B, A #when the third decide runs #then it stops with oscillation", () => { const { decisions } = runSequence({ maxAttempts: 5 }, [ { codes: ["emoji_in_prose", "heading_generic"] }, { codes: ["unsourced_number"] }, { codes: ["heading_generic", "emoji_in_prose"] }, ]) expect(decisions.map((d) => d.action)).toEqual(["repair", "repair", "deliver"]) expect(decisions[2].reason).toBe("oscillation") }) test("#given oscillation and plateau both hold #when decided #then oscillation wins by precedence", () => { const { decisions } = runSequence({ maxAttempts: 5, plateauLimit: 1 }, [ { codes: ["unsourced_number"] }, { codes: ["unsourced_number"] }, ]) expect(decisions[1]).toMatchObject({ action: "deliver", reason: "oscillation" }) }) test("#given maxAttempts 3 and improving measures #when the fourth decide runs #then it stops with max_attempts", () => { // [0,3,0] -> [0,2,0] -> [0,1,0] -> [0,1,0], every signature distinct const { decisions } = runSequence({ maxAttempts: 3 }, [ { codes: ["emoji_in_prose", "em_dash_in_prose", "unsourced_number"] }, { codes: ["chart_without_figure", "chart_svg_no_text"] }, { codes: ["korean_serif_font"] }, { codes: ["layout_text_clipped"] }, ]) expect(decisions.slice(0, 3).map((d) => d.action)).toEqual(["repair", "repair", "repair"]) expect(decisions[3]).toMatchObject({ action: "deliver", reason: "max_attempts" }) }) test("#given the literal blocker max_attempts sequence #when the fourth decide runs #then it blocks", () => { // [3,0,0] -> [2,0,0] -> [1,0,0] -> [1,0,0] const { decisions } = runSequence({ maxAttempts: 3 }, [ { codes: ["missing_citation_section", "missing_closing_section", "broken_asset_reference"] }, { codes: ["missing_citation_section", "missing_required_section"] }, { codes: ["missing_closing_section"] }, { codes: ["missing_citation_section"] }, ]) expect(decisions.slice(0, 3).map((d) => d.action)).toEqual(["repair", "repair", "repair"]) expect(decisions[3]).toMatchObject({ action: "block", reason: "blocking" }) }) test("#given wallClockMs 900000 and improving measures #when now is start + 15 min #then it stops with wall_clock", () => { const { decisions } = runSequence({ maxAttempts: 5, wallClockMs: 900000 }, [ { codes: ["emoji_in_prose", "em_dash_in_prose"], minutes: 0 }, { codes: ["unsourced_number"], minutes: 7 }, { codes: ["heading_generic"], minutes: 15 }, ]) expect(decisions.map((d) => d.action)).toEqual(["repair", "repair", "deliver"]) expect(decisions[2].reason).toBe("wall_clock") }) test("#given an integrity code and no stop condition #when decided #then it schedules a repair", () => { const { decisions } = runSequence({}, [{ codes: ["missing_citation_section"] }]) expect(decisions[0]).toMatchObject({ action: "repair", reason: null }) expect(decisions[0].blocking.map((d: { code: string }) => d.code)).toEqual(["missing_citation_section"]) }) test("#given a stop without a usable artifact #when decided #then it blocks with the stop reason", () => { const { decisions } = runSequence({ maxAttempts: 1 }, [{ codes: ["emoji_in_prose"] }, { codes: ["heading_generic"] }], { artifactOk: false, }) expect(decisions[1]).toMatchObject({ action: "block", reason: "max_attempts", blocking: [] }) }) test("#given zero defects #when decided #then it delivers clean", () => { const { decisions } = runSequence({ maxAttempts: 1 }, [{ codes: ["emoji_in_prose"] }, { codes: [] }], { artifactOk: false }) expect(decisions[1]).toEqual({ action: "deliver", reason: "clean", residual: [], blocking: [] }) }) test("#given a defect with an unknown code #when decided #then it throws a typed error", () => { const state = createTracker({ startedAt: START }) let caught: unknown try { decide(state, [{ code: "made_up_code", message: "x" }], at(1), OK) } catch (error) { caught = error } expect(caught).toBeInstanceOf(RepairTrackerError) expect((caught as RepairTrackerError).code).toBe("unknown_defect_code") }) test("#given an invalid now #when decided #then it throws a typed error", () => { const state = createTracker({ startedAt: START }) expect(() => decide(state, [], "not a date", OK)).toThrow(RepairTrackerError) }) test("#given a state #when decided #then the input state is not mutated", () => { const state = createTracker({ startedAt: START }) const before = serializeState(state) decide(state, defects("emoji_in_prose"), at(1), OK) expect(serializeState(state)).toBe(before) }) }) describe("isArtifactOk", () => { test("#given bytes and renders #when checked #then it needs at least 512 bytes and one render", () => { expect(isArtifactOk({ bytes: 512, renders: 1 })).toBe(true) expect(isArtifactOk({ bytes: 511, renders: 1 })).toBe(false) expect(isArtifactOk({ bytes: 4096, renders: 0 })).toBe(false) }) }) describe("serializeState / deserializeState", () => { test("#given a state after several decides #when round-tripped #then the bytes are identical and valid", () => { const { state } = runSequence({ maxAttempts: 5 }, [{ codes: ["emoji_in_prose"] }, { codes: ["heading_generic"] }]) const text = serializeState(state) const restored = deserializeState(text) expect(serializeState(restored)).toBe(text) expect(validateRepairState(restored)).toEqual({ ok: true, errors: [] }) }) test("#given a restored state #when decide continues #then it keeps the attempt history", () => { const { state } = runSequence({ maxAttempts: 5 }, [{ codes: ["emoji_in_prose"] }, { codes: ["heading_generic"] }]) const { decision } = decide(deserializeState(serializeState(state)), defects("emoji_in_prose"), at(3), OK) expect(decision.reason).toBe("oscillation") }) test("#given an invalid state file #when deserialized #then it throws a typed error", () => { expect(() => deserializeState('{"schemaVersion":1}')).toThrow(RepairTrackerError) expect(() => deserializeState("not json")).toThrow(RepairTrackerError) }) }) -
report-tools-commands.mjs 10.9 KB · in bundle
-
report-tools.mjs 1.8 KB · in bundle
-
report-tools.test.ts 10.6 KB
import { describe, expect, test } from "bun:test" import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { delimiter, join } from "node:path" import { COMMANDS, run } from "./report-tools.mjs" const SCRIPT = join(import.meta.dir, "report-tools.mjs") const FIXTURES = join(import.meta.dir, "tests", "fixtures") function sessionDir() { const root = mkdtempSync(join(tmpdir(), "report-tools-cli-")) const dir = join(root, "20260101-120000") mkdirSync(dir) return dir } function nodeWithoutBun(args: string[]) { const nodePath = Bun.which("node") if (nodePath === null) throw new Error("node not found on PATH") const path = (process.env.PATH ?? "").split(delimiter).filter((entry) => !entry.includes(".bun")).join(delimiter) return Bun.spawnSync({ cmd: [nodePath, SCRIPT, ...args], env: { ...process.env, PATH: path } }) } function defectsFile(dir: string, name: string, codes: string[]) { const file = join(dir, name) writeFileSync(file, JSON.stringify(codes.map((code) => ({ code, message: code })))) return file } describe("help and usage", () => { test("#given --help --json #when run #then it lists exactly the command matrix", async () => { const result = await run(["--help", "--json"]) expect(result.json.commands).toEqual([...COMMANDS]) expect(COMMANDS).toEqual(["check", "layout-probe", "repair decide", "outcome init", "outcome set", "outcome gate", "outcome render", "outcome state", "outcome verify", "outcome finish", "outcome briefing", "format-extract"]) }) test("#given no arguments under node #when spawned #then it exits 2 with a usage line on stderr", () => { const proc = nodeWithoutBun([]) expect(proc.exitCode).toBe(2) expect(proc.stderr.toString()).toContain("usage") }) test("#given a missing report file #when checked #then it exits 2 naming the path", () => { const proc = nodeWithoutBun(["check", "missing-report.html", "--design-spec", "missing-spec.md"]) expect(proc.exitCode).toBe(2) expect(proc.stderr.toString()).toContain("missing-spec.md") }) }) describe("check", () => { test("#given the bad fixture under node #when checked #then it exits 1 with blockers in stdout JSON", () => { const proc = nodeWithoutBun(["check", join(FIXTURES, "bad-report.html"), "--design-spec", join(FIXTURES, "design-spec.md")]) expect(proc.exitCode).toBe(1) const parsed = JSON.parse(proc.stdout.toString()) expect(parsed.command).toBe("check") expect(parsed.counts.blocker).toBeGreaterThanOrEqual(1) expect(parsed.layout).toBe("not_run") }) test("#given the good fixture #when checked #then it passes with exit 0", () => { const proc = nodeWithoutBun(["check", join(FIXTURES, "good-report.html"), "--design-spec", join(FIXTURES, "design-spec.md")]) expect(proc.exitCode).toBe(0) expect(JSON.parse(proc.stdout.toString()).status).toBe("pass") }) test("#given a layout probe file #when checked #then layout gates run and the layout field is set", async () => { const result = await run(["check", join(FIXTURES, "good-report.html"), "--design-spec", join(FIXTURES, "design-spec.md"), "--layout", join(FIXTURES, "boxes.json")]) expect(result.json.gatesRun).toContain("layout") expect(["pass", "fail"]).toContain(result.json.layout) }) }) describe("outcome", () => { test("#given a promised pdf and docx #when verified before and after setting them #then verify fails then passes", async () => { // given const dir = sessionDir() const pdf = join(dir, "report.pdf") writeFileSync(pdf, "x") await run(["outcome", "init", "--session-dir", dir, "--promised", "pdf,docx", "--lane", "no-format"]) // when const before = await run(["outcome", "verify", "--session-dir", dir]) await run(["outcome", "set", "pdf", "delivered", "--path", pdf, "--session-dir", dir]) await run(["outcome", "set", "docx", "skipped", "--reason", "no editing audience", "--session-dir", dir]) await run(["outcome", "gate", "static", "pass", "--session-dir", dir]) const after = await run(["outcome", "verify", "--session-dir", dir]) // then expect(before.exitCode).toBe(1) expect(after.exitCode).toBe(0) const manifest = JSON.parse(readFileSync(join(dir, "outcome.json"), "utf8")) expect(manifest.deliverables.find((row: { format: string }) => row.format === "pdf").path).toBe(pdf) expect(manifest.gates.static).toBe("pass") }) test("#given a ledger and a fixed now #when the briefing is asked as JSON #then counts come from the ledger and the dir stamp", async () => { const dir = sessionDir() await run(["outcome", "init", "--session-dir", dir, "--promised", "html"]) writeFileSync(join(dir, "sources-ledger.md"), ["[S1] https://a.example/x", "[S2] https://b.example/y", "[Source 3] https://a.example/z"].join("\n")) const start = new Date(2026, 0, 1, 12, 0, 0) const now = new Date(start.getTime() + 61 * 60_000).toISOString() const result = await run(["outcome", "briefing", "--json", "--session-dir", dir, "--now", now]) expect(result.json.sources).toBe(3) expect(result.json.domains).toBe(2) expect(result.json.minutes).toBe(61) }) }) describe("repair decide", () => { test("#given three minor code sets with the same measure #when decided in turn #then the third call stops on plateau", async () => { const dir = sessionDir() const state = join(dir, "repair-state.json") const reasons = [] for (const [index, code] of ["heading_generic", "heading_too_long", "en_dash_in_prose"].entries()) { const result = await run(["repair", "decide", "--state", state, "--defects", defectsFile(dir, `d${index}.json`, [code]), "--artifact-bytes", "4096", "--renders", "1", "--max-attempts", "5"]) reasons.push(result.json.decision.reason) } expect(reasons.at(-1)).toBe("plateau") }) test("#given the same defects twice #when decided #then the second call stops on oscillation", async () => { const dir = sessionDir() const state = join(dir, "repair-state.json") const file = defectsFile(dir, "d.json", ["heading_generic"]) await run(["repair", "decide", "--state", state, "--defects", file, "--artifact-bytes", "4096", "--renders", "1"]) const second = await run(["repair", "decide", "--state", state, "--defects", file, "--artifact-bytes", "4096", "--renders", "1"]) expect(second.json.decision.reason).toBe("oscillation") expect(second.json.decision.action).toBe("deliver") }) test("#given no artifact proof #when a stop condition holds #then delivery is blocked (fail closed)", async () => { const dir = sessionDir() const state = join(dir, "repair-state.json") const file = defectsFile(dir, "d.json", ["heading_generic"]) await run(["repair", "decide", "--state", state, "--defects", file]) const second = await run(["repair", "decide", "--state", state, "--defects", file]) expect(second.json.decision.action).toBe("block") expect(second.exitCode).toBe(1) }) }) describe("layout-probe", () => { test("#given --json #when run #then the source is an async IIFE", async () => { const result = await run(["layout-probe", "--json", "--cap", "20"]) expect(result.json.source.startsWith("(async () => {")).toBe(true) expect(result.json.source).toContain("const CAP = 20;") }) }) describe("manifest bookkeeping", () => { test("#given a session manifest #when repair decides deliver-on-exhaustion #then outcome.json carries the repair summary and the residual defects", async () => { // given const dir = sessionDir() await run(["outcome", "init", "--session-dir", dir, "--promised", "html"]) const state = join(dir, "repair-state.json") const file = defectsFile(dir, "d.json", ["heading_generic"]) const proof = ["--artifact-bytes", "4096", "--renders", "1", "--session-dir", dir] // when await run(["repair", "decide", "--state", state, "--defects", file, ...proof]) await run(["repair", "decide", "--state", state, "--defects", file, ...proof]) // then const manifest = JSON.parse(readFileSync(join(dir, "outcome.json"), "utf8")) expect(manifest.repair).toEqual({ attempts: 2, action: "deliver", reason: "oscillation" }) expect(manifest.residualDefects.map((d: { code: string }) => d.code)).toEqual(["heading_generic"]) const briefing = await run(["outcome", "briefing", "--json", "--session-dir", dir]) expect(briefing.json.residualDefects).toBe(1) expect(briefing.json.repair).toEqual({ attempts: 2, action: "deliver", reason: "oscillation" }) }) test("#given a clean repair decision #when recorded #then residual defects are emptied", async () => { const dir = sessionDir() await run(["outcome", "init", "--session-dir", dir, "--promised", "html"]) const state = join(dir, "repair-state.json") await run(["repair", "decide", "--state", state, "--defects", defectsFile(dir, "a.json", ["heading_generic"]), "--session-dir", dir]) await run(["repair", "decide", "--state", state, "--defects", defectsFile(dir, "b.json", []), "--session-dir", dir]) const manifest = JSON.parse(readFileSync(join(dir, "outcome.json"), "utf8")) expect(manifest.residualDefects).toEqual([]) expect(manifest.repair.reason).toBe("clean") }) test("#given a ledger and a fixed now #when the run is finished #then finishedAt, elapsedMinutes and source counts are stamped and still validate", async () => { const dir = sessionDir() const pdf = join(dir, "report.pdf") writeFileSync(pdf, "x") await run(["outcome", "init", "--session-dir", dir, "--promised", "pdf"]) await run(["outcome", "set", "pdf", "delivered", "--path", pdf, "--session-dir", dir]) writeFileSync(join(dir, "sources-ledger.md"), ["[S1] https://a.example/x", "[S2] https://b.example/y"].join("\n")) const now = new Date(new Date(2026, 0, 1, 12, 0, 0).getTime() + 30 * 60_000).toISOString() const result = await run(["outcome", "finish", "--session-dir", dir, "--now", now]) const manifest = JSON.parse(readFileSync(join(dir, "outcome.json"), "utf8")) expect(result.exitCode).toBe(0) expect(manifest.finishedAt).toBe(now) expect(manifest.elapsedMinutes).toBe(30) expect(manifest.sources).toEqual({ total: 2, domains: 2 }) expect((await run(["outcome", "verify", "--session-dir", dir])).exitCode).toBe(0) }) }) describe("format-extract exit codes", () => { test("#given a pdf reference under node #when extracted #then it exits 2 with the unsupported message", () => { const dir = sessionDir() const pdf = join(dir, "reference.pdf") writeFileSync(pdf, "%PDF-1.7") const proc = nodeWithoutBun(["format-extract", pdf, "--out", join(dir, "design-spec.md")]) expect(proc.exitCode).toBe(2) expect(proc.stderr.toString()).toContain("unsupported") }) test("#given an unreachable url #when extracted #then it exits 2", () => { const dir = sessionDir() const proc = nodeWithoutBun(["format-extract", "http://127.0.0.1:9/report.html", "--from-url", "--out", join(dir, "design-spec.md")]) expect(proc.exitCode).toBe(2) }) })
-
-
AGENTS.md 5.4 KB
# ulw-research — Research Orchestration + report-tools Runtime ## OVERVIEW `SKILL.md` is the research orchestration contract (team-first saturation, claim graph, convergence, delivery). `scripts/` is the deliverable runtime that contract calls: `report-tools.mjs`, a zero-dependency Node CLI for the deliverable phase (outcome manifest, static and layout gates, bounded repair, format extraction). `references/` holds the edition-neutral deliverable contract and the defect glossary. Earned this file: the only shared skill whose runtime is overlaid into a second, separately authored edition (the senpi native skill) at sync time. ## STRUCTURE ``` ulw-research/ ├── SKILL.md # shared-edition prose contract (router to the references) ├── references/ │ ├── deliverable-phase.md # lanes, state, destination defaults, interview, memory episodes, design spec, gates, repair, manifest, command reference │ ├── report-gates.md # one row per defect code: severity, integrity, what it detects, how to fix │ └── latex-report.md └── scripts/ ├── report-tools.mjs # CLI entry + dispatcher (COMMANDS, run, main) ├── report-tools-commands.mjs # one function per subcommand ├── cli-support.mjs / entry-guard.mjs # flags, CliError, exit codes, atomic JSON, symlink-safe entry check ├── contracts.mjs # frozen vocabulary: DEFECT_CODES (severity lives only here), enums, manifest + repair-state validators ├── outcome.mjs # outcome.json manifest, deliverable state, verify, closing briefing ├── repair-tracker.mjs # bounded repair decisions (attempts, plateau, oscillation, wall clock, integrity codes) ├── html-lite.mjs / entities.mjs # HTML tokenizer, selector paths, prose text ├── css-lite.mjs / design-spec.mjs # CSS scanning, palette + lineage parsing, design-spec rendering ├── gates-static.mjs + gates-text.mjs / gates-figures.mjs / gates-structure.mjs # static gates G1-G15 ├── layout-probe.mjs / gates-layout.mjs # in-page probe source + layout gates L1-L5 over its output ├── format-extract.mjs / format-extract-css.mjs # design-spec draft from a pointed-at HTML or Markdown document ├── *.test.ts # co-located bun tests, one per module (unshipped) └── tests/fixtures/ # good/bad reports, boxes.json, reference docs (unshipped) ``` ## SURFACE `node "$SKILL_DIR/scripts/report-tools.mjs" <command>`: `check`, `layout-probe`, `repair decide`, `outcome init`, `outcome set`, `outcome gate`, `outcome render`, `outcome state`, `outcome verify`, `outcome finish`, `outcome briefing`, `format-extract`, `--help --json`. Exit codes: `0` pass, `1` semantic failure (blockers, a failed verify, a blocked repair), `2` usage or IO. JSON on stdout, one summary line on stderr. Flags per command: `references/deliverable-phase.md` section 10. ## CONVENTIONS - Zero runtime dependencies: plain ESM `.mjs`, `node:` builtins and sibling imports only, no `Bun.*`. Runs under Node >= 20 or Bun 1.4 (global `fetch` for `format-extract --from-url`). - Each `.mjs` stays at or under 250 lines; split by responsibility, never by size. - The CLI never launches a browser. The layout probe is printed as one async expression; the orchestrator evaluates it through the browser skill's owned headless engine and passes the result back with `check --layout`. - Time is always injected (`--now`, `startedAt`); no module reads the clock inside a decision. - Tests are co-located given/when/then bun tests; CLI tests spawn `node` with bun removed from `PATH`. Fixtures live only under `scripts/tests/fixtures/`, are depersonalized, and never ship. - The senpi source dir (`packages/omo-senpi/skills/ulw-research/`) intentionally has no `scripts/`, `references/report-gates.md`, or `references/deliverable-phase.md`: the senpi sync overlays them from here byte-for-byte (`sharedAssets` in `packages/omo-senpi/plugin/scripts/native-skill-sources.mjs`), and both editions' sync suites assert the shipped copies equal these sources. - References and scripts carry no Hangul (Korean match strings are `\u` escapes) and never three consecutive newlines (the senpi sync normalizes those and would break byte equality). ## ANTI-PATTERNS - NEVER hand-edit a shipped copy under `packages/omo-senpi/plugin/skills/` or the packaged plugin tree; edit here and re-sync. - NEVER add a severity override or a caller-supplied severity. The only sanctioned exception is `unsourced_number` following the design spec's `Lineage:` mode. - Never let a gate or the repair loop silently skip: an unrunnable gate is recorded `not_run`, and an integrity code blocks delivery. - Never name a browser library in the references or `SKILL.md`; route through the browser skill. - Never test prose: pin machine-consumed values (CLI JSON, exit codes, extractor output, tracker decisions, defect-code vocabulary) and shipped-copy equality only. ## COMMANDS ```bash # from the repository root bun test --timeout 20000 packages/shared-skills/skills/ulw-research/scripts node packages/shared-skills/skills/ulw-research/scripts/report-tools.mjs --help --json node packages/omo-senpi/plugin/scripts/sync-skills.mjs && bun test packages/omo-senpi/src/skills-sync.test.ts ``` - Parent: [`packages/shared-skills/AGENTS.md`](../../AGENTS.md). -
ATTRIBUTION.md 2.4 KB
# ATTRIBUTION / NOTICE This skill (`ulw-research`, part of `@oh-my-opencode/shared-skills`) is authored by the oh-my-openagent project. One design idea is adapted from a third-party project, credited below. No third-party source is vendored here — only the verification idea is adapted into this skill's prompt contract. --- ## 1. insane-research (fivetaku) — inspiration for the claim-graph verification gate The non-code claim-graph verification gate (Phase 3b: a data-flow-lock where the synthesis may assert a high-risk non-code claim only after it clears `>= 2 independent source domains + 1 counter-search + a primary source`, otherwise it is abstained to an unresolved/refuted annex) is inspired by the data-flow-lock verification design in **insane-research** by fivetaku. - Source: https://github.com/fivetaku/insane-research - License: MIT (declared in the project's `README.md`). - **What is adapted:** the IDEA only — a verification gate whose output is the sole allowlist the synthesis draws from, so skipping verification leaves nothing to synthesize. No insane-research code is copied or redistributed. The upstream gate is a Python checker (`validate_ledger.py`); this skill translates the concept into a runtime-agnostic prompt + message-text contract, because the Codex Light edition has no guaranteed Python. ``` MIT License Copyright (c) 2026 fivetaku Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -
SKILL.md 54 KB
--- name: ulw-research description: "Runs maximum-saturation research with a cooperating team, claim-graph gating, and a cited, QA'd deliverable. Use when the user explicitly asks for research or a deep investigation, including any 'ulw' research wording." --- ## Codex Harness Tool Compatibility This skill may include examples copied from the OpenCode harness. In Codex, do not call OpenCode-only tools such as `call_omo_agent(...)`, `task(...)`, `background_output(...)`, or `team_*(...)` literally. Translate those examples to Codex native tools: | OpenCode example | Codex tool to use | | --- | --- | | `call_omo_agent(subagent_type="explore", ...)` | `multi_agent_v1.spawn_agent({"message":"TASK: act as an explorer. ...","agent_type":"explorer","fork_context":false})` | | `call_omo_agent(subagent_type="librarian", ...)` | `multi_agent_v1.spawn_agent({"message":"TASK: act as a librarian. ...","agent_type":"librarian","fork_context":false})` | | `task(subagent_type="plan", ...)` | `multi_agent_v1.spawn_agent({"message":"TASK: act as a planning agent. ...","agent_type":"plan","fork_context":false})` | | `task(subagent_type="oracle", ...)` for final verification | `multi_agent_v1.spawn_agent({"message":"TASK: act as a rigorous reviewer. ...","agent_type":"lazycodex-gate-reviewer","fork_context":false})` | | `task(category="...", ...)` for implementation or QA | `multi_agent_v1.spawn_agent({"message":"TASK: act as an implementation or QA worker. ...","fork_context":false})` | | `background_output(task_id="...")` | `multi_agent_v1.wait_agent(...)` for mailbox signals | | `team_*(...)` | Use Codex native subagents via `multi_agent_v1.spawn_agent` and `multi_agent_v1.wait_agent`; use `multi_agent_v1.send_input` and `multi_agent_v1.close_agent` only when exposed in the active tools list | Role-specific behavior must be described in a self-contained `message`. Use `fork_context: false` to start the child with only the initial prompt (no parent history); use `fork_context: true` only when full parent history is truly required. Include any required conversation context, files, diffs, constraints, and requested skill names directly in the spawned agent's `message`. OMO installs these selectable agent roles into `~/.codex/agents/`: `explorer`, `librarian`, `plan`, `momus`, `metis`, `lazycodex-code-reviewer`, `lazycodex-qa-executor`, and `lazycodex-gate-reviewer` — pass the matching name as `agent_type` so the child gets that role's model and instructions. If the spawn tool exposes no `agent_type` parameter, omit it and describe the role inside `message`. If a code block below conflicts with this section, this section wins. Codex exposes ONE of two subagent tool surfaces per session; check your own tool list and route accordingly. If `multi_agent_v1.*` tools exist, use the table above as written. If instead a flat `spawn_agent` with a required `task_name` exists (`multi_agent_v2`), rewrite every `multi_agent_v1.*` example: `multi_agent_v1.spawn_agent({...,"fork_context":false})` becomes `spawn_agent({"task_name":"<lowercase_digits_underscores>","message":...,"agent_type":...,"fork_turns":"none"})` (`"all"` only when full parent history is truly required); `send_input` becomes `send_message`; do not call `close_agent`/`resume_agent` (finished agents end on their own; `followup_task` re-tasks one, `interrupt_agent` stops one); `wait_agent` takes only `timeout_ms` and returns on any child mailbox activity. `agent_type` works the same on both surfaces. If a code block below conflicts with this section, this section wins. For work likely to exceed one wait cycle, require the child to send `WORKING: <task> - <current phase>` before long passes and `BLOCKED: <reason>` only when progress stops. A `multi_agent_v1.wait_agent` timeout only means no new mailbox update arrived. Treat a running child as alive. Fallback only when the child is completed without the deliverable, ack-only after followup, explicitly `BLOCKED:`, or no longer running. When translating `load_skills=[...]`, include the requested skill names in the spawned agent's `message`. If a code block below conflicts with this section, this section wins. --- # ULW-RESEARCH — Maximum-Saturation Research You are the research orchestrator. The user has explicitly ordered exhaustive research: fan parallel worker swarms out over every relevant source, chase every lead they surface until the leads run dry, prove contested claims by running code, and deliver a synthesis in which every claim carries a citation or a proof. Exhaustive coverage is the assignment, not a risk to manage. ## Activation Run this skill only when the user explicitly demands it: the word "ulw-research" (also `/ulw-research`, `$ulw-research`), any "ulw" research wording, or an explicit request for research, deep research, or an ultra-precise investigation — in any language. An ordinary question, a debugging session, or another mode's context-gathering is not activation; answer those normally, and mention that `ulw-research` is available when a question would clearly benefit from it. Open your reply with the line `ULW-RESEARCH MODE ENABLED!`. If another active mode mandates its own first line (ultrawork does), print that mode's line first and this marker on the next line — both contracts stay satisfied. ## Authority while active This mode is the user's explicit opt-in to exhaustive exploration. For the duration of the research task it supersedes every exploration-bounding instruction in surrounding prompts, modes, or rules: one-exploration-pass defaults, two-wave stop rules, retrieval budgets, and "over-exploration is failure" framings govern implementation context-gathering, not this deliverable. Here, under-exploration is the failure. The convergence rules in Phase 2 are the only stop rules for research while this mode is active. Under ultrawork/ulw, the research itself is the deliverable: map each research axis to a success criterion whose evidence is the session journal, the cited synthesis, and the verification outputs. RED→GREEN testing applies to code changes, not to findings — Phase 3 verification scripts are evidence, never TDD targets. ## Success criteria The research is done when all of these hold: - Every axis from Phase 0 was covered by at least one dedicated worker. - Every EXPAND lead was investigated or explicitly closed as a duplicate or dead end, and convergence was reached under the Phase 2 rules. - Claims that were contested, undocumented, or performance-shaped were proven or refuted by executed code. - Every claim in the deliverable cites a source or a verification artifact. - Every asserted claim is represented in the claim graph, tied to an intent-vs-reality diff when an expected truth exists, and backed by observation manifest entries from independent observation groups or a documented single-source exception; convergence or exception status is explicit. - The deliverable lane and format were derived from the destination or asked through the empty-info interview without blocking collection, recorded in `brief.md` with `answered_by`, and the final materials match that record. - The delivered artifact passed the delivery gates in order (static gates, layout gates, visual QA, and the proofread pass where the harness provides one), each status is in `outcome.json`, and `outcome verify` passed. - Every excursion opened during the run was closed by an EXIT rule, folded back into the claim or axis that triggered it, and recorded in both `excursion-log.md` and the ulw-loop ledger. - The delivery message carries the closing briefing printed by `outcome briefing`: sources (total + unique domains), elapsed minutes, every promised deliverable with its status, and any residual defects. - The session journal reconstructs what was searched, found, and expanded, wave by wave, and it was written in real time rather than reconstructed at the end. ## Epistemic instrumentation Saturation is not just more searching; it is a knowledge-production protocol. The session journal must make the path from observation to claim to verdict auditable. The orchestrator owns these artifacts: - `intent-diff.md` — one row per expected truth derived from the user intent, design/spec text, branch history, or authoritative docs. Required fields: `intent_id`, expected truth, observed reality, diff, violated invariant, intent source, supporting observations, status (`true`, `violated`, or `unknown`), and linked claim ids. - `claim-graph.md` — the single claim store; one node per claim. Required fields: `claim_id`, statement, claim type, risk tier, scope, intent ids, supporting observations, contradicting observations, independent observation groups, convergence status, counter-search result, primary source backing, dependencies, status (`supported`, `partial`, `refuted`, or `unresolved`), and final synthesis location. High-risk non-code nodes that clear the Phase 3b gate are mirrored into a `verified-claims` digest section at the top of the file — the sole allowlist the synthesis draws non-code claims from. - `observation-manifest.md` — one row per observation. Required fields: `observation_id`, source path or URL, evidence layer, observer group, independence basis, observer, `observed_at`, `valid_at` or `claim_valid_at`, artifact path, quote or line anchor, and contamination notes. - `verification-economics.md` — one row per proof decision. Required fields: claim, risk, error cost, verification cost/time, chosen verification path, defer/verify decision, outcome, and residual risk. - `cause-disappearance.md` — one row per causal finding. Required fields: cause id, expected truth, previous observation, `last_seen`, disconfirming observation, replacement cause if any, current status, and whether the violation is no longer observed. Observation candidates and claim candidates travel back from workers as message text. The orchestrator writes the instrumentation artifacts, links candidates into the intent diff and claim graph, and records where each observation entered the synthesis. A conclusion is not ready for final materials until its expected truth/reality diff is closed or marked unknown, its claim node exists, and its independent-observation convergence status is supported or explicitly excepted. ## Run the swarm as a cooperating team Saturation research defaults to teammode, not isolated fire-and-forget workers: a lead one worker surfaces almost always reshapes what another should search next. When your harness gives you real cooperating members — Codex: the `teammode` skill (MultiAgentV2 agents, or `codex_app` threads as its fallback); OpenCode: `team_mode` — run this swarm as a team. Fall back to the background-worker swarm below only when team mode is unavailable, or the axes are genuinely independent with no cross-pollination expected. - **One member per axis — by part, ownership, or perspective, never a job title.** Each Phase 0 axis is one member owning one concrete slice: a codebase part, a source territory, or a question lens. No two members share an angle. "Backend researcher" or "the web person" gives no real boundary and invites overlap — name what the member owns. - **Always the maximum roster.** The team is not sized by taste: fill every member slot your harness allows (OpenCode `team_mode` caps members at 8; Codex teammode has its own ceiling) on every run. If you can only name five axes, split the broadest one — by source territory, by time window, by perspective — until the roster is full. A half-empty team is a half-covered topic. - **Compose deliberately across everything you can spawn.** Before writing the roster, enumerate what this session actually has — every delegation tier the harness or config defines and every spawnable role — and give each slot the cheapest tier that can do ITS job: broad recon on fast tiers, contested analysis on deep tiers, attack lanes on the strongest reasoning tier. Mixed tiers by design, never one tier across the whole board. - **Routing words from the user are literal.** "quick", "fast", "deep", "all quick", "max parallel" — in any language — are hard instructions, not mood. Route exactly as asked and journal `requested tier -> spawned worker -> fallback reason` for every slot. Silently promoting a "quick" roster to a heavier tier is a defect, and so is dropping to a cheaper one without saying why. - **Debate members are mandatory for ultradebate/hyperdebate, default otherwise.** At least one skeptic or red-team perspective attacks claims, evidence quality, source independence, synthesis structure, and report choices before they reach the deliverable. When the user says ultradebate or hyperdebate, run at least two attacking perspectives and give every contested claim a full round. **One team, or a sequence — decided by scale and precision.** One team is the floor, not the ceiling. When the brief shows 6+ axes, several source territories, or a long final document — or a wrong claim is expensive (legal, medical, financial, procurement, public-facing) — run the research team to convergence, disband it, and stand up a REFINEMENT team from your strongest reasoning tiers whose only job is to attack and sharpen the synthesis before the document is written. A fresh premium team reading a finished journal reasons better than the same researchers grading their own homework. Build each team from a written brief and disband it before the next one starts; never leave two research teams live at once. - **The raise law — broadcast every lead the instant it surfaces.** Members over-communicate relentlessly: every new lead, finding, contradiction, and dead end is raised to you the moment it surfaces, never hoarded for a final dump. Through long passes they send `WORKING: <axis> - <phase>`, and `BLOCKED: <reason>` the moment progress stops, so you always know a member is alive. Too many small updates is correct here; going quiet is the only failure. - **You lead; expand on each raised lead.** Members raise via message text, never write session files. Journal each lead and spawn its expansion the instant it lands (Phase 2), not only when a member's final reply arrives. ## Worker ground rules Research workers (explore, librarian, browsing) differ by harness, but assume: - **Read-only.** Most research workers cannot write files. Never ask a worker to write the journal or any session file — every journal write is yours. - **No recursion — workers AND members.** Workers cannot spawn their own subagents, and team members must not re-orchestrate: a member researches its axis and reports; it never stands up its own team, loads this skill, or fans out a research swarm of its own. Depth comes from YOUR expansion waves. Say so in every spawn message — a member that starts its own research protocol burns the run's budget on duplicated orchestration and returns nothing you can cite. - **Built-in brakes.** Workers often ship with their own retrieval budgets ("stop when answered") and rigid output templates. Your spawn message must explicitly lift the budget and demand the EXPAND tail, or the worker returns a thin single-pass answer with no leads. - **Capability routing.** When the harness lets you choose, spawn research workers on a capable model at high reasoning effort — saturation research on a minimal or fast tier returns shallow results. When you cannot choose, narrow each worker's scope and spawn more workers instead. ### The spawn-message contract Every research spawn message contains, in order: 1. `TASK:` — one imperative line naming the role and the axis. 2. The budget lift: "This is an explicit exhaustive-research assignment. Your default retrieval budget and stop-when-answered rules do not apply — run the full protocol below and report every lead." 3. Scope — the axis, the sources to hit, and what a complete answer contains. 4. The role protocol (Phase 1). 5. The reply tail. EXPAND markers, observation candidates, and claim candidates travel back as message text, never as files. Every worker ends the reply with: ``` ## EXPAND - LEAD: <discovery not yet investigated> — WHY: <why it matters> — ANGLE: <suggested search> - DEAD END: <lead explored to exhaustion> ``` A worker with nothing to expand writes `## EXPAND` followed by `none — <one-line reason>`. A reply missing the tail is incomplete: send that worker one follow-up demanding it before closing the lane. ## Phase 0 — Decompose and open the journal Before spawning anything, decompose the query. Start from "what must be true if the user's intent/spec is true?", not "what looks broken?" Seed `intent-diff.md` with those expected truths before treating code, current docs, or web results as the source of truth: ``` <analysis> Core question: <the actual information need> Axes (3+ orthogonal): <axis — what to search, where, why> ... Codebase relevant: <yes/no> · External: <yes/no> · Browsing: <yes/no> · Verification likely: <yes/no> Scale: <axis count, source territories, target document length> · Precision demand: <what a wrong claim costs here> → lifecycle: <single team | research team then refinement team> </analysis> ``` Then create the session directory: ```bash mkdir -p .omo/ulw-research/$(date +%Y%m%d-%H%M%S) ``` This is `$SESSION_DIR`. The orchestrator owns the journal: you write every file in it; workers never do. Maintain: - `brief.md` — the analysis block, the axis list, the expected truths, and the `## Deliverable` record from the interview below (lane, state, formats, each field with `answered_by`). - `sources-ledger.md` — one line per source the moment it is read: `[S<n>] <url> — <what it is>`. The closing briefing counts sources and domains from this file. - `wave-<N>-<kind>-<axis>.md` — your digest of each worker return: key findings, sources with URLs, and the worker's EXPAND markers verbatim. - `expansion-log.md` — per wave: workers spawned, markers gained, leads opened and closed. - `excursion-log.md` — one ENTER row and one EXIT row per excursion: `excursion_id`, parent claim or axis, ENTER trigger, depth, workers spent, the EXIT rule that closed it, what it changed in the top-level answer (`none` is a valid, required answer), and the ulw-loop steer/evidence id it was mirrored into. - `intent-diff.md` — orchestrator-owned expected-truth ledger comparing intent/spec/history to observed reality. - `claim-graph.md` — orchestrator-owned claim graph linking every final assertion to observations, counterevidence, dependencies, and verdict. - `observation-manifest.md` — orchestrator-owned observation manifest with `observed_at`, temporal validity, artifact paths, and contamination notes. - `verification-economics.md` — proof-cost ledger mapping claim risk to verification path, deferral decisions, and residual risk. - `cause-disappearance.md` — cause ledger tracking expected truth, previous observation, `last_seen`, disconfirming observation, and whether the violation is no longer observed. - `verify-<slug>.md`, `SYNTHESIS.md`, `REPORT.*` from later phases. Append each digest the moment its worker returns, not in a batch at the end — the journal is your recovery point after context loss and the user's audit trail. ### Run it as a loop, and journal in real time ulw-loop is ON by default for this mode: when the `ulw-loop` skill is available, register the research axes as loop goals so the run has durable state and survives a compaction. The session directory's timestamp is the run's start clock — the closing briefing is computed from it, so create it once and never rename it. From that point every finding, source, quote, number, and lead is written into `$SESSION_DIR` **the instant it lands** — never held in the conversation for an end-of-run dump. After any context loss, re-read the brief and the journal before doing anything else, then resume from the open wave. ### Deliverable lane, format, and the empty-info interview Never block collection on the shape of the deliverable, and never guess it either. Read [references/deliverable-phase.md](references/deliverable-phase.md) sections 1-6 before writing the brief, then: 1. **Derive first.** Name the lane (`template-strict`, `template-vibe`, `no-format`, `edit-existing`) and the promised formats from the request and its destination (the reference's section 3). When the request refers to an existing deliverable, decide its state with `node "$SKILL_DIR/scripts/report-tools.mjs" outcome state --deliverable <path> --session-dir "$SESSION_DIR"`: `partial` resumes the skeleton on disk, `complete` means edit-existing and never a regenerated report. `$SKILL_DIR` is this skill's own directory, the folder containing this SKILL.md. 2. **Read the requester's format memory** with the memory tool when the harness exposes one: the pointer `system/human/report-style.md`, then `reference/human-report-style.md`; take the choice recorded for the most similar context (same destination kind and audience) as the first option. 3. **Ask only what is still missing**: at most the reference's three questions (destination and format, audience and length, template lineage) in one call to the harness question tool (`ask_user_question` or `request_user_input`), non-blocking where the harness supports it, each with its default first, a free-text "describe the format" path, and "don't care, you decide". Collection starts without waiting; a late answer is folded in until the assembly worker starts, and after that it becomes a re-render request. 4. **Record it.** Write `## Deliverable` into `brief.md` (lane, state, formats, destination, audience, template, format description, each with `answered_by: user|default|request`) and open the manifest in the same step: `node "$SKILL_DIR/scripts/report-tools.mjs" outcome init --promised <formats> --lane <lane> --session-dir "$SESSION_DIR"`. Phase 5 opens by turning the recorded fields into `design-spec.md`. ## Phase 1 — Saturation wave **When the user asked for MASS research, the wave is sized by the topic's angles, not by the roster ceiling.** "mass ulw research", "mulw research", "ulw mass research" — in any language — order over-collection that a team of 8 cannot produce. Where the harness has a dependency-graph surface (the `mass-ulw` skill and its `references/planning.md`), run collection through it instead: a 60+ node opening wave covering every angle the topic has, routed across the whole difficulty ladder in one graph — mechanical sweeps and per-item harvest batches on the cheapest tier, judgment-shaped angles a step up, cross-territory angles a step above that, and the deepest tier reserved for genuinely hairy contradictions. Each wave's EXPAND leads define the next wave's nodes, and the synthesis reduces through several parallel architect-tier nodes into ONE architect-tier reducer (the strongest reasoning tier substitutes wherever no architect tier exists). Absent that surface, hold the same shape with background workers in batched waves. Everything else in this skill still binds: the deliverable interview, the journal, the claim graph, the convergence rules, and the delivery gates. Otherwise launch the entire first wave in one turn — every axis at once, as team members if you formed a team, else as background workers. Sequential launches and "start with one and see" defeat the mode. Scaling floor — more angles always justify more workers: | Query scope | explore | librarian | browsing | repo-dive | floor | |---|---|---|---|---|---| | Single topic, codebase only | 3 | 0 | 0 | 0 | 3 | | Single topic, web only | 0 | 4 | 1 | 1 | 6 | | Single topic, both | 2 | 3 | 1 | 1 | 7 | | Multi-faceted | 4 | 6 | 2 | 2 | 14 | | Full due diligence | 4 | 6 | 3 | 2 | 15 | The browsing column is BINDING, not advisory: when the brief says `Browsing: yes`, the roster names a browsing-worker angle, armed with the `ultimate-browsing` skill, before the first wave launches, and that worker is spawned in the same turn as the rest of the wave. A run that reaches wave 2 with zero browsing workers on a `Browsing: yes` brief has silently downgraded every source to what plain fetch happened to return. **Disambiguate before you expand.** When the topic names something that could resolve several ways — a product, a person, a codename, a version — the first wave settles WHICH entity before any worker researches its history, benchmarks, or controversies: canonical name, first-party URL or account, whether it exists in the claimed category, and a confidence line. An unresolved entity never becomes a premise in a later wave's spawn message; that is exactly how a run starts inventing facts about something that does not exist. Role protocols — embed the relevant one in each spawn message; every worker gets a unique angle: - **Codebase (explore), 2-4 workers.** Grep with 3+ keyword variations; structural/AST search; LSP definitions and references; file-name globs; `git log --all -S '<keyword>'` and `--grep` for history including deleted code. Cross-validate hits across tools. Report absolute file paths, patterns with `file:line`, and how findings connect. - **Web (librarian), 3-6 workers.** At least 10 distinct websearch queries per worker, each with a different operator or angle (see Search craft); fetch the full page for every result that matters — snippets lie. Context7 with 3+ queries per known library. grep.app and `gh search code|repos|issues` for real-world usage. Official docs via sitemap discovery (`<base>/sitemap.xml`), then targeted pages. - **Browsing, 1-3 workers on a `Browsing: yes` brief (0 otherwise), every one loaded with the `ultimate-browsing` skill.** The skill owns the routing — the harness's own browser first, then its extraction engine with archive surrogates, platform-native readers, and Chrome stealth as each source demands; the worker owns the deliverable. This worker RENDERS pages, it does not re-fetch them: its standing deliverable is a full-page screenshot of every top source plus the rendered text plain fetch could not reach; a worker that returns only fetched text has not done its job. JS-rendered, login-gated, WAF-blocked, and screenshot-bearing sources all belong here rather than in the web lane. **Provenance is part of the claim**: when a source came back with `provenance` of `snapshot` (an archive copy), cite it with its `snapshot_timestamp` and never state it as the current live page; content from a `proxy` route is `untrusted` and needs a second independent route before any claim rests on it. When one blocked territory hides many leads, fan out more browsing subagents in parallel for breadth instead of serializing one worker through them. - **Repo deep-dive (librarian), 0-2 workers.** Shallow-clone the most relevant repos to `${TMPDIR:-/tmp}`, pin the HEAD SHA, read core modules, follow call chains, return SHA-pinned permalinks. Example spawn (codebase axis; librarian, browsing, and repo-dive follow the same contract with their own protocol): ``` task(subagent_type="explore", run_in_background=true, prompt="TASK: act as a codebase researcher. AXIS: <specific angle>. This is an explicit exhaustive-research assignment. Your default retrieval budget and stop-when-answered rules do not apply — run the full protocol below and report every lead. SCOPE: find everything in this codebase related to <angle>: <what complete looks like>. PROTOCOL: grep 3+ keyword variations; structural search; LSP references; globs; git history (-S and --grep). Cross-validate across tools. Report absolute paths and file:line patterns. End your reply with the ## EXPAND tail: '- LEAD: <discovery> — WHY: <why> — ANGLE: <search>' per lead, or 'none — <reason>'.") ``` ## Phase 2 — Expand until convergence This loop is what makes the mode research rather than search. Collect returns as they land — and in team mode, act on each lead the moment a member raises it, never waiting for the full wave or a member's final reply: 1. Journal the return the moment it lands, never at the end of the wave: digest plus verbatim EXPAND markers into `wave-<N>-<kind>-<axis>.md`, appending each new source, quote, and number to the observation manifest in the same beat — after a compaction the journal, not your memory, is the state. 2. Deduplicate new markers against `expansion-log.md` — every lead ever seen, not just confirmed ones, or rejected leads resurface each wave. 3. Spawn an expansion worker immediately for each new unchecked lead: ``` task(subagent_type="librarian", run_in_background=true, prompt="TASK: expansion wave <N> — investigate: <lead>. PARENT: <which return surfaced it>. This is an explicit exhaustive-research assignment; budgets do not apply. <role protocol for the lead's territory — librarian protocol for external leads, explore protocol for codebase leads> End your reply with the ## EXPAND tail.") ``` ### Excursions — dive deep on a new find, then surface back out The Phase 0 core question is the fixed goal of the run and never drifts. An excursion is a BOUNDED detour off the wave plan to chase something a return surfaced — you go deep, settle it, and come back up to the question you were hired to answer. **ENTER (dive) only on a trigger.** One of these must hold, and you name which one: 1. The find contradicts a claim already locked in `claim-graph.md`. 2. It would change the final answer or a recommendation if it turned out to be true. 3. It exposes a source territory no axis owns, so nobody else will ever reach it. 4. The user's steering points at it — their words are the trigger, quoted verbatim. Interest alone is not a trigger. Anything without one stays a queued lead in `expansion-log.md`, and the wave plan continues. **Budget the dive before you take it.** State the worker count and the probe count for this level in the ENTER row. An excursion may spawn at most ONE nested sub-excursion; a third level means the thing has become its own research question — surface immediately and either promote it to a real axis with its own worker or record it as an out-of-scope gap in `SYNTHESIS.md`. When the `ulw-loop` skill is driving the run, a promotion is `omo-agent-toolkit ulw-loop steer --kind add_subgoal --title "<axis>" --objective "<what it must answer>" --evidence "<what surfaced it>" --rationale "<why the plan changes>"`. **EXIT (surface) the moment any of these holds** — you do not need all of them: - The ENTER trigger is resolved: the claim is confirmed, refuted, or its dependency is closed. - Two consecutive probes changed nothing in the parent answer. - The finding stops moving any claim's status — diminishing return is an exit, not a reason to push harder. - The level's stated budget is spent. **Fold back on the way out.** Every EXIT writes one line saying what the excursion changed in the top-level answer, and `none — <reason>` is a legitimate, required outcome; an excursion whose result is silently dropped is a lost run. Update the parent claim node or axis digest with the result, then mirror the whole excursion into the loop ledger — `omo-agent-toolkit ulw-loop steer --kind annotate_ledger --evidence "<what the excursion observed>" --rationale "<what it changed, or none>"`, and `omo-agent-toolkit ulw-loop record-evidence --goal-id <id> --criterion-id <id> --status pass|fail|blocked --evidence "<artifact>"` when it settled a criterion. After a compaction, `omo-agent-toolkit ulw-loop status --json` plus `excursion-log.md` tell you which excursions are still open. **Anti-drift.** After every EXIT, re-read the core question in the journal and confirm the run still answers it. Three consecutive excursions that changed nothing end excursions for the run: converge on what you have. 4. Record the wave in `expansion-log.md`: spawned, markers gained, leads opened/closed. 5. **Relay the user's steering to everyone.** When the user changes scope, cadence, target sources, language, or format mid-run, broadcast it to every live worker and member immediately and record the exact wording in `expansion-log.md`. Steering only you saw silently splits the swarm's assignment from the user's actual ask. **Convergence — the only stop rules while this mode is active.** Run at least 2 expansion waves on any multi-faceted query before claiming convergence; then stop only when one holds: - Zero unchecked leads remain — each investigated or closed as duplicate/dead end. - 3 consecutive waves produced no new actionable leads. - Expansion depth reached 5 waves — pause, show the open leads, and ask the user whether to extend. **Never end the run on a worker's completion.** Workers finishing is not the deliverable; your synthesis is. Reserve the last fifth of the run's context and time for Phases 4-5 and stop opening waves the moment that reserve is threatened. A converged answer with two open leads beats nine finished workers and no report. ## Phase 3 — Verify contested claims by running code Settle with executed code, not judgment, whenever sources disagree, a behavior is undocumented, a claim is performance- or compatibility-shaped, or the honest answer is "it should work". Spawn one verification worker per claim: ``` task(category="deep-low", run_in_background=true, prompt="TASK: verify by execution: <claim>. SOURCE: <where it came from>; CONTRADICTION: <opposing source, if any>. Write a minimal self-contained script that tests the claim; run it (uv run --with <deps> python / bun / direct compile); capture full stdout+stderr; pin versions. Reply with: the exact code, the full output, environment (OS, runtime, dependency versions), and a verdict — CONFIRMED / REFUTED / PARTIAL — grounded in the output.") ``` Journal each verdict to `verify-<slug>.md`. ## Phase 3b — Lock non-code claims through the claim graph Code settles code-shaped claims (Phase 3). Numeric, market-share, legal, dated, causal, and financial claims cannot be run — so they pass through a data-flow-lock instead (the verification idea adapted from fivetaku/insane-research): the synthesis may assert a high-risk non-code claim **only** if it cleared this gate, and the gate's output is the sole allowlist the synthesis draws from. Skip the gate and there is nothing to synthesize — the lock is self-enforcing. The claim graph is orchestrator-owned. Workers only return verified-claim markers, observation candidates, and claim candidates as message text, the same channel as EXPAND markers — never a file. As leads resolve, you record one node per asserted claim in `claim-graph.md` and compute its status; workers report claim candidates in their replies, and you decide. The graph is the single claim store: final synthesis may not draw from free-form claims that skipped it. A high-risk claim clears the gate to `verified-claims` only when all hold: - **>= 2 independent source domains** corroborate it (two pages on the same domain count once). - **>= 2 independent observation groups** converge on it, unless the graph records why a primary-only source is the correct single-source exception. - **One counter-search** actively looked for a refutation and did not find a stronger one. - **A primary source** (the standard, filing, dataset, or first-party doc) backs it, not only secondary commentary. - **Temporal evidence is explicit**: each supporting observation records `observed_at` and either `valid_at` or `claim_valid_at`, so branch-only, historical, release, and current-runtime claims cannot be conflated. Anything that fails goes to an `Unresolved` (insufficient evidence) or `Refuted` (counter-search won) annex — abstention is a correct outcome, not a gap to paper over. Record each gate outcome on the claim node itself — risk tier, independent source domains, counter-search result, primary source backing, and status — and mirror the cleared nodes into the `verified-claims` digest section at the top of `claim-graph.md`. Worker reply marker (message text, same channel as EXPAND): ``` ## CLAIMS - CLAIM: <non-code assertion> — RISK: high|normal — SOURCES: <domain1, domain2> — COUNTER: <refutation search result> — PRIMARY: <primary source or none> ``` ## Phase 4 — Synthesize After convergence and all verifications, re-read the whole journal, start from `intent-diff.md`, `claim-graph.md`, and `observation-manifest.md`, then write `SYNTHESIS.md`: ``` # ULW-Research Synthesis: <query> Workers: <total> · Waves: <count> · Excursions: <count> · Sources: <count> (<unique domains> domains) · Verifications: <count> · Elapsed: <minutes> min ## Executive summary — 2-3 paragraphs answering the core question ## Findings by theme — per theme: consensus, evidence links, key quote (<20 words, attributed), verified yes/no ## Codebase findings — absolute paths with line references ## Sources (ranked) — URL, what it contains, reliability, access date ## Verified claims — code: claim | verdict | verify-<slug>.md · non-code: only rows cleared into verified-claims ## Epistemic instrumentation — intent-vs-reality diff closure, claim graph coverage, observation manifest coverage, independent-observation convergence, verification economics summary, cause-disappearance records ## Contradictions — source A vs source B, resolution with evidence ## Gaps — what saturation could not answer · unresolved/refuted claim-graph nodes ## Expansion trace — per wave: workers → markers; convergence reason ``` `SYNTHESIS.md` is the citation source of truth for final materials: every claim carries inline `[Source N]` citations, and every high-risk non-code claim you assert must be a verified-claims row from Phase 3b. Assert nothing the gate left in the unresolved/refuted annex. **Write the skeleton early and fill it as claims lock.** The moment the brief records the deliverable, create the deliverable file with its section headings and a `STATUS: draft — <n> sections open` line as line 2 (an HTML comment in HTML); that line is the partial-state marker `outcome state` reads, removed only when the deliverable is complete. An interrupted run must leave a partial report on disk, never an empty directory and a lost conversation. **Keep sourced numbers, assumptions, and derived results visibly apart.** Every quantitative claim carries its lineage: `MEASURED` (a number a source states, cited), `ASSUMED` (a coefficient, distribution, or scope you chose — say why), `DERIVED` (computed from those, showing the formula), plus a sensitivity line whenever the assumption moves the answer. Presenting a derived estimate with the confidence of a measured one is the most damaging thing this mode can ship. **Search in English, deliver in the user's language.** Retrieval stays English-first (Search craft), but the synthesis and every final material are written in the language the user wrote to you in unless they ask otherwise — and a translated report still quotes its original-language sources verbatim. ## Phase 5 — Final materials The promised formats recorded in `brief.md` and `outcome.json` are binding; `pdf` + `docx` is the default pair only when a document was asked for with no format word and no destination: | Target | How | |---|---| | PDF (default) | Author the report as one self-contained HTML file, then print it headless: `chrome --headless --disable-gpu --no-pdf-header-footer --print-to-pdf=<out.pdf> file://<report.html>`. Embed the `design-spec.md` fonts as real webfonts (CJK included) instead of trusting system fallbacks. `uv run --with weasyprint python` is the fallback renderer. | | DOCX (default) | `pandoc <report.md> -o <out.docx>`, adding `--reference-doc=<template.docx>` when the user has a house style; `uv run --with python-docx python` when pandoc is unavailable. Charts and Mermaid renders go in as images. | | Slides / deck | `uv run --with python-pptx python` — one claim per slide, a chart or diagram per claim. | | Standalone HTML / Markdown | The authored source itself. | **Write `design-spec.md` the moment the deliverable is recorded — before any asset worker spawns.** When a document was pointed at, extract it instead of retyping its stylesheet: `node "$SKILL_DIR/scripts/report-tools.mjs" format-extract <reference> --out "$SESSION_DIR/design-spec.md"` (`--from-url` for a URL), then fill every `TODO: ask` from the interview answers, never by guessing. It is the one design contract every asset and assembly worker receives: template family (absent a reference, the clean analyst-report register — one accent over neutral tones, generous margins, styled section headings, no emoji, no clipart), palette tokens, body/heading fonts (a real gothic CJK webfont — Pretendard, Noto Sans KR — when the report language needs one, with `word-break: keep-all` for Korean), responsive breakpoints and HTML/CSS charts for web deliverables, the figure standard below, and the lineage mode on its own line: `Lineage: inline` (analyst default: every unit-bearing number tagged MEASURED / ASSUMED / DERIVED or cited in the same element) or `Lineage: section` (web and blog register: every section cited, lineage in the figure captions). The reference's section 6 lists the full defaults. One font family and one palette govern prose, charts, Mermaid, and generated images alike; a diagram rendering in a random default font inside a styled report is a defect, not a style choice. **The figure standard — binding for every image, chart, and diagram.** Each figure sits in a fixed-size container styled from the spec (border, background, caption); the image scales to fit entirely inside it with its original aspect ratio preserved — object-fit: contain semantics — never stretched, never cropped, never spilling out. Every chart carries a title, axis labels, units, and value labels in the report's language; a bare number the reader cannot name is a defect. Asset workers (background, parallel, each fed `design-spec.md`) — visuals are the DEFAULT deliverable of this phase, not garnish the user must ask for; a delivered report without figures is an incomplete run: - **Charts for every quantitative finding, computed from real data.** Pull the numbers into an actual table first (CSV/JSON under `$SESSION_DIR`), then plot from that table, never from prose. Follow the data-scientist tool doctrine — numpy always, Polars for filtering/sorting/transforms, DuckDB for joins/aggregations/window functions, never pandas — and load the `data-scientist` skill when this session has it: `uv run --with numpy --with polars --with duckdb --with pyarrow --with matplotlib python`. Keep `pyarrow` in that set — the DuckDB-to-Polars handoff (`.pl()`) fails without it, and `.df()` fails without pandas, so hand data across through `.pl()`, never `.df()`. Save to `$SESSION_DIR/assets/`. - **Mermaid graphs** for process, architecture, argument, timeline, and evidence-flow structure, themed to the spec's fonts and palette. Render each to SVG and confirm the file exists before the document references it. - **Generated visuals through the imagegen skill whenever the session has it:** a cover plus a concept illustration per major theme, prompted from the spec's style, palette, and mood — document-styled illustration, never generic stock art dropped into a designed page. - **Full-page screenshots** of the top 5-10 sources (browsing worker) as provenance you can show. **Verify the asset manifest before rendering.** List every asset the document references, assert each file exists and is non-empty on disk, and re-render whatever is missing. A document that renders with three broken diagrams is a document you will publish twice. Assembly worker — `task(category="deep-low", load_skills=["frontend", "visual-qa", "open-design", "data-scientist", "imagegen", "ulw-loop"], run_in_background=true, ...)`: before writing, read every available design and visualization skill and apply it — the report is a designed artifact, not a text dump; the worker's prompt carries `design-spec.md`. Use the template the user approved; absent a stronger house style the default skeleton is executive summary → key findings by theme → detailed analysis (quotes under 20 words with attribution, charts, Mermaid graphs, generated visuals, SHA-pinned permalinks, verification results) → comparative analysis when options compete → numbered sources with access dates → methodology appendix (workers, waves, searches, verifications, debate rounds) → correction log naming what verification overturned. Write it long and specific: every claim cites `[Source N]`, and the sources section lists every source the run actually used rather than a curated few. ### The delivery gates — every gate for the QA tier must PASS, in order Nothing reaches the user until the gates pass. Record each result with `node "$SKILL_DIR/scripts/report-tools.mjs" outcome gate <static|layout|visual|proofread> <pass|fail|not_run> --session-dir "$SESSION_DIR"`; a gate this harness does not provide is recorded `not_run`. A chat or post answer (light tier) runs the static gates and one render; every paginated or web deliverable runs them all (the reference's sections 3 and 7). 1. **Static gates (always, before anyone looks at pixels).** `node "$SKILL_DIR/scripts/report-tools.mjs" check "$SESSION_DIR/report.html" --design-spec "$SESSION_DIR/design-spec.md" > "$SESSION_DIR/defects.json"`: keep-all, palette tokens, emoji, em dashes, heading length, unsourced numbers, figure containers, chart text, gothic-only fonts, citations, the closing and sources sections, broken assets. Codes and fixes: [references/report-gates.md](references/report-gates.md). 2. **Layout gates.** Print the probe with `node "$SKILL_DIR/scripts/report-tools.mjs" layout-probe --json`, evaluate its `source` in the rendered page through the `browser` skill's owned headless engine, save the result as `$SESSION_DIR/boxes.json`, and re-run `check` with `--layout "$SESSION_DIR/boxes.json"` (overflow, clipped text, distorted images, overlapping siblings). 3. **Visual QA.** Render the produced artifact back to images — PDF pages to PNG, the HTML through the browser skill at desktop and phone widths — and look at them: missing or broken figures, images stretched or spilling their containers, diagram or chart text rendered off the spec's font or palette, clipped tables, overflowing CJK text, blank pages, unlabeled chart values, wrong page breaks. Reading the source markup is not visual QA; inspect the pixels. 4. **Proofread gate — `task(category="writing", ...)`.** Hand the final text to a dedicated `writing` worker whose only job is language: grammar, spelling, punctuation, terminology consistency, and whether the prose reads NATIVELY in the report's own language. It proofreads only and never composes. It returns a defect list; fix every item and re-run the gate on the delta. Deliver only on a clean pass — this gate runs BEFORE the first delivery, not after the user finds the typo. **Repair is bounded.** After every gate run that found defects, ask `node "$SKILL_DIR/scripts/report-tools.mjs" repair decide --state "$SESSION_DIR/repair-state.json" --defects "$SESSION_DIR/defects.json" --artifact-bytes <bytes> --renders <pages> --session-dir "$SESSION_DIR"` and obey it: `repair` means fix and re-run; `deliver` means stop and ship with the residual defects the manifest now lists; `block` means a content-integrity defect remains (or there is no usable artifact), so do not deliver: tell the user what blocks it and record the row as `failed` with the reason. Never repair past a stop decision (3 attempts, a 2-attempt plateau, an oscillation, or 15 minutes; the reference's section 8). Then resolve every manifest row (`outcome set <format> delivered --path <file>` or `blocked_capability` / `skipped` / `failed` with `--reason`) and deliver: the artifact plus a compact chat-readable summary of what it says — the answer in a few sentences, the numbers that matter, and what to look at first. The document is the deliverable; the summary is what gets it read. ### The closing briefing — every run ends with it The last thing the user reads states, in one compact block, what the answer is made of: - **The printed block.** `node "$SKILL_DIR/scripts/report-tools.mjs" outcome verify --session-dir "$SESSION_DIR" && node "$SKILL_DIR/scripts/report-tools.mjs" outcome finish --session-dir "$SESSION_DIR" && node "$SKILL_DIR/scripts/report-tools.mjs" outcome briefing --session-dir "$SESSION_DIR"`: sources and distinct domains counted from `sources-ledger.md`, elapsed minutes from the session directory's own timestamp, every promised deliverable with its status, the gate results, the residual defects, and the repair summary. A failing `verify` means the run is not done. - **Effort.** Workers, waves, excursions, and verifications — the same counters as the `SYNTHESIS.md` header — plus how many sources were primary and how many claims went to the unresolved/refuted annex. Never ship the artifact without this block, and never fill it from memory — paste the printed block and read the rest off the journal. **Record the format choice.** After delivery, when the harness exposes a memory tool and the run qualifies (the user declared a preference, or made the same unsolicited choice on 2+ runs), append one episode line to `reference/human-report-style.md` in the grammar of the reference's section 5, and update the `system/human/report-style.md` pointer when a generalization changed. If you stood up a team, disband it and confirm every worker is terminal before the final answer. ## Search craft English first: run every search in English by default — it is the largest, most authoritative corpus on every engine, GitHub, and documentation site. Add a secondary local-language sweep (1-2 librarians) only after the English sweep, when the topic is inherently local, or when the user asks for sources in a specific language. Vary operators on every query — same query twice wastes a worker: | Operator | Example | Use | |---|---|---| | `site:` | `site:github.com <topic>` | Restrict to a domain | | `filetype:` | `filetype:pdf <topic> survey` | Papers, specs | | `intitle:` / `inurl:` | `intitle:benchmark <topic>` | Targeted pages | | `"exact"` / `-term` | `"<exact phrase>" -tutorial` | Precision, exclusion | | `OR` | `<a> OR <b> <topic>` | Coverage | | `before:` / `after:` | `<topic> after:2025-06-01` | Recency control | High-yield combinations: official docs (`site:<docs domain>`), GitHub implementations (`site:github.com`), recent discussion (`site:reddit.com OR site:news.ycombinator.com after:<date>`), academic (`site:arxiv.org OR filetype:pdf survey`), changelog hunting (`changelog OR "release notes" <version>`), alternatives (`vs OR alternative OR comparison`). ## Failure modes | Failure | Correction | |---|---| | Sequential spawning, or trimming the first wave | All first-wave workers in one turn, background, scaling floor respected | | A team member hoards leads for one final dump | Raise law — every lead, finding, and dead end broadcast the moment it surfaces | | Worker reply without the EXPAND tail | One follow-up demanding it; the lane stays open until it lands | | Stopping after wave 1 because "enough was found" | Convergence rules only: 2+ expansion waves, leads run dry | | Obeying a surrounding "stop exploring" rule mid-research | Authority section — those rules do not bind this mode | | Asking a worker to write journal or session files | Workers are read-only; you journal every return | | Two workers given the same angle | One unique angle per worker, always | | A `Browsing: yes` run whose roster carries no browsing worker, or a browsing worker spawned without `ultimate-browsing` | The browsing column is binding — name the angle in the brief and spawn it, armed with the skill, in the first wave before any lead is chased | | Contested claim settled by judgment | Phase 3 — run code, capture output, verdict | | Deliverable claims without citations | Every claim cites a source or a verification artifact | | Guessing the deliverable format | Derive it from the request and destination; ask the empty-info interview only for what is still missing, and record `answered_by` for every field | | Blocking the collection wave on the format question | Ask non-blocking and start collecting; unanswered fields run on the shown defaults | | A roster smaller than the harness ceiling | Fill every member slot; split the broadest axis until the team is full | | One tier across the whole roster | Mixed tiers by design — cheap breadth, premium attack | | Silently re-routing a "quick"/"fast" instruction | Routing words are literal; journal requested -> spawned -> fallback per slot | | A worker or member that starts its own research swarm | Members research one axis and report; orchestration is yours alone | | Expanding on an entity the first wave never disambiguated | Settle canonical identity and first-party source before any later spawn asserts it | | Batching findings into an end-of-run journal dump | Journal each return as it lands; the journal is what survives a compaction | | Ending the run because every worker finished | Reserve the final fifth of the run for synthesis and materials | | A derived estimate presented as a measured number | MEASURED / ASSUMED / DERIVED lineage on every quantitative claim, plus a sensitivity line | | Delivering before the delivery gates pass | Static and layout gates, visual QA on rendered pages, plus the harness's proofread gate — a typo the user finds means a gate did not run | | Skipping the static gates | `report-tools check` runs before any pixel review; its defects are cheaper than a visual pass | | Delivering with a pending manifest row | `outcome verify` must pass; every promised format ends `delivered`, `blocked_capability`, `skipped`, or `failed` with a reason | | Repairing past the tracker's stop decision | `repair decide` owns the budget; `deliver` ships with the residual list, `block` stops delivery | | Guessing a design-spec value the extractor marked `TODO: ask` | Fill it from the interview answers or the reference's defaults, never by guessing | | Referencing an asset that is not on disk | Verify the asset manifest before rendering; re-render whatever is missing | | A figure stretched, cropped, or styled off the report's design language | `design-spec.md` binds every asset: fixed containers, contain-fit with aspect preserved, spec fonts and palette in charts and Mermaid | | Chasing an interesting find with no ENTER trigger | Excursions need a named trigger; everything else stays a queued lead | | An excursion that never came back, or drifted into a new mission | EXIT rules are unconditional; depth 3 means promote it to an axis or record it as a gap | | An excursion whose result was never folded back | Every EXIT writes what it changed in the top-level answer, `none` included, and mirrors into the loop ledger | | Delivering without the closing briefing | Paste the block `outcome briefing` prints; never compute or recall its numbers by hand |
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.