diff --git a/README.md b/README.md index 3495f988f..37a744fb9 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,8 @@ sudo install -m 755 target/release/thirdshift /usr/local/bin/thirdshift Editing a skill in `skills/` has no effect until you rebuild and reinstall ([ADR-0001](docs/adr/0001-rust-binary-with-embedded-skills.md)). +The [Prompts and skills page](https://thirdshift.app/prompts/) is generated from the prompts, the `claude` arguments and the skills, and `cargo test` fails until it is regenerated after a change to any of them. Regenerate it with `UPDATE_PROMPTS_PAGE=1 cargo test prompts_page`. + Running the test suite (`cargo test`) also needs **`python3`** on `PATH`: the integration tests swap in fake `gh` and `claude`, which are Python scripts in `tests/fakes/`. ## Releasing diff --git a/docs/screenshots/issue-68/prompts-ci-390.png b/docs/screenshots/issue-68/prompts-ci-390.png new file mode 100644 index 000000000..d7d1a6bda Binary files /dev/null and b/docs/screenshots/issue-68/prompts-ci-390.png differ diff --git a/docs/screenshots/issue-68/prompts-tdd-1440.png b/docs/screenshots/issue-68/prompts-tdd-1440.png new file mode 100644 index 000000000..4121ec4b5 Binary files /dev/null and b/docs/screenshots/issue-68/prompts-tdd-1440.png differ diff --git a/docs/screenshots/issue-68/prompts-top-1440.png b/docs/screenshots/issue-68/prompts-top-1440.png new file mode 100644 index 000000000..0606e029d Binary files /dev/null and b/docs/screenshots/issue-68/prompts-top-1440.png differ diff --git a/docs/screenshots/issue-68/prompts-top-390.png b/docs/screenshots/issue-68/prompts-top-390.png new file mode 100644 index 000000000..f054c5c8c Binary files /dev/null and b/docs/screenshots/issue-68/prompts-top-390.png differ diff --git a/site/index.html b/site/index.html index e4f8ab246..2a7ad7340 100644 --- a/site/index.html +++ b/site/index.html @@ -167,7 +167,7 @@

1 · C · Prepare

2 · M · Implement

A headless Claude Code session, with the Factory skills loaded, implements the issue in the worktree, test-first where it can, and commits.

- +
  1. thirdshift: logging the session to /home/jacob/.thirdshift/logs/JacobStephens2-thirdshift-issue-24-20260925T134143Z-implement.jsonl
  2. thirdshift: implement: session started
  3. @@ -187,7 +187,7 @@

    2 · M · Implement<

    3 · Y · Review

    The same session reviews its work against the Base branch, fixes the Standards findings and Spec findings it agrees with, and lists the rest as Unaddressed findings in the pull request body. Then it opens the pull request, ready for review.

    -
    • code-review
    • pr
    +
    1. thirdshift: implement: skill thirdshift:code-review
    2. thirdshift: implement: $ git rev-parse main && git log main..HEAD --oneline && git diff main...HEAD --stat; ls .agents/skills…
    3. @@ -212,7 +212,7 @@

      3 · Y · Review

      4 · K · Finish

      thirdshift pushes, checks the pull request, merges the Base branch in and watches CI. A merge conflict or failing CI starts a Repair, at most 3. A Resume can happen in any session and isn't a Repair. The Run ends once the pull request is open, ready, mergeable and passing CI. This Run needed no Repair.

      -
      • resolving-merge-conflicts in a Repair
      +
      1. thirdshift: pushing issue-24
      2. thirdshift: checking the PR
      3. diff --git a/site/main.js b/site/main.js index 08d026ba2..1f2e25bd0 100644 --- a/site/main.js +++ b/site/main.js @@ -13,3 +13,21 @@ for (const button of document.querySelectorAll(".install button.copy")) { setTimeout(() => { button.textContent = "Copy"; }, 1600); }); } + +// The Prompts and skills page's commit label: the commit of main the deploy +// published, from the commit.txt it writes. Without one (e.g. served locally), +// the label keeps its generic text. +const commitLabel = document.querySelector("[data-commit]"); +if (commitLabel) { + fetch("/commit.txt") + .then((response) => (response.ok ? response.text() : "")) + .then((text) => { + const commit = text.trim(); + if (!/^[0-9a-f]{40}$/.test(commit)) return; + const code = (text) => Object.assign(document.createElement("code"), { textContent: text }); + const link = Object.assign(document.createElement("a"), { href: `https://github.com/JacobStephens2/thirdshift/commit/${commit}` }); + link.append(code(commit.slice(0, 7))); + commitLabel.replaceChildren("commit ", link, " of ", code("main")); + }) + .catch(() => {}); +} diff --git a/site/prompts/index.html b/site/prompts/index.html new file mode 100644 index 000000000..9afea9e06 --- /dev/null +++ b/site/prompts/index.html @@ -0,0 +1,723 @@ + + + + + +Prompts and skills · thirdshift + + + + + + + + + + +
        +
        +
        +
        + + + Job sheets · every prompt and skill +
        +

        Prompts and skills

        +

        Everything that steers thirdshift's agents: the claude command line each session runs, every prompt a Run sends, and every Factory skill in full, as the agent reads it. This page is generated from the same code and embedded skills the binary uses, so it shows what thirdshift does, not a summary of it. Each prompt and skill names the unit of the press line that uses it.

        +

        As of the commit of main this copy of the site comes from. The latest release may differ: see the latest release and the skills/ directory at its tag for the skills it ships.

        +
        +
        +
        + +
        + +
        +
        +

        The command line

        How each session starts

        +

        Every session runs claude headless in the Run's worktree, in auto permission mode, with the Factory skills loaded as a plugin from a temporary directory. The prompt is one of the templates below.

        +
        claude -p --permission-mode auto --plugin-dir <plugin dir> --output-format stream-json --verbose <prompt>
        +

        A Resume continues the session that ended:

        +
        claude -p --permission-mode auto --plugin-dir <plugin dir> --output-format stream-json --verbose --resume <session id> <prompt>
        +
        +
        +
        +
        +

        Prompts

        What a Run tells the agent

        +

        Every prompt a Run can send, with its placeholders highlighted: they change for each Run.

        +
        +
        +

        Fresh

        +

        Starts the implement session, which goes on to review and open the pull request, when the Run starts a new Issue branch.

        +

        Sent by 2 · M · Implement

        +
        /thirdshift:implement <Issue URL>
        +The base branch is <base>. Review with /thirdshift:code-review using <base> as the fixed point.
        +Address the Standards and Spec findings you agree with.
        +Push branch <branch> and create a pull request against <base> using /thirdshift:pr, marked ready for review.
        +In the PR body, add an "Unaddressed findings" section listing each skipped finding under Standards or Spec, with at least a one-line reason.
        +Include "Closes #<n>" in the PR body.
        +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the Bash timeout if needed. Never end your turn while a background task you depend on is still running: ending the turn kills it.
        +
        +
        +
        +

        Continuation, with no pull request

        +

        Starts the implement session when the Run is a Continuation of an Issue branch that has no pull request.

        +

        Sent by 2 · M · Implement

        +
        /thirdshift:implement <Issue URL>
        +
        +You are continuing work on branch <branch>, which already has commits (see git log <base>..HEAD). Build on them; don't start over.
        +
        +The base branch is <base>. Review with /thirdshift:code-review using <base> as the fixed point.
        +
        +Address the Standards and Spec findings you agree with.
        +
        +Push branch <branch>.
        +
        +Create a pull request against <base> using /thirdshift:pr, marked ready for review.
        +
        +In the PR body, add an "Unaddressed findings" section listing each skipped finding under Standards or Spec, with at least a one-line reason.
        +
        +Include "Closes #<n>" in the PR body.
        +
        +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the Bash timeout if needed. Never end your turn while a background task you depend on is still running: ending the turn kills it.
        +
        +
        +
        +

        Continuation, with an open pull request

        +

        Starts the implement session when the Run is a Continuation of an Issue branch whose pull request is open.

        +

        Sent by 2 · M · Implement

        +
        /thirdshift:implement <Issue URL>
        +
        +You are continuing work on branch <branch>, which already has commits (see git log <base>..HEAD). Build on them; don't start over.
        +
        +The base branch is <base>. Review with /thirdshift:code-review using <base> as the fixed point.
        +
        +Address the Standards and Spec findings you agree with.
        +
        +Push branch <branch>.
        +
        +Update PR <pull request URL> using /thirdshift:pr, rewriting its body to cover the whole branch, marked ready for review.
        +
        +In the PR body, add an "Unaddressed findings" section listing each skipped finding under Standards or Spec, with at least a one-line reason.
        +
        +Include "Closes #<n>" in the PR body.
        +
        +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the Bash timeout if needed. Never end your turn while a background task you depend on is still running: ending the turn kills it.
        +
        +
        +
        +

        Conflict Repair

        +

        Starts a Repair session when merging the Base branch into the Issue branch leaves conflicts.

        +

        Sent by 4 · K · Finish

        +
        /thirdshift:resolving-merge-conflicts
        +
        +A merge of origin/<base> into <branch> is in progress in this worktree and has conflicts.
        +<branch> implements <Issue URL>; its pull request is <pull request URL>.
        +
        +Resolve the conflicts, finish the merge, and push <branch>. Do not rebase or force-push.
        +
        +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the Bash timeout if needed. Never end your turn while a background task you depend on is still running: ending the turn kills it.
        +
        +
        +
        +

        CI-fix Repair

        +

        Starts a Repair session when CI fails on the pull request's head commit, listing each failed check.

        +

        Sent by 4 · K · Finish

        +
        CI failed on pull request <pull request URL> (branch <branch>, implementing <Issue URL>).
        +
        +Failed checks:
        +- <failing check>: <check URL>
        +
        +Read the failure logs (e.g. `gh run view <run-id> --log-failed`), find the root cause, and fix it. Do not skip, disable, or weaken tests or checks to make them pass.
        +Run the affected checks locally, commit, and push <branch>.
        +
        +If a failure is not caused by this branch (it is flaky, or also fails on <base>), do not change code for it. Instead, add it to a "CI notes" section of the pull request body with a one-line explanation.
        +
        +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the Bash timeout if needed. Never end your turn while a background task you depend on is still running: ending the turn kills it.
        +
        +
        +
        +

        Resume

        +

        Continues any session, once, that ended its turn while waiting on background work, which was killed with it.

        +

        Sent by 2 · M · Implement, 3 · Y · Review or 4 · K · Finish

        +
        Your background work (<background work>) was killed when your turn ended, because ending the turn ends the session.
        +
        +Re-run whatever you were waiting on in the foreground, then finish your job.
        +
        +You run headless: nobody is watching, and ending your turn ends the session. Run tests and other long commands in the foreground, raising the Bash timeout if needed. Never end your turn while a background task you depend on is still running: ending the turn kills it.
        +
        +
        +
        +
        +
        +
        +
        +

        Factory skills

        The skills, as the agent reads them

        +

        Every Factory skill and supporting file, raw, exactly as the plugin writes it out.

        +
        +
        +

        code-review

        +

        Used by 3 · Y · Review

        +

        code-review/SKILL.md

        +
        ---
        +name: code-review
        +description: "Review the changes since a fixed point (commit, branch, tag, or merge-base) along two axes: Standards (does the code follow this repo's documented coding standards?) and Spec (does the code match what the originating issue/spec asked for?). Runs both reviews in parallel sub-agents and reports them side by side. Use when the user wants to review a branch, a PR, work-in-progress changes, or asks to \"review since X\"."
        +---
        +
        +Two-axis review of the diff between `HEAD` and a fixed point the user supplies:
        +
        +- **Standards**: does the code conform to this repo's documented coding standards?
        +- **Spec**: does the code faithfully implement the originating issue / spec?
        +
        +Both axes run as **parallel sub-agents** so they don't pollute each other's context, then this skill aggregates their findings.
        +
        +The issue tracker should have been provided to you. If `docs/agents/issue-tracker.md` is missing, fall back to `gh issue view`.
        +
        +## Process
        +
        +### 1. Pin the fixed point
        +
        +Whatever the user said is the fixed point (a commit SHA, branch name, tag, `main`, `HEAD~5`, etc.).
        +
        +Capture the diff command once: `git diff <fixed-point>...HEAD` (three-dot, so the comparison is against the merge-base). Also note the list of commits via `git log <fixed-point>..HEAD --oneline`.
        +
        +Before going further, confirm the fixed point resolves (`git rev-parse <fixed-point>`) and the diff is non-empty. A bad ref or empty diff should fail here, not inside two parallel sub-agents.
        +
        +### 2. Identify the spec source
        +
        +Look for the originating spec, in this order:
        +
        +1. Issue references in the commit messages (`#123`, `Closes #45`, GitLab `!67`, etc.), fetched via the workflow in `docs/agents/issue-tracker.md`.
        +2. A path the user passed as an argument.
        +3. A spec file under `docs/`, `specs/`, or `.scratch/` matching the branch name or feature.
        +4. If nothing is found, the **Spec** sub-agent will skip and report "no spec available".
        +
        +### 3. Identify the standards sources
        +
        +Anything in the repo that documents how code should be written, such as `CODING_STANDARDS.md` or `CONTRIBUTING.md`.
        +
        +On top of whatever the repo documents, the Standards axis always carries the **smell baseline** below: a fixed set of Fowler code smells (_Refactoring_, ch.3) that applies even when a repo documents nothing. Two rules bind it:
        +
        +- **The repo overrides.** A documented repo standard always wins; where it endorses something the baseline would flag, suppress the smell.
        +- **Always a judgement call.** Each smell is a labelled heuristic ("possible Feature Envy"), never a hard violation. Like any standard here, skip anything tooling already enforces.
        +
        +Each smell reads *what it is* → *how to fix*; match it against the diff:
        +
        +- **Mysterious Name**: a function, variable, or type whose name doesn't reveal what it does or holds. → rename it; if no honest name comes, the design's murky.
        +- **Duplicated Code**: the same logic shape appears in more than one hunk or file in the change. → extract the shared shape, call it from both.
        +- **Feature Envy**: a method that reaches into another object's data more than its own. → move the method onto the data it envies.
        +- **Data Clumps**: the same few fields or params keep travelling together (a type wanting to be born). → bundle them into one type, pass that.
        +- **Primitive Obsession**: a primitive or string standing in for a domain concept that deserves its own type. → give the concept its own small type.
        +- **Repeated Switches**: the same `switch`/`if`-cascade on the same type recurs across the change. → replace with polymorphism, or one map both sites share.
        +- **Shotgun Surgery**: one logical change forces scattered edits across many files in the diff. → gather what changes together into one module.
        +- **Divergent Change**: one file or module is edited for several unrelated reasons. → split so each module changes for one reason.
        +- **Speculative Generality**: abstraction, parameters, or hooks added for needs the spec doesn't have. → delete it; inline back until a real need shows.
        +- **Message Chains**: long `a.b().c().d()` navigation the caller shouldn't depend on. → hide the walk behind one method on the first object.
        +- **Middle Man**: a class or function that mostly just delegates onward. → cut it, call the real target direct.
        +- **Refused Bequest**: a subclass or implementer that ignores or overrides most of what it inherits. → drop the inheritance, use composition.
        +
        +### 4. Spawn both sub-agents in parallel
        +
        +**Standards sub-agent prompt** should include:
        +
        +- The full diff command and commit list.
        +- The list of standards-source files you found in step 3, **plus the smell baseline from step 3** pasted in full (the sub-agent has no other access to it).
        +- The brief: "Report, per file/hunk where relevant, (a) every place the diff violates a documented standard: cite the standard (file + the rule); and (b) any baseline smell you spot: name it and quote the hunk. Distinguish hard violations from judgement calls: documented-standard breaches can be hard, but baseline smells are always judgement calls, and a documented repo standard overrides the baseline. Skip anything tooling enforces. Under 400 words."
        +
        +**Spec sub-agent prompt** should include:
        +
        +- The diff command and commit list.
        +- The path or fetched contents of the spec.
        +- The brief: "Report: (a) requirements the spec asked for that are missing or partial; (b) behaviour in the diff that wasn't asked for (scope creep); (c) requirements that look implemented but where the implementation looks wrong. Quote the spec line for each finding. Under 400 words."
        +
        +If the spec is missing, skip the Spec sub-agent and note this in the final report.
        +
        +### 5. Aggregate
        +
        +Present the two reports under `## Standards` and `## Spec` headings, verbatim or lightly cleaned. Do **not** merge or rerank findings, because the two axes are deliberately separate (see _Why two axes_).
        +
        +End with a one-line summary: total findings per axis, and the worst issue _within each axis_ (if any). Don't pick a single winner across axes: that's the reranking the separation exists to prevent.
        +
        +## Why two axes
        +
        +A change can pass one axis and fail the other:
        +
        +- Code that follows every standard but implements the wrong thing → **Standards pass, Spec fail.**
        +- Code that does exactly what the issue asked but breaks the project's conventions → **Spec pass, Standards fail.**
        +
        +Reporting them separately stops one axis from masking the other.
        +
        +

        code-review/agents/openai.yaml

        +
        interface:
        +  display_name: "Code Review"
        +  short_description: "Review a diff on standards and spec"
        +
        +
        +
        +

        implement

        +

        Used by 2 · M · Implement

        +

        implement/SKILL.md

        +
        ---
        +name: implement
        +description: "Implement a piece of work based on a spec or set of tickets."
        +disable-model-invocation: false
        +---
        +
        +Implement the work described by the user in the spec or tickets.
        +
        +Use /thirdshift:tdd where possible, at pre-agreed seams.
        +
        +Run typechecking regularly, single test files regularly, and the full test suite once at the end.
        +
        +Once done, use /thirdshift:code-review to review the work.
        +
        +Commit your work to the current branch.
        +
        +

        implement/agents/openai.yaml

        +
        interface:
        +  display_name: "Implement"
        +  short_description: "Build work from a spec or tickets"
        +policy:
        +  allow_implicit_invocation: true
        +
        +
        +
        +

        pr

        +

        Used by 3 · Y · Review

        +

        pr/SKILL.md

        +
        ---
        +name: pr
        +description: "Use when writing a PR body."
        +metadata:
        +  credits:
        +    skill: show-me
        +    author: Dex Horthy
        +    organisation: Humanlayer
        +    url: "https://github.com/humanlayer/skills/blob/main/plugins/show-me/skills/show-me/SKILL.md"
        +---
        +
        +Use this template for writing the PR body:
        +
        +```markdown
        +## Summary
        +
        +<diagram, diff-sketch, or tree>
        +
        +## Evidence
        +
        +- **Before:** <screenshot/output/failing test run>
        +  **After:** <screenshot/output/passing test run>
        +
        +## Merge Danger
        +
        +**Door:** <one-way or two-way>
        +
        +<optional: description>
        +
        +**Blast Radius:** <one-word description>
        +
        +<optional: potential ramifications of merge>
        +```
        +
        +## Sections
        +
        +Skip all preambles and keep prose brief. Use the user's domain language from `CONTEXT.md`.
        +
        +### Summary
        +
        +Pick the smallest view that makes the key point clear.
        +
        +- Show logic or an algorithm as pseudocode:
        +
        +```text
        +on(save)
        +  if content is unchanged
        +    return cached result
        +  write new content
        +  return fresh result
        +```
        +
        +- Show runtime control flow as a call tree:
        +
        +```text
        +submitForm
        +  createSession
        +    persistPrompt
        +    launchAgent
        +  navigateToSession
        +```
        +
        +- Show UI structure as a component tree, including state and module boundaries that matter:
        +
        +```tsx
        +<SessionPage>(apps / example / src / routes / session.tsx);
        +useSessionEvents() < SessionToolbar > <RunSkillButton>(packages / ui);
        +```
        +
        +- Show file responsibility or a broad refactor as a shallow file tree:
        +
        +```text
        +src/
        +├── commands/       # parses user actions
        +├── sessions/       # owns session state
        +└── transport/      # sends API requests
        +```
        +
        +- Show component interaction, control flow, or data flow with Mermaid:
        +
        +```mermaid
        +sequenceDiagram
        +    participant User
        +    participant UI
        +    participant Daemon
        +    User->>UI: choose command
        +    UI->>Daemon: send expanded prompt
        +    Daemon-->>UI: stream result
        +```
        +
        +- Use `diff` when the point is what changes and the surrounding shape already exists. Match the diff shape to the topic.
        +
        +For a component change:
        +
        +```diff
        + <SessionPage>
        +   useSessionEvents()
        +   <SessionToolbar>
        ++    <RunSkillButton />
        +   <SessionTimeline>
        ++    <SkillResultCard />
        +```
        +
        +For a file-layout change:
        +
        +```diff
        + src/
        + ├── commands/
        ++│   └── show-me.ts       # expands the slash command
        + ├── sessions/
        +-└── transport.ts
        ++└── transport/
        ++    ├── client.ts
        ++    └── stream.ts
        +```
        +
        +For a call-tree or call-stack change:
        +
        +```diff
        + submitForm
        +   createSession
        +     persistPrompt
        ++    expandSkillMention
        +     launchAgent
        +-  navigateToSession
        ++  navigateToSession
        ++    subscribeToEvents
        +```
        +
        +For a state or control-flow change:
        +
        +```diff
        + on(save)
        +-  write content
        ++  if content is unchanged
        ++    return cached result
        ++  write new content
        ++  invalidate cache
        +```
        +
        +- Show the whole block when most of it is new, when omitted context would hide ownership or order, or when the user needs a copyable target shape:
        +
        +```ts
        +function expandSkill(command: string): string {
        +  const skillName = command.slice(1);
        +  return `use the ${skillName} skill`;
        +}
        +```
        +
        +#### Guidance
        +
        +Place each visual next to the short text it supports. Keep only the calls, files, props, states, and boundaries needed to answer the user's current question or the options to resolve the current discussion point.
        +
        +You may use one of these, you may use several, it is unlikely you will use all of them. Use your judgement and don't overwhelm the user.
        +
        +### Evidence
        +
        +Concrete evidence that the change works. Show a before and after.
        +
        +Screenshots are S-tier - when the environment is set up for it and the change is visual.
        +
        +Execution-based evidence is A-tier. Test results, console output. Show the exact test that now fails and passes, using pseudocode.
        +
        +### Merge Danger
        +
        +Describe whether it's a one-way or two-way door. You can walk back through two-way doors, but not one-way doors. A PR that is cheap to roll back is lower risk. Changes that involve destructive actions or hard-to-reverse decisions are one-way doors.
        +
        +The blast radius is the potential impact or scope of the changes introduced by this PR. Consider all possibilities. Examples are layout shift, breakages for consumers, mobile responsiveness, etc.
        +
        +

        pr/CREDITS.md

        +
        # Credits
        +
        +"The shape of the change" section is [Dex Horthy](https://github.com/dexhorthy)'s [`show-me`](https://github.com/humanlayer/humanlayer) skill, reproduced almost word for word, aimed at a diff instead of a live conversation. `pr` does not depend on `show-me` as a skill (it isn't part of this repo, and a hard dependency would break standalone installs), so the content is copied in rather than pointed at; this file is the attribution a dependency would otherwise have carried.
        +
        +

        pr/agents/openai.yaml

        +
        interface:
        +  display_name: "PR"
        +  short_description: "Write a PR body that's fast to review"
        +
        +
        +
        +

        resolving-merge-conflicts

        +

        Used by 4 · K · Finish, in a Repair

        +

        resolving-merge-conflicts/SKILL.md

        +
        ---
        +name: resolving-merge-conflicts
        +description: "Use when you need to resolve an in-progress git merge/rebase conflict."
        +---
        +
        +1. **See the current state** of the merge/rebase. Check git history, and the conflicting files.
        +
        +2. **Find the primary sources** for each conflict. Understand deeply why each change was made, and what the original intent was. Read the commit messages, check the PRs, check original issues/tickets.
        +
        +3. **Resolve each hunk.** Preserve both intents where possible. Where incompatible, pick the one matching the merge's stated goal and note the trade-off. Do **not** invent new behaviour. Always resolve; never `--abort`.
        +
        +4. Discover the project's **automated checks** and run them, typically typecheck, then tests, then format. Fix anything the merge broke.
        +
        +5. **Finish the merge/rebase.** Stage everything, commit, and push. If rebasing, continue the rebase process until all commits are rebased.
        +

        resolving-merge-conflicts/agents/openai.yaml

        +
        interface:
        +  display_name: "Resolving Merge Conflicts"
        +  short_description: "Resolve merge and rebase conflicts"
        +
        +
        +
        +

        tdd

        +

        Used by 2 · M · Implement

        +

        tdd/SKILL.md

        +
        ---
        +name: tdd
        +description: Test-driven development. Use when the user wants to build features or fix bugs test-first, mentions "red-green-refactor", or wants integration tests.
        +---
        +
        +# Test-Driven Development
        +
        +TDD is the red → green loop. This skill is the reference that makes that loop produce tests worth keeping: what a good test is, where tests go, the anti-patterns, and the rules of the loop. Every section applies on every cycle: consult them before and during the loop, not after.
        +
        +When exploring the codebase, read `CONTEXT.md` (if it exists) so test names and interface vocabulary match the project's domain language, and respect ADRs in the area you're touching.
        +
        +## What a good test is
        +
        +Tests verify behavior through public interfaces, not implementation details. Code can change entirely; tests shouldn't. A good test reads like a specification: "user can checkout with valid cart" tells you exactly what capability exists, and it survives refactors because it doesn't care about internal structure.
        +
        +See [tests.md](tests.md) for examples and [mocking.md](mocking.md) for mocking guidelines.
        +
        +## Seams: where tests go
        +
        +A **seam** is the public boundary you test at: the interface where you observe behavior without reaching inside. Tests live at seams, never against internals.
        +
        +**Test only at pre-agreed seams.** Before writing any test, write down the seams under test. No test is written at a seam you haven't written down. Have the seams listed in the PR body only. You can't test everything, so agreeing the seams up front is how testing effort lands on the critical paths and complex logic instead of every edge case.
        +
        +Decide: "What's the public interface, and which seams should we test?"
        +
        +When the shape of that interface is itself in question (how deep the module is, where the seam belongs, what the interface should expose), call the Skill tool with "codebase-design" for the vocabulary. It is the shared source of the module, interface, depth, seam, adapter, leverage and locality terms, and it is a reference to consult, not a session to run.
        +
        +## Anti-patterns
        +
        +- **Implementation-coupled**: mocks internal collaborators, tests private methods, or verifies through a side channel (querying the database instead of using the interface). The tell: the test breaks when you refactor but behavior hasn't changed.
        +- **Tautological**: the assertion recomputes the expected value the way the code does (`expect(add(a, b)).toBe(a + b)`, a snapshot derived by hand the same way, a constant asserted equal to itself), so it passes by construction and can never disagree with the code. Expected values must come from an independent source of truth: a known-good literal, a worked example, the spec.
        +- **Horizontal slicing**: writing all tests first, then all implementation. Bulk tests verify _imagined_ behavior: you test the _shape_ of things rather than user-facing behavior, the tests go insensitive to real changes, and you commit to test structure before understanding the implementation. Work in **vertical slices** instead: one test → one implementation → repeat, each test a **tracer bullet** that responds to what the last cycle taught you.
        +
        +## Rules of the loop
        +
        +- **Red before green.** Write the failing test first, then only enough code to pass it. Don't anticipate future tests or add speculative features.
        +- **One slice at a time.** One seam, one test, one minimal implementation per cycle.
        +- **Refactoring is not part of the loop.** It belongs to the review stage (see the `code-review` skill), not the red → green implementation cycle.
        +
        +

        tdd/agents/openai.yaml

        +
        interface:
        +  display_name: "TDD"
        +  short_description: "Test-driven red-green-refactor"
        +
        +

        tdd/mocking.md

        +
        # When to Mock
        +
        +Mock at **system boundaries** only:
        +
        +- External APIs (payment, email, etc.)
        +- Databases (sometimes - prefer test DB)
        +- Time/randomness
        +- File system (sometimes)
        +
        +Don't mock:
        +
        +- Your own classes/modules
        +- Internal collaborators
        +- Anything you control
        +
        +## Designing for Mockability
        +
        +At system boundaries, design interfaces that are easy to mock:
        +
        +**1. Use dependency injection**
        +
        +Pass external dependencies in rather than creating them internally:
        +
        +```typescript
        +// Easy to mock
        +function processPayment(order, paymentClient) {
        +  return paymentClient.charge(order.total);
        +}
        +
        +// Hard to mock
        +function processPayment(order) {
        +  const client = new StripeClient(process.env.STRIPE_KEY);
        +  return client.charge(order.total);
        +}
        +```
        +
        +**2. Prefer SDK-style interfaces over generic fetchers**
        +
        +Create specific functions for each external operation instead of one generic function with conditional logic:
        +
        +```typescript
        +// GOOD: Each function is independently mockable
        +const api = {
        +  getUser: (id) => fetch(`/users/${id}`),
        +  getOrders: (userId) => fetch(`/users/${userId}/orders`),
        +  createOrder: (data) => fetch('/orders', { method: 'POST', body: data }),
        +};
        +
        +// BAD: Mocking requires conditional logic inside the mock
        +const api = {
        +  fetch: (endpoint, options) => fetch(endpoint, options),
        +};
        +```
        +
        +The SDK approach means:
        +- Each mock returns one specific shape
        +- No conditional logic in test setup
        +- Easier to see which endpoints a test exercises
        +- Type safety per endpoint
        +
        +

        tdd/tests.md

        +
        # Good and Bad Tests
        +
        +## Good Tests
        +
        +**Integration-style**: Test through real interfaces, not mocks of internal parts.
        +
        +```typescript
        +// GOOD: Tests observable behavior
        +test("user can checkout with valid cart", async () => {
        +  const cart = createCart();
        +  cart.add(product);
        +  const result = await checkout(cart, paymentMethod);
        +  expect(result.status).toBe("confirmed");
        +});
        +```
        +
        +Characteristics:
        +
        +- Tests behavior users/callers care about
        +- Uses public API only
        +- Survives internal refactors
        +- Describes WHAT, not HOW
        +- One logical assertion per test
        +
        +## Bad Tests
        +
        +**Implementation-detail tests**: Coupled to internal structure.
        +
        +```typescript
        +// BAD: Tests implementation details
        +test("checkout calls paymentService.process", async () => {
        +  const mockPayment = jest.mock(paymentService);
        +  await checkout(cart, payment);
        +  expect(mockPayment.process).toHaveBeenCalledWith(cart.total);
        +});
        +```
        +
        +Red flags:
        +
        +- Mocking internal collaborators
        +- Testing private methods
        +- Asserting on call counts/order
        +- Test breaks when refactoring without behavior change
        +- Test name describes HOW not WHAT
        +- Verifying through external means instead of interface
        +
        +```typescript
        +// BAD: Bypasses interface to verify
        +test("createUser saves to database", async () => {
        +  await createUser({ name: "Alice" });
        +  const row = await db.query("SELECT * FROM users WHERE name = ?", ["Alice"]);
        +  expect(row).toBeDefined();
        +});
        +
        +// GOOD: Verifies through interface
        +test("createUser makes user retrievable", async () => {
        +  const user = await createUser({ name: "Alice" });
        +  const retrieved = await getUser(user.id);
        +  expect(retrieved.name).toBe("Alice");
        +});
        +```
        +
        +**Tautological tests**: Expected value restates the implementation, so the test passes by construction.
        +
        +```typescript
        +// BAD: Expected value is recomputed the way the code computes it
        +test("calculateTotal sums line items", () => {
        +  const items = [{ price: 10 }, { price: 5 }];
        +  const expected = items.reduce((sum, i) => sum + i.price, 0);
        +  expect(calculateTotal(items)).toBe(expected);
        +});
        +
        +// GOOD: Expected value is an independent, known literal
        +test("calculateTotal sums line items", () => {
        +  expect(calculateTotal([{ price: 10 }, { price: 5 }])).toBe(15);
        +});
        +```
        +
        +
        +
        +
        +
        +
        +
        +

        Licence and credits

        Whose skills these are

        +

        The Factory skills are adapted from Matt Pocock's skills, under this licence. The pr skill credits Dex Horthy's show-me skill in its CREDITS.md.

        +

        LICENSE

        +
        MIT License
        +
        +Copyright (c) 2026 Matt Pocock
        +
        +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.
        +
        +
        +
        + +
        + + + + + diff --git a/site/styles.css b/site/styles.css index 62de4bf32..d17a43552 100644 --- a/site/styles.css +++ b/site/styles.css @@ -152,6 +152,8 @@ body::after { /* scanlines */ .factory-skills { display: flex; flex-wrap: wrap; gap: 6px; list-style: none; margin: 0; padding: 0; } .factory-skills li { font: 700 12px/1 var(--f-mono); padding: 6px 8px; background: var(--k3); border: 1px solid var(--rule); } .factory-skills li span { font-weight: 400; color: var(--dim); } +.factory-skills a { color: var(--paper); } +.factory-skills a:hover { color: var(--nc); } .stderr-lines { list-style: none; margin: 0; padding: 10px 12px; font: 12px/1.6 var(--f-mono); background: #000; border: 1px solid var(--rule); } .stderr-lines li { white-space: pre-wrap; overflow-wrap: anywhere; } .stderr-lines .prefix { color: var(--dim); } @@ -253,6 +255,20 @@ body::after { /* scanlines */ .colophon blockquote p { margin: 0; } .colophon cite { font-size: .8em; color: var(--dim); } +/* ---------- Prompts and skills: job sheets ---------- */ +.page-head .home { display: block; color: inherit; } +.paper-sheet .as-of { margin: 0; font-size: 14px; color: #444; max-width: 90ch; } +.job-sheets { display: grid; gap: 28px; } +.job-sheet { display: grid; gap: 10px; align-content: start; border-top: 4px solid var(--c); padding-top: 12px; min-width: 0; } +.job-sheet h3 { font-size: 30px; } +.job-sheet h4 { margin: 10px 0 0; font: 700 13px/1.3 var(--f-mono); color: var(--dim); } +.job-sheet p { margin: 0; max-width: 90ch; } +.sent-by { font-size: 13px; color: var(--dim); } +.job-text { margin: 0; padding: 14px 16px; background: #000; border: 1px solid var(--rule); font-size: 13px; line-height: 1.55; + white-space: pre-wrap; overflow-wrap: anywhere; } +.job-text .ph { background: color-mix(in srgb, var(--y) 22%, transparent); color: var(--ny); padding-inline: 2px; } +.lede + .job-text, .job-text + .lede { margin-top: 14px; } + /* ---------- Footer ---------- */ .foot { border-top: 1px solid var(--rule); padding-block: 28px; display: flex; flex-wrap: wrap; gap: 10px 26px; font-size: 13px; color: var(--dim); } diff --git a/src/main.rs b/src/main.rs index e4e3f10e0..787f3443d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,8 @@ mod poll; mod preflight; mod progress; mod prompt; +#[cfg(test)] +mod prompts_page; mod run; mod session; mod update; diff --git a/src/plugin.rs b/src/plugin.rs index 632b4e1c5..b08544508 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -8,7 +8,9 @@ use anyhow::{Context, Result}; use include_dir::{Dir, include_dir}; use tempfile::TempDir; -static SKILLS: Dir = include_dir!("$CARGO_MANIFEST_DIR/skills"); +/// The Factory skills, as the plugin writes them out and the Prompts and skills +/// page shows them. +pub static SKILLS: Dir = include_dir!("$CARGO_MANIFEST_DIR/skills"); /// A temp directory holding the Factory skills and a generated plugin /// manifest, removed when dropped. diff --git a/src/prompts_page.rs b/src/prompts_page.rs new file mode 100644 index 000000000..234fed4b9 --- /dev/null +++ b/src/prompts_page.rs @@ -0,0 +1,417 @@ +//! The Prompts and skills page's generated fragment: the `claude` command +//! line, every prompt a Run sends, and every Factory skill, rendered from the +//! same functions and embedded files the binary uses, so the page can't drift +//! from what it does. Test-only: the golden-file test below keeps the +//! checked-in fragment in `site/prompts/index.html` up to date. + +use std::ffi::OsStr; +use std::fmt::Write; + +use include_dir::{Dir, File}; + +use crate::github::{Check, CheckState}; +use crate::issue::IssueUrl; +use crate::plugin::SKILLS; +use crate::prompt; +use crate::session::claude_args; + +/// A press unit on the home page, which each prompt and skill links back to. +struct Unit { + anchor: &'static str, + name: &'static str, +} + +const IMPLEMENT: Unit = Unit { + anchor: "unit-2", + name: "2 · M · Implement", +}; +const REVIEW: Unit = Unit { + anchor: "unit-3", + name: "3 · Y · Review", +}; +const FINISH: Unit = Unit { + anchor: "unit-4", + name: "4 · K · Finish", +}; + +/// The placeholder values the prompts are rendered with, highlighted on the +/// page. The issue number can't be text, so a sentinel stands in for it. +const ISSUE_URL: &str = ""; +const ISSUE_NUMBER: u64 = u64::MAX; +const NUMBER: &str = ""; +const BASE: &str = ""; +const BRANCH: &str = ""; +const PR_URL: &str = ""; +const CHECK: &str = ""; +const CHECK_URL: &str = ""; +const BACKGROUND_WORK: &str = ""; +const PLUGIN_DIR: &str = ""; +const SESSION_ID: &str = ""; +const PROMPT: &str = ""; +const PLACEHOLDERS: [&str; 11] = [ + ISSUE_URL, + NUMBER, + BASE, + BRANCH, + PR_URL, + CHECK, + CHECK_URL, + BACKGROUND_WORK, + PLUGIN_DIR, + SESSION_ID, + PROMPT, +]; + +/// A prompt as the page shows it: when a Run sends it, from which units, and +/// its text with placeholders. +struct Prompt { + id: &'static str, + title: &'static str, + when: &'static str, + units: &'static [Unit], + text: String, +} + +fn prompts() -> Vec { + let issue = IssueUrl { + url: ISSUE_URL.to_string(), + owner: "".to_string(), + repo: "".to_string(), + number: ISSUE_NUMBER, + }; + let failed = [Check { + name: CHECK.to_string(), + state: CheckState::Failed, + url: Some(CHECK_URL.to_string()), + }]; + vec![ + Prompt { + id: "prompt-fresh", + title: "Fresh", + when: "Starts the implement session, which goes on to review and open the pull request, when the Run starts a new Issue branch.", + units: &[IMPLEMENT], + text: prompt::fresh(&issue, BASE, BRANCH), + }, + Prompt { + id: "prompt-continuation", + title: "Continuation, with no pull request", + when: "Starts the implement session when the Run is a Continuation of an Issue branch that has no pull request.", + units: &[IMPLEMENT], + text: prompt::continuation(&issue, BASE, BRANCH, None), + }, + Prompt { + id: "prompt-continuation-pr", + title: "Continuation, with an open pull request", + when: "Starts the implement session when the Run is a Continuation of an Issue branch whose pull request is open.", + units: &[IMPLEMENT], + text: prompt::continuation(&issue, BASE, BRANCH, Some(PR_URL)), + }, + Prompt { + id: "prompt-conflict-repair", + title: "Conflict Repair", + when: "Starts a Repair session when merging the Base branch into the Issue branch leaves conflicts.", + units: &[FINISH], + text: prompt::conflict_repair(&issue, BASE, BRANCH, PR_URL), + }, + Prompt { + id: "prompt-ci-fix-repair", + title: "CI-fix Repair", + when: "Starts a Repair session when CI fails on the pull request's head commit, listing each failed check.", + units: &[FINISH], + text: prompt::ci_fix_repair(&issue, BASE, BRANCH, PR_URL, &failed), + }, + Prompt { + id: "prompt-resume", + title: "Resume", + when: "Continues any session, once, that ended its turn while waiting on background work, which was killed with it.", + units: &[IMPLEMENT, REVIEW, FINISH], + text: prompt::resume(&[BACKGROUND_WORK]), + }, + ] +} + +/// The unit whose session uses each Factory skill, and when, if not always. +fn skill_unit(skill: &str) -> (Unit, Option<&'static str>) { + match skill { + "implement" | "tdd" => (IMPLEMENT, None), + "code-review" | "pr" => (REVIEW, None), + "resolving-merge-conflicts" => (FINISH, Some("in a Repair")), + _ => panic!("the Factory skill {skill} has no unit: add it to skill_unit"), + } +} + +/// The fragment: the command line, the prompts, the skills, and the skills' +/// licence. +fn render() -> String { + let mut html = String::new(); + command_line(&mut html); + prompt_section(&mut html); + skills_section(&mut html); + licence_section(&mut html); + html +} + +fn command_line(html: &mut String) { + let line = |resume| { + let args = claude_args(OsStr::new(PLUGIN_DIR), resume, PROMPT); + let args: Vec<_> = args.iter().map(|arg| arg.to_string_lossy()).collect(); + format!("claude {}", args.join(" ")) + }; + let _ = write!( + html, + r##"
        +
        +

        The command line

        How each session starts

        +

        Every session runs claude headless in the Run's worktree, in auto permission mode, with the Factory skills loaded as a plugin from a temporary directory. The prompt is one of the templates below.

        +
        {new}
        +

        A Resume continues the session that ended:

        +
        {resume}
        +
        +
        +"##, + new = highlighted(&line(None)), + resume = highlighted(&line(Some(SESSION_ID))), + ); +} + +fn prompt_section(html: &mut String) { + html.push_str( + r##"
        +
        +

        Prompts

        What a Run tells the agent

        +

        Every prompt a Run can send, with its placeholders highlighted: they change for each Run.

        +
        +"##, + ); + for prompt in prompts() { + let text = prompt + .text + .replace(&format!("#{ISSUE_NUMBER}"), &format!("#{NUMBER}")); + let _ = write!( + html, + r##"
        +

        {title}

        +

        {when}

        +

        Sent by {units}

        +
        {text}
        +
        +"##, + id = prompt.id, + title = prompt.title, + when = prompt.when, + units = unit_links(prompt.units), + text = highlighted(&text), + ); + } + html.push_str("
        \n
        \n
        \n"); +} + +fn skills_section(html: &mut String) { + html.push_str( + r##"
        +
        +

        Factory skills

        The skills, as the agent reads them

        +

        Every Factory skill and supporting file, raw, exactly as the plugin writes it out.

        +
        +"##, + ); + let mut skills: Vec<&Dir> = SKILLS.dirs().collect(); + skills.sort_by_key(|dir| dir.path()); + for skill in skills { + let name = skill.path().to_string_lossy(); + let (unit, note) = skill_unit(&name); + let note = note.map_or(String::new(), |note| format!(", {note}")); + let _ = write!( + html, + r##"
        +

        {name}

        +

        Used by {unit}{note}

        +"##, + unit = unit_links(&[unit]), + ); + let mut files = Vec::new(); + collect_files(skill, &mut files); + // SKILL.md first, as the agent reads it first; then the rest by path. + files.sort_by_key(|file| { + ( + file.path().file_name() != Some(OsStr::new("SKILL.md")), + file.path(), + ) + }); + for file in files { + file_block(html, file, " "); + } + html.push_str("
        \n"); + } + html.push_str("
        \n
        \n
        \n"); +} + +fn licence_section(html: &mut String) { + html.push_str( + r##"
        +
        +

        Licence and credits

        Whose skills these are

        +

        The Factory skills are adapted from Matt Pocock's skills, under this licence. The pr skill credits Dex Horthy's show-me skill in its CREDITS.md.

        +"##, + ); + let mut files: Vec<&File> = SKILLS.files().collect(); + files.sort_by_key(|file| file.path()); + for file in files { + assert_eq!( + file.path(), + OsStr::new("LICENSE"), + "a file outside any skill needs a place on the page" + ); + file_block(html, file, " "); + } + html.push_str("
        \n
        \n"); +} + +/// Every file under `dir`, at any depth. +fn collect_files<'a>(dir: &'a Dir<'a>, files: &mut Vec<&'a File<'a>>) { + files.extend(dir.files()); + for dir in dir.dirs() { + collect_files(dir, files); + } +} + +/// A file's path as a heading, then its raw text. +fn file_block(html: &mut String, file: &File, indent: &str) { + let path = file.path().to_string_lossy(); + let text = file + .contents_utf8() + .unwrap_or_else(|| panic!("{path} is not UTF-8")); + let id: String = path + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + let _ = write!( + html, + "{indent}

        {path}

        \n{indent}
        {text}
        \n", + path = escape(&path), + text = escape(text), + ); +} + +/// Links to `units` on the home page, as a list ending "or". +fn unit_links(units: &[Unit]) -> String { + let links: Vec<_> = units + .iter() + .map(|unit| format!(r##"{}"##, unit.anchor, unit.name)) + .collect(); + match links.split_last() { + Some((last, [])) => last.clone(), + Some((last, rest)) => format!("{} or {last}", rest.join(", ")), + None => String::new(), + } +} + +/// `text` escaped, with each placeholder marked up. +fn highlighted(text: &str) -> String { + let mut html = escape(text); + for placeholder in PLACEHOLDERS { + let escaped = escape(placeholder); + html = html.replace(&escaped, &format!(r##"{escaped}"##)); + } + html +} + +fn escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + + use super::*; + + const UPDATE: &str = "UPDATE_PROMPTS_PAGE=1 cargo test prompts_page"; + const BEGIN: &str = "\n"; + const END: &str = "\n"; + + /// The golden-file test: the page carries the fragment as rendered now. + /// The crates.io package leaves `site/` out, so it skips there. + #[test] + fn prompts_page_shows_what_the_binary_sends() { + let site = Path::new(env!("CARGO_MANIFEST_DIR")).join("site"); + if !site.is_dir() { + eprintln!( + "skipping: no {} (the crates.io package leaves it out)", + site.display() + ); + return; + } + let page_path = site.join("prompts/index.html"); + let page = fs::read_to_string(&page_path) + .unwrap_or_else(|error| panic!("could not read {}: {error}", page_path.display())); + let (before, rest) = page + .split_once(BEGIN) + .unwrap_or_else(|| panic!("{} has no line {BEGIN}", page_path.display())); + let (checked_in, after) = rest + .split_once(END) + .unwrap_or_else(|| panic!("{} has no line {END}", page_path.display())); + let rendered = render(); + if std::env::var_os("UPDATE_PROMPTS_PAGE").is_some() { + fs::write(&page_path, format!("{before}{BEGIN}{rendered}{END}{after}")) + .unwrap_or_else(|error| panic!("could not write {}: {error}", page_path.display())); + return; + } + assert!( + checked_in == rendered, + "{} is out of date with the prompts, the claude arguments or the skills:\n{}\nRegenerate it with: {UPDATE}", + page_path.display(), + diff(checked_in, &rendered, before.lines().count() + 2), + ); + } + + /// The lines of `old` and `new` that differ, as `-` and `+` lines by + /// longest common subsequence, numbered as lines of `new` from `first`. + fn diff(old: &str, new: &str, first: usize) -> String { + let (old, new): (Vec<_>, Vec<_>) = (old.lines().collect(), new.lines().collect()); + // common[i][j]: the longest common subsequence of old[i..] and new[j..]. + let mut common = vec![vec![0usize; new.len() + 1]; old.len() + 1]; + for i in (0..old.len()).rev() { + for j in (0..new.len()).rev() { + common[i][j] = if old[i] == new[j] { + common[i + 1][j + 1] + 1 + } else { + common[i + 1][j].max(common[i][j + 1]) + }; + } + } + let (mut i, mut j, mut out) = (0, 0, String::new()); + while i < old.len() || j < new.len() { + if i < old.len() && j < new.len() && old[i] == new[j] { + (i, j) = (i + 1, j + 1); + } else if j == new.len() || (i < old.len() && common[i + 1][j] >= common[i][j + 1]) { + let _ = writeln!(out, "{:>5} - {}", first + j, old[i]); + i += 1; + } else { + let _ = writeln!(out, "{:>5} + {}", first + j, new[j]); + j += 1; + } + } + out + } + + #[test] + fn diff_shows_only_the_changed_lines() { + assert_eq!( + diff("a\nb\nc\n", "a\nB\nc\nd\n", 1), + " 2 - b\n 2 + B\n 4 + d\n" + ); + } + + #[test] + fn placeholders_are_highlighted_and_fixed_text_is_escaped() { + assert_eq!( + highlighted("Push ; see `gh run view ` & "), + r##"Push <branch>; see `gh run view <run-id>` & <n>"## + ); + } +} diff --git a/src/session.rs b/src/session.rs index 130ca45ce..c1d8f365e 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,5 +1,6 @@ //! Headless Claude Code sessions and their logs. +use std::ffi::OsStr; use std::fs::{self, File}; use std::io::{BufRead, BufReader, Read, Write}; use std::os::unix::process::CommandExt; @@ -103,16 +104,8 @@ fn run( let mut log_file = File::create(log).with_context(|| format!("could not create {}", log.display()))?; let started = Instant::now(); - let mut command = Command::new("claude"); - command - .args(["-p", "--permission-mode", "auto", "--plugin-dir"]) - .arg(plugin_dir) - .args(["--output-format", "stream-json", "--verbose"]); - if let Some(session_id) = resume { - command.args(["--resume", session_id]); - } - let mut child = command - .arg(prompt) + let mut child = Command::new("claude") + .args(claude_args(plugin_dir.as_os_str(), resume, prompt)) .current_dir(worktree) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -176,6 +169,27 @@ fn run( Ok(progress) } +/// The arguments every session runs `claude` with: headless in auto mode, +/// with the Factory skills plugin at `plugin_dir` loaded, streaming JSON. With +/// `resume`, the session with that id continues. The prompt comes last. +pub fn claude_args<'a>( + plugin_dir: &'a OsStr, + resume: Option<&'a str>, + prompt: &'a str, +) -> Vec<&'a OsStr> { + let mut args: Vec<&OsStr> = ["-p", "--permission-mode", "auto", "--plugin-dir"] + .into_iter() + .map(OsStr::new) + .collect(); + args.push(plugin_dir); + args.extend(["--output-format", "stream-json", "--verbose"].map(OsStr::new)); + if let Some(session_id) = resume { + args.extend(["--resume", session_id].map(OsStr::new)); + } + args.push(OsStr::new(prompt)); + args +} + const POLL: Duration = Duration::from_millis(100); /// How long a session gets to exit after SIGTERM before it is killed.