From 080e5c8ce637f6fbc7b1bed64fc525a16c203d35 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Wed, 16 Sep 2026 22:05:13 +0300 Subject: [PATCH] The routine commands: four command skills, and a schedule line that says how often at most A schedule line takes `every m|h|d` beside or instead of its `when` check: the command is due only once that long has passed since its last recorded start, read off the run records on agent-data. Four command skills of work-queue's shape, one package each: update-tickets, plan-tickets, triage-quick, triage-consensual. The tickets skill gains `meta`, printing the last-import stamp, for the schedule's check. gemstack tracks the four skills and schedules them. (#1774) Co-Authored-By: Claude Fable 5.1 --- .agents/skills/plan-tickets/SKILL.md | 7 ++ .agents/skills/tickets/SKILL.md | 2 + .agents/skills/triage-consensual/SKILL.md | 7 ++ .agents/skills/triage-quick/SKILL.md | 7 ++ .agents/skills/update-tickets/SKILL.md | 7 ++ .claude/skills/plan-tickets | 1 + .claude/skills/triage-consensual | 1 + .claude/skills/triage-quick | 1 + .claude/skills/update-tickets | 1 + LOGIC.md | 5 +- agent-schedule.md | 6 +- packages/LOGIC.md | 1 + packages/agent-scheduler/DECISIONS.md | 8 +++ packages/agent-scheduler/LOGIC.md | 4 +- packages/agent-scheduler/src/LOGIC.md | 4 +- packages/agent-scheduler/src/records.LOGIC.md | 1 + .../agent-scheduler/src/records.test.LOGIC.md | 1 + packages/agent-scheduler/src/records.test.ts | 16 ++++- packages/agent-scheduler/src/records.ts | 10 +++ .../agent-scheduler/src/schedule.LOGIC.md | 13 ++-- .../src/schedule.test.LOGIC.md | 2 + packages/agent-scheduler/src/schedule.test.ts | 26 +++++++ packages/agent-scheduler/src/schedule.ts | 69 ++++++++++++++++--- .../agent-scheduler/src/scheduler.LOGIC.md | 2 +- packages/agent-scheduler/src/scheduler.ts | 3 +- packages/agent-scheduler/src/state.LOGIC.md | 2 +- packages/agent-scheduler/src/tick.LOGIC.md | 5 +- .../agent-scheduler/src/tick.test.LOGIC.md | 1 + packages/agent-scheduler/src/tick.test.ts | 24 +++++++ packages/agent-scheduler/src/tick.ts | 41 ++++++++--- packages/skill-plan-tickets/DECISIONS.md | 27 ++++++++ packages/skill-plan-tickets/LOGIC.md | 11 +++ packages/skill-plan-tickets/SKILL.LOGIC.md | 16 +++++ packages/skill-plan-tickets/SKILL.md | 7 ++ packages/skill-plan-tickets/package.json | 17 +++++ packages/skill-tickets/SKILL.LOGIC.md | 2 +- packages/skill-tickets/SKILL.md | 2 + packages/skill-tickets/src/cli.LOGIC.md | 8 ++- packages/skill-tickets/src/cli.test.LOGIC.md | 2 +- packages/skill-tickets/src/cli.test.ts | 4 ++ packages/skill-tickets/src/cli.ts | 9 ++- packages/skill-triage-consensual/DECISIONS.md | 26 +++++++ packages/skill-triage-consensual/LOGIC.md | 11 +++ .../skill-triage-consensual/SKILL.LOGIC.md | 16 +++++ packages/skill-triage-consensual/SKILL.md | 7 ++ packages/skill-triage-consensual/package.json | 17 +++++ packages/skill-triage-quick/DECISIONS.md | 26 +++++++ packages/skill-triage-quick/LOGIC.md | 11 +++ packages/skill-triage-quick/SKILL.LOGIC.md | 16 +++++ packages/skill-triage-quick/SKILL.md | 7 ++ packages/skill-triage-quick/package.json | 17 +++++ packages/skill-update-tickets/DECISIONS.md | 27 ++++++++ packages/skill-update-tickets/LOGIC.md | 11 +++ packages/skill-update-tickets/SKILL.LOGIC.md | 16 +++++ packages/skill-update-tickets/SKILL.md | 7 ++ packages/skill-update-tickets/package.json | 17 +++++ pnpm-lock.yaml | 8 +++ 57 files changed, 581 insertions(+), 42 deletions(-) create mode 100644 .agents/skills/plan-tickets/SKILL.md create mode 100644 .agents/skills/triage-consensual/SKILL.md create mode 100644 .agents/skills/triage-quick/SKILL.md create mode 100644 .agents/skills/update-tickets/SKILL.md create mode 120000 .claude/skills/plan-tickets create mode 120000 .claude/skills/triage-consensual create mode 120000 .claude/skills/triage-quick create mode 120000 .claude/skills/update-tickets create mode 100644 packages/skill-plan-tickets/DECISIONS.md create mode 100644 packages/skill-plan-tickets/LOGIC.md create mode 100644 packages/skill-plan-tickets/SKILL.LOGIC.md create mode 100644 packages/skill-plan-tickets/SKILL.md create mode 100644 packages/skill-plan-tickets/package.json create mode 100644 packages/skill-triage-consensual/DECISIONS.md create mode 100644 packages/skill-triage-consensual/LOGIC.md create mode 100644 packages/skill-triage-consensual/SKILL.LOGIC.md create mode 100644 packages/skill-triage-consensual/SKILL.md create mode 100644 packages/skill-triage-consensual/package.json create mode 100644 packages/skill-triage-quick/DECISIONS.md create mode 100644 packages/skill-triage-quick/LOGIC.md create mode 100644 packages/skill-triage-quick/SKILL.LOGIC.md create mode 100644 packages/skill-triage-quick/SKILL.md create mode 100644 packages/skill-triage-quick/package.json create mode 100644 packages/skill-update-tickets/DECISIONS.md create mode 100644 packages/skill-update-tickets/LOGIC.md create mode 100644 packages/skill-update-tickets/SKILL.LOGIC.md create mode 100644 packages/skill-update-tickets/SKILL.md create mode 100644 packages/skill-update-tickets/package.json diff --git a/.agents/skills/plan-tickets/SKILL.md b/.agents/skills/plan-tickets/SKILL.md new file mode 100644 index 000000000..8ed37f09e --- /dev/null +++ b/.agents/skills/plan-tickets/SKILL.md @@ -0,0 +1,7 @@ +--- +name: plan-tickets +description: Queue a plan for every open ticket that has none, unattended. +disable-model-invocation: true +--- + +Queue plans for the tickets that need one. Nobody will answer you: never ask, decide yourself. Read every open ticket. Among those that are neither planned nor held by someone, take the ten most important and put one entry per ticket on the agent queue asking for that ticket's plan to be written, naming the ticket's file. Pick each entry's priority after reading the ticket, by a mix of sensible criteria: a ticket that looks low effort ranks higher. Skip a ticket whose plan is already asked for on the queue. You only queue work, you never do it: write no plan yourself. If no ticket needs a plan, say so and stop. If this project has no ticketing system or no AI queue, show an error to the user and stop. diff --git a/.agents/skills/tickets/SKILL.md b/.agents/skills/tickets/SKILL.md index b45c41d49..001eb9754 100644 --- a/.agents/skills/tickets/SKILL.md +++ b/.agents/skills/tickets/SKILL.md @@ -17,6 +17,8 @@ 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 one ticket: its text, its plan, who holds it +npx tickets meta when the tickets last caught up with the issue tracker: + {"lastImportedAt": }, or {} when no import was recorded ``` ## Change diff --git a/.agents/skills/triage-consensual/SKILL.md b/.agents/skills/triage-consensual/SKILL.md new file mode 100644 index 000000000..b2da13e6e --- /dev/null +++ b/.agents/skills/triage-consensual/SKILL.md @@ -0,0 +1,7 @@ +--- +name: triage-consensual +description: Put the significant, consensual tickets on the project's agent queue, unattended. +disable-model-invocation: true +--- + +Choose consensual work for the agent queue. Nobody will answer you: never ask, decide yourself. Read every open ticket with its plan. Pick only the tickets that are significant, no quick wins, and consensual: no open question, no variability, one fairly obvious plan. Put each picked ticket on the agent queue as a link to the ticket labeled with its title, at the ticket's own priority, 5 when it has none. Skip a ticket already on the queue or held by someone. You only queue work, you never do it: the only thing you change is the queue. Do not implement a ticket, however clear its plan; a human can still veto it on the queue before an agent works it. If nothing qualifies, say so and stop. If this project has no ticketing system or no AI queue, show an error to the user and stop. diff --git a/.agents/skills/triage-quick/SKILL.md b/.agents/skills/triage-quick/SKILL.md new file mode 100644 index 000000000..792362011 --- /dev/null +++ b/.agents/skills/triage-quick/SKILL.md @@ -0,0 +1,7 @@ +--- +name: triage-quick +description: Put the quick-win tickets on the project's agent queue, unattended. +disable-model-invocation: true +--- + +Choose quick wins for the agent queue. Nobody will answer you: never ask, decide yourself. Read every open ticket with its plan's effort and uncertainty. Pick the tickets whose plan shows a quick win: a low effort and no uncertainty. Put each picked ticket on the agent queue as a link to the ticket labeled with its title, with a sensible priority, and consider bumping the lowest-effort tickets so agents work them first. Skip a ticket already on the queue or held by someone. You only queue work, you never do it: the only thing you change is the queue. Do not implement a ticket, however small its plan; a human can still veto it on the queue before an agent works it. If nothing qualifies, say so and stop. If this project has no ticketing system or no AI queue, show an error to the user and stop. diff --git a/.agents/skills/update-tickets/SKILL.md b/.agents/skills/update-tickets/SKILL.md new file mode 100644 index 000000000..797102a85 --- /dev/null +++ b/.agents/skills/update-tickets/SKILL.md @@ -0,0 +1,7 @@ +--- +name: update-tickets +description: Bring the project's tickets up to date with its issue tracker, unattended. +disable-model-invocation: true +--- + +Bring the tickets up to date with the project's issue tracker. Nobody will answer you: never ask, decide yourself. Note the current UTC time first, in ISO 8601: it is the import time you record at the end, and noting it before you fetch is deliberate, so an issue edited while you work is picked up by the next update instead of being missed. Read when the tickets last caught up with the tracker. If there are tickets but no such time, or the tracker cannot be reached, or you are not logged in to it, show an error to the user saying which of those it is and stop. If there are no tickets at all, treat it as a first import and bring every open issue across. Otherwise fetch only the issues and the discussion changed since that time. Reconcile, one ticket per issue: an issue with no ticket yet gets one; an issue that has a ticket has it updated in place, keeping its file name and its plan, and marking the plan outdated when the change calls for it; a comment is folded in only where it changes what the work is, never pasted; an issue now closed has its ticket closed. Record the time you noted as the new import time, and say in one line how many tickets you added, updated and removed. If this project has no ticketing system, show an error to the user and stop. diff --git a/.claude/skills/plan-tickets b/.claude/skills/plan-tickets new file mode 120000 index 000000000..d18d49974 --- /dev/null +++ b/.claude/skills/plan-tickets @@ -0,0 +1 @@ +../../.agents/skills/plan-tickets \ No newline at end of file diff --git a/.claude/skills/triage-consensual b/.claude/skills/triage-consensual new file mode 120000 index 000000000..953d09313 --- /dev/null +++ b/.claude/skills/triage-consensual @@ -0,0 +1 @@ +../../.agents/skills/triage-consensual \ No newline at end of file diff --git a/.claude/skills/triage-quick b/.claude/skills/triage-quick new file mode 120000 index 000000000..fabec7763 --- /dev/null +++ b/.claude/skills/triage-quick @@ -0,0 +1 @@ +../../.agents/skills/triage-quick \ No newline at end of file diff --git a/.claude/skills/update-tickets b/.claude/skills/update-tickets new file mode 120000 index 000000000..091731e41 --- /dev/null +++ b/.claude/skills/update-tickets @@ -0,0 +1 @@ +../../.agents/skills/update-tickets \ No newline at end of file diff --git a/LOGIC.md b/LOGIC.md index 193e27d7a..816c07098 100644 --- a/LOGIC.md +++ b/LOGIC.md @@ -1,4 +1,4 @@ -The Framework: autonomous AI programming. The user registers repositories as projects in a browser dashboard and states what to build or fix; a coding agent — Claude Code or Codex, on the user's own subscription — works the task unattended in a checkout [3] of its own, stops only for decisions a human must make, and hands the result off as a pull request. While nobody is at the keyboard, a scheduler the project's own hook starts with the dashboard works the agent queue [7] one entry at a time, each entry by an agent that publishes its own pull request set to merge on green, and the daemon keeps the pull requests it opened green — both standing down before unattended work could eat into the quota a human will want. The product never calls a model itself, and the user's own checkout is never touched. +The Framework: autonomous AI programming. The user registers repositories as projects in a browser dashboard and states what to build or fix; a coding agent — Claude Code or Codex, on the user's own subscription — works the task unattended in a checkout [3] of its own, stops only for decisions a human must make, and hands the result off as a pull request. While nobody is at the keyboard, a scheduler the project's own hook starts with the dashboard works the agent queue [7] one entry at a time, each entry by an agent that publishes its own pull request set to merge on green, and keeps the queue fed on a schedule: issues become tickets, tickets get plans, planned tickets are queued, and the daemon keeps the pull requests it opened green — both standing down before unattended work could eat into the quota a human will want. The product never calls a model itself, and the user's own checkout is never touched. ## Context @@ -50,7 +50,8 @@ The Framework: autonomous AI programming. The user registers repositories as pro - `packages/agent-data` — a branch of the project's repository used as a file store: the `agent-data` branch [6], checked out under `.branches/`, written through one sync → commit → push cycle that re-applies when a push loses a race. A library, not a skill: read by code, never by an agent. Every skill depends on it; nothing else does. - `packages/skill-branches`, `packages/skill-tickets`, `packages/skill-queue`, `packages/skill-logs` — the four skills [8]: an agent's own checkout [3] and branch; the tickets with their plans and claims; the agent queue [7]; the record of every run [16]. Tickets, queue and runs live on the `agent-data` branch, never on a code branch, so the default branch stays code only. No skill depends on another. - `packages/skill-work-queue` — the first command skill: a job for an agent, one skill file and no code, composing the four skills [8] without naming one; a project tracks it as `.claude/skills/work-queue`, and the scheduler fires it by its slash command, `/work-queue`. One package per command, `@gemstack/skill-`; nothing depends on it. -- `packages/agent-scheduler` — the scheduler: a tool like `agent-driver`, one command line and one small process per project, that reads the project's tracked `agent-schedule.md`, ticks every minute, and starts one agent per due command in its own checkout, recording every run on the `agent-data` branch. Depends on `agent-driver`, `agent-data`, the branches and the logs packages; never on The Framework, which never depends on it. +- `packages/skill-update-tickets`, `packages/skill-plan-tickets`, `packages/skill-triage-quick`, `packages/skill-triage-consensual` — the routine commands, one package each of the same shape: bring the tickets up to date with the issue tracker, queue a plan for every unplanned ticket, queue the quick wins, queue the consensual work. The schedule says how often at most each runs. +- `packages/agent-scheduler` — the scheduler: a tool like `agent-driver`, one command line and one small process per project, that reads the project's tracked `agent-schedule.md` (per command a check that says there is work, an interval that says how often at most, or both), ticks every minute, and starts one agent per due command in its own checkout, recording every run on the `agent-data` branch. Depends on `agent-driver`, `agent-data`, the branches and the logs packages; never on The Framework, which never depends on it. - `packages/chrome-extension` — the far end of the Claude web bridge [12], a Chrome extension reading claude.ai in a signed-in browser; it talks to the daemon over HTTP only. - `packages/the-framework.ai` — the marketing site at https://the-framework.ai; it presents the product and shares no code with it. - `.github/workflows` — every push builds, type-checks and tests the monorepo; the website deploys itself from the default branch; and one workflow is the far end of the `github-actions` driver implementation, running one turn per workflow run. diff --git a/agent-schedule.md b/agent-schedule.md index dad8ab125..88f4c9e63 100644 --- a/agent-schedule.md +++ b/agent-schedule.md @@ -1,5 +1,9 @@ # Agent schedule -One line per command. The command is the project's `.claude/skills/`. `when` is a check, run at the repository root: the command is due while the check prints a JSON value that is not empty. `cap` is how many runs of the command may be in flight at once, across every machine that shares this repository. +One line per command. The command is the project's `.claude/skills/`. `every` is how often at most: the command is due only once that long has passed since its last recorded start. `when` is a check, run at the repository root: the command is due while the check prints a JSON value that is not empty. A line carries one or both; with both, an agent starts only when both hold. `cap` is how many runs of the command may be in flight at once, across every machine that shares this repository. - work-queue: when `npx queue`, cap 1 +- update-tickets: every 1h, when `gh issue list --limit 1 --json number --search "updated:>=$(npx tickets meta | jq -r .lastImportedAt)"` +- plan-tickets: every 6h, when `npx tickets list | jq '[.[] | select(.planned or .locked | not)]'` +- triage-quick: every 6h +- triage-consensual: every 7d diff --git a/packages/LOGIC.md b/packages/LOGIC.md index 7d6e7f701..57815436b 100644 --- a/packages/LOGIC.md +++ b/packages/LOGIC.md @@ -18,6 +18,7 @@ The deliverables The Framework ships, one directory each. The product is the `fr - **The `queue` skill** (`skill-queue/`) - the `@gemstack/skill-queue` npm package: the agent queue on the `agent-data` branch, the `queue` command that reads it, adds an entry at a priority and takes one off, and the skill text. Depends on `agent-data`. - **The `logs` skill** (`skill-logs/`) - the `@gemstack/skill-logs` npm package: the record of every run agents made on a project, on the `agent-data` branch, and the read-only `logs` command; the product writes every run through it. Depends on `agent-data`. - **The `work-queue` command** (`skill-work-queue/`) - the `@gemstack/skill-work-queue` npm package: a command skill, one `SKILL.md` and no code, the job of working one queued task off the agent queue; it composes the capability skills of a project without naming one. One package per command; a project tracks its skill file under `.claude/skills/`, and the scheduler fires it by its slash command. +- **The routine commands** (`skill-update-tickets/`, `skill-plan-tickets/`, `skill-triage-quick/`, `skill-triage-consensual/`) - four more command skills of the same shape, one package each: bring the tickets up to date with the issue tracker; queue a plan for every ticket that has none; put the quick-win tickets on the agent queue; put the significant, consensual tickets on the agent queue. Each is fired by the scheduler on the interval the project's schedule gives it. - **The scheduler** (`agent-scheduler/`) - the `agent-scheduler` npm package: a tool, not a skill, with one command line (`tick`, `run`, `start`, `stop`, `status`) and one small process per project; it reads the project's tracked `agent-schedule.md`, pulls the `agent-data` branch [4], counts the runs in flight there, and starts one agent per due command in its own checkout through the branches package and agent-driver [2], the run recorded through the logs package. Names no skill. Depends on `agent-driver`, `agent-data`, `skill-branches` and `skill-logs`; nothing depends on it. - **The Claude web bridge's extension** (`chrome-extension/`) - a Chrome extension, not an npm package: the far end of the Claude web bridge [5], reading claude.ai in a signed-in browser. Talks to the product over HTTP only. - **The website** (`the-framework.ai/`) - the marketing site at https://the-framework.ai. Presents the product; shares no code with it. diff --git a/packages/agent-scheduler/DECISIONS.md b/packages/agent-scheduler/DECISIONS.md index d16336240..712703f21 100644 --- a/packages/agent-scheduler/DECISIONS.md +++ b/packages/agent-scheduler/DECISIONS.md @@ -36,6 +36,14 @@ decision. An AI proposes a bullet and asks; it never adds or rewrites one. empty), over the tool reading the queue (the tool would know a skill), and over a bare clock (empty runs). A list line the parser cannot read is skipped and named, never guessed. +- A schedule line paces a command two ways, alone or together: `when` says there is work + (the check's output), `every` says how often at most (the least time since the command's + last recorded start, read off the run records on the branch, so every machine agrees and + nothing new is stored). A routine whose run changes nothing cannot be paced by a check + alone: it would start every minute. Picked over a rotation of the routines in a fixed + order behind an empty queue (one line then depends on another, and idle it started an + agent every 30 minutes), and over a clock time (`at 09:00`: machine-local, and two + machines fire twice). Order is what the numbers say. - The state, `.agent-scheduler/state.json`, untracked, per user, hidden through git's exclude file the way `.branches/` is: on or off, keep-alive, the model, the spend cushion, the scheduler's pid, the last tick and what it decided. Nothing the tool knows is only in diff --git a/packages/agent-scheduler/LOGIC.md b/packages/agent-scheduler/LOGIC.md index 7880a2459..00001895e 100644 --- a/packages/agent-scheduler/LOGIC.md +++ b/packages/agent-scheduler/LOGIC.md @@ -2,7 +2,7 @@ The `agent-scheduler` package: a standalone tool, like `agent-driver`, that star ## Context -**User story**: the user writes `- work-queue: when \`npx queue\`, cap 1` in `agent-schedule.md`, commits it, and runs `agent-scheduler start`; from then on, whenever the project's agent queue holds an entry, one agent works it in its own checkout and opens a pull request, never two at once, and never past the share of the week's quota that has elapsed; the user reads why nothing started (`not due`, `cap reached (…)`, `quota: …`) in the state file or on a dashboard, changes the model or the spend cushion for their own machine, and runs `agent-scheduler stop` to turn it off while the agents in flight run to the end. +**User story**: the user writes `- work-queue: when \`npx queue\`, cap 1` and `- triage-quick: every 6h` in `agent-schedule.md`, commits it, and runs `agent-scheduler start`; from then on, whenever the project's agent queue holds an entry, one agent works it in its own checkout and opens a pull request, never two at once, and at most every six hours one agent triages the tickets, and never past the share of the week's quota that has elapsed; the user reads why nothing started (`not due`, `cap reached (…)`, `quota: …`) in the state file or on a dashboard, changes the model or the spend cushion for their own machine, and runs `agent-scheduler stop` to turn it off while the agents in flight run to the end. **Business logic story**: the daemon of The Framework used to watch the branch and start agents on its own rules, in its own memory. This tool replaces that with files and small processes: the schedule is a tracked file a person writes; the tool's state [12] is an untracked JSON file under `.agent-scheduler/`, per user; a run in flight is a run record [11] on the `agent-data` branch with `status: running`, so two machines sharing the repository count the same runs against a cap; a run is one detached process of the tool's own, `agent-scheduler run`, that makes a checkout through the `branches` package, prompts Claude Code once through `agent-driver` with no system prompt (the command's skill file is the whole instruction, and the agent publishes its own work with the `branches` skill), then writes the run record and reclaims [13] the checkout; and the scheduler's process [14] is a loop of ticks that holds nothing a restart would lose. The spend-boundary rule is copied from The Framework rather than imported, because this tool depends on `agent-driver`, which reads the quota, and not on The Framework, which draws the panel. @@ -42,4 +42,4 @@ The `agent-scheduler` package: a standalone tool, like `agent-driver`, that star - A tick first pulls the `agent-data` branch [5] (a pull that fails ends the tick: a stale branch must start nothing), then sweeps [6], then, when the state is on and the schedule exists, decides per command [3] in the cheapest order: the project has the command; the check says due; the cap [8] is not reached, counted from the run records [11] saying `running` on the branch, whatever the machine; the quota [9] has headroom, read only when everything else says start. A command that passes gets a run record written as a marker, `status: running`, with the tool's mark (`caller.scheduler`: the command and this machine; the run's pid joins it once its process exists); the tool counts again, withdraws the marker if another machine's landed first past the cap, and otherwise spawns the run [7] as a detached process. - The run makes a checkout [10] through the `branches` package, keeps a live log in the checkout in the shape The Framework's dashboard reads, prompts Claude Code once with `/` and lets the agent's own loop run to the end; the agent commits, names its branch and opens a pull request itself. The run then reads the pull request back off the branch, writes the run record over the marker with the card and the diary, and reclaims [13] the checkout under the `branches` rule: a dirty tree, or a branch not on the remote, keeps it. - On every tick the sweep records and reclaims what a run of this machine could not: a live log still `running` under a dead pid is recorded `failed`; one that ended but was never recorded is recorded as it ended; a marker of this machine with no checkout behind it is recorded `failed` with the spawn's stderr, else `stopped`. A running record from another machine is never touched: only that machine's sweep, or a person, changes it, and a machine that never comes back leaves its command capped on purpose. -- Every decision of the tick is one line in the state (`started `, `not due`, `cap reached (…)`, `quota: …`, `no such command in this project`, …), so a dashboard or a person reads why nothing started without a log. +- Every decision of the tick is one line in the state (`started `, `not due`, `not due (last start 2h ago, every 6h)`, `cap reached (…)`, `quota: …`, `no such command in this project`, …), so a dashboard or a person reads why nothing started without a log. diff --git a/packages/agent-scheduler/src/LOGIC.md b/packages/agent-scheduler/src/LOGIC.md index ee8a85e8f..39f852456 100644 --- a/packages/agent-scheduler/src/LOGIC.md +++ b/packages/agent-scheduler/src/LOGIC.md @@ -26,7 +26,7 @@ The rules and the processes of `agent-scheduler`: the schedule [1] a person writ ## Business logic — TL;DR - **The names** (`names.ts`) - the schedule file, the state directory and file, the commands directory, and the defaults: `opus`, a spend cushion of half a day, a cap of 1, a tick every minute, a check's budget of one minute. -- **The schedule** (`schedule.ts`, `schedule.test.ts`) - which lines name a command [14], its check and its cap [11]; a line that cannot be read is named by its number; a check's output says due when it is non-empty JSON; a command's prompt is its slash command. +- **The schedule** (`schedule.ts`, `schedule.test.ts`) - which lines name a command [14], its check, its interval (`every 6h`: at most that often, from the command's last recorded start) or both, and its cap [11]; a line that cannot be read is named by its number; a check's output says due when it is non-empty JSON; a command's prompt is its slash command. - **The state** (`state.ts`, `state.test.ts`) - the JSON file and its defaults, hidden from git on the first write, read as the default when missing or unreadable; where a spawned run's stderr lands. - **The spend boundary** (`quota-boundary.ts`, `quota-boundary.test.ts`) - the reset prose read as an instant, the elapsed share of the week, the windows in force, the limit the cushion moves, and the one line that says why a run may not start. - **Run records as markers** (`records.ts`, `records.test.ts`) - a marker is a running card with the tool's mark, counted per command across machines; withdrawn when the cap was lost; overwritten by the record at the end. @@ -34,7 +34,7 @@ The rules and the processes of `agent-scheduler`: the schedule [1] a person writ - **The pull request** (`pr.ts`) - the pull request a branch has, read back with `gh`, or none. - **One run** (`run.ts`, `run.test.ts`) - marker, checkout, live log, the prompt once, the pull request, the record over the marker, the checkout reclaimed; a run with no checkout is recorded `failed`. - **The sweep** (`sweep.ts`, `sweep.test.ts`) - dead runs of this machine recorded and reclaimed; markers with nothing behind them ended; another machine's runs never touched. -- **The tick** (`tick.ts`, `tick.test.ts`) - pull, sweep, then per command in order: the command exists, the check, due, the cap, the quota, the marker, the re-count, the spawn; every outcome's exact line. +- **The tick** (`tick.ts`, `tick.test.ts`) - pull, sweep, then per command in order: the command exists, the interval since its last start, the check, due, the cap, the quota, the marker, the re-count, the spawn; every outcome's exact line. - **The processes** (`scheduler.ts`) - the tick wired to the real project, the detached run, `start`, `stop` (which leaves a keep-alive scheduler running when asked to) and `status`, and the loop that ticks every minute. - **The command line** (`cli.ts`, `cli.test.ts`) - the seven commands, JSON on stdout, a line on stderr, exit 0, 1 or 2, and the project found from inside a checkout. - **The entry point** (`index.ts`) - re-exports everything a program or a dashboard imports. diff --git a/packages/agent-scheduler/src/records.LOGIC.md b/packages/agent-scheduler/src/records.LOGIC.md index 2c319a24b..a05358512 100644 --- a/packages/agent-scheduler/src/records.LOGIC.md +++ b/packages/agent-scheduler/src/records.LOGIC.md @@ -18,6 +18,7 @@ A run in flight is a run record [1]: the `logs` skill's card on the project's `a - **The marker** - a card with the run's id, its start time, `status: running`, the prompt as the intent, the driver, the model and the tool's mark, written to the branch with an empty diary; the write says whether it reached origin. - **In flight** - the running cards of one command on the branch, whatever the machine; a running card without the tool's mark, a dashboard's own run for instance, is not counted. +- **The last start** - the newest start time among one command's cards on the branch, whatever the machine and whatever became of the run, for the schedule's interval; nothing when the command never started. - **Withdrawing** - a marker whose tick lost the cap is deleted from the branch, so no record says running for a run that never was. - **The record at the end** - the card and the diary written over the marker, same id, same file; the mark stays on the card. diff --git a/packages/agent-scheduler/src/records.test.LOGIC.md b/packages/agent-scheduler/src/records.test.LOGIC.md index 32527dfce..259af2d4b 100644 --- a/packages/agent-scheduler/src/records.test.LOGIC.md +++ b/packages/agent-scheduler/src/records.test.LOGIC.md @@ -1,5 +1,6 @@ What the tests cover, against a real repository with an origin and an `agent-data` branch: - **The marker** - a running card with the tool's mark, pushed to origin as `logs: record run `, that the `logs` skill finds like any run with an empty diary. +- **The last start** - the newest start among a command's cards on any machine, a done and a failed one included, while a running card without the tool's mark and another command's cards do not count; a command never started has none. - **In flight** - the running cards of one command count whatever the machine; another command's and a running card without the tool's mark do not. - **The record at the end** - written over the marker, same id, with the status, the pull request and the mark kept, and the run no longer in flight; a withdrawn marker is gone from the branch. diff --git a/packages/agent-scheduler/src/records.test.ts b/packages/agent-scheduler/src/records.test.ts index 76afc6ead..d83d8379c 100644 --- a/packages/agent-scheduler/src/records.test.ts +++ b/packages/agent-scheduler/src/records.test.ts @@ -2,7 +2,7 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' import { DATA_BRANCH } from '@gemstack/agent-data' import { findRun, listRuns, readDiary } from '@gemstack/skill-logs' -import { inFlight, markerCard, recordRun, schedulerMark, withdrawMarker, writeMarker } from './records.js' +import { inFlight, lastStart, markerCard, recordRun, schedulerMark, withdrawMarker, writeMarker } from './records.js' import { git, removeRepo, testRepo } from './test-repo.js' // The marker is the logs skill's record, on the real branch: written before the agent exists, @@ -27,6 +27,20 @@ test('a marker is a running card on agent-data, pushed to origin, that the logs } }) +test('the last start of a command is its newest card on any machine, whatever became of the run; a command never started has none', async () => { + const repo = await testRepo() + try { + await writeMarker(repo, markerCard({ id: 'a1', startedAt: '2026-09-16T14:01:00.000Z', prompt: '/work-queue', driver: 'claude-code', model: 'opus', mark })) + await recordRun(repo, { id: 'a0', startedAt: '2026-09-16T09:00:00.000Z', status: 'done', caller: { scheduler: { command: 'work-queue', host: 'other-box' } } }, []) + await recordRun(repo, { id: 'a2', startedAt: '2026-09-16T14:02:00.000Z', status: 'failed', caller: { scheduler: { command: 'work-queue', host: 'other-box' } } }, []) + await recordRun(repo, { id: 'd1', startedAt: '2026-09-16T15:00:00.000Z', status: 'running', intent: 'a dashboard run' }, []) + assert.equal(await lastStart(repo, 'work-queue'), '2026-09-16T14:02:00.000Z') + assert.equal(await lastStart(repo, 'triage-quick'), undefined) + } finally { + await removeRepo(repo) + } +}) + test('in flight counts the running cards of one command, whatever the machine; a card this tool did not write is not counted', async () => { const repo = await testRepo() try { diff --git a/packages/agent-scheduler/src/records.ts b/packages/agent-scheduler/src/records.ts index b8c106fc0..530e99296 100644 --- a/packages/agent-scheduler/src/records.ts +++ b/packages/agent-scheduler/src/records.ts @@ -32,6 +32,16 @@ export function schedulerMark(card: RunCard): SchedulerMark | undefined { return { command, host, ...(typeof pid === 'number' ? { pid } : {}) } } +/** When one command last started, on any machine, whatever became of the run; nothing when it never did. */ +export async function lastStart(repo: string, command: string, deps: LogsDeps = {}): Promise { + const cards = await listRuns(repo, {}, deps) + let latest: string | undefined + for (const card of cards) { + if (schedulerMark(card)?.command === command && (latest === undefined || card.startedAt > latest)) latest = card.startedAt + } + return latest +} + /** The runs of one command still in flight, on any machine. */ export async function inFlight(repo: string, command: string, deps: LogsDeps = {}): Promise { const cards = await listRuns(repo, {}, deps) diff --git a/packages/agent-scheduler/src/schedule.LOGIC.md b/packages/agent-scheduler/src/schedule.LOGIC.md index 93e44dbfc..bcec5be1e 100644 --- a/packages/agent-scheduler/src/schedule.LOGIC.md +++ b/packages/agent-scheduler/src/schedule.LOGIC.md @@ -1,8 +1,8 @@ -The schedule [1]: how `agent-schedule.md` is read into commands [2], each with its check [3] and its cap [4]; which lines are the person's and not read; how a line that cannot be read is named rather than silently skipped; what a check's output must say for a command to be due; and the prompt a command runs with. +The schedule [1]: how `agent-schedule.md` is read into commands [2], each with its interval [5], its check [3] or both, and its cap [4]; which lines are the person's and not read; how a line that cannot be read is named rather than silently skipped; what a check's output must say for a command to be due; and the prompt a command runs with. ## Context -**User story**: the user writes one line per command they want run unattended, `- work-queue: when \`npx queue\`, cap 1`, commits the file, and every machine sharing the repository runs the same schedule; a typo on one line stands down that one command and is named in the state, while the other lines still run. +**User story**: the user writes one line per command they want run unattended, `- work-queue: when \`npx queue\`, cap 1` for work that is due while something is queued, `- triage-quick: every 6h` for a routine that runs at most that often, `- update-tickets: every 1h, when \`gh issue list …\`` for one that runs at most hourly and only when the tracker has news, commits the file, and every machine sharing the repository runs the same schedule; a typo on one line stands down that one command and is named in the state, while the other lines still run. **Business logic story**: the tool names no command of its own. This file is where a command's name enters the system, and `.claude/skills/` in the project is what runs. The tick (`tick.ts`) runs the check and asks this file whether the output says due. @@ -12,13 +12,14 @@ The schedule [1]: how `agent-schedule.md` is read into commands [2], each with i [2] command: a `.claude/skills/` folder tracked in the project, which the coding agent's harness expands from the slash command `/`. [3] check: the shell command a schedule line puts after `when`, run at the repository root on every tick; its output says whether the command is due. [4] cap: how many runs of one command may be in flight at once, across every machine that shares the repository; 1 when the line names none. +[5] interval: the `every` clause of a schedule line: the least time since the command's last recorded start before it may start again. ## Business logic — TL;DR -- **A schedule line** - `- : when \`\`, cap `; the name is lowercase letters, digits and dashes; `, cap N` is optional and reads as 1 when absent or 0. +- **A schedule line** - `- :` then clauses in any order, each at most once: `every m|h|d`, `when \`\``, `cap `; at least one of `every` and `when`; the name is lowercase letters, digits and dashes; a missing cap reads as 1, as does 0; an `every` of 0, an unknown unit, a clause twice or a word the parser does not know make the line unreadable. - **What is not read** - headings, blank lines, prose: anything not starting a list item is the person's. - **An unreadable list line** - kept aside with its line number and text; the tick names it as `line N` with `unreadable: `. -- **Due** - the check's output, parsed as JSON, is something other than empty; output that is not JSON is due when non-blank. +- **Due** - the check's output, parsed as JSON, is something other than empty; output that is not JSON is due when non-blank. The interval [5] is the tick's to apply, from the run records; a line with both clauses starts only when both hold. - **The prompt** - a command's prompt is its slash command, `/`. ## Business logic @@ -31,7 +32,7 @@ See `## Context`. #### Business logic -A list line, one starting with `- `, names one command: `- : when \`\`` with an optional `, cap `. The name is one or more lowercase letters, digits and dashes, starting with a letter or a digit, the shape of a skill folder's name. The check is the text between the backticks, surrounding whitespace removed, a shell command line. The cap is the whole number after `cap`; a line naming none has a cap of 1, and a cap of 0 reads as 1, since zero would spell "never", which is the line being absent. Each command remembers the file's line number it came from, for a message. A file with no schedule at all (no `agent-schedule.md` at the repository root) is read as no schedule, which the tick reports as `no agent-schedule.md`. +A list line, one starting with `- `, names one command: `- :` followed by clauses separated by commas outside backticks, in any order, each at most once. The name is one or more lowercase letters, digits and dashes, starting with a letter or a digit, the shape of a skill folder's name. `every ` is the interval [5], a whole number above zero and a unit of `m` (minutes), `h` (hours) or `d` (days), remembered as a duration and as written (`6h`) for the tick's line. `when \`\`` is the check [3], the text between the backticks, surrounding whitespace removed, a shell command line; a comma inside the backticks belongs to the check. `cap ` is the cap [4]: a line naming none has a cap of 1, and a cap of 0 reads as 1, since zero would spell "never", which is the line being absent. A line needs at least one of `every` and `when`, else nothing says when it runs. An `every` of 0 is refused rather than read as "always", which is the clause being absent; an unknown unit (`2w`), a clause given twice, and a word the parser does not know (`always`, `every day`) each make the line unreadable. Each command remembers the file's line number it came from, for a message. A file with no schedule at all (no `agent-schedule.md` at the repository root) is read as no schedule, which the tick reports as `no agent-schedule.md`. ### What is not read @@ -47,7 +48,7 @@ Only lines that start a list item (`- `) are read. Headings, blank lines and pro #### Context -**Problem**: a typo (`- Work Queue: every day`, `- triage: cap 3` with no check, `- plan: when npx plan` with no backticks) must stand down that one command and say so, rather than silently doing nothing. +**Problem**: a typo (`- Work Queue: every day`, `- triage: cap 3` with neither an interval nor a check, `- plan: when npx plan` with no backticks) must stand down that one command and say so, rather than silently doing nothing. #### Business logic diff --git a/packages/agent-scheduler/src/schedule.test.LOGIC.md b/packages/agent-scheduler/src/schedule.test.LOGIC.md index f8d9e20fc..b85b657f0 100644 --- a/packages/agent-scheduler/src/schedule.test.LOGIC.md +++ b/packages/agent-scheduler/src/schedule.test.LOGIC.md @@ -1,6 +1,8 @@ What the tests cover, on the schedule's text alone: - **A schedule line** - `- work-queue: when \`npx queue\`, cap 2` reads as the command, its check and its cap with its line number; a line with no cap reads as cap 1; headings and prose are not read. +- **An interval** - `every 6h` alone, `every 7d, cap 2`, `every 1h` beside a check whose text holds a comma, and the three clauses in any order each read as their command with the duration and the text as written. +- **An unreadable interval** - an unknown unit, `every 0h`, `every` twice, an unknown word beside it, and `every day` are each unreadable. - **Unreadable lines** - a capitalized name, a line with a cap but no check, and a check without backticks are each kept aside with their line number and text, while the readable line still counts. - **A cap of zero** - reads as one. - **Due** - a non-empty JSON array or object, `true` and non-JSON text are due; `[]`, `{}`, `null`, `false`, `""`, no output and blank output are not. diff --git a/packages/agent-scheduler/src/schedule.test.ts b/packages/agent-scheduler/src/schedule.test.ts index 6bd7cc880..b12c88a7f 100644 --- a/packages/agent-scheduler/src/schedule.test.ts +++ b/packages/agent-scheduler/src/schedule.test.ts @@ -17,6 +17,32 @@ Some words a person wrote. assert.deepEqual(schedule.unreadable, []) }) +test('a line paces by time with `every`, alone or beside a check, the clauses in any order; a comma inside the check is the check\'s', () => { + const schedule = parseSchedule(`- triage-quick: every 6h +- triage-consensual: every 7d, cap 2 +- update-tickets: every 1h, when \`gh issue list --search "a, b"\` +- plan-tickets: cap 1, when \`npx tickets list\`, every 30m +`) + assert.deepEqual(schedule.commands, [ + { name: 'triage-quick', every: { ms: 6 * 3_600_000, text: '6h' }, cap: 1, line: 1 }, + { name: 'triage-consensual', every: { ms: 7 * 86_400_000, text: '7d' }, cap: 2, line: 2 }, + { name: 'update-tickets', when: 'gh issue list --search "a, b"', every: { ms: 3_600_000, text: '1h' }, cap: 1, line: 3 }, + { name: 'plan-tickets', when: 'npx tickets list', every: { ms: 30 * 60_000, text: '30m' }, cap: 1, line: 4 }, + ]) + assert.deepEqual(schedule.unreadable, []) +}) + +test('an `every` the parser cannot read is unreadable: a unit it does not know, zero, a clause twice, a word it does not know', () => { + const schedule = parseSchedule(`- a: every 2w +- b: every 0h +- c: every 1h, every 2h +- d: every 1h, always +- e: every day +`) + assert.deepEqual(schedule.commands, []) + assert.deepEqual(schedule.unreadable.map(u => u.line), [1, 2, 3, 4, 5]) +}) + test('a list line the parser cannot read is skipped and named with its line', () => { const schedule = parseSchedule(`- work-queue: when \`npx queue\` - Work Queue: every day diff --git a/packages/agent-scheduler/src/schedule.ts b/packages/agent-scheduler/src/schedule.ts index 21397a2ce..33d2059e5 100644 --- a/packages/agent-scheduler/src/schedule.ts +++ b/packages/agent-scheduler/src/schedule.ts @@ -8,9 +8,13 @@ import { DEFAULT_CAP, SCHEDULE_FILE } from './names.js' * command is named, and `.claude/skills/` in the repository is what runs. * * - work-queue: when `npx queue`, cap 1 + * - triage-quick: every 6h + * - update-tickets: every 1h, when `gh issue list …` * * `when` is a shell command, run at the repository root. The command is due while the check - * exits 0 and prints something other than an empty JSON value. `cap` is how many runs of the + * exits 0 and prints something other than an empty JSON value. `every` is how often at most: the + * command is due only once that long has passed since its last recorded start. A line carries + * one or both; with both, the command starts only when both hold. `cap` is how many runs of the * command may be in flight at once, across every machine that shares the repository. * * Every other line — headings, blank lines, prose — is the person's, and is not read. A list line @@ -22,8 +26,10 @@ import { DEFAULT_CAP, SCHEDULE_FILE } from './names.js' export interface ScheduledCommand { /** The command: the `.claude/skills/` the agent's harness expands from `/`. */ name: string - /** The check, a shell command line. */ - when: string + /** The check, a shell command line; absent when the line paces by time alone. */ + when?: string + /** How often at most: the least time since the command's last recorded start, and the text as written (`6h`). */ + every?: { ms: number; text: string } /** Runs in flight at once, across every machine. */ cap: number /** The file's line number, for a message. */ @@ -38,7 +44,10 @@ export interface Schedule { } const COMMAND_LINE = /^-\s+([a-z0-9][a-z0-9-]*):\s*(.+)$/ -const WHEN = /^when\s+`([^`]+)`\s*(?:,\s*cap\s+(\d+))?\s*$/ +const EVERY = /^every\s+(\d+)(m|h|d)$/ +const WHEN = /^when\s+`([^`]+)`$/ +const CAP = /^cap\s+(\d+)$/ +const UNIT_MS = { m: 60_000, h: 3_600_000, d: 86_400_000 } as const /** The schedule out of the file's markdown. Pure. */ export function parseSchedule(md: string): Schedule { @@ -47,17 +56,61 @@ export function parseSchedule(md: string): Schedule { const line = index + 1 if (!/^-\s/.test(text)) return const head = COMMAND_LINE.exec(text.trim()) - const rule = head ? WHEN.exec(head[2]!) : null - if (!head || !rule) { + const command = head ? parseRule(head[1]!, head[2]!, line) : undefined + if (!command) { schedule.unreadable.push({ line, text: text.trim() }) return } - const cap = rule[2] === undefined ? DEFAULT_CAP : Number(rule[2]) - schedule.commands.push({ name: head[1]!, when: rule[1]!.trim(), cap: Math.max(1, cap), line }) + schedule.commands.push(command) }) return schedule } +/** + * The clauses after the name, in any order, each at most once: `every `, `when \`…\``, + * `cap `. At least one of `every` and `when`, else nothing says when. `every 0` is refused + * rather than read as "always", which is the clause being absent. + */ +function parseRule(name: string, rule: string, line: number): ScheduledCommand | undefined { + let when: string | undefined + let every: { ms: number; text: string } | undefined + let cap: number | undefined + for (const clause of clauses(rule)) { + const asEvery = EVERY.exec(clause) + const asWhen = WHEN.exec(clause) + const asCap = CAP.exec(clause) + if (asEvery && every === undefined && Number(asEvery[1]) > 0) { + every = { ms: Number(asEvery[1]) * UNIT_MS[asEvery[2] as keyof typeof UNIT_MS], text: `${asEvery[1]}${asEvery[2]}` } + } else if (asWhen && when === undefined) { + when = asWhen[1]!.trim() + } else if (asCap && cap === undefined) { + cap = Math.max(1, Number(asCap[1])) + } else { + return undefined + } + } + if (when === undefined && every === undefined) return undefined + return { name, ...(when !== undefined ? { when } : {}), ...(every ? { every } : {}), cap: cap ?? DEFAULT_CAP, line } +} + +/** The rule split on the commas outside backticks, each piece trimmed. */ +function clauses(rule: string): string[] { + const out: string[] = [] + let current = '' + let quoted = false + for (const ch of rule) { + if (ch === '`') quoted = !quoted + if (ch === ',' && !quoted) { + out.push(current) + current = '' + } else { + current += ch + } + } + out.push(current) + return out.map(s => s.trim()).filter(Boolean) +} + /** The repository's schedule, or `undefined` when it has none. */ export async function readSchedule(repo: string): Promise { const md = await readFile(join(repo, SCHEDULE_FILE), 'utf8').catch(() => undefined) diff --git a/packages/agent-scheduler/src/scheduler.LOGIC.md b/packages/agent-scheduler/src/scheduler.LOGIC.md index a4cea85ea..dea018803 100644 --- a/packages/agent-scheduler/src/scheduler.LOGIC.md +++ b/packages/agent-scheduler/src/scheduler.LOGIC.md @@ -16,7 +16,7 @@ The tool's process side: the tick [1] wired to the real project, the run's [2] d ## Business logic — TL;DR -- **A tick of the real project** - the state and the schedule read, the tick decided with this host, the `agent-data` pull, the sweep with a real pid probe, the command's folder, the check with a one-minute budget, the branch's markers, Claude Code's quota, ids from the clock, the driver `claude-code`; the record written to the state as `lastTick` and told line by line on the log (`[agent-scheduler] tick