diff --git a/FEATURES-SPEC.md b/FEATURES-SPEC.md index 337fd63b4..40917c255 100644 --- a/FEATURES-SPEC.md +++ b/FEATURES-SPEC.md @@ -89,11 +89,11 @@ happens while nobody is at the keyboard. - Hottest tickets - Projects sidebar - A project whose directory was renamed or deleted leaves the sidebar on the next refresh, and comes back when the directory does; the registration is kept -- Project errors: a project whose bookkeeping branches cannot reach origin (push rejected, or no remote) — the `tickets` branch or the `agents-logs` branch — is flagged with a red dot in the sidebar and a banner on its page, until a sync converges +- Project errors: a project whose bookkeeping branches cannot reach origin (push rejected, or no remote) — the `agent-data` branch or the `agents-logs` branch — is flagged with a red dot in the sidebar and a banner on its page, until a sync converges ## Tickets -- Tickets and the queue live on the `tickets` branch, never on a code branch; every change is one commit pushed straight to it, so what one agent writes the next one sees without waiting for a pull request (#1748) +- Tickets and the queue live on the `agent-data` branch, never on a code branch; every change is one commit pushed straight to it, so what one agent writes the next one sees without waiting for a pull request (#1748) - A `tickets` link at your repository's root shows that branch's tickets from your own checkout, made only where nothing of yours sits at that path and kept out of git - Cross-project ticket list - Faceted filtering: text, priority/effort/uncertainty as buckets *or* ranges, topics, planning stage, project @@ -102,7 +102,7 @@ happens while nobody is at the keyboard. - Ticket detail page - A plan page when a plan exists; a button to start an agent writing one when it doesn't - "Resume agent" on the plan page: opens the session of the agent that wrote the plan, so the conversation continues with the plan already in its context -- Queue a ticket into the AI queue — the entry is written onto the `tickets` branch, linked to the ticket and filed under the ticket's priority +- Queue a ticket into the AI queue — the entry is written onto the `agent-data` branch, linked to the ticket and filed under the ticket's priority - Queue every ticket the filters show into the AI queue, in one click from the page heading - Queue a plan for every unplanned ticket the filters show, from the same heading - A ticket whose work is done leaves the branch with its plan and its claim; a queue entry whose work is done is taken off the queue — done means deleted, never ticked off @@ -115,8 +115,10 @@ happens while nobody is at the keyboard. - Every agent gets its own git worktree under `.branches/` and its own branch (`agent-`), created before it starts; your checkout is never touched - The `branches` skill: every checkout The Framework creates carries the `@gemstack/skill-branches` package's `SKILL.md` where the agent's harness looks for skills (`.claude/skills/branches` for Claude Code, `.agents/skills/branches` for Codex), and the system prompt tells the agent to use it — that checkout is your whole workspace, name the session with the command, commit as you go, leave a clean tree, never push or open the PR yourself; an agent anywhere else (a terminal run in your checkout, an Actions runner, a cloud session) is told to branch with git itself - `branches` on every agent's PATH — the skill's command line: `name ` renames the agent's branch to `agent-` and prints the name it got (suffixed when taken); `status` says whether the tree is clean and the branch on the remote; `create`, `attach`, `list`, `remove`, `prune` are the same operations the dashboard runs -- The `tickets` skill: every checkout The Framework creates carries the `@gemstack/skill-tickets` package's `SKILL.md` beside the `branches` skill, where the agent's harness looks for skills (`.claude/skills/tickets` for Claude Code, `.agents/skills/tickets` for Codex), and the presets tell the agent to use it — the tickets and the agent queue are on the `tickets` branch, read and changed with the command, claimed before they are planned or worked; an agent anywhere else (a terminal run in your checkout, an Actions runner, a cloud session) is told instead how to do the same with git, temporarily, until the skill is committed into the repository (#1748) -- `tickets` on every agent's PATH — the skill's command line: `list` and `show ` read the tickets with their plans and their holders, `queue` reads the queue in the order it is worked, `queue add ` puts an entry on it at a priority and linked to a ticket, `queue done ` takes an entry off, `put ` writes a ticket, a plan or the import stamp, `close ` removes a ticket with its plan and claim, `claim ` and `release ` are the claim; every one of them is one commit pushed to the `tickets` branch +- The `tickets` skill: every checkout The Framework creates carries the `@gemstack/skill-tickets` package's `SKILL.md` beside the `branches` skill, where the agent's harness looks for skills (`.claude/skills/tickets` for Claude Code, `.agents/skills/tickets` for Codex), and the presets tell the agent to use it — the tickets are on the `agent-data` branch, read and changed with the command, claimed before they are planned or worked, queued as a link through the `queue` skill; an agent anywhere else (a terminal run in your checkout, an Actions runner, a cloud session) is told instead how to do the same with git, temporarily, until the skill is committed into the repository (#1748) +- `tickets` on every agent's PATH — the skill's command line: `list` and `show ` read the tickets with their plans and their holders, `put ` writes a ticket, a plan or the import stamp, `close ` removes a ticket with its plan and claim, `claim ` and `release ` are the claim; every one of them is one commit pushed to the `agent-data` branch +- The `queue` skill: every checkout The Framework creates carries the `@gemstack/skill-queue` package's `SKILL.md` beside the other two (`.claude/skills/queue`, `.agents/skills/queue`), and the presets tell the agent to use it — the agent queue is on the `agent-data` branch, read and changed with the command; an agent anywhere else gets the same git bridge as for the tickets (#1750) +- `queue` on every agent's PATH — the skill's command line: `queue` reads the queue in the order it is worked, `queue add [--priority N]` puts an entry on it in its priority section, `queue done ` takes an entry off; every one of them is one commit pushed to the `agent-data` branch - The session name is the branch: an agent is labelled by its `agent-` branch, read from git after every turn — nothing to signal, nothing to record twice - Dependency directories shared from the parent checkout instead of reinstalled — as directories of links, so an agent's own install stays in its checkout and never rewrites or purges the parent's - A checkout whose work is not on the remote is kept — and a publish-nothing (`handoff: local`) agent's is kept until you publish or delete it diff --git a/SPEC.md b/SPEC.md index 30a1659ac..081ecb5ca 100644 --- a/SPEC.md +++ b/SPEC.md @@ -5,7 +5,8 @@ Six top-level pieces, one product: - `packages/framework` — the product itself, published as the npm package `framework`: one CLI (`the-framework`) that runs a foreground daemon, the agent lifecycle it orchestrates, and the browser dashboard it serves — the product's only user interface. - `packages/agent-data` — a branch of the project's repository used as a file store, published as `@gemstack/agent-data`: the shared `agent-data` branch every skill keeps its files on, written through one sync → commit → push cycle that re-applies the change when the push loses a race, from a long-lived process through a persistent checkout or one-shot from any clone. A library, not a skill — read by code, never by an agent. Every skill depends on it, and so does the product; it depends on nothing. - `packages/skill-branches` — the git conventions and operations behind an agent's own checkout, as an API, as the `branches` command every agent the daemon starts on its machine gets on its PATH, and as the skill (`SKILL.md`) every agent's built-in system prompt carries, published as `@gemstack/skill-branches`: the first of the skills the product is being split into. The product depends on it; it depends on agent-data and on nothing of the product. -- `packages/skill-tickets` — the project's tickets and its agent queue, on the `tickets` branch of the project's own repository, published as `@gemstack/skill-tickets`: the same three faces — an API, the `tickets` command every agent the daemon starts gets on its PATH, and the skill (`SKILL.md`) every such agent finds in its checkout — over reading tickets, writing them, claiming one so two agents never work the same, and keeping the queue. The second of the skills. The product depends on it, and it depends on agent-data for the branch it stores everything on. +- `packages/skill-tickets` — the project's tickets, on the `agent-data` branch of the project's own repository, published as `@gemstack/skill-tickets`: the same three faces — an API, the `tickets` command every agent the daemon starts gets on its PATH, and the skill (`SKILL.md`) every such agent finds in its checkout — over reading tickets, writing them, and claiming one so two agents never work the same. The second of the skills. The product depends on it, and it depends on agent-data for the branch it stores everything on. +- `packages/skill-queue` — the project's agent queue, on the same `agent-data` branch, published as `@gemstack/skill-queue`: the same three faces — an API, the `queue` command on every agent's PATH, and the skill (`SKILL.md`) — over reading the queue, adding an entry at a priority, and taking one off. The third of the skills. The product depends on it, and it depends on agent-data only. - `packages/agent-driver` — the driver seam as its own package, published as `agent-driver`: one contract for driving a coding-agent CLI as a black box — a session in a directory, one full turn per prompt, a stream of what the agent did — and the implementations for Claude Code and Codex on this device, Claude Code on a GitHub Actions runner, and a scripted fake. The product depends on it and adds the one implementation that needs the product, the hand-off to a Claude Code cloud session; it depends on nothing of the product. - `packages/chrome-extension` — the Claude web bridge, a companion Chrome extension: when an agent's task was handed to a Claude Code cloud session on claude.ai, it carries the question that session is parked on into the local dashboard, and types the answer picked there back into the session. - `packages/the-framework.ai` — the marketing website at https://the-framework.ai. @@ -24,7 +25,7 @@ Every user-facing feature is enumerated in `FEATURES-SPEC.md`. - **The agent is the unit of work** - one task, in its own git worktree on its own branch, streaming everything it does as events; finished work is pushed and leaves as a pull request. - **Black-box driving** - the framework prompts the wrapped coding-agent CLI, lets the CLI's own loop run a full turn, and learns everything from the turn's final message: the session name the agent invented, the questions it stops to ask, and the ready-for-merge signal. - **Autonomy bounded by the account's own quota** - unattended work runs only while the account is under its pro-rated quota boundary; work a human asks for is never blocked, and a running agent is never interrupted over quota. -- **Nothing bookkeeping-shaped on a code branch** - the tickets and the agent queue live on the `tickets` branch, which is the `tickets` skill's; The Framework's own records of its runs — the agent archives, the routine locks — live on the `agents-logs` branch. The default branch stays 100% code. +- **Nothing bookkeeping-shaped on a code branch** - the tickets and the agent queue live on the `agent-data` branch, the `tickets` skill's and the `queue` skill's; The Framework's own records of its runs — the agent archives, the routine locks — live on the `agents-logs` branch. The default branch stays 100% code. ## Business logic diff --git a/package.json b/package.json index 8853d1fd1..f6bbf3bdd 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "preview": "pnpm -C packages/framework preview", "dev": "pnpm -C packages/framework dev", "typecheck": "pnpm -C packages/framework typecheck && pnpm -C packages/the-framework.ai typecheck", - "clean": "pnpm -C packages/agent-data clean && pnpm -C packages/skill-branches clean && pnpm -C packages/skill-tickets clean && pnpm -C packages/agent-driver clean && pnpm -C packages/framework clean", - "test": "pnpm -C packages/agent-data test && pnpm -C packages/skill-branches test && pnpm -C packages/skill-tickets test && pnpm -C packages/agent-driver test && pnpm -C packages/framework test", + "clean": "pnpm -C packages/agent-data clean && pnpm -C packages/skill-branches clean && pnpm -C packages/skill-tickets clean && pnpm -C packages/skill-queue clean && pnpm -C packages/agent-driver clean && pnpm -C packages/framework clean", + "test": "pnpm -C packages/agent-data test && pnpm -C packages/skill-branches test && pnpm -C packages/skill-tickets test && pnpm -C packages/skill-queue test && pnpm -C packages/agent-driver test && pnpm -C packages/framework test", "========= Website": "", "// Develop website https://the-framework.ai locally": "", "website": "cd packages/the-framework.ai/ && pnpm run dev", @@ -24,6 +24,7 @@ "devDependencies": { "@gemstack/skill-branches": "workspace:*", "@gemstack/skill-tickets": "workspace:*", + "@gemstack/skill-queue": "workspace:*", "typescript": "^7.0.2" } } diff --git a/packages/SPEC.md b/packages/SPEC.md index 00dad9557..b8406d184 100644 --- a/packages/SPEC.md +++ b/packages/SPEC.md @@ -3,12 +3,13 @@ The deliverables The Framework ships, one directory each: - `framework/` — the product: the `framework` npm package (CLI, daemon, agent lifecycle, dashboard). - `agent-data/` — the `@gemstack/agent-data` npm package: a branch of the project's repository used as a file store — the shared `agent-data` branch every skill keeps its files on — with the git runner and the exclude rule it is built on. A library, not a skill: read by code, never by an agent, so no `SKILL.md` and no command. Every skill depends on it; no skill depends on another (#1750). - `skill-branches/` — the `@gemstack/skill-branches` npm package: the git conventions and operations behind an agent's own checkout, with the skill's instructions (`SKILL.md`) and the `branches` command an agent follows them with: the first skill of the skills-plus architecture (#1725). The product depends on it, and so does the skill-tickets package; it depends on agent-data. -- `skill-tickets/` — the `@gemstack/skill-tickets` npm package: the project's tickets and its agent queue on the `tickets` branch of its own repository, with the skill's instructions (`SKILL.md`) and the `tickets` command an agent reads, writes and claims them with: the second skill of the skills-plus architecture (#1748). The product depends on it; it depends on agent-data for the branch it keeps everything on, and on skill-branches for who an agent is. +- `skill-tickets/` — the `@gemstack/skill-tickets` npm package: the project's tickets on the `agent-data` branch of its own repository, with the skill's instructions (`SKILL.md`) and the `tickets` command an agent reads, writes and claims them with: the second skill of the skills-plus architecture (#1748). The product depends on it; it depends on agent-data for the branch it keeps everything on. +- `skill-queue/` — the `@gemstack/skill-queue` npm package: the project's agent queue on the `agent-data` branch of its own repository, with the skill's instructions (`SKILL.md`) and the `queue` command an agent reads and changes it with: the third skill, split out of skill-tickets (#1750). The product depends on it; it depends on agent-data only. - `agent-driver/` — the `agent-driver` npm package: the driver seam — start a coding-agent CLI in a directory, prompt it for one full turn, stream what it does, resume it later — with the Claude Code, Codex, GitHub Actions and fake implementations. The product depends on it and adds its own cloud-session implementation behind the same contract. - `chrome-extension/` — the Claude web bridge, a companion Chrome extension that connects Claude Code cloud sessions on claude.ai back to the local dashboard. - `the-framework.ai/` — the marketing website. -The product depends on the agent-data, skill-branches, skill-tickets and agent-driver packages in code; the extension and the website depend on the product only in what they present. See the root `SPEC.md` for how they relate as a product. +The product depends on the agent-data, skill-branches, skill-tickets, skill-queue and agent-driver packages in code; the extension and the website depend on the product only in what they present. See the root `SPEC.md` for how they relate as a product. ## Before modifying/creating SPEC.md files diff --git a/packages/framework/SPEC.md b/packages/framework/SPEC.md index 9c1fe2771..2308a6089 100644 --- a/packages/framework/SPEC.md +++ b/packages/framework/SPEC.md @@ -2,7 +2,7 @@ The product: the `framework` npm package. One CLI, `the-framework`, runs a foreg How the package's pieces relate: -- `src/` — everything that runs in Node: the CLI, the daemon, the agent lifecycle (checkouts through the `skill-branches` package, gates, handoff), the drivers (the agent-driver package's, plus the product's own hand-off to a Claude Code cloud session), the `agents-logs` branch its own records live on (a branch used as a file store through the `@gemstack/agent-data` package; the tickets and the queue are the `skill-tickets` package's), autonomy (Auto PM, sweeps, CI watch), and the server side of the dashboard. +- `src/` — everything that runs in Node: the CLI, the daemon, the agent lifecycle (checkouts through the `skill-branches` package, gates, handoff), the drivers (the agent-driver package's, plus the product's own hand-off to a Claude Code cloud session), the `agents-logs` branch its own records live on (a branch used as a file store through the `@gemstack/agent-data` package; the tickets are the `skill-tickets` package's and the queue the `skill-queue` package's), autonomy (Auto PM, sweeps, CI watch), and the server side of the dashboard. - `dashboard/` — the browser app the daemon serves: a single-page app that is a pure projection of the files the daemon writes, reading over `POST /_rpc/` and a live event stream, and steering agents back through the same daemon. - `prompts/` — every prompt The Framework sends an agent, as markdown: the built-in system prompt, the protocols agents answer through, and the presets. The markdown is the only source of truth for agent-facing text. - `scripts/` — the build steps that compile the prompts into importable strings and run the package's test suites. diff --git a/packages/framework/dashboard/components/AiQueue.tsx b/packages/framework/dashboard/components/AiQueue.tsx index 12480d174..f96f68722 100644 --- a/packages/framework/dashboard/components/AiQueue.tsx +++ b/packages/framework/dashboard/components/AiQueue.tsx @@ -30,13 +30,13 @@ import { StartAgentButton } from './StartAgentButton.js' /** * The prompt the play button starts an agent with: the drain preset's vocabulary (work one entry - * through the `tickets` skill, then take it off the queue; do not start any other entry) narrowed + * through the `queue` skill, then take it off the queue; do not start any other entry) narrowed * from "the FIRST open entry" to the one entry the row shows. The raw `TODO_AGENTS.md` line, not * the pretty label: the agent must name exactly this entry to take it off, and the line's link is * how it opens the ticket (#1164). Exported so the test asserts against this and not a copy. */ export function workOnEntryPrompt(entry: string): string { - return `Use the \`tickets\` skill: work on this one open queue entry only, and when the work is done and published run \`tickets queue done ""\`. Do not start any other entry. The entry:\n\n${entry}` + return `Use the \`queue\` skill: work on this one open queue entry only, and when the work is done and published run \`queue done ""\`. Do not start any other entry. The entry:\n\n${entry}` } /** How many agents the fan-out button starts until its count says otherwise. */ diff --git a/packages/framework/package.json b/packages/framework/package.json index a4455152a..a14596cb9 100644 --- a/packages/framework/package.json +++ b/packages/framework/package.json @@ -33,14 +33,14 @@ "the-framework": "dist/bin.js" }, "scripts": { - "build": "pnpm -C ../skill-branches build && pnpm -C ../skill-tickets build && pnpm -C ../agent-driver build && node scripts/gen-prompts.mjs && tsc -p tsconfig.build.json && cd dashboard && vite build", + "build": "pnpm -C ../skill-branches build && pnpm -C ../skill-tickets build && pnpm -C ../skill-queue build && pnpm -C ../agent-driver build && node scripts/gen-prompts.mjs && tsc -p tsconfig.build.json && cd dashboard && vite build", "preview": "pnpm build && node dist/bin.js", - "dev": "pnpm -C ../skill-branches build && pnpm -C ../skill-tickets build && pnpm -C ../agent-driver build && node scripts/gen-prompts.mjs && tsc -p tsconfig.build.json --watch", + "dev": "pnpm -C ../skill-branches build && pnpm -C ../skill-tickets build && pnpm -C ../skill-queue build && pnpm -C ../agent-driver build && node scripts/gen-prompts.mjs && tsc -p tsconfig.build.json --watch", "dev:dashboard": "cd dashboard && vite", "dev:daemon": "cd dashboard && FRAMEWORK_DEV_DAEMON=1 vite", "design:build": "tsx dashboard/design/build.mts", - "typecheck": "pnpm -C ../skill-branches build && pnpm -C ../skill-tickets build && pnpm -C ../agent-driver build && node scripts/gen-prompts.mjs && tsc --noEmit && tsc --noEmit -p dashboard/tsconfig.json", - "test": "pnpm -C ../skill-branches build && pnpm -C ../skill-tickets build && pnpm -C ../agent-driver build && node scripts/gen-prompts.mjs && tsc -p tsconfig.test.json && node scripts/run-tests.mjs && cd dashboard && vitest run", + "typecheck": "pnpm -C ../skill-branches build && pnpm -C ../skill-tickets build && pnpm -C ../skill-queue build && pnpm -C ../agent-driver build && node scripts/gen-prompts.mjs && tsc --noEmit && tsc --noEmit -p dashboard/tsconfig.json", + "test": "pnpm -C ../skill-branches build && pnpm -C ../skill-tickets build && pnpm -C ../skill-queue build && pnpm -C ../agent-driver build && node scripts/gen-prompts.mjs && tsc -p tsconfig.test.json && node scripts/run-tests.mjs && cd dashboard && vitest run", "clean": "rm -rf dist dist-test src/prompts.generated.ts", "gen:prompts": "node scripts/gen-prompts.mjs" }, @@ -49,6 +49,7 @@ "@gemstack/agent-data": "workspace:*", "@gemstack/skill-branches": "workspace:*", "@gemstack/skill-tickets": "workspace:*", + "@gemstack/skill-queue": "workspace:*", "agent-driver": "workspace:*", "@puppeteer/browsers": "3.2.1", "@shadcn/react": "^0.2.1", diff --git a/packages/framework/prompts/README.md b/packages/framework/prompts/README.md index 40d77b3fa..02f5fe4e5 100644 --- a/packages/framework/prompts/README.md +++ b/packages/framework/prompts/README.md @@ -7,7 +7,7 @@ is written in TypeScript any more, so prompting can change without touching the |---|---| | `system_prompt.md` | The built-in system prompt (#326). Rom's doc. Its `# User prompt` slot is where the user's own text is rendered, for a build and a prompt session alike (#1691). | | `branch_yourself.md` | The "Branch management" section for an agent that runs outside a checkout The Framework created (#1725): the command is not there, so it branches with git itself. Agents in their own checkout get the `skill-branches` package's `SKILL.md` instead. | -| `tickets_yourself.md` | `branch_yourself.md`'s counterpart for the tickets and the agent queue (#1748): the `tickets` command is not there either, so the agent reads and writes the `agent-data` branch with git itself, and the `skill-tickets` package's `SKILL.md` follows it for the formats. Agents in their own checkout get the `tickets` skill in the checkout instead. Temporary: it goes when the skill is committed into the repository. | +| `tickets_yourself.md` | `branch_yourself.md`'s counterpart for the tickets and the agent queue (#1748): the `tickets` and `queue` commands are not there either, so the agent reads and writes the `agent-data` branch with git itself, and the `skill-tickets` and `skill-queue` packages' `SKILL.md` follow it for the formats. Agents in their own checkout get the `tickets` and `queue` skills in the checkout instead. Temporary: it goes when the skills are committed into the repository. | | `triage_scope.md` | The queue-only rule appended to both triage presets: a triage writes the queue through the skill, never a ticket's code (#1641). | | `on_before_mergeable_prompt.md` | The optional extra turn an agent gets when it signals ready for merge: queue quality follow-ups, fold what it learned into the knowledge base. | | `protocols/await.md` | How to emit an awaited choice so the turn-boundary gate can detect it (#337/#339). | @@ -22,8 +22,9 @@ Edit the markdown, then `pnpm build`. `scripts/gen-prompts.mjs` compiles this di `src/prompts.generated.ts` (git-ignored, rebuilt by `build` / `test` / `typecheck`), which is what the code imports. The markdown is the only source of truth. -It also compiles one file from outside this directory: the `@gemstack/skill-tickets` package's -`SKILL.md`, which `tickets_yourself.md` is followed by. Temporary, with that prompt (#1748). +It also compiles two files from outside this directory: the `@gemstack/skill-tickets` and +`@gemstack/skill-queue` packages' `SKILL.md`, which `tickets_yourself.md` is followed by, in that +order. Temporary, with that prompt (#1748). Adding `foo/bar.md` exports `FOO_BAR`. A file's exact bytes become the string, minus one trailing newline. diff --git a/packages/framework/prompts/on_before_mergeable_prompt.SPEC.md b/packages/framework/prompts/on_before_mergeable_prompt.SPEC.md index 03d446ed9..012397ac7 100644 --- a/packages/framework/prompts/on_before_mergeable_prompt.SPEC.md +++ b/packages/framework/prompts/on_before_mergeable_prompt.SPEC.md @@ -7,7 +7,7 @@ The extra turn an agent is given the moment it signals ready for merge, when the ## Business logic — TL;DR -- **Quality work is queued, not run** - the agent puts entries on the agent queue (`TODO_AGENTS.md`), with the `tickets` skill's command, asking for the quality presets to be applied to its own changes; a later drain does the work. +- **Quality work is queued, not run** - the agent puts entries on the agent queue (`TODO_AGENTS.md`), with the `queue` skill's command, asking for the quality presets to be applied to its own changes; a later drain does the work. - **Two conditions, two presets** - non-trivial changes with refactor potential queue the maintainability preset, changes that could lead to security issues queue the security-audit preset; each entry scopes the preset to "changes introduced by ". - **What was learned goes into the knowledge base** - the agent updates the project's decisions, facts and insights files from the session's changes and discussions, creating them when missing, and writes only what a future agent could not get from the code itself. @@ -21,7 +21,7 @@ See `## User story`: the user wants the follow-up passes to happen, but on their #### Business logic -The prompt asks the agent to *queue* work rather than perform it, and names how: the `tickets` skill's command, which is what writes an entry onto the agent queue. Each entry names a preset file and the target to apply it to, so a later drain of the queue turns it into its own agent. The agent judges both conditions itself against the changes it just made: +The prompt asks the agent to *queue* work rather than perform it, and names how: the `queue` skill's command, which is what writes an entry onto the agent queue. Each entry names a preset file and the target to apply it to, so a later drain of the queue turns it into its own agent. The agent judges both conditions itself against the changes it just made: - changes that are not trivial and have refactor potential queue the maintainability preset; - changes that can potentially lead to security issues queue the security-audit preset. diff --git a/packages/framework/prompts/on_before_mergeable_prompt.md b/packages/framework/prompts/on_before_mergeable_prompt.md index 3691ef02e..8d787eb7f 100644 --- a/packages/framework/prompts/on_before_mergeable_prompt.md +++ b/packages/framework/prompts/on_before_mergeable_prompt.md @@ -1,4 +1,4 @@ -TODO_FILE: the agent queue, `TODO_AGENTS.md` — add an entry with `tickets queue add ""` from the `tickets` skill +TODO_FILE: the agent queue, `TODO_AGENTS.md` — add an entry with `queue add ""` from the `queue` skill ## Maintenance diff --git a/packages/framework/prompts/presets/drain_queue.SPEC.md b/packages/framework/prompts/presets/drain_queue.SPEC.md index 65f95baa7..4e033be51 100644 --- a/packages/framework/prompts/presets/drain_queue.SPEC.md +++ b/packages/framework/prompts/presets/drain_queue.SPEC.md @@ -1,4 +1,4 @@ -The drain-queue preset: takes exactly one task off the agent queue and works it. The agent reads the queue with the `tickets` skill's command, works on the first open entry only, takes that entry off the queue with the same command once the work is done and published — an entry that is done is deleted, not marked — and starts no other entry, so each queued task becomes its own agent, its own branch and its own pull request, and an entry whose work never landed stays on the queue for the next drain to pick up. +The drain-queue preset: takes exactly one task off the agent queue and works it. The agent reads the queue with the `queue` skill's command, works on the first open entry only, takes that entry off the queue with the same command once the work is done and published — an entry that is done is deleted, not marked — and starts no other entry, so each queued task becomes its own agent, its own branch and its own pull request, and an entry whose work never landed stays on the queue for the next drain to pick up. ## Before modifying/creating SPEC.md files diff --git a/packages/framework/prompts/presets/drain_queue.md b/packages/framework/prompts/presets/drain_queue.md index 026dcfe7d..3c45510d1 100644 --- a/packages/framework/prompts/presets/drain_queue.md +++ b/packages/framework/prompts/presets/drain_queue.md @@ -1 +1 @@ -Use the `tickets` skill: run `tickets queue` and work on the FIRST open entry only. When the work is done and published, run `tickets queue done ""`. Do not start any other entry. +Use the `queue` skill: run `queue` and work on the FIRST open entry only. When the work is done and published, run `queue done ""`. Do not start any other entry. diff --git a/packages/framework/prompts/presets/maintenance.SPEC.md b/packages/framework/prompts/presets/maintenance.SPEC.md index a5a41f458..3a549348d 100644 --- a/packages/framework/prompts/presets/maintenance.SPEC.md +++ b/packages/framework/prompts/presets/maintenance.SPEC.md @@ -2,7 +2,7 @@ The maintenance preset: the periodic sweep that turns a codebase into queued qua ## Business logic — TL;DR -- **It queues work, it does not refactor** - for every part of the codebase that needs it, the agent puts entries on the agent queue (`TODO_AGENTS.md`), with the `tickets` skill's command, asking for the maintainability preset and the security-audit preset to be applied to that part. +- **It queues work, it does not refactor** - for every part of the codebase that needs it, the agent puts entries on the agent queue (`TODO_AGENTS.md`), with the `queue` skill's command, asking for the maintainability preset and the security-audit preset to be applied to that part. - **Each entry names its own target** - the agent replaces the placeholder with a clear designation of the codebase subset, so the queued entry stands on its own when a later drain picks it up. - **Queued low** - the entries usually go on at low priority, so routine quality work never displaces the roadmap. @@ -16,7 +16,7 @@ The user wants quality passes to happen across the whole codebase over time, wit #### Business logic -The agent's output is entries on the agent queue rather than code changes. It divides the analyzed area into codebase subsets, and for each subset that needs it queues two entries through the `tickets` skill's command: one applying the maintainability preset to that subset, one applying the security-audit preset to it. Each entry names the subset explicitly, and the entries are given a low priority. A later drain of the queue turns each entry into its own agent. +The agent's output is entries on the agent queue rather than code changes. It divides the analyzed area into codebase subsets, and for each subset that needs it queues two entries through the `queue` skill's command: one applying the maintainability preset to that subset, one applying the security-audit preset to it. Each entry names the subset explicitly, and the entries are given a low priority. A later drain of the queue turns each entry into its own agent. ## Before modifying/creating SPEC.md files diff --git a/packages/framework/prompts/presets/maintenance.md b/packages/framework/prompts/presets/maintenance.md index a48f66a1a..32875000c 100644 --- a/packages/framework/prompts/presets/maintenance.md +++ b/packages/framework/prompts/presets/maintenance.md @@ -1,5 +1,5 @@ Analyze ${{ tf.params.what }} and look for opportunities to refactor code. -For each codebase subset that needs it, put the following entries on the queue with `tickets queue add "" --priority ` (from the `tickets` skill; usually a low priority) and replace with a clear designation. +For each codebase subset that needs it, put the following entries on the queue with `queue add "" --priority ` (from the `queue` skill; usually a low priority) and replace with a clear designation. - "Apply ${{ tf.presets.maintainability.filePath }} with tf.params.what set to " - "Apply ${{ tf.presets.security_audit.filePath }} with tf.params.what set to " diff --git a/packages/framework/prompts/presets/market_research.SPEC.md b/packages/framework/prompts/presets/market_research.SPEC.md index af32c1b99..c601c9c04 100644 --- a/packages/framework/prompts/presets/market_research.SPEC.md +++ b/packages/framework/prompts/presets/market_research.SPEC.md @@ -1,4 +1,4 @@ -The market-research preset: the agent researches the market the project competes in thoroughly, writes the result to `knowledge-base/MARKET_RESEARCH.md`, and puts one entry on the agent queue (`TODO_AGENTS.md`), with the `tickets` skill's command, naming this session and asking for new tickets to be suggested from it. +The market-research preset: the agent researches the market the project competes in thoroughly, writes the result to `knowledge-base/MARKET_RESEARCH.md`, and puts one entry on the agent queue (`TODO_AGENTS.md`), with the `queue` skill's command, naming this session and asking for new tickets to be suggested from it. ## Business logic — TL;DR diff --git a/packages/framework/prompts/presets/market_research.md b/packages/framework/prompts/presets/market_research.md index aa573e684..85033fade 100644 --- a/packages/framework/prompts/presets/market_research.md +++ b/packages/framework/prompts/presets/market_research.md @@ -1,5 +1,5 @@ - Make a thorough market research - Write it to `knowledge-base/MARKET_RESEARCH.md` -- Put this entry on the queue with `tickets queue add` (from the `tickets` skill): "Read then suggest new tickets" +- Put this entry on the queue with `queue add` (from the `queue` skill): "Read then suggest new tickets" SESSION_NAME: the name of the session diff --git a/packages/framework/prompts/presets/plan_tickets.SPEC.md b/packages/framework/prompts/presets/plan_tickets.SPEC.md index 5b7a7f6f5..86bcbf3e7 100644 --- a/packages/framework/prompts/presets/plan_tickets.SPEC.md +++ b/packages/framework/prompts/presets/plan_tickets.SPEC.md @@ -1,4 +1,4 @@ -The plan-tickets preset: queues planning work for the tickets that still have none. Working through the `tickets` skill, the agent picks the 10 most important tickets that have neither a plan nor a claim on them, and puts one entry per ticket on the agent queue (`TODO_AGENTS.md`) asking for that ticket's plan to be written. +The plan-tickets preset: queues planning work for the tickets that still have none. Reading through the `tickets` skill, the agent picks the 10 most important tickets that have neither a plan nor a claim on them, and puts one entry per ticket on the agent queue (`TODO_AGENTS.md`), with the `queue` skill's command, asking for that ticket's plan to be written. ## Business logic — TL;DR diff --git a/packages/framework/prompts/presets/plan_tickets.md b/packages/framework/prompts/presets/plan_tickets.md index 2a62d1a96..54e2c1427 100644 --- a/packages/framework/prompts/presets/plan_tickets.md +++ b/packages/framework/prompts/presets/plan_tickets.md @@ -1,5 +1,5 @@ Use the `tickets` skill. Run `tickets list` and, for the most important tickets that are neither planned nor locked, put the following on the queue: -- `tickets queue add "Create tickets/.plan.md" --priority ` +- `queue add "Create tickets/.plan.md" --priority ` (from the `queue` skill) Pick each entry's priority following a mix of sensible criteria after reading the ticket (`tickets show .md`; e.g. if the ticket seems low effort => higher priority). diff --git a/packages/framework/prompts/presets/suggest_tickets_to_work_on.SPEC.md b/packages/framework/prompts/presets/suggest_tickets_to_work_on.SPEC.md index faa316608..87778c8c5 100644 --- a/packages/framework/prompts/presets/suggest_tickets_to_work_on.SPEC.md +++ b/packages/framework/prompts/presets/suggest_tickets_to_work_on.SPEC.md @@ -1,4 +1,4 @@ -The suggest-tickets-to-work-on preset: the agent lists all tickets with the `tickets` skill, picks the ones to work on next, and offers them to the user as a multi-select gate — pre-checking only the ones it is highly confident about — then puts the tickets the user approved on the agent queue (`TODO_AGENTS.md`), one entry each, linked to its ticket. +The suggest-tickets-to-work-on preset: the agent lists all tickets with the `tickets` skill, picks the ones to work on next, and offers them to the user as a multi-select gate — pre-checking only the ones it is highly confident about — then puts the tickets the user approved on the agent queue (`TODO_AGENTS.md`) with the `queue` skill's command, one entry each, written as a link to its ticket at the ticket's own priority (5 when it has none). ## Business logic — TL;DR @@ -16,7 +16,7 @@ The user wants help choosing what to work on next but keeps the choice: what lan #### Business logic -The agent looks at all tickets and picks the ones to work on next, shows exactly those picks as a multi-select gate, and stops. Its confidence is expressed as the default state of each entry — checked when it is highly confident the ticket is a good candidate, unchecked otherwise — so answering can be as cheap as accepting the defaults. Once the user answers, the approved tickets go on the agent queue through the skill's command, each entry naming the ticket it came from. +The agent looks at all tickets and picks the ones to work on next, shows exactly those picks as a multi-select gate, and stops. Its confidence is expressed as the default state of each entry — checked when it is highly confident the ticket is a good candidate, unchecked otherwise — so answering can be as cheap as accepting the defaults. Once the user answers, the approved tickets go on the agent queue through the `queue` skill's command, each entry naming the ticket it came from. #### Rationale diff --git a/packages/framework/prompts/presets/suggest_tickets_to_work_on.md b/packages/framework/prompts/presets/suggest_tickets_to_work_on.md index 87f14f58d..97c449964 100644 --- a/packages/framework/prompts/presets/suggest_tickets_to_work_on.md +++ b/packages/framework/prompts/presets/suggest_tickets_to_work_on.md @@ -2,6 +2,6 @@ 2. Show the tickets you picked via `showMultiSelect()` - For each ticket, if high confidence the ticket is a good candidate to work on next => set its default to `true`, otherwise `false` 3. -4. Put each approved ticket on the queue: `tickets queue add "" --ticket <file>` +4. Put each approved ticket on the queue: `queue add "[<title>](tickets/<file>)" --priority <N>` (from the `queue` skill; <N> the ticket's `Priority:`, 5 when it has none) AWAIT: Stop, await user answer before resuming diff --git a/packages/framework/prompts/presets/triage_consensual.SPEC.md b/packages/framework/prompts/presets/triage_consensual.SPEC.md index 70628c1b9..c3aac2fda 100644 --- a/packages/framework/prompts/presets/triage_consensual.SPEC.md +++ b/packages/framework/prompts/presets/triage_consensual.SPEC.md @@ -1,4 +1,4 @@ -The triage-consensual preset: fills the agent queue with the significant work that needs no human input. The agent lists all tickets with the `tickets` skill, picks only the ones that are significant — explicitly not quick wins — and consensual, meaning zero open questions and zero variability, such as a ticket with a single fairly obvious plan, and puts them on the agent queue (`TODO_AGENTS.md`), one entry each, linked to its ticket. +The triage-consensual preset: fills the agent queue with the significant work that needs no human input. The agent lists all tickets with the `tickets` skill, picks only the ones that are significant — explicitly not quick wins — and consensual, meaning zero open questions and zero variability, such as a ticket with a single fairly obvious plan, and puts them on the agent queue (`TODO_AGENTS.md`) with the `queue` skill's command, one entry each, written as a link to its ticket at the ticket's own priority (5 when it has none). ## Business logic — TL;DR diff --git a/packages/framework/prompts/presets/triage_consensual.md b/packages/framework/prompts/presets/triage_consensual.md index 4414b8a76..7f8553af4 100644 --- a/packages/framework/prompts/presets/triage_consensual.md +++ b/packages/framework/prompts/presets/triage_consensual.md @@ -1,5 +1,5 @@ 1. Use the `tickets` skill. Look at all tickets (`tickets list`; `effort` and `uncertainty` are in its output) and choose tickets to work on next - Only pick tickets that are significant (no quick-wins) and consensual (zero open questions, zero variability, e.g. a single fairly obvious plan) -2. Put each picked ticket on the queue: `tickets queue add "<title>" --ticket <file>` +2. Put each picked ticket on the queue: `queue add "[<title>](tickets/<file>)" --priority <N>` (from the `queue` skill; <N> the ticket's `Priority:`, 5 when it has none) Always set <SESSION_NAME> to triage-consensual diff --git a/packages/framework/prompts/presets/triage_quick.SPEC.md b/packages/framework/prompts/presets/triage_quick.SPEC.md index fa1594622..0c9b9d530 100644 --- a/packages/framework/prompts/presets/triage_quick.SPEC.md +++ b/packages/framework/prompts/presets/triage_quick.SPEC.md @@ -1,4 +1,4 @@ -The triage-quick preset: fills the agent queue with the cheap, decision-free work. The agent lists the tickets with the `tickets` skill and picks only those whose plan shows a quick win — a low effort rating with an uncertainty of zero — then puts them on the agent queue (`TODO_AGENTS.md`), one entry each, linked to its ticket and given a priority. +The triage-quick preset: fills the agent queue with the cheap, decision-free work. The agent lists the tickets with the `tickets` skill and picks only those whose plan shows a quick win — a low effort rating with an uncertainty of zero — then puts them on the agent queue (`TODO_AGENTS.md`) with the `queue` skill's command, one entry each, written as a link to its ticket and given a priority. ## Business logic — TL;DR diff --git a/packages/framework/prompts/presets/triage_quick.md b/packages/framework/prompts/presets/triage_quick.md index 76c80f31e..d5eb48495 100644 --- a/packages/framework/prompts/presets/triage_quick.md +++ b/packages/framework/prompts/presets/triage_quick.md @@ -1,6 +1,6 @@ Use the `tickets` skill. Look at all tickets (`tickets list`; `effort` and `uncertainty` are in its output) and choose tickets to work on next: - Pick a ticket if it has a plan that shows it's a quick-win (low `effort` value) with `uncertainty: 0` -- Put each picked ticket on the queue: `tickets queue add "<title>" --ticket <file> --priority <N>` +- Put each picked ticket on the queue: `queue add "[<title>](tickets/<file>)" --priority <N>` (from the `queue` skill) - With sensible prioritization, and consider bumping the priority of lowest effort tickets (e.g. to make `effort: 0` the next tasks agents work on) Always set <SESSION_NAME> to triage-quick diff --git a/packages/framework/prompts/system_prompt.SPEC.md b/packages/framework/prompts/system_prompt.SPEC.md index 71ce4084e..88a7cedc3 100644 --- a/packages/framework/prompts/system_prompt.SPEC.md +++ b/packages/framework/prompts/system_prompt.SPEC.md @@ -8,7 +8,7 @@ The Framework's built-in system prompt: the standing instructions every agent st ## Glossary -- **macro** - a shorthand the prompt defines for itself at the top (`<SHOW_MD>`, `<SHOW_CHOICES>`, `<AWAIT>`, `<SESSION_NAME>`, `<TODO_FILE>`) and then uses throughout, so one instruction is written once and referenced everywhere. The agent expands them itself; nothing in The Framework substitutes them. `<TODO_FILE>` is the agent queue, and its definition also says how an entry reaches it: through the `tickets` skill's command. +- **macro** - a shorthand the prompt defines for itself at the top (`<SHOW_MD>`, `<SHOW_CHOICES>`, `<AWAIT>`, `<SESSION_NAME>`, `<TODO_FILE>`) and then uses throughout, so one instruction is written once and referenced everywhere. The agent expands them itself; nothing in The Framework substitutes them. `<TODO_FILE>` is the agent queue, and its definition also says how an entry reaches it: through the `queue` skill's command. ## Business logic — TL;DR @@ -41,7 +41,7 @@ The user asks for something that turns out to be days of work. Reviewing the dir When the scope of the work is *large*, the agent writes a plan file named `PLAN_<session name>.agent.md` describing what it will work on, shows it to the user as a markdown view, and stops until the user approves. -When the scope is *potentially very large* — spanning many hours or days — the agent additionally considers adding follow-up tasks to the agent queue (`TODO_AGENTS.md`) and showing the new entries as a markdown view, so the overflow becomes queued work rather than being lost. The prompt says how such an entry is added: with the `tickets` skill's command, which puts it on the queue at the priority the agent gives it. +When the scope is *potentially very large* — spanning many hours or days — the agent additionally considers adding follow-up tasks to the agent queue (`TODO_AGENTS.md`) and showing the new entries as a markdown view, so the overflow becomes queued work rather than being lost. The prompt says how such an entry is added: with the `queue` skill's command, which puts it on the queue at the priority the agent gives it. ### Name the session diff --git a/packages/framework/prompts/system_prompt.md b/packages/framework/prompts/system_prompt.md index 36779908b..6d8c9e7b6 100644 --- a/packages/framework/prompts/system_prompt.md +++ b/packages/framework/prompts/system_prompt.md @@ -4,7 +4,7 @@ SHOW_MD: Show it via `showMarkdown()` SHOW_CHOICES: Show it via `showChoices()` AWAIT: Stop, await user answer before resuming SESSION_NAME: the name of the session -TODO_FILE: the agent queue, `TODO_AGENTS.md` — add an entry with `tickets queue add "<entry>" --priority <N>` from the `tickets` skill +TODO_FILE: the agent queue, `TODO_AGENTS.md` — add an entry with `queue add "<entry>" --priority <N>` from the `queue` skill ## Analyze the user prompt diff --git a/packages/framework/prompts/tickets_yourself.SPEC.md b/packages/framework/prompts/tickets_yourself.SPEC.md index b2af0d748..5e05bc0e9 100644 --- a/packages/framework/prompts/tickets_yourself.SPEC.md +++ b/packages/framework/prompts/tickets_yourself.SPEC.md @@ -1,4 +1,4 @@ -Told to an agent that runs outside a checkout The Framework created — a plain terminal run in the user's own checkout, a GitHub Actions runner, a cloud session — in place of the `tickets` skill's command: the tickets and the agent queue live on the `agent-data` branch, so the agent reads them off that branch with git, writes a change as one commit on a checkout of the branch pushed straight to it (fetching, rebasing and pushing again when the push is rejected), and claims a ticket by adding its lock file naming itself — the `AGENT_ID` its environment carries, else its own branch, backing off when the lock's push is rejected because someone claimed the ticket first. The `tickets` skill's own text follows it in the agent's context, for the formats. Temporary: gone once the skill is committed into the repository and every agent finds it there. +Told to an agent that runs outside a checkout The Framework created — a plain terminal run in the user's own checkout, a GitHub Actions runner, a cloud session — in place of the `tickets` and `queue` skills' commands: the tickets and the agent queue live on the `agent-data` branch, so the agent reads them off that branch with git, writes a change as one commit on a checkout of the branch pushed straight to it (fetching, rebasing and pushing again when the push is rejected), and claims a ticket by adding its lock file naming itself — the `AGENT_ID` its environment carries, else its own branch, backing off when the lock's push is rejected because someone claimed the ticket first. The `tickets` and `queue` skills' own text follows it in the agent's context, for the formats. Temporary: gone once the skill is committed into the repository and every agent finds it there. ## Before modifying/creating SPEC.md files diff --git a/packages/framework/prompts/tickets_yourself.md b/packages/framework/prompts/tickets_yourself.md index 1e9f2c727..b14a1ab81 100644 --- a/packages/framework/prompts/tickets_yourself.md +++ b/packages/framework/prompts/tickets_yourself.md @@ -1,6 +1,6 @@ -# Tickets and the agent queue, without the command +# Tickets and the agent queue, without the commands -The `tickets` command is not available here. The tickets and the queue described below live on the branch `agent-data` of this repository; do with git what the command would do: +The `tickets` and `queue` commands are not available here. The tickets and the queue described below live on the branch `agent-data` of this repository; do with git what the commands would do: - Read: `git fetch origin agent-data`, then `git show origin/agent-data:<FILE>` (e.g. `origin/agent-data:TODO_AGENTS.md`, `origin/agent-data:tickets/<DATE>_<SLUG>.md`). - Write, only when your instructions say to change tickets or the queue: commit the change on a checkout of `origin/agent-data` (`git worktree add --detach <dir> origin/agent-data`) and push it straight to the branch (`git push origin HEAD:refs/heads/agent-data`); on a rejected push, fetch, rebase and push again. Never switch your own checkout to the branch, and never put these files on your own branch. diff --git a/packages/framework/prompts/triage_scope.SPEC.md b/packages/framework/prompts/triage_scope.SPEC.md index edfd2c61d..064854c5d 100644 --- a/packages/framework/prompts/triage_scope.SPEC.md +++ b/packages/framework/prompts/triage_scope.SPEC.md @@ -1,4 +1,4 @@ -The rule shared by both triage presets, appended to each so the pair cannot drift apart on it: a triage agent only queues work, it never does it. The only thing it changes is the agent queue (`TODO_AGENTS.md`), and only through the `tickets` skill's command — it must not implement a ticket however small that ticket's plan is, no code changes and no pull request for it. Every ticket it picks goes onto the agent queue, where a human can still veto it before an agent implements it. +The rule shared by both triage presets, appended to each so the pair cannot drift apart on it: a triage agent only queues work, it never does it. The only thing it changes is the agent queue (`TODO_AGENTS.md`), and only through the `queue` skill's command — it must not implement a ticket however small that ticket's plan is, no code changes and no pull request for it. Every ticket it picks goes onto the agent queue, where a human can still veto it before an agent implements it. ## Before modifying/creating SPEC.md files diff --git a/packages/framework/prompts/triage_scope.md b/packages/framework/prompts/triage_scope.md index b24219c74..87dc58720 100644 --- a/packages/framework/prompts/triage_scope.md +++ b/packages/framework/prompts/triage_scope.md @@ -1 +1 @@ -You only queue work, you never do it: the only thing you change is the queue, through `tickets queue add`. Do not implement a ticket, however small its plan — no code changes, no pull request for it. Every ticket you pick goes on the queue, where a human can still veto it before an agent implements it. +You only queue work, you never do it: the only thing you change is the queue, through `queue add`. Do not implement a ticket, however small its plan — no code changes, no pull request for it. Every ticket you pick goes on the queue, where a human can still veto it before an agent implements it. diff --git a/packages/framework/scripts/gen-prompts.mjs b/packages/framework/scripts/gen-prompts.mjs index dbd4da94d..d6d8cf487 100644 --- a/packages/framework/scripts/gen-prompts.mjs +++ b/packages/framework/scripts/gen-prompts.mjs @@ -42,19 +42,20 @@ function constName(relPath) { .toUpperCase() } -// TEMPORARY (#1748): the `tickets` skill's text rides in the system channel of an agent that -// runs outside a checkout the daemon created (a terminal run, an Actions runner, a cloud -// session), because nothing links the skill into such a checkout yet. Read from wherever the -// package is installed, so the formats come from one place. Dies when use-npm-skills commits the -// skill into the repository. Its front matter is the skill catalogue's metadata, not -// instructions, and is dropped. -const ticketsSkillPath = createRequire(import.meta.url).resolve('@gemstack/skill-tickets/SKILL.md') +// TEMPORARY (#1748): the `tickets` and `queue` skills' text rides in the system channel of an +// agent that runs outside a checkout the daemon created (a terminal run, an Actions runner, a +// cloud session), because nothing links the skills into such a checkout yet. Read from wherever +// the packages are installed, so the formats come from one place. Dies when use-npm-skills +// commits the skills into the repository. Their front matter is the skill catalogue's metadata, +// not instructions, and is dropped. +const resolveSkill = createRequire(import.meta.url).resolve const sources = [ ...(await findMarkdown(promptsDir)).map(path => { const relPath = relative(promptsDir, path).split('\\').join('/') return { label: `prompts/${relPath}`, name: constName(relPath), path, frontMatter: false } }), - { label: '@gemstack/skill-tickets/SKILL.md', name: 'TICKETS_SKILL', path: ticketsSkillPath, frontMatter: true }, + { label: '@gemstack/skill-tickets/SKILL.md', name: 'TICKETS_SKILL', path: resolveSkill('@gemstack/skill-tickets/SKILL.md'), frontMatter: true }, + { label: '@gemstack/skill-queue/SKILL.md', name: 'QUEUE_SKILL', path: resolveSkill('@gemstack/skill-queue/SKILL.md'), frontMatter: true }, ] const entries = await Promise.all( sources.map(async source => { diff --git a/packages/framework/src/SPEC.md b/packages/framework/src/SPEC.md index dd1c4dd4d..7b5385585 100644 --- a/packages/framework/src/SPEC.md +++ b/packages/framework/src/SPEC.md @@ -6,7 +6,7 @@ Everything of The Framework that runs in Node: the CLI entry, the daemon, the ag - **The agent lifecycle** - an agent gets a worktree and a branch, is framed with the built-in system prompt, works turn by turn through the driver, parks on gates when it needs the user, works the agent queue when its main task settles, and publishes itself per its handoff level when done. - **The driver seam** - the wrapped coding-agent CLI is a black box (the `agent-driver` package; `driver/` holds only the product's own cloud-session implementation): prompt in, full turn out, everything learned by parsing the turn's final message. Which CLI (Claude Code, Codex) and where it runs (this device, a GitHub Actions runner, a Claude Code cloud session) are two separate axes. - **Files are the seam** - an agent appends events to `.the-framework/events.jsonl`; steering flows back through `.the-framework/control.jsonl`; the agent's status lives in `agent.json`. The daemon and every surface are projections of these files (`store/`) — there is no process-to-process IPC. -- **Two branches carry the records** - the tickets and the agent queue belong to the `tickets` skill (the `@gemstack/skill-tickets` package) and live on the project's `agent-data` branch; The Framework's own records — the agent archives and the routine locks — live on its `agents-logs` branch. Both are branches used as file stores (the `@gemstack/agent-data` package's): nobody edits them in a working tree, and every change goes through one serialized sync → apply → commit → push cycle, so every machine and cloud session sees the same thing while the default branch stays 100% code. +- **Two branches carry the records** - the tickets and the agent queue belong to the `tickets` and `queue` skills (the `@gemstack/skill-tickets` and `@gemstack/skill-queue` packages) and live on the project's `agent-data` branch; The Framework's own records — the agent archives and the routine locks — live on its `agents-logs` branch. Both are branches used as file stores (the `@gemstack/agent-data` package's): nobody edits them in a working tree, and every change goes through one serialized sync → apply → commit → push cycle, so every machine and cloud session sees the same thing while the default branch stays 100% code. - **Autonomy, bounded** - on one shared background clock the daemon runs Auto PM (drain the agent queue, triage, plan — at most one firing per routine at a time, guarded by a routine lock on the `agents-logs` branch), the CI watch (merge on green, fix on red), and the sweeps (reclaim pushed checkouts, adopt cloud work, expire dead refs) — each start gated by the quota boundary. - **The dashboard's server side** - `dashboard/` (in this directory) serves the built browser app and implements its reads, live event stream, actions, and daemon-to-daemon relay; `dashboard-rpc/` is the RPC surface itself. diff --git a/packages/framework/src/daemon-runtime.SPEC.md b/packages/framework/src/daemon-runtime.SPEC.md index 81813e600..0a2f83991 100644 --- a/packages/framework/src/daemon-runtime.SPEC.md +++ b/packages/framework/src/daemon-runtime.SPEC.md @@ -38,7 +38,7 @@ Whatever the framework's own CLI entry is, the agent is spawned from it. The dae The spawned agent's error output is written to a file inside its checkout instead of being discarded, so an agent that dies at boot leaves a trace to read. -The spawned agent's environment is the daemon's own, with the `branches` and `tickets` commands first on its PATH — the agent names its session, checks its tree, and reads and changes the tickets and the queue through the same packages the daemon does — plus the daemon's address, when the daemon has one: a web run uses it to ask this daemon for a cloud session created by the browser extension. A run nobody's daemon spawned has no such address, and a web run among them stops saying web runs start from the dashboard. +The spawned agent's environment is the daemon's own, with the `branches`, `tickets` and `queue` commands first on its PATH — the agent names its session, checks its tree, and reads and changes the tickets and the queue through the same packages the daemon does — plus the daemon's address, when the daemon has one: a web run uses it to ask this daemon for a cloud session created by the browser extension. A run nobody's daemon spawned has no such address, and a web run among them stops saying web runs start from the dashboard. ### Every agent gets its own checkout @@ -50,7 +50,7 @@ The user runs several agents on the same project at once, and keeps working in t Each agent is given its own git worktree under the project's `.branches/`, on its own `agent-<agent id>` branch. Concurrent agents on one project therefore never fight over a working tree, and the user's own checkout — uncommitted work included — is left untouched. -A fresh worktree has no installed dependencies, since those are not tracked by git, so the project's are mirrored in (the `skill-branches` package's dependency linking). The `tickets` skill is linked into every checkout the daemon creates or re-attaches, where the agent's harness looks for skills — a temporary arrangement, until skills are committed into the repository itself and every checkout carries them as tracked files. +A fresh worktree has no installed dependencies, since those are not tracked by git, so the project's are mirrored in (the `skill-branches` package's dependency linking). The `tickets` and `queue` skills are linked into every checkout the daemon creates or re-attaches, where the agent's harness looks for skills — a temporary arrangement, until skills are committed into the repository itself and every checkout carries them as tracked files. #### Rationale diff --git a/packages/framework/src/daemon-runtime.ts b/packages/framework/src/daemon-runtime.ts index 7547603c1..70b84987a 100644 --- a/packages/framework/src/daemon-runtime.ts +++ b/packages/framework/src/daemon-runtime.ts @@ -6,6 +6,7 @@ import { agentIdFromStartedAt, startedAtFromAgentId, archiveWorktreeAgent, resto import { isGitRepo, nodeGitRunner, isGitTimeout, withFileBranch } from '@gemstack/agent-data' import { createCheckout, attachCheckout, agentBranchName, worktreePath, worktreeBranch, removeWorktree, pruneWorktrees, agentIdFromWorktreeDir, CLI_BIN_DIR as BRANCHES_BIN_DIR } from '@gemstack/skill-branches' import { isTicketPath, CLI_BIN_DIR as TICKETS_BIN_DIR, SKILL_DIR as TICKETS_SKILL_DIR, SKILL_NAME as TICKETS_SKILL_NAME, AGENT_ID_ENV } from '@gemstack/skill-tickets' +import { CLI_BIN_DIR as QUEUE_BIN_DIR, SKILL_DIR as QUEUE_SKILL_DIR, SKILL_NAME as QUEUE_SKILL_NAME } from '@gemstack/skill-queue' import { LOGS_BRANCH, THE_FRAMEWORK_DIR } from './framework-dir.js' import type { FrameworkEvent } from './events.js' import { removeAgentSpec, writeAgentSpec } from './agent-spec.js' @@ -106,23 +107,26 @@ function spawnDetached(binPath: string, specPath: string, stderrFile?: string, e } /** - * A spawned run's environment: ours, with the `branches` and `tickets` commands on its PATH - * (#1725/#1748) — the agent names its session, checks its tree, and reads and changes the tickets - * through the same packages the daemon does — its id as `AGENT_ID`, so a claim it makes names the - * agent and not its branch, plus the daemon's URL when it has one (#1328). + * A spawned run's environment: ours, with the `branches`, `tickets` and `queue` commands on its + * PATH (#1725/#1748) — the agent names its session, checks its tree, and reads and changes the + * tickets and the queue through the same packages the daemon does — its id as `AGENT_ID`, so a + * claim it makes names the agent and not its branch, plus the daemon's URL when it has one (#1328). */ function childEnv(daemonUrl: string | undefined, agentId: string | undefined, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { ...base, PATH: [BRANCHES_BIN_DIR, TICKETS_BIN_DIR, base['PATH']].filter(Boolean).join(delimiter) } + const env: NodeJS.ProcessEnv = { ...base, PATH: [BRANCHES_BIN_DIR, TICKETS_BIN_DIR, QUEUE_BIN_DIR, base['PATH']].filter(Boolean).join(delimiter) } if (agentId) env[AGENT_ID_ENV] = agentId return daemonUrl ? { ...env, [DAEMON_URL_ENV]: daemonUrl } : env } /** - * TEMPORARY (#1748): the `tickets` skill linked into every checkout beside the `branches` skill, - * through the branches package's caller-given list. Gone when use-npm-skills commits the skills - * into the repository, where a checkout carries them as tracked files. + * TEMPORARY (#1748): the `tickets` and `queue` skills linked into every checkout beside the + * `branches` skill, through the branches package's caller-given list. Gone when use-npm-skills + * commits the skills into the repository, where a checkout carries them as tracked files. */ -const CHECKOUT_SKILLS = [{ name: TICKETS_SKILL_NAME, dir: TICKETS_SKILL_DIR }] +const CHECKOUT_SKILLS = [ + { name: TICKETS_SKILL_NAME, dir: TICKETS_SKILL_DIR }, + { name: QUEUE_SKILL_NAME, dir: QUEUE_SKILL_DIR }, +] /** Where a spawned agent's stderr lands (#1261), so a child that dies at boot leaves a trace. */ export function agentStderrPath(cwd: string): string { diff --git a/packages/framework/src/daemon-services.SPEC.md b/packages/framework/src/daemon-services.SPEC.md index e0ead348a..1c124d5fd 100644 --- a/packages/framework/src/daemon-services.SPEC.md +++ b/packages/framework/src/daemon-services.SPEC.md @@ -41,7 +41,7 @@ The job list and its cadences: - **worktree sweep**, every ten minutes — reclaims the checkout of an agent whose work has reached the remote; the branch and the agent's record are kept, so this frees disk rather than discarding work. It is the retry for a push that could not land at teardown. - **branches view**, every ten minutes — keeps one symlink per checkout under `.branches/`, named after its branch. -- **data sync**, every other tick — converges each project's two branches with origin: first the `agent-data` branch, which the `tickets` skill sets up as well as pulls (its checkout, the queue file, the repository-root link to the tickets), then the `agents-logs` branch. This machine ends up seeing what other machines and cloud sessions pushed, and anything a failed write cycle left local is carried out. +- **data sync**, every other tick — converges each project's two branches with origin: first the `agent-data` branch, which the `tickets` skill sets up as well as pulls (its checkout, the repository-root link to the tickets) and the `queue` skill then seeds and pulls (the queue file), then the `agents-logs` branch. This machine ends up seeing what other machines and cloud sessions pushed, and anything a failed write cycle left local is carried out. - **CI watch**, every other tick — roughly a minute, which is the latency chosen for noticing a check result. - **Discord watchers**, every other tick. - **Auto PM**, every ten minutes. diff --git a/packages/framework/src/daemon-services.ts b/packages/framework/src/daemon-services.ts index 45809ef23..abcc046b9 100644 --- a/packages/framework/src/daemon-services.ts +++ b/packages/framework/src/daemon-services.ts @@ -16,7 +16,8 @@ import { startDaemonTick, DAEMON_TICK_MS } from './daemon-tick.js' import { ciFixPrompt, startCiWatch } from './ci-watch.js' import { acquireRoutineLock, releaseDeadRoutineLocks, releaseRoutineLock } from './routine-locks.js' import { maintenanceDue, readMaintenanceState, mergeMaintenanceState } from './maintenance.js' -import { claimTickets, queueDone, readQueueEntries, readTickets, releaseTicket, syncTickets, TICKETS_DIR, ticketFromQueueEntry, ticketsDir } from '@gemstack/skill-tickets' +import { claimTickets, readTickets, releaseTicket, syncTickets, TICKETS_DIR, ticketFromQueueEntry, ticketsDir } from '@gemstack/skill-tickets' +import { queueDone, readQueueEntries, syncQueue } from '@gemstack/skill-queue' import { nextQueuedTicket } from './todo-loop.js' import { LOGS_BRANCH } from './framework-dir.js' import type { ProjectErrors } from './project-errors.js' @@ -117,15 +118,17 @@ export interface BackgroundServiceDeps { } /** - * One project's data-sync turn (#1599): converge the `agent-data` branch (the skill's: the checkout, - * the queue seed, the root link, the pull) and the `agents-logs` branch (the archives and routine - * locks) with origin, and set or clear the project's `data-sync` error by the outcome. The clear - * is unconditional on success, so the error lives exactly as long as the condition — the next - * tick after the user fixes the remote, it is gone. + * One project's data-sync turn (#1599): converge the `agent-data` branch (the skills': the + * `tickets` skill's checkout, root link and pull, then the `queue` skill's seed and pull) and the + * `agents-logs` branch (the archives and routine locks) with origin, and set or clear the + * project's `data-sync` error by the outcome. The clear is unconditional on success, so the error + * lives exactly as long as the condition — the next tick after the user fixes the remote, it is + * gone. */ export async function syncProjectData(path: string, errors: ProjectErrors, log: (message: string) => void): Promise<void> { const tickets = await syncTickets(path, { log }) - const result = tickets.ok ? await pullFileBranch(path, LOGS_BRANCH, { log }) : tickets + const queue = tickets.ok ? await syncQueue(path, { log }) : tickets + const result = queue.ok ? await pullFileBranch(path, LOGS_BRANCH, { log }) : queue if (result.ok) errors.clear(path, 'data-sync') else { log(`[framework] data sync: ${result.error}`) diff --git a/packages/framework/src/daemon-workspace.test.SPEC.md b/packages/framework/src/daemon-workspace.test.SPEC.md index 0874f9add..7b2490f0a 100644 --- a/packages/framework/src/daemon-workspace.test.SPEC.md +++ b/packages/framework/src/daemon-workspace.test.SPEC.md @@ -12,7 +12,7 @@ Refusing a start the driver cannot serve: a start on a logged-out driver is refu Held slots: a live agent's slot names the agent and its process id, and stopping the agents reports the same name and clears the slot. -The agent's environment: the spawned process's PATH starts with the `skill-branches` package's executable directory and continues with the daemon's own, and the `branches` command found through it lists the very checkout the daemon allocated. +The agent's environment: the spawned process's PATH starts with the `skill-branches`, `skill-tickets` and `skill-queue` packages' executable directories, in that order, and continues with the daemon's own; the `branches` command found through it lists the very checkout the daemon allocated; and that checkout links the three skills, each under its name, where every harness looks for skills, each link pointing at the package holding its `SKILL.md`. ## Before modifying/creating SPEC.md files diff --git a/packages/framework/src/daemon-workspace.test.ts b/packages/framework/src/daemon-workspace.test.ts index a75038da4..ea55bbbd7 100644 --- a/packages/framework/src/daemon-workspace.test.ts +++ b/packages/framework/src/daemon-workspace.test.ts @@ -1,7 +1,7 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { mkdtemp, mkdir, writeFile, readdir, readFile, rm, stat, realpath } from 'node:fs/promises' -import { join, delimiter } from 'node:path' +import { mkdtemp, mkdir, writeFile, readdir, readFile, readlink, rm, stat, realpath } from 'node:fs/promises' +import { join, delimiter, resolve } from 'node:path' import { execFile } from 'node:child_process' import { tmpdir } from 'node:os' import { createProjectRuntime, cleanupTimedOutWorktree, markFailedStart, agentStderrPath, isTransientAgentFailure, lastAgentFailureDetail, MAX_TRANSIENT_RETRIES } from './daemon-runtime.js' @@ -15,8 +15,9 @@ import type { PreflightResult } from './preflight.js' const agentReady = (): Promise<PreflightResult> => Promise.resolve({ ok: true, checks: [] }) import { EVENTS_FILE, META_FILE, startedAtFromAgentId, type AgentMeta } from './store/index.js' import { BRANCHES_DIR, nodeGitRunner, GitTimeoutError } from '@gemstack/agent-data' -import { worktreePath, agentBranchName, CLI_BIN_DIR } from '@gemstack/skill-branches' -import { CLI_BIN_DIR as TICKETS_BIN_DIR } from '@gemstack/skill-tickets' +import { worktreePath, agentBranchName, CLI_BIN_DIR, HARNESS_SKILL_DIRS, SKILL_DIR as BRANCHES_SKILL_DIR } from '@gemstack/skill-branches' +import { CLI_BIN_DIR as TICKETS_BIN_DIR, SKILL_DIR as TICKETS_SKILL_DIR } from '@gemstack/skill-tickets' +import { CLI_BIN_DIR as QUEUE_BIN_DIR, SKILL_DIR as QUEUE_SKILL_DIR } from '@gemstack/skill-queue' import { THE_FRAMEWORK_DIR } from './framework-dir.js' import { addProject, projectId } from './registry.js' import type { AgentSpec } from './agent-spec.js' @@ -664,8 +665,8 @@ test('a spawned agent finds the `branches` command on its PATH (#1725)', async ( recorded = await readFile(log, 'utf8').catch(() => '') } const path = recorded.trim() - assert.deepEqual(path.split(delimiter).slice(0, 2), [CLI_BIN_DIR, TICKETS_BIN_DIR], 'the packages\' bin dirs come first: branches, then tickets (#1748)') - assert.equal(path.split(delimiter).slice(2).join(delimiter), process.env['PATH'], "after the daemon's own") + assert.deepEqual(path.split(delimiter).slice(0, 3), [CLI_BIN_DIR, TICKETS_BIN_DIR, QUEUE_BIN_DIR], 'the packages\' bin dirs come first: branches, tickets, queue (#1748)') + assert.equal(path.split(delimiter).slice(3).join(delimiter), process.env['PATH'], "after the daemon's own") // By name, the way the agent's shell resolves it, against the project the daemon started it in. const listed = await new Promise<string>((resolvePromise, rejectPromise) => execFile('branches', ['list'], { cwd, env: { ...process.env, PATH: path } }, (err, stdout) => (err ? rejectPromise(err) : resolvePromise(stdout))), @@ -675,6 +676,13 @@ test('a spawned agent finds the `branches` command on its PATH (#1725)', async ( [result.agentId], 'and it reports the checkout the daemon allocated', ) + // The three skills are in the checkout too, where each harness looks for them (#1739/#1748). + for (const harnessDir of HARNESS_SKILL_DIRS) { + for (const [name, dir] of [['branches', BRANCHES_SKILL_DIR], ['tickets', TICKETS_SKILL_DIR], ['queue', QUEUE_SKILL_DIR]] as const) { + const target = await readlink(join(worktreePath(cwd, result.agentId!), harnessDir, name)) + assert.equal(await realpath(resolve(join(worktreePath(cwd, result.agentId!), harnessDir), target)), await realpath(dir), `${harnessDir}/${name} links the package holding its SKILL.md`) + } + } await runtime.dispose() } finally { await rm(cwd, RETRIED_RM) diff --git a/packages/framework/src/dashboard-rpc/control.SPEC.md b/packages/framework/src/dashboard-rpc/control.SPEC.md index b87c6ce2c..ba09b005c 100644 --- a/packages/framework/src/dashboard-rpc/control.SPEC.md +++ b/packages/framework/src/dashboard-rpc/control.SPEC.md @@ -110,7 +110,7 @@ The user sees a ticket worth doing and queues it, so the next drain agent picks #### Business logic -The entry is written straight onto the project's agent queue, through the `tickets` skill that owns it. Given the ticket it came from, the entry is placed in the agent queue section matching that ticket's own priority rather than appended at the end, and is written as a link back to the ticket, so the drain agent working the queue front to back can open it. An empty entry is refused, and so is a project with no local path here. +The entry is written straight onto the project's agent queue, through the `queue` skill that owns it; the ticket's priority is read through the `tickets` skill. Given the ticket it came from, the entry is placed in the agent queue section matching that ticket's own priority rather than appended at the end, and is written as a link back to the ticket, so the drain agent working the queue front to back can open it. An empty entry is refused, and so is a project with no local path here. A ticket's plan is queued the same way: one entry asking for that ticket's plan file — the same wording the plan-tickets preset itself queues — placed by the ticket's priority. Deliberately not a link back to the ticket: a leading ticket link is exactly what every reader takes as "this ticket is queued for implementation", and a plan ask is not that. A name that is not a plain ticket filename is refused. diff --git a/packages/framework/src/dashboard-rpc/control.ts b/packages/framework/src/dashboard-rpc/control.ts index 289e30549..88bd44acc 100644 --- a/packages/framework/src/dashboard-rpc/control.ts +++ b/packages/framework/src/dashboard-rpc/control.ts @@ -6,7 +6,8 @@ import type { BridgeBrowserAction } from '../bridge-browser.js' import { relayOr } from './relay-agent.js' import { ticketForPrompt } from '../todo-loop.js' import { planTicketPrompt } from '../tickets.js' -import { isTicketFile, QUEUE_FILE, queueAdd, queuePriorityForTicket, releaseTicket, TICKETS_DIR } from '@gemstack/skill-tickets' +import { isTicketFile, queuePriorityForTicket, releaseTicket, TICKETS_DIR } from '@gemstack/skill-tickets' +import { QUEUE_FILE, queueAdd } from '@gemstack/skill-queue' import { findAgent, type AgentMeta } from '../store/index.js' import { pushBranch } from '@gemstack/agent-data' import { isSafeAgentId, worktreePath } from '@gemstack/skill-branches' diff --git a/packages/framework/src/dashboard/docs.ts b/packages/framework/src/dashboard/docs.ts index 3f3cdaf54..4b6c0df49 100644 --- a/packages/framework/src/dashboard/docs.ts +++ b/packages/framework/src/dashboard/docs.ts @@ -1,6 +1,6 @@ import { readFile, readdir } from 'node:fs/promises' import { join } from 'node:path' -import { QUEUE_FILE, readQueue } from '@gemstack/skill-tickets' +import { QUEUE_FILE, readQueue } from '@gemstack/skill-queue' /** * The plan/backlog document categories the dashboard surfaces in its sidebar diff --git a/packages/framework/src/dashboard/queue.SPEC.md b/packages/framework/src/dashboard/queue.SPEC.md index 9f5959128..ad787da0c 100644 --- a/packages/framework/src/dashboard/queue.SPEC.md +++ b/packages/framework/src/dashboard/queue.SPEC.md @@ -20,7 +20,7 @@ Within a `TODO*` document, headings, prose and blank lines are ignored; every ma #### Rationale -This is deliberately the same rule the `tickets` skill applies, which is what the daemon's own queue-draining sweep reads the queue with. If the two disagreed, the dashboard could report "Nothing queued" while the daemon drains the very same file. An earlier rule recognised only checkbox entries, which read a whole queue of ticket-link entries as empty. +This is deliberately the same rule the `queue` skill applies, which is what the daemon's own queue-draining sweep reads the queue with. If the two disagreed, the dashboard could report "Nothing queued" while the daemon drains the very same file. An earlier rule recognised only checkbox entries, which read a whole queue of ticket-link entries as empty. ### Most-open first diff --git a/packages/framework/src/dashboard/queue.test.ts b/packages/framework/src/dashboard/queue.test.ts index 3779d4cfc..796c18762 100644 --- a/packages/framework/src/dashboard/queue.test.ts +++ b/packages/framework/src/dashboard/queue.test.ts @@ -74,7 +74,7 @@ test('link-style entries with no checkbox are open items, like the sweep reads t }) test('parseTodoItems agrees with the sweep parser on which entries are open (#1296)', async () => { - const { parseQueueEntries } = await import('@gemstack/skill-tickets') + const { parseQueueEntries } = await import('@gemstack/skill-queue') const md = [ '- [Link entry](tickets/a.md) — do the thing', '- [ ] open checkbox entry', diff --git a/packages/framework/src/e2e/harness.ts b/packages/framework/src/e2e/harness.ts index bd8b7d5d1..c339393c5 100644 --- a/packages/framework/src/e2e/harness.ts +++ b/packages/framework/src/e2e/harness.ts @@ -15,7 +15,8 @@ import { registryDiscordCredentialsStore } from '../discord-credentials-store.js import { resolveAgentEventsPath, type AgentMeta, type AgentStatus } from '../store/index.js' import { withFileBranch, DATA_BRANCH } from '@gemstack/agent-data' import { worktreePath } from '@gemstack/skill-branches' -import { QUEUE_FILE, TICKETS_DIR } from '@gemstack/skill-tickets' +import { TICKETS_DIR } from '@gemstack/skill-tickets' +import { QUEUE_FILE } from '@gemstack/skill-queue' import { withAgentLock } from '../agent-locks.js' import { tailAgentEvents } from '../dashboard-rpc/events-tail.js' import { sendAddProject } from '../dashboard-rpc/projects.js' diff --git a/packages/framework/src/framework-dir.SPEC.md b/packages/framework/src/framework-dir.SPEC.md index 8b32b947a..558b9bda7 100644 --- a/packages/framework/src/framework-dir.SPEC.md +++ b/packages/framework/src/framework-dir.SPEC.md @@ -1,4 +1,4 @@ -Names `.the-framework/` — the directory under a project root where The Framework keeps its own files — and the logs branch, `agents-logs`, which carries what The Framework records about its own runs (the agent archives and the routine locks), with the place its checkout sits under a project: `.branches/agents-logs`, beside the agent checkouts and named as its branch like each of them. Nothing of the product rides there: the tickets and the agent queue are the `tickets` skill's, on their own branch. Kept on its own so browser-side surfaces (the dashboard renders preset file paths built from it) can name paths under it without touching any node-only module. +Names `.the-framework/` — the directory under a project root where The Framework keeps its own files — and the logs branch, `agents-logs`, which carries what The Framework records about its own runs (the agent archives and the routine locks), with the place its checkout sits under a project: `.branches/agents-logs`, beside the agent checkouts and named as its branch like each of them. Nothing of the product rides there: the tickets and the agent queue are the `tickets` and `queue` skills', on their own branch. Kept on its own so browser-side surfaces (the dashboard renders preset file paths built from it) can name paths under it without touching any node-only module. ## Before modifying/creating SPEC.md files diff --git a/packages/framework/src/framework-dir.ts b/packages/framework/src/framework-dir.ts index 821d84b13..40757a4d9 100644 --- a/packages/framework/src/framework-dir.ts +++ b/packages/framework/src/framework-dir.ts @@ -11,8 +11,8 @@ export const THE_FRAMEWORK_DIR = '.the-framework' /** * The branch holding what The Framework itself records about its runs (#1582/#1748): the agent - * archives and the routine locks. The tickets and the queue are the `tickets` skill's, on the - * shared `agent-data` branch — nothing of the product's rides there. + * archives and the routine locks. The tickets and the queue are the `tickets` and `queue` skills', + * on the shared `agent-data` branch — nothing of the product's rides there. */ export const LOGS_BRANCH = 'agents-logs' diff --git a/packages/framework/src/layout.ts b/packages/framework/src/layout.ts index 5b34a0762..a2cd2c226 100644 --- a/packages/framework/src/layout.ts +++ b/packages/framework/src/layout.ts @@ -1,7 +1,8 @@ import { join } from 'node:path' import { LOGS_BRANCH, THE_FRAMEWORK_DIR } from './framework-dir.js' import { ARCHIVE_DIR, EVENTS_FILE, META_FILE, nodeStoreFs, type StoreFs } from './store/index.js' -import { QUEUE_FILE, TICKETS_DIR } from '@gemstack/skill-tickets/names' +import { TICKETS_DIR } from '@gemstack/skill-tickets/names' +import { QUEUE_FILE } from '@gemstack/skill-queue/names' import { DATA_BRANCH } from '@gemstack/agent-data/names' /** diff --git a/packages/framework/src/on-before-mergeable-prompt.SPEC.md b/packages/framework/src/on-before-mergeable-prompt.SPEC.md index 1f7cdb279..dabc9701d 100644 --- a/packages/framework/src/on-before-mergeable-prompt.SPEC.md +++ b/packages/framework/src/on-before-mergeable-prompt.SPEC.md @@ -6,7 +6,7 @@ Renders the on-before-mergeable prompt: one extra agent turn sent after an agent ## Business logic — TL;DR -- **Queues quality work, never runs it** - the prompt tells the agent to put entries on the agent queue (`TODO_AGENTS.md`) with the `tickets` skill's own command, one per quality preset; a later routine drains them. +- **Queues quality work, never runs it** - the prompt tells the agent to put entries on the agent queue (`TODO_AGENTS.md`) with the `queue` skill's own command, one per quality preset; a later routine drains them. - **Folds knowledge back** - the prompt asks the agent to update the repo's business-knowledge docs with what it learned during the task. - **Every entry names the session** - each queued entry targets "changes introduced by <session name>"; rendering without a session name fails loudly. @@ -20,7 +20,7 @@ An agent finishes a task. The user wants the changes to also get a maintainabili #### Business logic -The prompt's `## Maintenance` section instructs the agent to put one agent queue entry per quality preset on `TODO_AGENTS.md`, through the `tickets` skill's queue command, each of the form "Apply `.the-framework/presets/<preset>.md` with tf.params.what set to 'changes introduced by <session name>'". The entry points at the preset's real on-disk file under `.the-framework/presets/`, so the agent that later picks the entry up opens the actual preset text. A later drain of the agent queue turns each entry into its own agent. +The prompt's `## Maintenance` section instructs the agent to put one agent queue entry per quality preset on `TODO_AGENTS.md`, through the `queue` skill's command, each of the form "Apply `.the-framework/presets/<preset>.md` with tf.params.what set to 'changes introduced by <session name>'". The entry points at the preset's real on-disk file under `.the-framework/presets/`, so the agent that later picks the entry up opens the actual preset text. A later drain of the agent queue turns each entry into its own agent. #### Rationale diff --git a/packages/framework/src/on-before-mergeable-prompt.test.SPEC.md b/packages/framework/src/on-before-mergeable-prompt.test.SPEC.md index 37a8d6d0b..5eb480f71 100644 --- a/packages/framework/src/on-before-mergeable-prompt.test.SPEC.md +++ b/packages/framework/src/on-before-mergeable-prompt.test.SPEC.md @@ -1,4 +1,4 @@ -What the tests cover: the on-before-mergeable prompt (the extra turn sent after an agent signals ready for merge) queues its follow-ups to the agent queue (`TODO_AGENTS.md`, derived from the `tickets` skill's own constant rather than a copied literal, and added with that skill's queue command) and points at the quality presets by their `.the-framework/presets/` file paths; its business-knowledge section names exactly the business-knowledge docs the built-in system prompt lists, so the agent is never told to read one set of files and update another; no `${{ }}` fragment nests inside another, which would truncate rendering at run time; a render names the session on every queued entry and leaves no unrendered fragment; absent settings read as off and render deterministically; and rendering without a session name throws an error naming the missing value instead of queueing entries about "undefined". +What the tests cover: the on-before-mergeable prompt (the extra turn sent after an agent signals ready for merge) queues its follow-ups to the agent queue (`TODO_AGENTS.md`, derived from the `queue` skill's own constant rather than a copied literal, and added with that skill's command) and points at the quality presets by their `.the-framework/presets/` file paths; its business-knowledge section names exactly the business-knowledge docs the built-in system prompt lists, so the agent is never told to read one set of files and update another; no `${{ }}` fragment nests inside another, which would truncate rendering at run time; a render names the session on every queued entry and leaves no unrendered fragment; absent settings read as off and render deterministically; and rendering without a session name throws an error naming the missing value instead of queueing entries about "undefined". ## Before modifying/creating SPEC.md files diff --git a/packages/framework/src/on-before-mergeable-prompt.test.ts b/packages/framework/src/on-before-mergeable-prompt.test.ts index 7c4c2e68c..654469e29 100644 --- a/packages/framework/src/on-before-mergeable-prompt.test.ts +++ b/packages/framework/src/on-before-mergeable-prompt.test.ts @@ -3,7 +3,7 @@ import { test } from 'node:test' import { renderOnBeforeMergeablePrompt, ON_BEFORE_MERGEABLE_PROMPT_TEMPLATE } from './on-before-mergeable-prompt.js' import { TemplateFragmentError } from './prompt-template.js' import { BUSINESS_KNOWLEDGE_DOCS } from './system-prompt.js' -import { QUEUE_FILE } from '@gemstack/skill-tickets' +import { QUEUE_FILE } from '@gemstack/skill-queue' test('ON_BEFORE_MERGEABLE_PROMPT_TEMPLATE carries the built-in on-before-mergeable (#326) block', () => { // Derived from the constant, not a literal (#885) — see the same assertion in diff --git a/packages/framework/src/preset-catalog.SPEC.md b/packages/framework/src/preset-catalog.SPEC.md index 560d6cd81..1dedaf26a 100644 --- a/packages/framework/src/preset-catalog.SPEC.md +++ b/packages/framework/src/preset-catalog.SPEC.md @@ -6,7 +6,7 @@ The catalog of every built-in preset: one table holding, per preset, its name (t ## Business logic — TL;DR -- **Two families** - the quality presets take one target ("what to run against", defaulting to the launching session or the whole codebase); the ticket/queue presets scope themselves to the project's own tickets, plans and queue — which they reach through the `tickets` skill — so there is no blank to fill. +- **Two families** - the quality presets take one target ("what to run against", defaulting to the launching session or the whole codebase); the ticket/queue presets scope themselves to the project's own tickets, plans and queue — which they reach through the `tickets` and `queue` skills — so there is no blank to fill. - **Each preset's contract** - what each canned prompt asks for, including which ones gate on a human and which run unattended. - **The triage pair** - two unattended triage presets split on cost and share one verbatim queue-only rule, making them safe to fire on a schedule. - **Recognizing the drain** - a bare prompt is recognized as the queue-draining one by exact match against the rendered preset, so the recognition can never drift from a reworded prompt. @@ -22,7 +22,7 @@ From the dashboard's launcher the user picks a preset to start an agent: quality #### Business logic -The quality presets — maintainability, readability, security_audit, ux, research, maintenance — take a single target the user may type; left blank, the target defaults to the session the preset was launched from, or to the entire codebase when no session exists yet. The ticket/queue presets — triage_quick, triage_consensual, plan_tickets, update_tickets, suggest_new_tickets, suggest_new_features, suggest_tickets_to_work_on, drain_queue, market_research — work the project's own tickets, plans and agent queue through the `tickets` skill, so their prompt renders verbatim with nothing to fill. +The quality presets — maintainability, readability, security_audit, ux, research, maintenance — take a single target the user may type; left blank, the target defaults to the session the preset was launched from, or to the entire codebase when no session exists yet. The ticket/queue presets — triage_quick, triage_consensual, plan_tickets, update_tickets, suggest_new_tickets, suggest_new_features, suggest_tickets_to_work_on, drain_queue, market_research — work the project's own tickets, plans and agent queue through the `tickets` and `queue` skills, so their prompt renders verbatim with nothing to fill. ### Each preset's contract @@ -52,7 +52,7 @@ Auto PM refills the agent queue from the ticket backlog on a schedule, with nobo #### Business logic -Both triage presets list the tickets through the `tickets` skill, pick tickets that are consensual (zero open questions, zero uncertainty), and put them on the agent queue with the skill's own command. They split on cost only: triage_quick picks quick wins (low effort, zero uncertainty per the plan's own numbers), triage_consensual picks significant work — kept apart so the cheap batch and the significant batch queue on separate turns. Both run unattended and never gate, and each pins its own fixed session name; keeping one triage at a time is the daemon's routine lock, not a rule the agent checks. Both end with the same queue-only rule, appended verbatim from one shared prompt file so the pair cannot drift apart on it: a triage changes the queue and nothing else, never a ticket's code. +Both triage presets list the tickets through the `tickets` skill, pick tickets that are consensual (zero open questions, zero uncertainty), and put them on the agent queue with the `queue` skill's command, each entry a link to its ticket. They split on cost only: triage_quick picks quick wins (low effort, zero uncertainty per the plan's own numbers), triage_consensual picks significant work — kept apart so the cheap batch and the significant batch queue on separate turns. Both run unattended and never gate, and each pins its own fixed session name; keeping one triage at a time is the daemon's routine lock, not a rule the agent checks. Both end with the same queue-only rule, appended verbatim from one shared prompt file so the pair cannot drift apart on it: a triage changes the queue and nothing else, never a ticket's code. #### Rationale diff --git a/packages/framework/src/preset-catalog.test.SPEC.md b/packages/framework/src/preset-catalog.test.SPEC.md index 38d95a7b8..e632ccab9 100644 --- a/packages/framework/src/preset-catalog.test.SPEC.md +++ b/packages/framework/src/preset-catalog.test.SPEC.md @@ -8,7 +8,7 @@ What the tests cover, for the whole built-in preset catalog: - research gates on a multi-select and routes the picks to a session-scoped TODO file beside its review file, deliberately never to the agent queue. - maintenance queues work per codebase subset with the skill's queue-add command, at a low priority, rather than doing it, and points at the other presets by their real materialized file paths with no placeholder surviving the render. - market_research researches, writes its findings file, queues the ticket follow-up, and defines its own session name (no session exists when it launches). -- suggest_new_tickets is the one prefilled line, which has each new ticket written with the `tickets` skill's own command; suggest_tickets_to_work_on lists the tickets through the skill, gates on a human multi-select, and puts each approved ticket on the queue with the skill's queue-add command, naming the ticket it came from. +- suggest_new_tickets is the one prefilled line, which has each new ticket written with the `tickets` skill's own command; suggest_tickets_to_work_on lists the tickets through the skill, gates on a human multi-select, and puts each approved ticket on the queue with the `queue` skill's command, as a link naming the ticket it came from. - The triage pair splits on cost (quick wins by the plan's own effort/uncertainty numbers vs. significant-only), both put their picks on the queue with the skill's queue-add command, both end with the shared queue-only rule verbatim (the queue, through that command, is the only thing they change), each pins its own distinct session name and neither carries any branch-already-exists abort — keeping one triage at a time is the daemon's routine lock, not a rule the agent checks — and neither ever gates, while the gated sibling still does. - Only update_tickets is marked to always open an agent of its own, and it stays offered in the launcher; its prompt resumes from the `tickets/meta.json` stamp (taken before fetching), syncs issue comments too, preserves existing plans, removes closed issues' tickets, and treats a project with no tickets as the full first import. diff --git a/packages/framework/src/preset-catalog.test.ts b/packages/framework/src/preset-catalog.test.ts index 61c90c648..266f8d24c 100644 --- a/packages/framework/src/preset-catalog.test.ts +++ b/packages/framework/src/preset-catalog.test.ts @@ -123,7 +123,7 @@ test('Research gates on a multi-select and writes its own review file (#331)', ( test('the Maintenance template queues work rather than doing it (#881)', () => { assert.match(presets.maintenance.template, /look for opportunities to refactor code/) - assert.match(presets.maintenance.template, /put the following entries on the queue with `tickets queue add "<entry>" --priority <N>` \(from the `tickets` skill; usually a low priority/) + assert.match(presets.maintenance.template, /put the following entries on the queue with `queue add "<entry>" --priority <N>` \(from the `queue` skill; usually a low priority/) assert.match(presets.maintenance.template, /<CODEBASE_SUBSET>/) }) @@ -143,7 +143,7 @@ test('Market research researches, then queues the follow-up (#694)', () => { const prompt = presets.marketResearch.render() assert.match(prompt, /thorough market research/) assert.match(prompt, /MARKET_RESEARCH\.md/) - assert.match(prompt, /Put this entry on the queue with `tickets queue add`/) + assert.match(prompt, /Put this entry on the queue with `queue add`/) assert.match(prompt, /suggest new tickets/) // It defines <SESSION_NAME> itself: the session does not exist yet when a preset renders. assert.match(prompt, /<SESSION_NAME>/) @@ -160,7 +160,7 @@ test('Suggest tickets to work on gates on a human, unlike the triage pair (#698) assert.match(prompt, /Look at all tickets \(`tickets list`, from the `tickets` skill\) and pick tickets to work on next/) assert.match(prompt, /showMultiSelect\(\)/) assert.match(prompt, /<AWAIT>/) - assert.match(prompt, /Put each approved ticket on the queue: `tickets queue add "<title>" --ticket <file>`/) + assert.match(prompt, /Put each approved ticket on the queue: `queue add "\[<title>\]\(tickets\/<file>\)" --priority <N>`/) assert.match(prompt, /set its default to `true`, otherwise `false`/) assert.doesNotMatch(prompt, /showChoices\(\)/) assert.match(prompt, /^AWAIT: Stop, await user answer before resuming$/m) @@ -171,16 +171,16 @@ test('the triage pair splits on cost and both append to the queue (#891/#892)', // Quick triage picks by the plan's own numbers: a low `effort` with `uncertainty: 0` is what // "quick-win" means now that every plan carries the two 0-10 keys. assert.match(presets.triageQuick.template, /quick-win \(low `effort` value\) with `uncertainty: 0`/) - assert.match(presets.triageQuick.template, /Put each picked ticket on the queue: `tickets queue add "<title>" --ticket <file> --priority <N>`/) + assert.match(presets.triageQuick.template, /Put each picked ticket on the queue: `queue add "\[<title>\]\(tickets\/<file>\)" --priority <N>`/) assert.match(presets.triageConsensual.template, /Only pick tickets that are significant \(no quick-wins\) and consensual/) - assert.match(presets.triageConsensual.template, /Put each picked ticket on the queue: `tickets queue add "<title>" --ticket <file>`/) + assert.match(presets.triageConsensual.template, /Put each picked ticket on the queue: `queue add "\[<title>\]\(tickets\/<file>\)" --priority <N>`/) }) test('both triage presets carry the one queue-only rule, and carry it whole (#1641)', () => { // A triage on a throwaway repo queued two tickets and implemented the third itself. The rule // against that is one shared file, so both presets are checked against that file's text // rather than against a phrase each could drift from. - assert.match(TRIAGE_SCOPE, /the only thing you change is the queue, through `tickets queue add`/) + assert.match(TRIAGE_SCOPE, /the only thing you change is the queue, through `queue add`/) for (const preset of [presets.triageQuick, presets.triageConsensual]) { assert.ok(preset.render().endsWith(TRIAGE_SCOPE), `${preset.name} must end with the shared scope rule`) } diff --git a/packages/framework/src/system-prompt.SPEC.md b/packages/framework/src/system-prompt.SPEC.md index 365d6e994..34a9e87ff 100644 --- a/packages/framework/src/system-prompt.SPEC.md +++ b/packages/framework/src/system-prompt.SPEC.md @@ -17,7 +17,7 @@ Composes an agent's system channel — the built-in system prompt, the repo-cont ## Business logic — TL;DR - **The built-in system prompt is a template in two halves** - the system half frames the session; the user-prompt half is the slot the user's prompt lands in. The boundary is fixed by the template itself, so a user prompt that contains the boundary heading can never move it. -- **The agent's repo context rides in the channel** - a `Context:` block lists the user's picked directories and then the framework's context docs, each with a one-line gloss; the two roadmap docs — the tickets and the agent queue — say they live on the `agent-data` branch and send the agent to the `tickets` skill for them. +- **The agent's repo context rides in the channel** - a `Context:` block lists the user's picked directories and then the framework's context docs, each with a one-line gloss; the two roadmap docs — the tickets and the agent queue — say they live on the `agent-data` branch and send the agent to the `tickets` skill and the `queue` skill for them. - **Composition is additive and ordered** - context first, then the built-in prompt, then — for an agent outside a checkout The Framework created — the two sections that have it branch, and read and write the tickets, with git itself, and then the user's `SYSTEM.md`; a repo can keep the built-in prompt *and* add its own, replace it, or leave both off. - **Vanilla drops everything framework-authored except the emit protocols** - the built-in prompt, the context docs, and those two sections all go together; the user's own dirs and `SYSTEM.md` survive, and the emit protocols stay because they are the contract the dashboard's gates run on, not prompt content. - **Transparent drops the whole channel** - no prompt, no docs, no protocols: the agent runs byte-identical to the raw wrapped CLI. It overrides every other option. @@ -45,9 +45,9 @@ Every agent should start knowing what the repo has learned about itself — its The context docs are the files the agent keeps in context, rendered as commented bullets under the `Context:` head: `knowledge-base/DECISIONS.md`, `GOAL.md`, `BUSINESS_LOGIC.md`, `knowledge-base/FACTS.md`, `knowledge-base/INSIGHTS.md`, `knowledge-base/MARKET_RESEARCH.md`, the `knowledge-base/**.md` catch-all, `tickets/**.md`, and `TODO_AGENTS.md`. A subset — the business-knowledge docs (`DECISIONS.md`, `FACTS.md`, `INSIGHTS.md`) — is what the agent also folds new knowledge back into at merge; the rest are read-only pointers. A repo's own `README.md` is deliberately left out: it already covers the overview. -Two of the docs are not the agent's to edit by hand — `tickets/**.md` and `TODO_AGENTS.md` — because they live on the `agent-data` branch rather than in the agent's checkout. Their bullets say so and send the agent to the `tickets` skill, which is where their format, and every way of reading and changing them, is written down. No format text of their own rides in the channel. The user's picked in-context directories, when any, come first on the `Context:` line, framing whatever follows. +Two of the docs are not the agent's to edit by hand — `tickets/**.md` and `TODO_AGENTS.md` — because they live on the `agent-data` branch rather than in the agent's checkout. Their bullets say so and send the agent to the `tickets` skill and the `queue` skill, which is where their format, and every way of reading and changing them, is written down. No format text of their own rides in the channel. The user's picked in-context directories, when any, come first on the `Context:` line, framing whatever follows. -In a checkout The Framework created, that skill is the checkout's: the package links it where the agent's harness looks for skills, and the `tickets` command is on the agent's PATH. Anywhere else — a terminal run in the user's own checkout, a GitHub Actions runner, a cloud session — neither is true, so the channel carries a temporary bridge instead, right after the built-in prompt and after the branch section that is its counterpart: how to read and write the branch, and claim a ticket, with git alone, followed by the skill's own text so the formats exist in exactly one place. Temporary is the word the code uses for it: it exists only until skills are committed into the repository itself, and then it goes. +In a checkout The Framework created, those skills are the checkout's: the packages link them where the agent's harness looks for skills, and the `tickets` and `queue` commands are on the agent's PATH. Anywhere else — a terminal run in the user's own checkout, a GitHub Actions runner, a cloud session — neither is true, so the channel carries a temporary bridge instead, right after the built-in prompt and after the branch section that is its counterpart: how to read and write the branch, and claim a ticket, with git alone, followed by the two skills' own text so the formats exist in exactly one place. Temporary is the word the code uses for it: it exists only until skills are committed into the repository itself, and then it goes. #### Rationale diff --git a/packages/framework/src/system-prompt.test.SPEC.md b/packages/framework/src/system-prompt.test.SPEC.md index 9d60ca70c..8e66242d0 100644 --- a/packages/framework/src/system-prompt.test.SPEC.md +++ b/packages/framework/src/system-prompt.test.SPEC.md @@ -1,10 +1,10 @@ What the tests cover: the composition of an agent's system channel and the reading of the user's `SYSTEM.md`. -- The context docs are exactly the expected set (knowledge-base docs, `GOAL.md`, `BUSINESS_LOGIC.md`, market research, the catch-all, `tickets/**.md`, `TODO_AGENTS.md`); the business-knowledge subset the agent updates at merge excludes the read-only pointers; the tickets and queue bullets say both live on the `agent-data` branch and send the agent to the `tickets` skill for them, and no doc may point into `node_modules/`. +- The context docs are exactly the expected set (knowledge-base docs, `GOAL.md`, `BUSINESS_LOGIC.md`, market research, the catch-all, `tickets/**.md`, `TODO_AGENTS.md`); the business-knowledge subset the agent updates at merge excludes the read-only pointers; the tickets and queue bullets say both live on the `agent-data` branch and send the agent to the `tickets` skill and the `queue` skill for them, and no doc may point into `node_modules/`. - `SYSTEM.md` is read and trimmed; absent or whitespace-only reads as no user prompt. - The built-in prompt template carries its section headings verbatim, names `TODO_AGENTS.md`, defers the branch to the "Branch management" section rather than creating one itself (the workspace rules are the skill's, not the template's), no longer asks for the retired analysis artifact or carries pre-rewrite headings, and has exactly one variable slot (the user's prompt). - Rendering splits the system and user halves, fully renders the system half, and is not confused by a user prompt that itself contains the boundary heading. -- The ticket and queue formats ride in the channel only for an agent outside a checkout The Framework created: the section that has it read and write the `agent-data` branch with git, followed by the `tickets` skill's own text with its front matter dropped, both after the branch section that is their counterpart. An agent in its own checkout gets neither, and no inline format section of the channel's own is left. Vanilla drops them along with the docs and the built-in prompt, keeps the user's own dirs and prompt, and an empty vanilla block is truly empty. +- The ticket and queue formats ride in the channel only for an agent outside a checkout The Framework created: the section that has it read and write the `agent-data` branch with git, followed by the `tickets` skill's own text and then the `queue` skill's, each with its front matter dropped, all after the branch section that is their counterpart. An agent in its own checkout gets neither, and no inline format section of the channel's own is left. Vanilla drops them along with the docs and the built-in prompt, keeps the user's own dirs and prompt, and an empty vanilla block is truly empty. - Composition order and exactness: context (user dirs first, docs after), built-in prompt, the branch and tickets sections, user prompt, then the await and signal protocols — and nothing else, whatever the options; the signal protocol is always last. - An agent in a checkout The Framework created is sent to the `branches` skill by the prompt's own session-name step, with none of the skill's text in the channel; every other agent, hands-off included, gets the "Branch management" section that has it branch with git instead; vanilla drops both along with the built-in prompt. - The browser protocol appears only when the agent has a browser; the hands-off protocol (land everything) appears only for a hands-off agent, whose await protocol is the same one every agent gets. A local agent gets neither. Both survive vanilla and none survives transparent. diff --git a/packages/framework/src/system-prompt.test.ts b/packages/framework/src/system-prompt.test.ts index 7fa24fdf7..d8cfa1092 100644 --- a/packages/framework/src/system-prompt.test.ts +++ b/packages/framework/src/system-prompt.test.ts @@ -11,7 +11,7 @@ import { systemPromptBlock, SYSTEM_PROMPT_TEMPLATE, } from './system-prompt.js' -import { BRANCH_YOURSELF, TICKETS_SKILL, TICKETS_YOURSELF } from './prompts.generated.js' +import { BRANCH_YOURSELF, QUEUE_SKILL, TICKETS_SKILL, TICKETS_YOURSELF } from './prompts.generated.js' import { loadUserSystemPrompt, SYSTEM_PROMPT_FILE } from './system-prompt-file.js' import { THE_FRAMEWORK_DIR } from './framework-dir.js' @@ -21,8 +21,8 @@ const KNOWLEDGE_LINES = CONTEXT_DOCS.map(d => `- \`${d.path}\` (${d.comment})`). const KNOWLEDGE_CONTEXT = `Context:\n${KNOWLEDGE_LINES}` /** The context block: the bullets alone — the formats are the `tickets` skill's, not the channel's (#1748). */ const CONTEXT_BLOCK = KNOWLEDGE_CONTEXT -/** What an agent outside a daemon-made checkout gets after the prompt: branch, then tickets, with git (temporary, #1748). */ -const ELSEWHERE = [BRANCH_YOURSELF, `${TICKETS_YOURSELF}\n\n${TICKETS_SKILL}`] +/** What an agent outside a daemon-made checkout gets after the prompt: branch, then tickets and queue, with git (temporary, #1748). */ +const ELSEWHERE = [BRANCH_YOURSELF, `${TICKETS_YOURSELF}\n\n${TICKETS_SKILL}\n\n${QUEUE_SKILL}`] test('CONTEXT_DOCS is the repo-context fragment (#683): business knowledge plus the roadmap/queue pointers', () => { const paths = CONTEXT_DOCS.map(d => d.path) @@ -45,11 +45,10 @@ test('CONTEXT_DOCS is the repo-context fragment (#683): business knowledge plus for (const p of ['GOAL.md', 'BUSINESS_LOGIC.md', 'knowledge-base/MARKET_RESEARCH.md', 'knowledge-base/**.md', 'tickets/**.md', 'TODO_AGENTS.md']) { assert.ok(!businessPaths.includes(p)) } - // The two roadmap bullets send the agent to the `tickets` skill (#1748), which the checkout - // carries: no format text rides in this channel, and nothing names a file to go and open. - for (const path of ['tickets/**.md', 'TODO_AGENTS.md']) { - assert.match(CONTEXT_DOCS.find(d => d.path === path)?.comment ?? '', /on the `agent-data` branch — read and change (them|it) with the `tickets` skill/) - } + // The two roadmap bullets send the agent to the `tickets` and `queue` skills (#1748), which the + // checkout carries: no format text rides in this channel, and nothing names a file to go and open. + assert.match(CONTEXT_DOCS.find(d => d.path === 'tickets/**.md')?.comment ?? '', /on the `agent-data` branch — read and change them with the `tickets` skill/) + assert.match(CONTEXT_DOCS.find(d => d.path === 'TODO_AGENTS.md')?.comment ?? '', /on the `agent-data` branch — read and change it with the `queue` skill/) // Nothing here may point into node_modules: that path resolves only when the framework is a root // dependency of the repo it works on, which is what left both specs unopenable (#1163). for (const doc of CONTEXT_DOCS) assert.ok(!doc.comment.includes('node_modules/'), `${doc.path} points into node_modules`) @@ -138,20 +137,22 @@ test('renderSystemPrompt is not confused by a user prompt containing the heading assert.equal(user, sneaky) }) -test('the ticket and queue formats are the `tickets` skill\'s: in the channel only for an agent outside a daemon-made checkout, as a temporary bridge (#1748)', () => { +test('the ticket and queue formats are the `tickets` and `queue` skills\': in the channel only for an agent outside a daemon-made checkout, as a temporary bridge (#1748)', () => { // In its own checkout the agent finds the skill where its harness looks; nothing rides here. const owned = systemPromptBlock({ ownedCheckout: true }) - assert.ok(!owned.includes(TICKETS_SKILL) && !owned.includes(TICKETS_YOURSELF)) + assert.ok(!owned.includes(TICKETS_SKILL) && !owned.includes(QUEUE_SKILL) && !owned.includes(TICKETS_YOURSELF)) assert.ok(!owned.includes('# Ticketing format') && !owned.includes('# The data branch'), 'the old inline formats are gone') // Elsewhere the command is not on the PATH: the git counterpart, then the skill's own formats. const elsewhere = systemPromptBlock() - assert.ok(elsewhere.includes(TICKETS_YOURSELF) && elsewhere.includes(TICKETS_SKILL)) + assert.ok(elsewhere.includes(TICKETS_YOURSELF) && elsewhere.includes(TICKETS_SKILL) && elsewhere.includes(QUEUE_SKILL)) assert.ok(TICKETS_YOURSELF.includes('git show origin/agent-data:<FILE>') && TICKETS_YOURSELF.includes('git push origin HEAD:refs/heads/agent-data')) - assert.ok(TICKETS_SKILL.includes('tickets/<DATE>_<SLUG>.md') && TICKETS_SKILL.includes('## Priority 9') && !TICKETS_SKILL.startsWith('---'), 'the skill text, front matter dropped') + assert.ok(TICKETS_SKILL.includes('tickets/<DATE>_<SLUG>.md') && !TICKETS_SKILL.startsWith('---'), 'the tickets skill text, front matter dropped') + assert.ok(QUEUE_SKILL.includes('## Priority 9') && !QUEUE_SKILL.startsWith('---'), 'the queue skill text, front matter dropped') + assert.ok(elsewhere.indexOf(QUEUE_SKILL) > elsewhere.indexOf(TICKETS_SKILL), 'the queue skill after the tickets skill') assert.ok(elsewhere.indexOf(TICKETS_YOURSELF) > elsewhere.indexOf(BRANCH_YOURSELF), 'after the branch counterpart') // Framework-authored content, so `--vanilla` drops it with the docs and the built-in prompt. const vanilla = systemPromptBlock({ vanilla: true, user: 'Only mine.' }) - assert.ok(!vanilla.includes(TICKETS_SKILL) && !vanilla.includes(TICKETS_YOURSELF)) + assert.ok(!vanilla.includes(TICKETS_SKILL) && !vanilla.includes(QUEUE_SKILL) && !vanilla.includes(TICKETS_YOURSELF)) }) test('systemPromptBlock defaults to the knowledge-doc context line + the built-in #326 prompt', () => { diff --git a/packages/framework/src/system-prompt.ts b/packages/framework/src/system-prompt.ts index f8b215c96..02cfa7512 100644 --- a/packages/framework/src/system-prompt.ts +++ b/packages/framework/src/system-prompt.ts @@ -1,5 +1,5 @@ import { renderTemplate } from './prompt-template.js' -import { BRANCH_YOURSELF, SYSTEM_PROMPT, TICKETS_SKILL, TICKETS_YOURSELF } from './prompts.generated.js' +import { BRANCH_YOURSELF, QUEUE_SKILL, SYSTEM_PROMPT, TICKETS_SKILL, TICKETS_YOURSELF } from './prompts.generated.js' import { AWAIT_PROTOCOL, BROWSER_PROTOCOL, HANDS_OFF_PROTOCOL, SIGNAL_PROTOCOL } from './turn-gate.js' // No Node imports here, deliberately. This module composes the prompt and the @@ -72,20 +72,21 @@ export const BUSINESS_KNOWLEDGE_DOCS: readonly ContextDoc[] = [DECISIONS_DOC, FA /** * TEMPORARY (#1748): what an agent outside a checkout the daemon created is told about the - * tickets and the queue, since nothing links the `tickets` skill into its checkout and the - * `tickets` command is not on its PATH — the counterpart of {@link BRANCH_YOURSELF}: how to read - * and write the branch with git, followed by the skill's own formats so they exist in one place. - * Dies when use-npm-skills commits the skill into the repository. + * tickets and the queue, since nothing links the `tickets` and `queue` skills into its checkout + * and their commands are not on its PATH — the counterpart of {@link BRANCH_YOURSELF}: how to read + * and write the branch with git, followed by the two skills' own formats so they exist in one + * place. Dies when use-npm-skills commits the skills into the repository. */ -const TICKETS_BRIDGE = `${TICKETS_YOURSELF}\n\n${TICKETS_SKILL}` +const TICKETS_BRIDGE = `${TICKETS_YOURSELF}\n\n${TICKETS_SKILL}\n\n${QUEUE_SKILL}` /** * Everything the agent keeps in context when it starts (#683), which * {@link systemPromptBlock} renders as the `Context:` bullets. A superset of * {@link BUSINESS_KNOWLEDGE_DOCS}: it adds `GOAL.md`, `BUSINESS_LOGIC.md`, and the * roadmap/queue pointers the agent reads but does *not* fold knowledge back into — the tickets - * (the potential work) and the agent queue, both the `tickets` skill's (#1748): they live on the - * `agent-data` branch, and the skill says how to read and change them and what their formats are. + * (the potential work, the `tickets` skill's) and the agent queue (the `queue` skill's) (#1748): + * they live on the `agent-data` branch, and each skill says how to read and change its own and + * what the format is. * Repo-root paths, because that is the agent's cwd. README is left out: a repo's own `README.md` * already covers the overview. */ @@ -105,7 +106,7 @@ export const CONTEXT_DOCS: readonly ContextDoc[] = [ // The catch-all (#683): any other file the agent parks under knowledge-base/. { path: 'knowledge-base/**.md', comment: 'more files holding knowledge related to the project' }, { path: 'tickets/**.md', comment: 'things to potentially work on; on the `agent-data` branch — read and change them with the `tickets` skill' }, - { path: 'TODO_AGENTS.md', comment: 'the AI task queue; on the `agent-data` branch — read and change it with the `tickets` skill' }, + { path: 'TODO_AGENTS.md', comment: 'the AI task queue; on the `agent-data` branch — read and change it with the `queue` skill' }, ] /** The two halves of the rendered {@link SYSTEM_PROMPT_TEMPLATE}. */ @@ -202,7 +203,7 @@ export function systemPromptBlock(opts: SystemPromptOptions = {}): string { const bullets = docs.map(d => `- \`${d.path}\` (${d.comment})`) parts.push([head, ...bullets].join('\n')) } - // In a checkout The Framework created, the `branches` and `tickets` skills are the checkout's + // In a checkout The Framework created, the `branches`, `tickets` and `queue` skills are the checkout's // (#1739/#1748): the packages link them where the agent's harness looks for skills, and the // built-in prompt tells the agent to use them — nothing rides in this channel. Anywhere else // the commands are not on the PATH, so the sections that have the agent branch, and read and diff --git a/packages/framework/src/todo-loop.SPEC.md b/packages/framework/src/todo-loop.SPEC.md index fbe74829c..7b4b05c6d 100644 --- a/packages/framework/src/todo-loop.SPEC.md +++ b/packages/framework/src/todo-loop.SPEC.md @@ -1,4 +1,4 @@ -The backlog loop: once an agent's main work settles, it consumes the agent queue one entry per turn until the queue is empty — the framework drives (read the next entry, gate, prompt, take the entry off the queue, repeat). The queue itself is the `tickets` skill's: it lives on the project's `agent-data` branch, and the framework reads and changes it only through that skill, holding no copy of it and editing no file of its own. +The backlog loop: once an agent's main work settles, it consumes the agent queue one entry per turn until the queue is empty — the framework drives (read the next entry, gate, prompt, take the entry off the queue, repeat). The queue itself is the `queue` skill's: it lives on the project's `agent-data` branch, and the framework reads and changes it only through that skill, holding no copy of it and editing no file of its own; which ticket an entry links to is the `tickets` skill's rule. ## User story @@ -8,7 +8,7 @@ The backlog loop: once an agent's main work settles, it consumes the agent queue ## Glossary -- **open entry** — an entry the queue still lists as work. What counts as one, and in what order entries come back, is the `tickets` skill's rule. +- **open entry** — an entry the queue still lists as work. What counts as one, and in what order entries come back, is the `queue` skill's rule. ## Business logic — TL;DR diff --git a/packages/framework/src/todo-loop.ts b/packages/framework/src/todo-loop.ts index 0ca36d5c0..c69d33f65 100644 --- a/packages/framework/src/todo-loop.ts +++ b/packages/framework/src/todo-loop.ts @@ -1,7 +1,8 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import type { DriverSession } from 'agent-driver' -import { parseQueueEntries, queueDone, readQueueEntries, ticketFromQueueEntry } from '@gemstack/skill-tickets' +import { parseQueueEntries, queueDone, readQueueEntries } from '@gemstack/skill-queue' +import { ticketFromQueueEntry } from '@gemstack/skill-tickets' import type { ChoicePick, ChoiceRequest, FrameworkEvent } from './events.js' import { requestChoices, runAwaitRounds } from './await-gate.js' import { drainsQueue } from './preset-catalog.js' @@ -16,8 +17,9 @@ import { createTurnSignalEmitter } from './turn-gate.js' * issue: stop when the queue is empty. The dashboard's autopilot auto-accepts the per-item gate, * so `[x] autopilot` consumes the whole queue unattended; autopilot off pauses before each entry. * - * The queue is the `tickets` skill's (#1748): it lives on the `agent-data` branch, read and changed - * through the skill's library — the framework holds no copy and edits no file of its own. + * The queue is the `queue` skill's (#1748): it lives on the `agent-data` branch, read and changed + * through the skill's library — the framework holds no copy and edits no file of its own. Which + * ticket an entry links to is the `tickets` skill's rule. */ /** diff --git a/packages/skill-queue/DECISIONS.md b/packages/skill-queue/DECISIONS.md new file mode 100644 index 000000000..2cca7f180 --- /dev/null +++ b/packages/skill-queue/DECISIONS.md @@ -0,0 +1,46 @@ +Non-obvious decisions only, grouped by business-logic flow. Anything not listed is left +to the implementer's judgment. Flag conflicts instead of silently deviating. Keep +outdated decisions (no history). + +A bullet is a person's pick, and says what it was picked over. What the code does belongs +in SPEC.md; a choice made while implementing is the implementer's judgment, not a +decision. An AI proposes a bullet and asks; it never adds or rewrites one. + +## The queue +- Two callers: the command an agent runs, and a long-lived program that keeps the branch + checked out and drains the queue through this package's functions. The executable is + `queue`. The package ships `SKILL.md`, the agent's instructions. +- The queue is one markdown file on the branch, `TODO_AGENTS.md`: sections `## Priority + 10` down to `## Priority 0`, any `## Priority N` counts, in any case; any `-`, `*` or + `N.` list item with text is an entry, wherever it sits. Entries are placed to keep the + file sorted high to low; nothing re-sorts on read. +- An entry is plain trimmed text: the task a future agent is started with. The package + does not know tickets: a caller that queues a ticket writes the entry itself as a + markdown link to the ticket, and reads the link back to claim the ticket for the agent + it starts. No `--ticket` flag on the command. +- `queue add` creates the queue file when the branch has none. An entry with no priority + goes at the end of the file, in whatever section ends it. +- Done means deleted, never checked off: a `- [x]` or `- [X]` line is not an open entry; a + `- [ ]` line is, printed without its box and deleted whole. + +## Flow: the command +- A read fetches origin once and reads everything from that copy (the library's queue read + fetches only when asked): only origin has every writer's pushes, this command's own + included. With no origin the local branch is read: writes are refused there, so nobody + else can have moved it. +- Every command that runs prints one JSON document, the result or the refusal. A refusal + also puts one line on stderr and exits 1. A malformed command line (an unknown flag, the + wrong argument count, an empty `queue add` text, a `--priority` off the 0-10 scale) is + rejected first: the usage on stderr, nothing on stdout, exit 2. Anything a command + throws refuses with `git-failed`. Outside a repository a command refuses `not-a-repo`; + only git's own "not a git repository" reads as that. +- A bare `queue` answers with a JSON array; every other result and every refusal is an + object with `ok`. +- The command's write is one commit per command, pushed straight to origin through a + throwaway worktree at origin's tip; a push that loses a race is re-applied on the new + tip by `@gemstack/agent-data`. The program's writes go through its persistent checkout's + cycle instead; its `queue done` of an entry already gone succeeds, changing nothing. A + program's queue edit counts only once pushed. +- `queue done` takes the entry as `queue` printed it, trimmed, removes the first such + line, and refuses a line the queue does not have, an empty one included, decided inside + the write. diff --git a/packages/skill-queue/SKILL.SPEC.md b/packages/skill-queue/SKILL.SPEC.md new file mode 100644 index 000000000..48694e446 --- /dev/null +++ b/packages/skill-queue/SKILL.SPEC.md @@ -0,0 +1,17 @@ +The skill's instructions: what an agent is told about the project's agent queue — where it lives, how to read and change it, and the format it is written in. + +## User story + +- The user expects agents to pick up work from one queue, to add follow-up work to it, and to take an entry off once its work is done — without the user having to put any of that in a prompt. + +## Business logic — TL;DR + +- **The queue is on a branch, not in your checkout** - `TODO_AGENTS.md` lives on the `agent-data` branch, never on a code branch; the agent's own checkout does not contain it. It lists every task agents will work on next, in the order they will be taken. +- **The `queue` command is the only way to change it** - it ships with the `@gemstack/skill-queue` package the repository depends on: the agent installs the repository's dependencies once, when there is no `node_modules` yet, and runs `npx queue`, so every command the skill names runs as written on a fresh clone. Every change it makes is one commit pushed straight to the `agent-data` branch; a refusal exits 1 with a line on stderr, a wrong command line exits 2 with the usage. +- **Read: the bare command** - the open entries in order of work, as one JSON array. +- **Change: `add`, `done`** - put an entry on the queue, placed in its `## Priority N` section by a 0–10 priority, at the end of the file without one; take an entry off, by its text as listed, which deletes it. +- **The format** - `## Priority N` sections from 10, critical and to be acted on immediately, down to 0, only if capacity; an entry is a list item, a link or a self-contained description; first within a band first to be taken; a done entry is removed, never ticked off. + +## Before modifying/creating SPEC.md files + +You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md diff --git a/packages/skill-queue/SKILL.md b/packages/skill-queue/SKILL.md new file mode 100644 index 000000000..c171b3de8 --- /dev/null +++ b/packages/skill-queue/SKILL.md @@ -0,0 +1,47 @@ +--- +name: queue +description: Where the project's agent queue lives, how to read it and change it, and its format. +--- + +# The agent queue + +The agent queue (`TODO_AGENTS.md`) lives on the branch `agent-data`, never on a code branch; your checkout does not contain it. It lists every task agents will work on next, in the order they will be taken. + +Read and change it with the `queue` command, a dependency of this repository (`@gemstack/skill-queue`). With no `node_modules`, install first with the lockfile's package manager (`npm install` for `package-lock.json`). Then run it as `npx queue`. Every change it makes is one commit pushed straight to the `agent-data` branch. A refusal exits 1 with a line on stderr; a wrong command line exits 2 with the usage. + +## Read + +``` +npx queue the open entries, in order of work, as one JSON array +``` + +## Change + +``` +npx queue add <text> [--priority N] + put an entry on the queue; --priority (0-10) places it in that + section; without it, the entry goes at the end of the file +npx queue done <entry> remove an entry: one quoted argument, exactly as `npx queue` + printed it; done means deleted +``` + +## Format + +```md +## Priority 10 (critical — act immediately) + +... + +## Priority 9 + +- [Succinct description](/link-for-more-details) +- Or self-contained TODO item with complete description of what should be done + +... + +## Priority 0 (only if capacity) + +... +``` + +The queue lists *all* tasks AI will work on next, sorted by priority. Priority 10 is rarely used (e.g. critical production bugs) and is treated as the utmost priority. Within a priority, the first tasks have higher priority (they are the "next" tasks within that "priority queue"). A done entry is removed (`npx queue done`). diff --git a/packages/skill-queue/SPEC.md b/packages/skill-queue/SPEC.md new file mode 100644 index 000000000..3f008e9fb --- /dev/null +++ b/packages/skill-queue/SPEC.md @@ -0,0 +1,21 @@ +The agent queue for coding agents, as an npm package: the priority-banded list of what agents work on next, `TODO_AGENTS.md`, on the `agent-data` branch of the project's own repository, never on a code branch; the `queue` command that reads and changes it from any clone; and the instructions an agent follows to use it (`SKILL.md`). + +The package knows git, the filesystem and the queue's markdown, nothing else: an entry is text, and what the text means — a task, a link to a ticket elsewhere — is the writer's. The same functions serve every caller: a long-lived process (a daemon that drains the queue into agents, a dashboard that shows it) that keeps a checkout of the branch, and an agent's own shell, through the `queue` command a caller puts on the PATH of every agent it starts on its machine. + +The branch is a file store, the primitive `@gemstack/agent-data` provides: a branch nobody edits in a working tree, safe to push and pull eagerly. It gives this package two writers over one rule. A long-lived process writes through its persistent checkout, `.branches/agent-data`, in one serialized cycle per branch — sync with origin, apply, commit, push — and pulls the same way so it reads what other machines pushed. The `queue` command writes as a one-shot remote writer instead: a throwaway checkout of origin's tip, one commit, pushed straight to the branch, gone afterwards — so a command an agent runs never touches a checkout that belongs to another process. Both treat a change as an intent: a push that loses a race is re-applied against the fresher state rather than forced. + +## Glossary + +- **the funnel** - a caller's write cycle over the branch: apply a change to a checkout of it, commit, push. A long-lived process passes its own; the package's default is the persistent checkout's cycle. + +## Business logic — TL;DR + +- **The name** (`names`) - the queue file, `TODO_AGENTS.md`, at the root of the shared data branch `agent-data` (`@gemstack/agent-data` names the branch). +- **The queue** (`queue`) - the open entries in order of work, an entry added into its `## Priority N` section, an entry taken off by deletion — done means gone, not checked off; read from anywhere in the repository, edited through the funnel. +- **Where it lives** (`store`) - the branch bound to a project: the seams every operation is injected with, and the sync that makes the branch, seeds the queue, and converges with origin. +- **The command line** (`cli`, `bin/`) - the same operations as commands for a shell: JSON on stdout, a reason on stderr, an exit code that tells a refusal from a usage error; the executable's directory and the skill's own directory are exported (`bin-dir`) for a caller that spawns agents. +- **The skill** (`SKILL.md`) - what the agent is told: the queue is on a branch and not in its checkout, the `queue` command is how it reads and changes it, and the format. + +## Before modifying/creating SPEC.md files + +You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md diff --git a/packages/skill-queue/bin/SPEC.md b/packages/skill-queue/bin/SPEC.md new file mode 100644 index 000000000..afd25a29c --- /dev/null +++ b/packages/skill-queue/bin/SPEC.md @@ -0,0 +1,5 @@ +The `queue` executable: it hands the shell's arguments, working directory and streams to the command line (`src/cli`) and exits with the code it returns. No logic of its own. + +## Before modifying/creating SPEC.md files + +You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md diff --git a/packages/skill-queue/bin/queue b/packages/skill-queue/bin/queue new file mode 100755 index 000000000..8b9e2ba32 --- /dev/null +++ b/packages/skill-queue/bin/queue @@ -0,0 +1,11 @@ +#!/usr/bin/env node +import { runCli } from '../dist/cli.js' + +runCli(process.argv.slice(2), { cwd: process.cwd(), stdout: line => console.log(line), stderr: line => console.error(line) }) + .then(code => { + process.exitCode = code + }) + .catch(err => { + console.error(err) + process.exitCode = 1 + }) diff --git a/packages/skill-queue/package.json b/packages/skill-queue/package.json new file mode 100644 index 000000000..0a9acfeb1 --- /dev/null +++ b/packages/skill-queue/package.json @@ -0,0 +1,50 @@ +{ + "name": "@gemstack/skill-queue", + "version": "0.0.1", + "description": "The agent queue for coding agents: the priority-banded list of what agents work on next, kept on a branch of the project's repository and changed with the queue command.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/framework/the-framework.git", + "directory": "packages/skill-queue" + }, + "type": "module", + "engines": { + "node": ">=22.12.0" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "bin/queue", + "SKILL.md" + ], + "bin": { + "queue": "bin/queue" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./names": { + "types": "./dist/names.d.ts", + "default": "./dist/names.js" + }, + "./SKILL.md": "./SKILL.md" + }, + "scripts": { + "build": "pnpm -C ../agent-data build && tsc -p tsconfig.build.json", + "typecheck": "pnpm -C ../agent-data build && tsc --noEmit", + "test": "pnpm build && tsc -p tsconfig.test.json && node --test --test-timeout=60000 'dist-test/**/*.test.js'", + "clean": "rm -rf dist dist-test" + }, + "dependencies": { + "@gemstack/agent-data": "workspace:*" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^7.0.2" + } +} diff --git a/packages/skill-queue/src/SPEC.md b/packages/skill-queue/src/SPEC.md new file mode 100644 index 000000000..49f563b73 --- /dev/null +++ b/packages/skill-queue/src/SPEC.md @@ -0,0 +1,5 @@ +The package's source: the queue file's name, the reads and the writes behind the agent queue on the `agent-data` branch. See the package `SPEC.md` for how the modules relate; each module's `*.SPEC.md` holds its business logic. + +## Before modifying/creating SPEC.md files + +You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md diff --git a/packages/skill-queue/src/bin-dir.SPEC.md b/packages/skill-queue/src/bin-dir.SPEC.md new file mode 100644 index 000000000..274f30333 --- /dev/null +++ b/packages/skill-queue/src/bin-dir.SPEC.md @@ -0,0 +1,5 @@ +Where the `queue` executable lives, for a caller that puts it on a spawned process's PATH: a daemon does so for every agent it starts on its machine, so the agent's shell finds the command by name. Beside it, the package's own directory — the one holding `SKILL.md` — and the skill's name, `queue`, which is what `SKILL.md` declares itself and the name a harness lists the skill under, for a caller that links the skill into an agent's checkout. All three sit beside the compiled code, so the same paths hold from a workspace checkout and from an installed package. + +## Before modifying/creating SPEC.md files + +You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md diff --git a/packages/skill-queue/src/bin-dir.ts b/packages/skill-queue/src/bin-dir.ts new file mode 100644 index 000000000..a0ee66b36 --- /dev/null +++ b/packages/skill-queue/src/bin-dir.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from 'node:url' + +/** + * Where the `queue` executable lives, for a caller that puts it on a spawned process's PATH — a + * daemon, for every agent it starts. Beside `dist/`, so it is the same path from a workspace + * checkout and from an installed package. + */ +export const CLI_BIN_DIR = fileURLToPath(new URL('../bin/', import.meta.url)) + +/** The skill's name: the `name` in `SKILL.md`'s front matter, and the directory a harness lists it under. */ +export const SKILL_NAME = 'queue' + +/** This package's directory — the one holding `SKILL.md`. Beside `dist/`, like the executable. */ +export const SKILL_DIR = fileURLToPath(new URL('..', import.meta.url)) diff --git a/packages/skill-queue/src/cli.SPEC.md b/packages/skill-queue/src/cli.SPEC.md new file mode 100644 index 000000000..eb7e2ad0c --- /dev/null +++ b/packages/skill-queue/src/cli.SPEC.md @@ -0,0 +1,68 @@ +The package's command line: the same operations a caller calls, for an agent (or a person) in a shell, in any clone of the repository — so an agent reads the queue and changes it through the one implementation a caller uses, and a second surface is never a second behaviour. + +## User story + +- An agent, started in a clone that holds no copy of the queue, reads it in the order it is worked. +- An agent puts an entry on the queue, and takes one off once its work is done. +- The user, in a terminal, does the same without any other tool. + +## Glossary + +- **refusal** - a rule saying no to a command: an entry the queue does not have. Not a failure: the command ran, and the answer is "not this one". + +## Business logic — TL;DR + +- **Three commands over the package** - the bare command, `add`, `done`; each is the corresponding package operation and nothing more. +- **JSON out, a reason for a person, an exit code that says which** - every result is one JSON document on stdout; a refusal or a git failure also puts one line on stderr and exits 1; a command that cannot be read gets the usage on stderr and exits 2. +- **Reads come off origin, fetched once** - so a command sees what every writer pushed, its own earlier writes included. +- **Writes are a remote writer's** - one commit each, pushed straight to the branch, never touching a caller's persistent checkout; a repository with no remote is refused. + +## Business logic + +### Three commands over the package + +#### User story + +See `## User story`. + +#### Business logic + +- the bare command, no arguments - the queue's open entries, in order of work, as a JSON array. +- `add <text> [--priority N]` - puts an entry on the queue, in its `## Priority N` section when a priority is given, else at the end of the file; the queue file is created when the branch has none. An empty entry, or a priority outside 0 to 10, is a usage error. +- `done <text>` - takes an entry off the queue; the text must match an open entry exactly, as the bare command printed it. Done means deleted. An entry the queue does not have is refused. + +### JSON out, a reason for a person, an exit code that says which + +#### User story + +An agent parses what it is told; a person reads it; a script branches on the exit code. + +#### Business logic + +Every command writes exactly one JSON document to stdout. A result is the operation's outcome. A refusal is `{ ok: false, reason }` — the reason a short fixed word plus what identifies the case (the entry) — with one sentence on stderr saying the same for a person, and exit code 1. The reasons: `no-entry`, `no-remote`, `not-a-repo`. A git failure past the decision is reported the same way, reason `git-failed`, with git's own line. A command that cannot be read — an unknown command, an argument missing or extra, an unknown option, an empty entry, a priority that is not 0 to 10 — gets the usage on stderr, no JSON, and exit code 2. + +### Reads come off origin, fetched once + +#### User story + +An agent reads the queue right after adding to it, and sees the entry it added. + +#### Business logic + +A read fetches the branch once, up front, and reads origin's copy — so the command sees what every writer pushed, including its own earlier writes, which never move any ref in the agent's own clone. A repository with no remote reads its local copy of the branch instead. Outside any repository the command is refused as `not-a-repo` rather than failing on git — only git's own "not a repository" reads as that; a timeout, a missing git or a corrupt repository stays the failure it is. + +### Writes are a remote writer's + +#### User story + +An agent changes the queue from a clone that holds no checkout of the branch, while a long-lived process on another machine is writing to it too. + +#### Business logic + +Each write is one commit: origin's tip is fetched and checked out in a throwaway checkout, the change applied, committed, and pushed straight to the `agent-data` branch, and the throwaway checkout removed. A push that loses a race re-fetches origin's tip and re-applies the same change before pushing again. Nothing lands in the agent's own working tree, and the persistent checkout a long-lived process keeps is never touched — it belongs to that process, and converges on its own next pull. + +A repository with no remote is refused as `no-remote`: a change nothing can carry is the user's error state, not a mode this supports. + +## Before modifying/creating SPEC.md files + +You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md diff --git a/packages/skill-queue/src/cli.test.SPEC.md b/packages/skill-queue/src/cli.test.SPEC.md new file mode 100644 index 000000000..3ea9dfa25 --- /dev/null +++ b/packages/skill-queue/src/cli.test.SPEC.md @@ -0,0 +1,13 @@ +What the tests cover: every command of the command line against real git, from clones acting as agents that share one origin, and the contract around them. + +- **Reads** - the bare command gives the open entries in order of work, read off origin, from a clone holding no checkout of the branch; another clone reads every push, this one's included. +- **`add`** - an entry lands in the section its priority names; with no priority it goes at the end; the file that results is the sections in order, high to low; each write is one commit named after what it did, authored by the agent's own clone; with no queue file on the branch yet, the first `add` creates it. +- **Nothing lands locally** - the agent's clone gains no copy of the branch and no change in its own working tree. +- **`done`** - the line is deleted rather than checked off, as one commit; an entry that is not open is refused as no such entry, on stdout and in one line on stderr. +- **Usage** - an unknown command, a bare command given an argument, an argument missing or extra, an empty entry and a priority off the scale or not a number all get the usage on stderr, no JSON, and exit code 2, and write nothing. +- **A repository with no remote** - reads come off its local copy of the branch, and every write is refused because nothing can carry it. +- **Outside a repository** - refused as such. + +## Before modifying/creating SPEC.md files + +You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md diff --git a/packages/skill-queue/src/cli.test.ts b/packages/skill-queue/src/cli.test.ts new file mode 100644 index 000000000..edb026c15 --- /dev/null +++ b/packages/skill-queue/src/cli.test.ts @@ -0,0 +1,147 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { nodeGitRunner, DATA_BRANCH } from '@gemstack/agent-data' +import { runCli, USAGE } from './cli.js' +import { QUEUE_FILE } from './names.js' + +const git = nodeGitRunner() +const RETRIED_RM = { recursive: true, force: true, maxRetries: 10 } as const + +/** A bare origin with an `agent-data` branch (holding `queue` when given), plus N clones acting as agents. */ +async function rig(clones: number, queue?: string) { + const bare = await realpath(await mkdtemp(join(tmpdir(), 'queue-cli-bare-'))) + await git(['init', '--bare', '-b', 'main', bare], bare) + const seed = await realpath(await mkdtemp(join(tmpdir(), 'queue-cli-seed-'))) + await git(['clone', bare, seed], seed) + await git(['config', 'user.email', 's@s'], seed) + await git(['config', 'user.name', 's'], seed) + await writeFile(join(seed, 'README.md'), '# t\n') + await git(['add', '-A'], seed) + await git(['commit', '-m', 'init'], seed) + await git(['push', 'origin', 'main'], seed) + const commit = (await git(['commit-tree', '4b825dc642cb6eb9a060e54bf8d69288fbee4904', '-m', 'create the agent-data branch'], seed)).trim() + await git(['checkout', '-B', DATA_BRANCH, commit], seed) + if (queue !== undefined) { + await writeFile(join(seed, QUEUE_FILE), queue) + await git(['add', '-A'], seed) + await git(['commit', '-m', 'seed'], seed) + } + await git(['push', 'origin', DATA_BRANCH], seed) + const agents: string[] = [] + for (let i = 0; i < clones; i++) { + const parent = await realpath(await mkdtemp(join(tmpdir(), `queue-cli-agent${i}-`))) + const clone = join(parent, 'clone') + await git(['clone', bare, clone], parent) + await git(['config', 'user.email', `a${i}@a`], clone) + await git(['config', 'user.name', `a${i}`], clone) + await git(['checkout', '-b', `agent-a${i}`], clone) + agents.push(clone) + } + const cleanup = async () => { + for (const dir of [bare, seed, ...agents.map(a => join(a, '..'))]) await rm(dir, RETRIED_RM) + } + return { bare, seed, agents, cleanup } +} + +/** Run one command, capturing the contract: parsed stdout, stderr lines, exit code. */ +async function run(cwd: string, argv: string[]) { + const out: string[] = [] + const err: string[] = [] + const code = await runCli(argv, { cwd, stdout: line => out.push(line), stderr: line => err.push(line) }) + return { code, json: out.length ? JSON.parse(out.join('\n')) : undefined, stderr: err.join('\n') } +} + +test('the bare command reads the queue off origin; add places an entry by priority as one pushed commit; done deletes the line', async () => { + const { bare, agents, cleanup } = await rig(2, '## Priority 5\n\n- [Do B](tickets/2026-08-29_b.md)\n') + const [a, b] = agents + try { + const listed = await run(a!, []) + assert.equal(listed.code, 0) + assert.deepEqual(listed.json, ['[Do B](tickets/2026-08-29_b.md)']) + const added = await run(a!, ['add', 'Tidy the loader', '--priority', '3']) + assert.deepEqual(added.json, { ok: true, entry: 'Tidy the loader', priority: 3 }) + assert.deepEqual((await run(a!, ['add', '[Do A](tickets/2026-08-30_a.md)', '--priority', '8'])).json, { ok: true, entry: '[Do A](tickets/2026-08-30_a.md)', priority: 8 }) + const plain = await run(a!, ['add', 'Last, unranked']) + assert.deepEqual(plain.json, { ok: true, entry: 'Last, unranked' }) + assert.equal(await git(['show', `${DATA_BRANCH}:${QUEUE_FILE}`], bare), '## Priority 8\n\n- [Do A](tickets/2026-08-30_a.md)\n\n## Priority 5\n\n- [Do B](tickets/2026-08-29_b.md)\n\n## Priority 3\n\n- Tidy the loader\n- Last, unranked\n') + assert.equal((await git(['log', '-1', '--format=%s %an', DATA_BRANCH], bare)).trim(), 'queue add: Last, unranked a0') + // The agent's own clone holds no local copy of the branch: the write was a remote writer's. + await assert.rejects(git(['rev-parse', '--verify', DATA_BRANCH], a!)) + assert.equal((await git(['status', '--porcelain'], a!)).trim(), '', 'nothing lands in the agent\'s checkout') + // Another clone reads every push, this one's included. + assert.deepEqual((await run(b!, [])).json, ['[Do A](tickets/2026-08-30_a.md)', '[Do B](tickets/2026-08-29_b.md)', 'Tidy the loader', 'Last, unranked']) + const done = await run(b!, ['done', '[Do B](tickets/2026-08-29_b.md)']) + assert.deepEqual(done.json, { ok: true, entry: '[Do B](tickets/2026-08-29_b.md)' }) + assert.equal((await git(['log', '-1', '--format=%s', DATA_BRANCH], bare)).trim(), 'queue done: [Do B](tickets/2026-08-29_b.md)') + const md = await git(['show', `${DATA_BRANCH}:${QUEUE_FILE}`], bare) + assert.ok(!md.includes('Do B')) + assert.ok(!md.includes('[x]'), 'deleted, not checked off') + const gone = await run(a!, ['done', 'never there']) + assert.equal(gone.code, 1) + assert.deepEqual(gone.json, { ok: false, reason: 'no-entry', entry: 'never there' }) + assert.match(gone.stderr, /no open queue entry reads "never there"/) + } finally { + await cleanup() + } +}) + +test('add creates the queue file when the branch has none', async () => { + const { bare, agents, cleanup } = await rig(1) + const [a] = agents + try { + assert.deepEqual((await run(a!, [])).json, []) + assert.equal((await run(a!, ['add', 'first ever', '--priority', '5'])).code, 0) + assert.equal(await git(['show', `${DATA_BRANCH}:${QUEUE_FILE}`], bare), '## Priority 5\n\n- first ever\n') + assert.deepEqual((await run(a!, [])).json, ['first ever']) + } finally { + await cleanup() + } +}) + +test('usage: an unknown command, a wrong argument count, an empty entry and a priority off the scale', async () => { + const { agents, cleanup } = await rig(1, '- one\n') + const [a] = agents + try { + const unknown = await run(a!, ['dance']) + assert.equal(unknown.code, 2) + assert.equal(unknown.json, undefined) + assert.equal(unknown.stderr, USAGE) + assert.equal((await run(a!, ['extra'])).code, 2) + assert.equal((await run(a!, ['add'])).code, 2) + assert.equal((await run(a!, ['add', ' '])).code, 2) + assert.equal((await run(a!, ['add', 'x', '--priority', '11'])).code, 2) + assert.equal((await run(a!, ['add', 'x', '--priority', 'high'])).code, 2) + assert.equal((await run(a!, ['done'])).code, 2) + assert.equal((await run(a!, ['done', 'one', 'two'])).code, 2) + // Nothing was written by any of them. + assert.deepEqual((await run(a!, [])).json, ['one']) + } finally { + await cleanup() + } +}) + +test('a repository with no remote reads its local branch and refuses to write; outside a repository is a refusal', async () => { + const solo = await realpath(await mkdtemp(join(tmpdir(), 'queue-cli-solo-'))) + try { + await git(['init', '-b', 'main'], solo) + await git(['config', 'user.email', 's@s'], solo) + await git(['config', 'user.name', 's'], solo) + await writeFile(join(solo, 'README.md'), '# t\n') + await git(['add', '-A'], solo) + await git(['commit', '-m', 'init'], solo) + const commit = (await git(['commit-tree', '4b825dc642cb6eb9a060e54bf8d69288fbee4904', '-m', 'create the agent-data branch'], solo)).trim() + await git(['branch', DATA_BRANCH, commit], solo) + assert.deepEqual((await run(solo, [])).json, []) + const refused = await run(solo, ['add', 'x']) + assert.equal(refused.code, 1) + assert.deepEqual(refused.json, { ok: false, reason: 'no-remote' }) + const outside = await run(tmpdir(), []) + assert.equal(outside.code, 1) + assert.deepEqual(outside.json, { ok: false, reason: 'not-a-repo' }) + } finally { + await rm(solo, RETRIED_RM) + } +}) diff --git a/packages/skill-queue/src/cli.ts b/packages/skill-queue/src/cli.ts new file mode 100644 index 000000000..f3972904f --- /dev/null +++ b/packages/skill-queue/src/cli.ts @@ -0,0 +1,169 @@ +import { parseArgs } from 'node:util' +import { join } from 'node:path' +import { checkoutRoot, gitReason, nodeBranchFileFs, nodeGitRunner, openBranchReader, writeFileBranchDetached, type BranchReader, type GitRunner, DATA_BRANCH } from '@gemstack/agent-data' +import { QUEUE_FILE } from './names.js' +import { appendQueueEntry, insertQueueEntry, parseQueueEntries, removeQueueEntry } from './queue.js' + +/** + * The command line over the package: the same operations a daemon calls, for an agent (and a + * person) in a shell, in any clone of the repository. + * + * The contract: JSON on stdout, one line for a person on stderr, and the exit code says how it + * went — 0 for a result, 1 for a refusal or a git failure, 2 for a command that could not be + * read. A refusal is a rule saying no (there is no such entry); it is reported on stdout as + * `{ ok: false, reason }` so a caller parsing the output learns why, and on stderr so a person does. + * + * Reads go to origin's copy of the branch, fetched first, so a command sees what every writer + * pushed — its own earlier writes included. Writes are a remote writer's: one commit each, on a + * throwaway checkout of origin's tip, pushed straight to the branch; a rejected push is re-applied + * on the new tip and pushed again. The persistent checkout a daemon keeps is never touched. + */ + +export const USAGE = `usage: queue [command] + + (no command) the queue's open entries, in order of work + add <text> [--priority N] put an entry on the queue, in its priority section + done <text> take an entry off the queue + +JSON on stdout. Exit code 1 for a refusal or a git failure (the reason on stderr), 2 for a usage error.` + +/** The streams and the working directory a run of the CLI sees. */ +export interface CliIo { + cwd: string + stdout: (line: string) => void + stderr: (line: string) => void +} + +/** A refusal: a rule said no, and the caller learns which. */ +export type CliRefusal = { ok: false; reason: string; [key: string]: unknown } + +/** Thrown inside a command to end it with a refusal. */ +class Refused extends Error { + constructor( + readonly outcome: CliRefusal, + readonly line: string, + ) { + super(line) + } +} + +/** Thrown inside a command for an argument that cannot be read: usage on stderr, exit 2. */ +class Usage extends Error {} + +/** Run the CLI: `argv` is everything after the program name. Resolves to the exit code. */ +export async function runCli(argv: string[], io: CliIo, git: GitRunner = nodeGitRunner()): Promise<number> { + const [command, ...rest] = argv + const run = command === undefined ? list : Object.hasOwn(COMMANDS, command) ? COMMANDS[command] : undefined + if (!run) { + io.stderr(USAGE) + return 2 + } + try { + io.stdout(JSON.stringify(await run(rest, io, git))) + return 0 + } catch (err) { + if (err instanceof Usage) { + io.stderr(`${err.message}\n\n${USAGE}`) + return 2 + } + if (err instanceof Refused) { + io.stdout(JSON.stringify(err.outcome)) + io.stderr(err.line) + return 1 + } + const detail = gitReason(err) + io.stdout(JSON.stringify({ ok: false, reason: 'git-failed', detail })) + io.stderr(detail) + return 1 + } +} + +type Command = (args: string[], io: CliIo, git: GitRunner) => Promise<unknown> + +/** The bare command: the open entries, in order of work. */ +const list: Command = async (args, io, git) => { + parse(args, {}, 0) + const reader = await open(io.cwd, git) + return parseQueueEntries((await reader.read(QUEUE_FILE)) ?? '') +} + +const COMMANDS: Record<string, Command> = { + async add(args, io, git) { + const { positionals, values } = parse(args, { priority: { type: 'string' } }, 1) + const entry = positionals[0]!.trim() + if (!entry) throw new Usage('the entry is empty') + const priority = values.priority === undefined ? undefined : priorityArg(values.priority) + await write(io.cwd, `queue add: ${entry}`, async dir => { + const md = await readOr(dir, '') + await writeQueue(dir, priority === undefined ? appendQueueEntry(md, entry) : insertQueueEntry(md, entry, priority)) + }, git) + return { ok: true, entry, ...(priority === undefined ? {} : { priority }) } + }, + + async done(args, io, git) { + const { positionals } = parse(args, {}, 1) + const entry = positionals[0]!.trim() + let found = false + await write(io.cwd, `queue done: ${entry}`, async dir => { + const md = await readOr(dir, '') + found = parseQueueEntries(md).includes(entry) + if (found) await writeQueue(dir, removeQueueEntry(md, entry)) + }, git) + if (!found) throw new Refused({ ok: false, reason: 'no-entry', entry }, `no open queue entry reads "${entry}"`) + return { ok: true, entry } + }, +} + +/** The queue file inside a checkout, read as `fallback` when absent. */ +async function readOr(dir: string, fallback: string): Promise<string> { + return nodeBranchFileFs().read(join(dir, QUEUE_FILE)).catch(() => fallback) +} + +async function writeQueue(dir: string, md: string): Promise<void> { + await nodeBranchFileFs().write(join(dir, QUEUE_FILE), md) +} + +/** The branch opened for reading, from wherever the command runs; outside a repo, a refusal. */ +async function open(cwd: string, git: GitRunner): Promise<BranchReader> { + await inRepo(() => checkoutRoot(cwd, git)) + return openBranchReader(cwd, DATA_BRANCH, { git }) +} + +/** One detached write, refusing where nothing can carry it. */ +async function write(cwd: string, message: string, op: (dir: string) => Promise<void>, git: GitRunner): Promise<void> { + await inRepo(() => checkoutRoot(cwd, git)) + const result = await writeFileBranchDetached(cwd, DATA_BRANCH, message, op, { git }) + if (!result.ok) throw new Refused({ ok: false, reason: result.reason }, 'the repository has no remote, so nothing can carry the change') +} + +function priorityArg(value: string): number { + if (!/^\d+$/.test(value) || Number(value) > 10) throw new Usage(`--priority takes 0 to 10, got ${value}`) + return Number(value) +} + +/** + * Outside a repo, the commands have nothing to act on: said as a refusal, not a git failure. + * Only git's own "not a git repository" reads as that; a timeout, a missing git, or a corrupt + * repo stays the failure it is. + */ +async function inRepo<T>(read: () => Promise<T>): Promise<T> { + try { + return await read() + } catch (err) { + if (!/not a git repository/i.test(err instanceof Error ? err.message : String(err))) throw err + throw new Refused({ ok: false, reason: 'not-a-repo' }, 'not inside a git repository') + } +} + +type Options = Record<string, { type: 'string' | 'boolean' }> + +/** `parseArgs` with the positional count checked: too few or too many is a usage error. */ +function parse<O extends Options>(args: string[], options: O, min: number, max: number = min) { + try { + const parsed = parseArgs({ args, options, allowPositionals: true, strict: true }) + if (parsed.positionals.length < min || parsed.positionals.length > max) throw new Usage(`expected ${max === min ? min : `${min} to ${max}`} argument(s), got ${parsed.positionals.length}`) + return parsed + } catch (err) { + throw err instanceof Usage ? err : new Usage(err instanceof Error ? err.message : String(err)) + } +} diff --git a/packages/skill-queue/src/index.SPEC.md b/packages/skill-queue/src/index.SPEC.md new file mode 100644 index 000000000..e9a2245e7 --- /dev/null +++ b/packages/skill-queue/src/index.SPEC.md @@ -0,0 +1,5 @@ +The package's main entry point: it gathers the queue file's name, the queue's reads and edits, the branch's sync and seams, the command line and the executable's directory into one place for a caller to import. No business logic of its own. The name is also reachable on its own (`names`), for code that runs in a browser and must not pull in git. + +## Before modifying/creating SPEC.md files + +You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md diff --git a/packages/skill-queue/src/index.ts b/packages/skill-queue/src/index.ts new file mode 100644 index 000000000..c274e55e5 --- /dev/null +++ b/packages/skill-queue/src/index.ts @@ -0,0 +1,15 @@ +export { QUEUE_FILE } from './names.js' +export { + parseQueueEntries, + appendQueueEntry, + insertQueueEntry, + removeQueueEntry, + readQueue, + readQueueEntries, + queueAdd, + queueDone, + type QueueEdit, +} from './queue.js' +export { syncQueue, queueFunnel, resolveQueueDeps, type QueueDeps, type QueueFiles, type QueueFunnel } from './store.js' +export { runCli, USAGE, type CliIo, type CliRefusal } from './cli.js' +export { CLI_BIN_DIR, SKILL_DIR, SKILL_NAME } from './bin-dir.js' diff --git a/packages/skill-queue/src/names.SPEC.md b/packages/skill-queue/src/names.SPEC.md new file mode 100644 index 000000000..51daec4ea --- /dev/null +++ b/packages/skill-queue/src/names.SPEC.md @@ -0,0 +1,5 @@ +The one name the package hangs off: the queue file, `TODO_AGENTS.md`, at the root of the shared data branch `agent-data` (named by `@gemstack/agent-data`, not here). A convention, not a setting: `SKILL.md` names the same file to every agent. Reachable on its own by code that runs in a browser and must not pull in git. + +## Before modifying/creating SPEC.md files + +You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md diff --git a/packages/skill-queue/src/names.ts b/packages/skill-queue/src/names.ts new file mode 100644 index 000000000..623762475 --- /dev/null +++ b/packages/skill-queue/src/names.ts @@ -0,0 +1,10 @@ +/** + * The one name the package hangs off. No node imports, so browser-side code can name it too. + * + * The branch itself is not named here: the queue lives on the shared data branch, `agent-data`, + * whose name `@gemstack/agent-data` exports as `DATA_BRANCH`. A convention, not a setting: + * `SKILL.md` names the same branch to every agent. + */ + +/** The agent queue: `TODO_AGENTS.md` at the branch root. */ +export const QUEUE_FILE = 'TODO_AGENTS.md' diff --git a/packages/skill-tickets/src/queue.SPEC.md b/packages/skill-queue/src/queue.SPEC.md similarity index 100% rename from packages/skill-tickets/src/queue.SPEC.md rename to packages/skill-queue/src/queue.SPEC.md diff --git a/packages/skill-tickets/src/queue.test.SPEC.md b/packages/skill-queue/src/queue.test.SPEC.md similarity index 100% rename from packages/skill-tickets/src/queue.test.SPEC.md rename to packages/skill-queue/src/queue.test.SPEC.md diff --git a/packages/skill-tickets/src/queue.test.ts b/packages/skill-queue/src/queue.test.ts similarity index 98% rename from packages/skill-tickets/src/queue.test.ts rename to packages/skill-queue/src/queue.test.ts index a26cb4721..deab18a85 100644 --- a/packages/skill-tickets/src/queue.test.ts +++ b/packages/skill-queue/src/queue.test.ts @@ -75,7 +75,7 @@ const git = nodeGitRunner() const RETRIED_RM = { recursive: true, force: true, maxRetries: 10 } as const async function repo(): Promise<string> { - const path = await realpath(await mkdtemp(join(tmpdir(), 'tickets-queue-'))) + const path = await realpath(await mkdtemp(join(tmpdir(), 'queue-'))) await git(['init', '-b', 'main'], path) await git(['config', 'user.email', 't@t'], path) await git(['config', 'user.name', 't'], path) diff --git a/packages/skill-tickets/src/queue.ts b/packages/skill-queue/src/queue.ts similarity index 96% rename from packages/skill-tickets/src/queue.ts rename to packages/skill-queue/src/queue.ts index 2646c79d8..c8edb8496 100644 --- a/packages/skill-tickets/src/queue.ts +++ b/packages/skill-queue/src/queue.ts @@ -1,7 +1,7 @@ import { join } from 'node:path' import { fileBranchRepo, readBranchFile, DATA_BRANCH } from '@gemstack/agent-data' import { QUEUE_FILE } from './names.js' -import { resolveTicketDeps, type TicketDeps } from './store.js' +import { resolveQueueDeps, type QueueDeps } from './store.js' // The agent queue, `TODO_AGENTS.md`: every task agents will work on next, in markdown list items // banded by `## Priority N` sections from 10 down to 0, first within a band first to be taken. An @@ -127,8 +127,8 @@ export type QueueEdit = { ok: true; changed: boolean } | { ok: false } * repository root is resolved, the pure edit applied, the funnel commits and pushes. Never throws — * a resume note is queued while a process is already unwinding, and must not mask why it stopped. */ -async function editQueue(cwd: string, message: string, edit: (md: string) => string, deps: TicketDeps): Promise<QueueEdit> { - const r = resolveTicketDeps(deps) +async function editQueue(cwd: string, message: string, edit: (md: string) => string, deps: QueueDeps): Promise<QueueEdit> { + const r = resolveQueueDeps(deps) const root = await fileBranchRepo(cwd).catch(() => undefined) if (!root) return { ok: false } const result = await r.funnel(root, message, async dir => { @@ -141,11 +141,11 @@ async function editQueue(cwd: string, message: string, edit: (md: string) => str } /** Put `entry` on the queue: in its priority section when a priority is given, else at the end. */ -export async function queueAdd(cwd: string, entry: string, priority?: number, deps: TicketDeps = {}): Promise<QueueEdit> { +export async function queueAdd(cwd: string, entry: string, priority?: number, deps: QueueDeps = {}): Promise<QueueEdit> { return editQueue(cwd, `queue add: ${entry}`, md => (priority === undefined ? appendQueueEntry(md, entry) : insertQueueEntry(md, entry, priority)), deps) } /** Take `entry` off the queue — it is done, or no longer wanted. Landed too when it was already gone, changing nothing. */ -export async function queueDone(cwd: string, entry: string, deps: TicketDeps = {}): Promise<QueueEdit> { +export async function queueDone(cwd: string, entry: string, deps: QueueDeps = {}): Promise<QueueEdit> { return editQueue(cwd, `queue done: ${entry}`, md => removeQueueEntry(md, entry), deps) } diff --git a/packages/skill-queue/src/store.SPEC.md b/packages/skill-queue/src/store.SPEC.md new file mode 100644 index 000000000..e60fd712c --- /dev/null +++ b/packages/skill-queue/src/store.SPEC.md @@ -0,0 +1,38 @@ +Where the queue lives, bound to the branch: `TODO_AGENTS.md` at the root of the `agent-data` branch of the project's repository, checked out at `.branches/agent-data` for a long-lived process. + +## User story + +- The user's caller shows the queue as other machines and cloud sessions push to it, without waiting for this machine to write something. + +## Business logic — TL;DR + +- **The seams every operation takes** - the file operations and the caller's write cycle, injected so every operation is testable off disk and git, with the persistent checkout's cycle as the default. +- **Bringing a caller's view up to date** - the branch and its checkout exist, the queue file is seeded, and the checkout converges with origin. + +## Business logic + +### The seams every operation takes + +#### Business logic + +Every operation in the package that changes the branch takes two things it does not own: plain file operations — read, write, delete, list — against whatever directory it is handed, and the caller's write cycle, which applies a change to a checkout of the branch, commits it and pushes it. A caller that leaves them out gets the defaults: the real filesystem, and the persistent checkout's serialized cycle on the `agent-data` branch. So the same operation reads the same way in a test with neither disk nor git and in production. + +### Bringing a caller's view up to date + +#### User story + +See `## User story`. + +#### Business logic + +A long-lived process brings its view of the branch up to date in one step, which never throws and reports why it could not converge: + +- The branch and its persistent checkout exist. +- The queue file is seeded on a branch born empty, so readers and people find a file rather than a mystery. +- The checkout converges with origin: reading what other machines and cloud sessions pushed, and pushing anything an earlier cycle left stranded. + +A repository with no remote is reported as an error state rather than treated as a mode: the queue exists to be shared, and one nothing can reach is something the caller has to surface. + +## Before modifying/creating SPEC.md files + +You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md diff --git a/packages/skill-queue/src/store.test.SPEC.md b/packages/skill-queue/src/store.test.SPEC.md new file mode 100644 index 000000000..3c3c860e1 --- /dev/null +++ b/packages/skill-queue/src/store.test.SPEC.md @@ -0,0 +1,10 @@ +What the tests cover: bringing a caller's view of the branch up to date, against real git. + +- **The first sync** - the branch is born, its persistent checkout is at `.branches/agent-data` on that branch, and the queue file is seeded and committed so the checkout is clean between cycles. +- **Idempotence** - a second sync seeds nothing new, and a queue written on the branch since is left as it is. +- **No remote** - a repository nothing can reach is reported as an error state, named as such. +- **Converging with origin** - a branch origin already has is adopted, and an entry another machine pushed is on disk after the next sync. + +## Before modifying/creating SPEC.md files + +You must always read and respect https://raw.githubusercontent.com/brillout/sdd/refs/heads/main/sdd.md diff --git a/packages/skill-queue/src/store.test.ts b/packages/skill-queue/src/store.test.ts new file mode 100644 index 000000000..5c1428ed7 --- /dev/null +++ b/packages/skill-queue/src/store.test.ts @@ -0,0 +1,70 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { nodeGitRunner, fileBranchPath, DATA_BRANCH } from '@gemstack/agent-data' +import { syncQueue } from './store.js' +import { QUEUE_FILE } from './names.js' + +const git = nodeGitRunner() +const RETRIED_RM = { recursive: true, force: true, maxRetries: 10 } as const + +async function repo(): Promise<string> { + const path = await realpath(await mkdtemp(join(tmpdir(), 'queue-store-'))) + await git(['init', '-b', 'main'], path) + await git(['config', 'user.email', 't@t'], path) + await git(['config', 'user.name', 't'], path) + await writeFile(join(path, 'README.md'), '# t\n') + await git(['add', '-A'], path) + await git(['commit', '-m', 'init'], path) + return path +} + +test('sync births the branch, seeds the queue, and names a repo with no remote', async () => { + const root = await repo() + try { + const result = await syncQueue(root) + assert.ok(!result.ok && /no remote/.test(result.error), 'a repo nothing can reach is an error state, said') + const wt = fileBranchPath(root, DATA_BRANCH) + assert.equal((await git(['rev-parse', '--abbrev-ref', 'HEAD'], wt)).trim(), DATA_BRANCH) + // Seeded and committed, so the checkout is clean between cycles. + assert.equal(await readFile(join(wt, QUEUE_FILE), 'utf8'), '') + assert.match(await git(['log', '-1', '--format=%s', `refs/heads/${DATA_BRANCH}`], root), /^seed the queue/) + assert.equal((await git(['status', '--porcelain'], wt)).trim(), '') + // Idempotent: a second sync seeds nothing new, and a queue written since is left as it is. + await writeFile(join(wt, QUEUE_FILE), '- by hand\n') + await git(['commit', '-am', 'by hand'], wt) + await syncQueue(root) + assert.match(await git(['log', '-1', '--format=%s', `refs/heads/${DATA_BRANCH}`], root), /^by hand$/m) + assert.equal(await readFile(join(wt, QUEUE_FILE), 'utf8'), '- by hand\n') + } finally { + await rm(root, RETRIED_RM) + } +}) + +test('sync converges with origin: the branch origin has is adopted, and a pushed change is read on the next sync', async () => { + const root = await repo() + const bare = await realpath(await mkdtemp(join(tmpdir(), 'queue-store-bare-'))) + const otherParent = await realpath(await mkdtemp(join(tmpdir(), 'queue-store-other-'))) + try { + await git(['init', '--bare', bare], bare) + await git(['remote', 'add', 'origin', bare], root) + await git(['push', 'origin', 'main'], root) + assert.deepEqual(await syncQueue(root), { ok: true }) + // Another machine clones and pushes an entry straight onto the branch. + const other = join(otherParent, 'clone') + await git(['clone', bare, other], otherParent) + await git(['config', 'user.email', 'o@o'], other) + await git(['config', 'user.name', 'o'], other) + await git(['checkout', '-B', DATA_BRANCH, `origin/${DATA_BRANCH}`], other) + await writeFile(join(other, QUEUE_FILE), '- from elsewhere\n') + await git(['add', '-A'], other) + await git(['commit', '-m', 'queue add: from elsewhere'], other) + await git(['push', 'origin', DATA_BRANCH], other) + assert.deepEqual(await syncQueue(root), { ok: true }) + assert.equal(await readFile(join(fileBranchPath(root, DATA_BRANCH), QUEUE_FILE), 'utf8'), '- from elsewhere\n') + } finally { + for (const dir of [root, bare, otherParent]) await rm(dir, RETRIED_RM) + } +}) diff --git a/packages/skill-queue/src/store.ts b/packages/skill-queue/src/store.ts new file mode 100644 index 000000000..9624ecc67 --- /dev/null +++ b/packages/skill-queue/src/store.ts @@ -0,0 +1,55 @@ +import { join } from 'node:path' +import { DATA_BRANCH, nodeBranchFileFs, nodeGitRunner, pullFileBranch, withFileBranch, type BranchFileFs, type CommitMessage, type FileBranchSync, type FileBranchWrite, type GitRunner } from '@gemstack/agent-data' +import { QUEUE_FILE } from './names.js' + +// Where the queue lives, bound to the branch: `TODO_AGENTS.md` at the root of the `agent-data` +// branch of the project's repository, checked out under `.branches/agent-data` for a long-lived +// process. + +/** The plain-file seams an operation on the branch's files needs. */ +export type QueueFiles = BranchFileFs + +/** + * A caller's write funnel: apply `op` to a checkout of the branch, commit, push. The daemon's is + * the persistent checkout's serialized cycle; a test's fake stands in. + */ +export type QueueFunnel = (root: string, message: CommitMessage, op: (dir: string) => Promise<void>) => Promise<FileBranchWrite> + +/** Injectable seams so every operation is unit-testable off disk and git; production takes the defaults. */ +export interface QueueDeps extends Partial<QueueFiles> { + funnel?: QueueFunnel + log?: (message: string) => void +} + +/** The default funnel: the persistent checkout's write cycle, on the `agent-data` branch. */ +export const queueFunnel: QueueFunnel = (root, message, op) => withFileBranch(root, DATA_BRANCH, message, op) + +/** Fill in whatever a caller left out, so an operation reads the same way in tests and out. */ +export function resolveQueueDeps(deps: QueueDeps): QueueFiles & { funnel: QueueFunnel; log: (message: string) => void } { + const fs = nodeBranchFileFs() + return { + read: deps.read ?? fs.read, + write: deps.write ?? fs.write, + remove: deps.remove ?? fs.remove, + list: deps.list ?? fs.list, + funnel: deps.funnel ?? queueFunnel, + log: deps.log ?? (() => {}), + } +} + +/** + * Bring a long-lived process's view of the branch up to date: the branch and its persistent + * checkout exist, the queue file is seeded on a branch born empty (so readers and people find a + * file, not a mystery), and the checkout converges with origin — reading what other machines and + * cloud sessions pushed, and pushing anything an earlier cycle left stranded. Reports why it + * could not converge; never throws. + */ +export async function syncQueue(root: string, deps: QueueDeps & { git?: GitRunner } = {}): Promise<FileBranchSync> { + const r = resolveQueueDeps(deps) + const seeded = await r.funnel(root, 'seed the queue', async dir => { + const queue = join(dir, QUEUE_FILE) + if (!(await r.read(queue).then(() => true, () => false))) await r.write(queue, '') + }) + if (!seeded.ok && !seeded.committed) return { ok: false, error: seeded.error } + return pullFileBranch(root, DATA_BRANCH, { git: deps.git ?? nodeGitRunner(), log: r.log }) +} diff --git a/packages/skill-queue/tsconfig.build.json b/packages/skill-queue/tsconfig.build.json new file mode 100644 index 000000000..e57806493 --- /dev/null +++ b/packages/skill-queue/tsconfig.build.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/skill-queue/tsconfig.json b/packages/skill-queue/tsconfig.json new file mode 100644 index 000000000..404aab404 --- /dev/null +++ b/packages/skill-queue/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/skill-queue/tsconfig.test.json b/packages/skill-queue/tsconfig.test.json new file mode 100644 index 000000000..eebda2fc6 --- /dev/null +++ b/packages/skill-queue/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist-test", "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/skill-tickets/DECISIONS.md b/packages/skill-tickets/DECISIONS.md index ec28e4693..c366fb457 100644 --- a/packages/skill-tickets/DECISIONS.md +++ b/packages/skill-tickets/DECISIONS.md @@ -52,21 +52,12 @@ decision. An AI proposes a bullet and asks; it never adds or rewrites one. - The lock's existence is the claim: the holder it names decides who may close or release, not whether the ticket counts as locked. -## The queue -- The queue is one markdown file on the branch, `TODO_AGENTS.md`: sections `## Priority - 10` down to `## Priority 0`, any `## Priority N` counts, in any case; any `-`, `*` or - `N.` list item with text is an entry, wherever it sits. Entries are placed to keep the - file sorted high to low; nothing re-sorts on read. -- An entry is plain trimmed text: the task a future agent is started with. `--ticket` - writes the entry as a markdown link to the ticket, the given text as its label; the - program reads the link back to claim the ticket for the agent it starts. -- `queue add` creates the queue file when the branch has none. An entry with no priority - goes at the end of the file, in whatever section ends it; one linked to a ticket takes - the ticket's priority unless `--priority` was given; a ticket whose `Priority:` is - missing or unreadable counts as 5, never 10 or 0: those ends (act immediately, only if - capacity) are deliberate picks. -- Done means deleted, never checked off: a `- [x]` or `- [X]` line is not an open entry; a - `- [ ]` line is, printed without its box and deleted whole. +## Flow: queueing a ticket +- The package never reads or writes the queue. A ticket is queued by the caller as a + markdown link to the ticket, the title as its label; the program reads the link back to + claim the ticket for the agent it starts. The entry takes the ticket's priority; a + ticket whose `Priority:` is missing or unreadable counts as 5, never 10 or 0: those ends + (act immediately, only if capacity) are deliberate picks. ## Flow: the command - Every command names a ticket by its bare filename or its `tickets/<file>` path, so a @@ -75,23 +66,18 @@ decision. An AI proposes a bullet and asks; it never adds or rewrites one. the exceptions. - No command reads `meta.json`: only the importing program does, for its one key `lastImportedAt`. -- A read fetches origin once and reads everything from that copy (the library's queue read - fetches only when asked): only origin has every writer's pushes, this command's own - included. With no origin the local branch is read: writes are refused there, so nobody - else can have moved it. +- A read fetches origin once and reads everything from that copy: only origin has every + writer's pushes, this command's own included. With no origin the local branch is read: + writes are refused there, so nobody else can have moved it. - Every command that runs prints one JSON document, the result or the refusal. A refusal also puts one line on stderr and exits 1. A malformed command line (an unknown flag, the - wrong argument count, an empty `queue add` text, a `--priority` off the 0-10 scale) is - rejected first: the usage on stderr, nothing on stdout, exit 2. A file no command may - touch refuses with `invalid-path`. Anything a command throws refuses with `git-failed`. - Outside a repository a command refuses `not-a-repo`; only git's own "not a git - repository" reads as that. -- `list` and a bare `queue` answer with a JSON array; every other result and every refusal - is an object with `ok`. + wrong argument count) is rejected first: the usage on stderr, nothing on stdout, exit 2. + A file no command may touch refuses with `invalid-path`. Anything a command throws + refuses with `git-failed`. Outside a repository a command refuses `not-a-repo`; only + git's own "not a git repository" reads as that. +- `list` answers with a JSON array; every other result and every refusal is an object with + `ok`. - The command's write is one commit per command, pushed straight to origin through a throwaway worktree at origin's tip; a push that loses a race is re-applied on the new tip by `@gemstack/agent-data`. The program's writes go through its persistent checkout's - cycle instead; its `queue done` of an entry already gone succeeds, changing nothing. -- `queue done` takes the entry as `queue` printed it, trimmed, removes the first such - line, and refuses a line the queue does not have, an empty one included, decided inside - the write. + cycle instead. diff --git a/packages/skill-tickets/SKILL.SPEC.md b/packages/skill-tickets/SKILL.SPEC.md index 1f2bd7450..e303bb740 100644 --- a/packages/skill-tickets/SKILL.SPEC.md +++ b/packages/skill-tickets/SKILL.SPEC.md @@ -1,17 +1,18 @@ -The skill's instructions: what an agent is told about the project's tickets and its agent queue — where they live, how to read and change them, how to claim one so no two agents work the same ticket, and the formats a ticket, a plan and the queue are written in. +The skill's instructions: what an agent is told about the project's tickets — where they live, how to read and change them, how to claim one so no two agents work the same ticket, how to queue one, and the formats a ticket and a plan are written in. ## User story -- The user expects agents to pick up work from one queue, to plan and implement tickets without two of them landing on the same one, and to leave the tickets current — without the user having to put any of that in a prompt. +- The user expects agents to plan and implement tickets without two of them landing on the same one, to queue a ticket where its priority puts it, and to leave the tickets current — without the user having to put any of that in a prompt. ## Business logic — TL;DR -- **The tickets are on a branch, not in your checkout** - the tickets (`tickets/<DATE>_<SLUG>.md`, with their `.plan.md` and `.lock.md` siblings) and the queue (`TODO_AGENTS.md`) live on the `agent-data` branch, never on a code branch; the agent's own checkout does not contain them. Where a long-lived process keeps that branch checked out, a `tickets` link at the repository root shows them: read there if you like, though it may trail what others pushed while the command reads fresh; never write there. +- **The tickets are on a branch, not in your checkout** - the tickets (`tickets/<DATE>_<SLUG>.md`, with their `.plan.md` and `.lock.md` siblings) live on the `agent-data` branch, never on a code branch; the agent's own checkout does not contain them. Where a long-lived process keeps that branch checked out, a `tickets` link at the repository root shows them: read there if you like, though it may trail what others pushed while the command reads fresh; never write there. - **The `tickets` command is the only way to change them** - it ships with the `@gemstack/skill-tickets` package the repository depends on: the agent installs the repository's dependencies once, when there is no `node_modules` yet, and runs `npx tickets`, so every command the skill names runs as written on a fresh clone. Every change it makes is one commit pushed straight to the `agent-data` branch; a refusal exits 1 with a line on stderr, a wrong command line exits 2 with the usage; nothing the agent commits on its own branch reaches them, and these files never belong on the agent's branch. -- **Read: `list`, `show`, `queue`** - every open ticket as JSON, its optional fields (priority, topics, github, effort, uncertainty, locked, lockedBy) absent when unset; one ticket with its plan and its holder; and the queue's open entries in order of work. -- **Change: `put`, `close`, `queue add`, `queue done`** - write or create a ticket, a plan or the import stamp from standard input, the whole file, empty input included, a plan for a name no ticket has written without complaint; once the work is merged, remove the ticket with its plan and claim, because `tickets/` holds only open tickets — refused while someone else holds the ticket, and its queue entry stays until taken off; put an entry on the queue, optionally linked to a ticket and placed by that ticket's priority (5 when it has none); take an entry off, by its text as listed, which deletes it. +- **Read: `list`, `show`** - every open ticket as JSON, its optional fields (priority, topics, github, effort, uncertainty, locked, lockedBy) absent when unset; and one ticket with its plan and its holder. +- **Change: `put`, `close`** - write or create a ticket, a plan or the import stamp from standard input, the whole file, empty input included, a plan for a name no ticket has written without complaint; once the work is merged, remove the ticket with its plan and claim, because `tickets/` holds only open tickets — refused while someone else holds the ticket, and its queue entry, if it has one, stays until the agent takes it off with the `queue` skill's command. +- **Queue a ticket** - when the repository has the `queue` skill, a ticket goes on the agent queue through that skill's command as a markdown link back to the ticket, its title as the label, at the ticket's own `Priority:` (5 when it has none); once the work is merged the agent closes the ticket and takes the entry off. Without that skill there is no queue to put it on, and the rest of this skill stands on its own. - **Claim before you plan or work a ticket** - `tickets claim` says the ticket is yours or names who holds it (no holder when the lock's line does not parse); someone else's claim means pick another, and never remove or overwrite their lock. A claim guards claim, close and release, never `put`, which overwrites whoever holds the ticket. `tickets release` lifts the agent's own claim once the plan is finished or the work is published, and before the agent stops, finished or not, unless it closed the ticket, since no timeout lifts it. The holder is `AGENT_ID` from the environment, else the current branch, so a rename or a branch switch between claim and release changes who the agent is, and a lock it can no longer lift stays until a person edits the branch. -- **The formats** - a ticket (its optional `Priority:`, `Topics:` and `GitHub:` keys above the title, `Priority:` a bare whole number or read as absent for queue placement, its title, its `## TLDR` and `## Why it matters`); a claim (one line, `CLAIMED: <holder>`); a plan (its `Effort:` and `Uncertainty:` on a 0–10 scale, with the sections it may use and how to gauge uncertainty by listing each aspect and rating it); and the queue (`## Priority N` sections from 10, critical and to be acted on immediately, down to 0, only if capacity, first within a band first to be taken). +- **The formats** - a ticket (its optional `Priority:`, `Topics:` and `GitHub:` keys above the title, `Priority:` a bare whole number or read as absent for queue placement, its title, its `## TLDR` and `## Why it matters`); a claim (one line, `CLAIMED: <holder>`); and a plan (its `Effort:` and `Uncertainty:` on a 0–10 scale, with the sections it may use and how to gauge uncertainty by listing each aspect and rating it). ## Before modifying/creating SPEC.md files diff --git a/packages/skill-tickets/SKILL.md b/packages/skill-tickets/SKILL.md index eecacca5c..6fd780e84 100644 --- a/packages/skill-tickets/SKILL.md +++ b/packages/skill-tickets/SKILL.md @@ -1,11 +1,11 @@ --- name: tickets -description: Where the project's tickets and its agent queue live, how to read and change them, how to claim a ticket so no two agents work the same one, and the formats. +description: Where the project's tickets live, how to read and change them, how to claim a ticket so no two agents work the same one, how to queue one, and the formats. --- -# Tickets and the agent queue +# Tickets -The tickets (`tickets/<DATE>_<SLUG>.md`, with their `.plan.md` and `.lock.md` siblings) and the agent queue (`TODO_AGENTS.md`) live on the branch `agent-data`, never on a code branch. A `tickets` link at the repository root, if present, shows a possibly stale copy; never write there. The command reads fresh. The queue is not under that link. +The tickets (`tickets/<DATE>_<SLUG>.md`, with their `.plan.md` and `.lock.md` siblings) live on the branch `agent-data`, never on a code branch. A `tickets` link at the repository root, if present, shows a possibly stale copy; never write there. The command reads fresh. Read and change them with the `tickets` command, a dependency of this repository (`@gemstack/skill-tickets`). With no `node_modules`, install first with the lockfile's package manager (`npm install` for `package-lock.json`). Then run it as `npx tickets`. Every change it makes is one commit pushed straight to the `agent-data` branch. A refusal exits 1 with a line on stderr; a wrong command line exits 2 with the usage. @@ -17,7 +17,6 @@ npx tickets list every open ticket, as one JSON array: file, tit (priority, topics, github, effort, uncertainty, locked, lockedBy absent when unset) npx tickets show <file> one ticket: its text, its plan, who holds it -npx tickets queue the queue's open entries, in order of work ``` ## Change @@ -27,18 +26,20 @@ npx tickets put <file> write one file under tickets/ from stdin, the w empty stdin writes an empty file (npx tickets put <file> < draft.md): a ticket or a plan npx tickets close <file> once the work is merged: remove the ticket with its plan and lock; - refused while someone else holds it; its queue entry stays, - `queue done` it -npx tickets queue add <text> [--priority N] [--ticket <file>] - put an entry on the queue; --priority places it in that section, - --ticket makes <text> the label of a link to the ticket (pass the - label, not a link) and places it by the ticket's - priority (5 when it has none) unless --priority says otherwise; - with neither, it goes at the end of the file -npx tickets queue done <entry> remove an entry: one quoted argument, exactly as `npx tickets queue` - printed it; done means deleted + refused while someone else holds it; its queue entry, if any, + stays: `npx queue done` it ``` +## Queue a ticket + +When the repository has the `queue` skill, a ticket goes on the agent queue as a link, its title as the label, at the ticket's own `Priority:` (5 when it has none): + +``` +npx queue add "[<title>](tickets/<file>)" --priority <N> +``` + +Once the work is merged, `npx tickets close <file>` and `npx queue done` the entry. + ## Claim before you plan or work a ticket ``` @@ -128,24 +129,3 @@ Notes: - List all aspects that need to be considered - Give an uncertainty rating (0-10) to each aspect following this criteria: is there an obviously optimal way to implement it (0), or is it highly unclear whether it can be implemented in a better way (10)? - Explore and suggest alternatives for each aspect with a low rating - -### The queue: `TODO_AGENTS.md` - -```md -## Priority 10 (critical — act immediately) - -... - -## Priority 9 - -- [Succinct description](/link-for-more-details) -- Or self-contained TODO item with complete description of what should be done - -... - -## Priority 0 (only if capacity) - -... -``` - -The queue lists *all* tasks AI will work on next, sorted by priority. Priority 10 is rarely used (e.g. critical production bugs) and is treated as the utmost priority. Within a priority, the first tasks have higher priority (they are the "next" tasks within that "priority queue"). A done entry is removed (`npx tickets queue done`). diff --git a/packages/skill-tickets/SPEC.md b/packages/skill-tickets/SPEC.md index 84c7cd081..bb4abbd93 100644 --- a/packages/skill-tickets/SPEC.md +++ b/packages/skill-tickets/SPEC.md @@ -1,4 +1,4 @@ -Tickets and the agent queue for coding agents, as an npm package: markdown tickets with their plans and their claims, and the priority-banded list of what agents work on next — all of it on the `agent-data` branch of the project's own repository, never on a code branch; the `tickets` command that reads and changes them from any clone; and the instructions an agent follows to use them (`SKILL.md`). +Tickets for coding agents, as an npm package: markdown tickets with their plans and their claims, on the `agent-data` branch of the project's own repository, never on a code branch; the `tickets` command that reads and changes them from any clone; and the instructions an agent follows to use them (`SKILL.md`). The agent queue a ticket is put on is another package's (`@gemstack/skill-queue`): this one only says how a ticket is written onto it and read back off it. The package knows git, the filesystem and the ticket format, nothing else. The same functions serve every caller: a long-lived process (a daemon that starts agents, a dashboard that lists the roadmap) that keeps a checkout of the branch, reads the tickets and claims them for the agents it starts, and an agent's own shell, through the `tickets` command a caller puts on the PATH of every agent it starts on its machine. What a caller knows beyond git — which agent it started, whether that agent ended with nothing, which ticket it wants planned next — is passed in; the package never reads a caller's records. @@ -11,14 +11,13 @@ The branch is a file store, the primitive `@gemstack/agent-data` provides: a bra ## Business logic — TL;DR -- **The conventions** (`names`) - the shared data branch `agent-data` (`@gemstack/agent-data` names it), its persistent checkout `.branches/agent-data`, the `tickets/` folder, the queue file `TODO_AGENTS.md`, the `meta.json` stamp; how a ticket's filename names its plan and its claim; the gates every filename arriving from outside passes; the ticket a queue entry links back to; the queue section a ticket's own priority earns it; the issue a ticket tracks. +- **The conventions** (`names`) - the shared data branch `agent-data` (`@gemstack/agent-data` names it), its persistent checkout `.branches/agent-data`, the `tickets/` folder, the `meta.json` stamp; how a ticket's filename names its plan and its claim; the gates every filename arriving from outside passes; the ticket a queue entry links back to; the queue section a ticket's own priority earns it; the issue a ticket tracks. - **What a ticket says** (`tickets`) - the read side: a ticket's head — title, TLDR, priority, topics, the issue link, its date — plus what the plan and the claim beside it add. Reads through a small filesystem seam, so the same reader serves a checkout on disk and a branch read straight off git. - **The claim** (`locks`) - one holder at a time per ticket, as a `.lock.md` file beside it reading `CLAIMED: <holder>`; claimed for a batch of tickets or one, released by its holder or by a caller cleaning up; no timed release. -- **The queue** (`queue`) - `TODO_AGENTS.md`: the open entries in order of work, an entry added into its `## Priority N` section, an entry taken off by deletion — done means gone, not checked off. - **Who claims** (`holder`) - the identity a claim is made under, read from where the command runs: `AGENT_ID` from the environment when the process that started the agent set it, else the current branch name; a detached checkout has none. -- **Where it all lives** (`store`) - the branch bound to a project: the persistent checkout's paths, the seams every operation is injected with, and the sync that makes the branch, seeds the queue, links `tickets` at the repository root hidden from git, and converges with origin. +- **Where it all lives** (`store`) - the branch bound to a project: the persistent checkout's paths, the seams every operation is injected with, and the sync that makes the branch, links `tickets` at the repository root hidden from git, and converges with origin. - **The command line** (`cli`, `bin/`) - the same operations as commands for a shell: JSON on stdout, a reason on stderr, an exit code that tells a refusal from a usage error; the executable's directory and the skill's own directory are exported (`bin-dir`) for a caller that spawns agents. -- **The skill** (`SKILL.md`) - what the agent is told: the tickets are on a branch and not in its checkout, the `tickets` command is how it reads and changes them, it claims a ticket before planning or working it and backs off from someone else's claim, and the formats a ticket, a plan and the queue are written in. +- **The skill** (`SKILL.md`) - what the agent is told: the tickets are on a branch and not in its checkout, the `tickets` command is how it reads and changes them, it claims a ticket before planning or working it and backs off from someone else's claim, how a ticket is queued through the `queue` skill, and the formats a ticket and a plan are written in. ## Before modifying/creating SPEC.md files diff --git a/packages/skill-tickets/package.json b/packages/skill-tickets/package.json index 58a040a80..1cadb73a2 100644 --- a/packages/skill-tickets/package.json +++ b/packages/skill-tickets/package.json @@ -1,7 +1,7 @@ { "name": "@gemstack/skill-tickets", "version": "0.0.1", - "description": "Tickets and the agent queue for coding agents: markdown tickets with their plans and claims, and the priority-banded queue of what agents work on next, kept on a branch of the project's repository and changed with the tickets command.", + "description": "Tickets for coding agents: markdown tickets with their plans and claims, kept on a branch of the project's repository and changed with the tickets command.", "license": "MIT", "repository": { "type": "git", diff --git a/packages/skill-tickets/src/SPEC.md b/packages/skill-tickets/src/SPEC.md index 892f566d6..78ce2d6de 100644 --- a/packages/skill-tickets/src/SPEC.md +++ b/packages/skill-tickets/src/SPEC.md @@ -1,4 +1,4 @@ -The package's source: the conventions, the reads and the writes behind the tickets and the agent queue on the `agent-data` branch. See the package `SPEC.md` for how the modules relate; each module's `*.SPEC.md` holds its business logic. +The package's source: the conventions, the reads and the writes behind the tickets on the `agent-data` branch. See the package `SPEC.md` for how the modules relate; each module's `*.SPEC.md` holds its business logic. ## Before modifying/creating SPEC.md files diff --git a/packages/skill-tickets/src/cli.SPEC.md b/packages/skill-tickets/src/cli.SPEC.md index 32e794cfd..5e0456792 100644 --- a/packages/skill-tickets/src/cli.SPEC.md +++ b/packages/skill-tickets/src/cli.SPEC.md @@ -4,7 +4,7 @@ The package's command line: the same operations a caller calls, for an agent (or - An agent, started in a clone that holds none of the tickets, lists them, reads one with its plan, and learns who holds it. - An agent claims a ticket before planning or working it, and is told to pick another when someone already holds it. -- An agent writes a ticket or a plan, closes a ticket it finished, and keeps the queue current. +- An agent writes a ticket or a plan, and closes a ticket it finished. - The user, in a terminal, does all of the same without any other tool. ## Glossary @@ -13,7 +13,7 @@ The package's command line: the same operations a caller calls, for an agent (or ## Business logic — TL;DR -- **Nine commands over the package** - `list`, `show`, `queue`, `queue add`, `queue done`, `put`, `close`, `claim`, `release`; each is the corresponding package operation and nothing more. +- **Six commands over the package** - `list`, `show`, `put`, `close`, `claim`, `release`; each is the corresponding package operation and nothing more. - **JSON out, a reason for a person, an exit code that says which** - every result is one JSON document on stdout; a refusal or a git failure also puts one line on stderr and exits 1; a command that cannot be read gets the usage on stderr and exits 2. - **Reads come off origin, fetched once** - so a command sees what every writer pushed, its own earlier writes included. - **Writes are a remote writer's** - one commit each, pushed straight to the branch, never touching a caller's persistent checkout; a repository with no remote is refused. @@ -21,7 +21,7 @@ The package's command line: the same operations a caller calls, for an agent (or ## Business logic -### Nine commands over the package +### Six commands over the package #### User story @@ -31,9 +31,6 @@ See `## User story`. - `list` - every open ticket, as the rows the reader produces (`tickets`): file, title, summary, priority, topics, the GitHub link, date, planned, effort, uncertainty, locked, and who holds it. - `show <file>` - one ticket with its whole text, its plan when it has one, and the holder when it is claimed. -- `queue` - the queue's open entries, in order of work. -- `queue add <text> [--priority N] [--ticket <file>]` - puts an entry on the queue, in its `## Priority N` section when a priority is given, else at the end of the file. With a ticket named, the entry becomes a markdown link back to that ticket and is placed by the ticket's own priority unless a priority was given — the same entry a caller writes when it queues a ticket. An empty entry, or a priority outside 0 to 10, is a usage error; a ticket that does not exist is a refusal. -- `queue done <text>` - takes an entry off the queue; the text must match an open entry exactly. Done means deleted. - `put <file>` - writes one file under `tickets/` from standard input: a ticket, its `.plan.md`, or `meta.json`, named bare or as its `tickets/…` path like every other command's argument. Anything else — a `.lock.md`, a path with segments, a non-markdown name — is refused. Claims are never written this way; they go through `claim`. - `close <file>` - removes a ticket together with its plan and its claim, because `tickets/` holds only open tickets. A ticket that is not there is refused; so is a ticket someone else holds, naming the holder — closing would take their claim with the ticket. - `claim <file>` - claims a ticket for the holder the working directory names, before planning or working it. A ticket that does not exist is refused; a ticket someone else holds is refused *and told who holds it* (when the lock names anyone readable), so the agent can back off and pick another. A claim naming this very holder again still counts as claimed, so an agent that re-runs the command after a lost race is not confused by its own lock. @@ -51,7 +48,7 @@ An agent parses what it is told; a person reads it; a script branches on the exi #### Business logic -Every command writes exactly one JSON document to stdout. A result is the operation's outcome. A refusal is `{ ok: false, reason }` — the reason a short fixed word plus what identifies the case (the file, the entry, the holder) — with one sentence on stderr saying the same for a person, and exit code 1. The reasons: `no-ticket`, `claimed` with the holder, `not-holder` with the holder, `no-lock`, `no-identity`, `no-remote`, `not-a-repo`, `invalid-path`, `no-entry`. A git failure past the decision is reported the same way, reason `git-failed`, with git's own line. A command that cannot be read — unknown command, an argument missing or extra, an unknown option, a priority that is not 0 to 10 — gets the usage on stderr, no JSON, and exit code 2. +Every command writes exactly one JSON document to stdout. A result is the operation's outcome. A refusal is `{ ok: false, reason }` — the reason a short fixed word plus what identifies the case (the file, the entry, the holder) — with one sentence on stderr saying the same for a person, and exit code 1. The reasons: `no-ticket`, `claimed` with the holder, `not-holder` with the holder, `no-lock`, `no-identity`, `no-remote`, `not-a-repo`, `invalid-path`. A git failure past the decision is reported the same way, reason `git-failed`, with git's own line. A command that cannot be read — unknown command, an argument missing or extra, an unknown option — gets the usage on stderr, no JSON, and exit code 2. ### Reads come off origin, fetched once diff --git a/packages/skill-tickets/src/cli.test.SPEC.md b/packages/skill-tickets/src/cli.test.SPEC.md index bde578bc7..7d330f60f 100644 --- a/packages/skill-tickets/src/cli.test.SPEC.md +++ b/packages/skill-tickets/src/cli.test.SPEC.md @@ -1,6 +1,6 @@ What the tests cover: every command of the command line against real git, from clones acting as agents that share one origin, and the contract around them. -- **Reads** - `list` gives every open ticket with its priority, whether it is planned and its plan's effort; `show` gives one ticket's whole text, its plan and its holder, and takes the ticket by its bare filename or by its path; `queue` gives the open entries in order of work; all of it read off origin, from a clone holding no checkout of the branch. +- **Reads** - `list` gives every open ticket with its priority, whether it is planned and its plan's effort; `show` gives one ticket's whole text, its plan and its holder, and takes the ticket by its bare filename or by its path; all of it read off origin, from a clone holding no checkout of the branch. - **A missing ticket** - refused as no such ticket, on stdout and in one line on stderr; a name that escapes the folder is refused as an invalid path. - **Claiming** - the lock is written as one pushed commit naming the holder, authored by the agent's own clone; a second claimer from another clone is refused and told who holds it, with exactly one lock commit on the remote; the holder shows up on the reads; claiming again yourself succeeds, because the lock is already yours; a ticket that does not exist cannot be claimed. - **Nothing lands locally** - the agent's clone gains no copy of the branch and no change in its own working tree. @@ -8,8 +8,6 @@ What the tests cover: every command of the command line against real git, from c - **Who the holder is** - `AGENT_ID` from the environment when the process that started the agent set it, and that id survives the session renaming its branch; without it the current branch, even inside a `.branches/agent-<id>` checkout; a detached checkout with no `AGENT_ID` is refused as having no identity. - **`put`** - a ticket, a plan (named by its `tickets/…` path) and `meta.json` are written from standard input, each as one commit named after the file; a `.lock.md`, a path that climbs out, a nested path and a non-markdown name are all refused as an invalid path; the ticket just written lists, planned, with its plan's effort. - **`close`** - the ticket goes with its plan and its claim, leaving the other tickets untouched; closing it again reports no such ticket; a ticket someone else holds (claimed from another clone) is refused as not the holder's, naming the holder. -- **`queue add`** - an entry lands in the section its priority names; with a ticket named it becomes a link back to that ticket, placed by the ticket's own priority; with no priority it goes at the end; the file that results is the sections in order, high to low; a priority outside the scale is a usage error, and a ticket that does not exist is a refusal. -- **`queue done`** - the line is deleted rather than checked off; an entry that is not open is refused as no such entry. - **A repository with no remote** - reads come off its local copy of the branch, and every write is refused because nothing can carry it. - **Outside a repository** - refused as such. - **Usage** - an unknown command and a wrong argument count get the usage on stderr, no JSON, and exit code 2. diff --git a/packages/skill-tickets/src/cli.test.ts b/packages/skill-tickets/src/cli.test.ts index 75a48e7f4..4d99b2810 100644 --- a/packages/skill-tickets/src/cli.test.ts +++ b/packages/skill-tickets/src/cli.test.ts @@ -30,7 +30,6 @@ async function rig(clones: number) { await writeFile(join(seed, 'tickets', '2026-08-30_a.md'), 'Priority: 8\n\n# A\n\n## TLDR\n\nThe first.\n') await writeFile(join(seed, 'tickets', '2026-08-29_b.md'), '# B\n') await writeFile(join(seed, 'tickets', '2026-08-29_b.plan.md'), 'Effort: 1\n\n# [Plan] B\n') - await writeFile(join(seed, 'TODO_AGENTS.md'), '## Priority 5\n\n- [Do B](tickets/2026-08-29_b.md)\n') await git(['add', '-A'], seed) await git(['commit', '-m', 'seed'], seed) await git(['push', 'origin', DATA_BRANCH], seed) @@ -58,7 +57,7 @@ async function run(cwd: string, argv: string[], stdin = '') { return { code, json: out.length ? JSON.parse(out.join('\n')) : undefined, stderr: err.join('\n') } } -test('reads: list, show and queue come off origin, and a missing ticket is a refusal', async () => { +test('reads: list and show come off origin, and a missing ticket is a refusal', async () => { const { agents, cleanup } = await rig(1) const [a] = agents try { @@ -75,8 +74,6 @@ test('reads: list, show and queue come off origin, and a missing ticket is a ref assert.equal(show.json.holder, undefined) // The ticket may be named by its path too. assert.equal((await run(a!, ['show', 'tickets/2026-08-29_b.md'])).code, 0) - const queue = await run(a!, ['queue']) - assert.deepEqual(queue.json, ['[Do B](tickets/2026-08-29_b.md)']) const missing = await run(a!, ['show', '2026-08-28_nope.md']) assert.equal(missing.code, 1) assert.deepEqual(missing.json, { ok: false, reason: 'no-ticket', file: '2026-08-28_nope.md' }) @@ -212,35 +209,6 @@ test('put writes a ticket, a plan or meta.json from stdin, never a lock; close r } }) -test('queue add places an entry by priority, links a ticket by --ticket, and queue done deletes the line', async () => { - const { bare, agents, cleanup } = await rig(1) - const [a] = agents - try { - const added = await run(a!, ['queue', 'add', 'Tidy the loader', '--priority', '3']) - assert.deepEqual(added.json, { ok: true, entry: 'Tidy the loader', priority: 3 }) - // By ticket: a link back to it, placed by the ticket's own priority (a is 8). - const linked = await run(a!, ['queue', 'add', 'Do A', '--ticket', '2026-08-30_a.md']) - assert.deepEqual(linked.json, { ok: true, entry: '[Do A](tickets/2026-08-30_a.md)', priority: 8 }) - const plain = await run(a!, ['queue', 'add', 'Last, unranked']) - assert.deepEqual(plain.json, { ok: true, entry: 'Last, unranked' }) - const md = await git(['show', `${DATA_BRANCH}:TODO_AGENTS.md`], bare) - assert.equal(md, '## Priority 8\n\n- [Do A](tickets/2026-08-30_a.md)\n\n## Priority 5\n\n- [Do B](tickets/2026-08-29_b.md)\n\n## Priority 3\n\n- Tidy the loader\n- Last, unranked\n') - assert.equal((await git(['log', '-1', '--format=%s', DATA_BRANCH], bare)).trim(), 'queue add: Last, unranked') - assert.deepEqual((await run(a!, ['queue'])).json, ['[Do A](tickets/2026-08-30_a.md)', '[Do B](tickets/2026-08-29_b.md)', 'Tidy the loader', 'Last, unranked']) - const done = await run(a!, ['queue', 'done', '[Do B](tickets/2026-08-29_b.md)']) - assert.deepEqual(done.json, { ok: true, entry: '[Do B](tickets/2026-08-29_b.md)' }) - assert.ok(!(await git(['show', `${DATA_BRANCH}:TODO_AGENTS.md`], bare)).includes('Do B')) - assert.ok(!(await git(['show', `${DATA_BRANCH}:TODO_AGENTS.md`], bare)).includes('[x]'), 'deleted, not checked off') - const gone = await run(a!, ['queue', 'done', 'never there']) - assert.equal(gone.code, 1) - assert.equal(gone.json.reason, 'no-entry') - assert.equal((await run(a!, ['queue', 'add', 'x', '--priority', '11'])).code, 2) - assert.equal((await run(a!, ['queue', 'add', 'x', '--ticket', 'nope.md'])).json.reason, 'no-ticket') - } finally { - await cleanup() - } -}) - test('a repository with no remote reads its local branch and refuses to write; outside a repository is a refusal', async () => { const solo = await realpath(await mkdtemp(join(tmpdir(), 'tickets-cli-solo-'))) try { @@ -253,7 +221,7 @@ test('a repository with no remote reads its local branch and refuses to write; o const commit = (await git(['commit-tree', '4b825dc642cb6eb9a060e54bf8d69288fbee4904', '-m', 'create the agent-data branch'], solo)).trim() await git(['branch', DATA_BRANCH, commit], solo) assert.deepEqual((await run(solo, ['list'])).json, []) - const refused = await run(solo, ['queue', 'add', 'x']) + const refused = await run(solo, ['put', '2026-08-31_c.md'], '# C\n') assert.equal(refused.code, 1) assert.deepEqual(refused.json, { ok: false, reason: 'no-remote' }) const outside = await run(tmpdir(), ['list']) diff --git a/packages/skill-tickets/src/cli.ts b/packages/skill-tickets/src/cli.ts index 8e4e15c64..b0befa0d1 100644 --- a/packages/skill-tickets/src/cli.ts +++ b/packages/skill-tickets/src/cli.ts @@ -1,10 +1,9 @@ import { parseArgs } from 'node:util' import { join } from 'node:path' import { checkoutRoot, gitReason, nodeBranchFileFs, nodeGitRunner, openBranchReader, writeFileBranchDetached, type BranchReader, type GitRunner, DATA_BRANCH } from '@gemstack/agent-data' -import { isTicketFile, isTicketPath, META_FILE, QUEUE_FILE, TICKETS_DIR, queuePriorityForTicket, ticketLockName, ticketPlanName, ticketStem } from './names.js' +import { isTicketFile, isTicketPath, META_FILE, TICKETS_DIR, ticketLockName, ticketPlanName, ticketStem } from './names.js' import { readTicket, readTickets, type TicketsFs } from './tickets.js' import { applyClaims, applyRelease, claimMessage, lockHolder, releaseMessage } from './locks.js' -import { appendQueueEntry, insertQueueEntry, parseQueueEntries, removeQueueEntry } from './queue.js' import { holderOf } from './holder.js' /** @@ -27,10 +26,6 @@ export const USAGE = `usage: tickets <command> list every open ticket, as one JSON array show <file> one ticket: its text, its plan, who holds it - queue the queue's open entries, in order of work - queue add <text> [--priority N] [--ticket <file>] - put an entry on the queue, in its priority section - queue done <text> take an entry off the queue put <file> write one file under tickets/ from stdin (a ticket, a plan, meta.json) close <file> remove a ticket with its plan and lock (not while someone else holds it) claim <file> claim a ticket before planning or working it @@ -110,48 +105,6 @@ const COMMANDS: Record<string, Command> = { return { ok: true, ticket, ...(plan === undefined ? {} : { plan }), ...(ticket.lockedBy === undefined ? {} : { holder: ticket.lockedBy }) } }, - async queue(args, io, git) { - const [sub, ...rest] = args - if (sub === 'add') { - const { positionals, values } = parse(rest, { priority: { type: 'string' }, ticket: { type: 'string' } }, 1) - const text = positionals[0]!.trim() - if (!text) throw new Usage('the entry is empty') - const priority = values.priority === undefined ? undefined : priorityArg(values.priority) - const reader = await open(io.cwd, git) - // A ticket named turns the entry into a link back to it, placed by the ticket's own - // priority unless one was given — the same entry a dashboard writes when it queues a ticket. - let entry = text - let at = priority - if (values.ticket !== undefined) { - const file = ticketArg(values.ticket) - const ticket = await readTicket(TICKETS_DIR, file, ticketsFsOver(reader)) - if (!ticket) throw noTicket(file) - entry = `[${text}](${TICKETS_DIR}/${file})` - at ??= queuePriorityForTicket(ticket.priority) - } - await write(io.cwd, `queue add: ${entry}`, async dir => { - const md = await readOr(dir, '') - await writeQueue(dir, at === undefined ? appendQueueEntry(md, entry) : insertQueueEntry(md, entry, at)) - }, git) - return { ok: true, entry, ...(at === undefined ? {} : { priority: at }) } - } - if (sub === 'done') { - const { positionals } = parse(rest, {}, 1) - const entry = positionals[0]!.trim() - let found = false - await write(io.cwd, `queue done: ${entry}`, async dir => { - const md = await readOr(dir, '') - found = parseQueueEntries(md).includes(entry) - if (found) await writeQueue(dir, removeQueueEntry(md, entry)) - }, git) - if (!found) throw new Refused({ ok: false, reason: 'no-entry', entry }, `no open queue entry reads "${entry}"`) - return { ok: true, entry } - } - if (sub !== undefined) throw new Usage(`unknown queue command: ${sub}`) - const reader = await open(io.cwd, git) - return parseQueueEntries((await reader.read(QUEUE_FILE)) ?? '') - }, - async put(args, io, git) { const { positionals } = parse(args, {}, 1) // The bare filename, or the `tickets/<file>` path a queue entry links to, like every other command. @@ -242,15 +195,6 @@ const COMMANDS: Record<string, Command> = { }, } -/** The queue file inside a checkout, read as `fallback` when absent. */ -async function readOr(dir: string, fallback: string): Promise<string> { - return nodeBranchFileFs().read(join(dir, QUEUE_FILE)).catch(() => fallback) -} - -async function writeQueue(dir: string, md: string): Promise<void> { - await nodeBranchFileFs().write(join(dir, QUEUE_FILE), md) -} - /** The branch opened for reading, from wherever the command runs; outside a repo, a refusal. */ async function open(cwd: string, git: GitRunner): Promise<BranchReader> { await inRepo(() => checkoutRoot(cwd, git)) @@ -287,11 +231,6 @@ function ticketArg(arg: string): string { return file } -function priorityArg(value: string): number { - if (!/^\d+$/.test(value) || Number(value) > 10) throw new Usage(`--priority takes 0 to 10, got ${value}`) - return Number(value) -} - /** * Outside a repo, the commands have nothing to act on: said as a refusal, not a git failure. * Only git's own "not a git repository" reads as that; a timeout, a missing git, or a corrupt diff --git a/packages/skill-tickets/src/index.SPEC.md b/packages/skill-tickets/src/index.SPEC.md index c02fb8c2a..e7ee22b0d 100644 --- a/packages/skill-tickets/src/index.SPEC.md +++ b/packages/skill-tickets/src/index.SPEC.md @@ -1,4 +1,4 @@ -The package's main entry point: it gathers the naming conventions, the ticket reader, the claims, the queue, the holder, the branch's paths and sync, the command line and the executable's directory into one place for a caller to import. No business logic of its own. The naming conventions are also reachable on their own (`names`), for code that runs in a browser and must not pull in git. +The package's main entry point: it gathers the naming conventions, the ticket reader, the claims, the holder, the branch's paths and sync, the command line and the executable's directory into one place for a caller to import. No business logic of its own. The naming conventions are also reachable on their own (`names`), for code that runs in a browser and must not pull in git. ## Before modifying/creating SPEC.md files diff --git a/packages/skill-tickets/src/index.ts b/packages/skill-tickets/src/index.ts index 169c7f2a7..cde518402 100644 --- a/packages/skill-tickets/src/index.ts +++ b/packages/skill-tickets/src/index.ts @@ -1,6 +1,5 @@ export { TICKETS_DIR, - QUEUE_FILE, META_FILE, ticketStem, ticketPlanName, @@ -37,17 +36,6 @@ export { type ClaimPhase, type ReleaseOutcome, } from './locks.js' -export { - parseQueueEntries, - appendQueueEntry, - insertQueueEntry, - removeQueueEntry, - readQueue, - readQueueEntries, - queueAdd, - queueDone, - type QueueEdit, -} from './queue.js' export { holderOf, AGENT_ID_ENV, type Holder } from './holder.js' export { ticketsCheckoutPath, diff --git a/packages/skill-tickets/src/names.SPEC.md b/packages/skill-tickets/src/names.SPEC.md index a4f924901..8f037bf7a 100644 --- a/packages/skill-tickets/src/names.SPEC.md +++ b/packages/skill-tickets/src/names.SPEC.md @@ -2,7 +2,7 @@ The names everything in the package hangs off, and the small rules that tie a ti ## Business logic — TL;DR -- **Where the tickets live, by name** - the shared data branch `agent-data` (named by `@gemstack/agent-data`, not here), its persistent checkout `.branches/agent-data`, the `tickets/` folder inside the branch, the queue file `TODO_AGENTS.md` beside it, and the `meta.json` stamp inside the folder. Conventions, not settings: `SKILL.md` names the same ones to every agent. +- **Where the tickets live, by name** - the shared data branch `agent-data` (named by `@gemstack/agent-data`, not here), its persistent checkout `.branches/agent-data`, the `tickets/` folder inside the branch, and the `meta.json` stamp inside the folder. Conventions, not settings: `SKILL.md` names the same ones to every agent. - **A ticket names its own siblings** - a ticket's plan and its claim are `<STEM>.plan.md` and `<STEM>.lock.md` beside it, derived from the ticket's filename, so nothing has to record the pairing. - **The gate for a filename from outside** - what counts as a ticket filename, and what counts as a ticket path. - **The ticket a queue entry came from** - read back off the entry's own markdown link. @@ -19,7 +19,7 @@ The names everything in the package hangs off, and the small rules that tie a ti #### Business logic -The tickets and the queue live on the git branch `agent-data` of the project's own repository — the shared data branch every skill keeps its files on, whose name `@gemstack/agent-data` exports. A long-lived process keeps that branch checked out at `.branches/agent-data`, beside the agent checkouts. On the branch, `tickets/` holds the tickets, their plans, their claims and `meta.json`; `TODO_AGENTS.md` sits beside that folder at the branch root. `tickets` is also the name of the link the repository root gets into the checkout (`store`), so a person browsing the project finds the tickets one listing away. +The tickets live on the git branch `agent-data` of the project's own repository — the shared data branch every skill keeps its files on, whose name `@gemstack/agent-data` exports. A long-lived process keeps that branch checked out at `.branches/agent-data`, beside the agent checkouts. On the branch, `tickets/` holds the tickets, their plans, their claims and `meta.json`. The agent queue (`@gemstack/skill-queue`'s `TODO_AGENTS.md`, at the branch root) is not named here: this package never reads or writes it, it only says what a queue entry that names a ticket looks like. `tickets` is also the name of the link the repository root gets into the checkout (`store`), so a person browsing the project finds the tickets one listing away. `tickets/` holds only open tickets: closing one deletes it with its siblings. `meta.json` records when the tickets last caught up with an issue tracker. @@ -47,7 +47,7 @@ One gate, used at both ends: what a queue entry is allowed to be read as, and wh #### Business logic -Queueing a ticket writes the entry as a markdown link back to the ticket, so the identity is on the line itself and nothing has to be stored elsewhere. Reading an entry gives the ticket's `tickets/<file>` path when the entry links to one, and nothing when the entry is just text — work with no ticket behind it. Only a link that stays inside the tickets folder counts. +Queueing a ticket writes the entry as a markdown link back to the ticket — the caller writes that line, through the `queue` skill — so the identity is on the line itself and nothing has to be stored elsewhere. Reading an entry gives the ticket's `tickets/<file>` path when the entry links to one, and nothing when the entry is just text — work with no ticket behind it. Only a link that stays inside the tickets folder counts. ### The queue section a ticket earns diff --git a/packages/skill-tickets/src/names.test.SPEC.md b/packages/skill-tickets/src/names.test.SPEC.md index 88174932b..e9053c91c 100644 --- a/packages/skill-tickets/src/names.test.SPEC.md +++ b/packages/skill-tickets/src/names.test.SPEC.md @@ -1,6 +1,6 @@ -What the tests cover: the conventions the skill names, and the pure rules tying a ticket to its siblings, to the queue and to the issue it tracks. +What the tests cover: the convention the skill names, and the pure rules tying a ticket to its siblings, to the queue and to the issue it tracks. -- **The conventions** - its persistent checkout of the `agent-data` branch is `.branches/agent-data`, the folder on the branch is `tickets/`, and the queue file is `TODO_AGENTS.md`. +- **The convention** - the folder on the branch is `tickets/`. - **The siblings** - a ticket's plan and claim are named from its stem. - **A ticket's priority on the queue's scale** - a number 0 to 10 is taken as written, padding included; an unmarked ticket, a word, an out-of-range value and a fractional one all land in the middle rather than being guessed at or clamped. - **The ticket behind a queue entry** - a link into `tickets/` gives the ticket; an entry that is just text gives none, and neither does a link to anything else or a traversal dressed as a link. diff --git a/packages/skill-tickets/src/names.test.ts b/packages/skill-tickets/src/names.test.ts index c26dd9a13..c4fb3e351 100644 --- a/packages/skill-tickets/src/names.test.ts +++ b/packages/skill-tickets/src/names.test.ts @@ -3,7 +3,6 @@ import { test } from 'node:test' import { isTicketFile, isTicketPath, - QUEUE_FILE, queuePriorityForTicket, ticketFromQueueEntry, ticketIssueRef, @@ -12,9 +11,8 @@ import { TICKETS_DIR, } from './names.js' -test('the branch, its checkout, the folder and the queue file are the conventions the skill names', () => { +test('the folder is the convention the skill names', () => { assert.equal(TICKETS_DIR, 'tickets') - assert.equal(QUEUE_FILE, 'TODO_AGENTS.md') }) test('a ticket names its plan and lock siblings by stem', () => { diff --git a/packages/skill-tickets/src/names.ts b/packages/skill-tickets/src/names.ts index 5b12bfd6d..44c3aa32b 100644 --- a/packages/skill-tickets/src/names.ts +++ b/packages/skill-tickets/src/names.ts @@ -1,6 +1,7 @@ /** * The names everything in the package hangs off, and the small pure rules that link a ticket to - * the queue and to the issue it tracks. No node imports, so browser-side code can name them too. + * the agent queue (the `queue` skill's file, which this package never reads or writes) and to the + * issue it tracks. No node imports, so browser-side code can name them too. * * The branch itself is not named here: the tickets live on the shared data branch, `agent-data`, * whose name `@gemstack/agent-data` exports as `DATA_BRANCH`. A convention, not a setting: @@ -14,9 +15,6 @@ */ export const TICKETS_DIR = 'tickets' -/** The agent queue: `TODO_AGENTS.md` at the branch root, beside `tickets/`. */ -export const QUEUE_FILE = 'TODO_AGENTS.md' - /** The file inside `tickets/` that records when the tickets last caught up with an issue tracker. */ export const META_FILE = 'meta.json' diff --git a/packages/skill-tickets/src/store.SPEC.md b/packages/skill-tickets/src/store.SPEC.md index 00b3f9cc1..466137e9e 100644 --- a/packages/skill-tickets/src/store.SPEC.md +++ b/packages/skill-tickets/src/store.SPEC.md @@ -9,7 +9,7 @@ Where the tickets live, bound to the branch: the `agent-data` branch of the proj - **The paths the branch has under a project** - the persistent checkout, and the `tickets/` folder inside it. - **The seams every operation takes** - the file operations and the caller's write cycle, injected so every operation is testable off disk and git, with the persistent checkout's cycle as the default. -- **Bringing a caller's view up to date** - the branch and its checkout exist, the queue file is seeded, the root link is made, and the checkout converges with origin. +- **Bringing a caller's view up to date** - the root link is made, and the branch and its checkout exist and converge with origin. - **The root link is made only over nothing, and hidden from git at once** - a pair of exclude rules keeps the link out of every code branch while the branch's own `tickets/` folder keeps committing. ## Business logic @@ -36,9 +36,8 @@ See `## User story`. A long-lived process brings its view of the branch up to date in one step, which never throws and reports why it could not converge: -- The branch and its persistent checkout exist. -- The queue file is seeded on a branch born empty, so readers and people find a file rather than a mystery. - The repository root links `tickets` into the checkout. +- The branch and its persistent checkout exist, born empty when nothing has them yet. - The checkout converges with origin: reading what other machines and cloud sessions pushed, and pushing anything an earlier cycle left stranded. A repository with no remote is reported as an error state rather than treated as a mode: the tickets exist to be shared, and one nothing can reach is something the caller has to surface. diff --git a/packages/skill-tickets/src/store.test.SPEC.md b/packages/skill-tickets/src/store.test.SPEC.md index c2d034b7d..326d68adf 100644 --- a/packages/skill-tickets/src/store.test.SPEC.md +++ b/packages/skill-tickets/src/store.test.SPEC.md @@ -1,9 +1,9 @@ What the tests cover: bringing a caller's view of the branch up to date, against real git. -- **The first sync** - the branch is born, its persistent checkout is at `.branches/agent-data` on that branch, the queue file is seeded and committed so the checkout is clean between cycles, and the paths reported are the checkout and its `tickets/` folder. +- **The first sync** - the branch is born an orphan with nothing of the package's written on it, its persistent checkout is at `.branches/agent-data` on that branch and clean, and the paths reported are the checkout and its `tickets/` folder. - **The root link** - `tickets` at the repository root points into the checkout by a relative path, so a moved repository keeps working; it is hidden from the project's git, so no sweeping "add everything" commits it onto a code branch; and the checkout's own `tickets/` folder still commits, which the pair of exclude rules exists to allow. - **A path of the user's own** - a `tickets` file already at the root is left exactly as it is, and stays visible to git. -- **Idempotence** - a second sync seeds and links nothing new. +- **Idempotence** - a second sync links nothing new and writes nothing. - **No remote** - a repository nothing can reach is reported as an error state, named as such. - **Converging with origin** - a branch origin already has is adopted, and a ticket another machine pushed is on disk after the next sync. diff --git a/packages/skill-tickets/src/store.test.ts b/packages/skill-tickets/src/store.test.ts index b558f0086..81755f541 100644 --- a/packages/skill-tickets/src/store.test.ts +++ b/packages/skill-tickets/src/store.test.ts @@ -5,7 +5,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { nodeGitRunner, BRANCHES_DIR, DATA_BRANCH } from '@gemstack/agent-data' import { syncTickets, ticketsCheckoutPath, ticketsDir } from './store.js' -import { QUEUE_FILE } from './names.js' const git = nodeGitRunner() const RETRIED_RM = { recursive: true, force: true, maxRetries: 10 } as const @@ -21,7 +20,7 @@ async function repo(): Promise<string> { return path } -test('sync births the branch, seeds the queue, links tickets/ at the root hidden from git, and names a repo with no remote', async () => { +test('sync births the branch, links tickets/ at the root hidden from git, and names a repo with no remote', async () => { const root = await repo() try { const result = await syncTickets(root) @@ -30,9 +29,8 @@ test('sync births the branch, seeds the queue, links tickets/ at the root hidden assert.equal(wt, join(root, BRANCHES_DIR, DATA_BRANCH)) assert.equal(ticketsDir(root), join(wt, 'tickets')) assert.equal((await git(['rev-parse', '--abbrev-ref', 'HEAD'], wt)).trim(), DATA_BRANCH) - // Seeded and committed, so the checkout is clean between cycles. - assert.equal(await readFile(join(wt, QUEUE_FILE), 'utf8'), '') - assert.match(await git(['log', '-1', '--format=%s', `refs/heads/${DATA_BRANCH}`], root), /^seed the queue/) + // Born an orphan and left clean between cycles: nothing of the package's is written on it. + assert.match(await git(['log', '--format=%s', `refs/heads/${DATA_BRANCH}`], root), /^create the agent-data branch\n?$/) assert.equal((await git(['status', '--porcelain'], wt)).trim(), '') // The root link reaches into the checkout, relatively, so a moved repo keeps working. assert.equal(await readlink(join(root, 'tickets')), join(BRANCHES_DIR, DATA_BRANCH, 'tickets')) @@ -46,7 +44,7 @@ test('sync births the branch, seeds the queue, links tickets/ at the root hidden await git(['add', '-A'], wt) await git(['commit', '-m', 't'], wt) assert.equal((await git(['show', `${DATA_BRANCH}:tickets/t.md`], root)).trim(), 'x') - // Idempotent: a second sync seeds and links nothing new. + // Idempotent: a second sync links nothing new and writes nothing. await syncTickets(root) assert.match(await git(['log', '-1', '--format=%s', `refs/heads/${DATA_BRANCH}`], root), /^t$/m) } finally { diff --git a/packages/skill-tickets/src/store.ts b/packages/skill-tickets/src/store.ts index f0e8c0bd1..e4bdda470 100644 --- a/packages/skill-tickets/src/store.ts +++ b/packages/skill-tickets/src/store.ts @@ -1,6 +1,6 @@ import { join } from 'node:path' import { BRANCHES_DIR, DATA_BRANCH, excludeFromGit, fileBranchPath, nodeBranchFileFs, nodeGitRunner, pullFileBranch, withFileBranch, type BranchFileFs, type CommitMessage, type FileBranchSync, type FileBranchWrite, type GitRunner } from '@gemstack/agent-data' -import { QUEUE_FILE, TICKETS_DIR } from './names.js' +import { TICKETS_DIR } from './names.js' // Where the tickets live, bound to the branch: the `agent-data` branch of the project's repository, // checked out under `.branches/agent-data` for a long-lived process, with a `tickets` link at the @@ -63,11 +63,10 @@ function nodeLinkFs(): LinkFs { } /** - * Bring a long-lived process's view of the branch up to date: the branch and its persistent - * checkout exist, the queue file is seeded on a branch born empty (so readers and people find a - * file, not a mystery), the repository root links `tickets` into the checkout, and the checkout - * converges with origin — reading what other machines and cloud sessions pushed, and pushing - * anything an earlier cycle left stranded. Reports why it could not converge; never throws. + * Bring a long-lived process's view of the branch up to date: the repository root links + * `tickets` into the checkout, and the branch and its persistent checkout exist and converge with + * origin — reading what other machines and cloud sessions pushed, and pushing anything an earlier + * cycle left stranded. Reports why it could not converge; never throws. * * The root link is created only over nothing — a real `tickets/` directory or a file of the * user's own is theirs — and hidden from git the moment it is made, as an uncommitted entry at @@ -83,11 +82,6 @@ export async function syncTickets( ): Promise<FileBranchSync> { const r = resolveTicketDeps(deps) const git = deps.git ?? nodeGitRunner() - const seeded = await r.funnel(root, 'seed the queue', async dir => { - const queue = join(dir, QUEUE_FILE) - if (!(await r.read(queue).then(() => true, () => false))) await r.write(queue, '') - }) - if (!seeded.ok && !seeded.committed) return { ok: false, error: seeded.error } const linkFs = deps.linkFs ?? nodeLinkFs() const rootLink = join(root, TICKETS_DIR) if (!(await linkFs.lexists(rootLink))) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6f451ed4..ad47efbca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@gemstack/skill-branches': specifier: workspace:* version: link:packages/skill-branches + '@gemstack/skill-queue': + specifier: workspace:* + version: link:packages/skill-queue '@gemstack/skill-tickets': specifier: workspace:* version: link:packages/skill-tickets @@ -47,6 +50,9 @@ importers: '@gemstack/skill-branches': specifier: workspace:* version: link:../skill-branches + '@gemstack/skill-queue': + specifier: workspace:* + version: link:../skill-queue '@gemstack/skill-tickets': specifier: workspace:* version: link:../skill-tickets @@ -152,6 +158,19 @@ importers: specifier: ^7.0.2 version: 7.0.2 + packages/skill-queue: + dependencies: + '@gemstack/agent-data': + specifier: workspace:* + version: link:../agent-data + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.43 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + packages/skill-tickets: dependencies: '@gemstack/agent-data':