diff --git a/.agents/skills/work-queue/SKILL.md b/.agents/skills/work-queue/SKILL.md new file mode 100644 index 000000000..9e93e5cff --- /dev/null +++ b/.agents/skills/work-queue/SKILL.md @@ -0,0 +1,7 @@ +--- +name: work-queue +description: Work one queued task off the project's agent queue, unattended. +disable-model-invocation: true +--- + +Get tickets to work on. Nobody will answer you: never ask, decide yourself. Take one queued task only. Commit your work on your branch and do not push; the run that started you publishes your branch when you stop. For you, committed counts as published: once the work is committed, close its ticket and mark the queue entry done. Before you stop, release any claim you still hold unless you closed the ticket. If nothing is queued, 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/.claude/skills/work-queue b/.claude/skills/work-queue new file mode 120000 index 000000000..8f97b27f4 --- /dev/null +++ b/.claude/skills/work-queue @@ -0,0 +1 @@ +../../.agents/skills/work-queue \ No newline at end of file diff --git a/packages/LOGIC.md b/packages/LOGIC.md index 11cf12622..e3382f554 100644 --- a/packages/LOGIC.md +++ b/packages/LOGIC.md @@ -17,7 +17,7 @@ The deliverables The Framework ships, one directory each. The product is the `fr - **The `tickets` skill** (`skill-tickets/`) - the `@gemstack/skill-tickets` npm package: the project's tickets with their plans and claims on the `agent-data` branch, the `tickets` command that reads, writes, claims and closes them, and the skill text. Depends on `agent-data`. - **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; the daemon fires it by its slash command. +- **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 daemon fires it by its slash command. - **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/framework/package.json b/packages/framework/package.json index db79d85bc..8fb71addb 100644 --- a/packages/framework/package.json +++ b/packages/framework/package.json @@ -47,7 +47,6 @@ "dependencies": { "@base-ui-components/react": "1.0.0-rc.0", "@gemstack/agent-data": "workspace:*", - "@gemstack/skill-work-queue": "workspace:*", "@gemstack/skill-branches": "workspace:*", "@gemstack/skill-logs": "workspace:*", "@gemstack/skill-queue": "workspace:*", diff --git a/packages/framework/src/auto-pm.LOGIC.md b/packages/framework/src/auto-pm.LOGIC.md index 95ac5957b..6400917d5 100644 --- a/packages/framework/src/auto-pm.LOGIC.md +++ b/packages/framework/src/auto-pm.LOGIC.md @@ -1,4 +1,4 @@ -Decides, once per look [1] and per project, whether the daemon may spend the account's quota [2] on work nobody asked for, and on what. Auto PM [3] either starts an agent [6] on the queued work [4] because the `agent-data` branch [11] moved, or, once a run found nothing queued, refills the agent queue [5] by firing the next routine [7] of a fixed rotation. The daemon reads no queue and names no skill: it reads the head of the branch, and it starts the queued work with one slash command, `/work-queue`, the command skill [22] shipped with The Framework. Every reason not to start is a sentence the daemon logs and the dashboard shows. +Decides, once per look [1] and per project, whether the daemon may spend the account's quota [2] on work nobody asked for, and on what. Auto PM [3] either starts an agent [6] on the queued work [4] because the `agent-data` branch [11] moved, or, once a run found nothing queued, refills the agent queue [5] by firing the next routine [7] of a fixed rotation. The daemon reads no queue and names no skill: it reads the head of the branch, and it starts the queued work with one slash command, `/work-queue`, the command skill [22] the project tracks as its own file. Every reason not to start is a sentence the daemon logs and the dashboard shows. ## Context @@ -29,7 +29,7 @@ Decides, once per look [1] and per project, whether the daemon may spend the acc [19] checkout: an agent's own working copy of the project: a git worktree under the project's `.branches/` directory, named as its branch. [20] agent id: an agent's stable id, derived from the moment it started; it names the agent's checkout directory, its branch until the agent names it, and its run. [21] holder: who a claim names: the agent's id when the daemon started the agent, else the branch the `tickets` command ran on. -[22] command skill: a skill file (`SKILL.md`) marked so that only a person or the daemon invokes it, whose body is the job's prompt, shipped as its own package; the daemon starts the agent with the skill's slash command and the coding agent's harness expands it. +[22] command skill: a skill file (`SKILL.md`) marked so that only a person or the daemon invokes it, whose body is the job's prompt, published as its own package and tracked by the project under `.claude/skills/`; the daemon starts the agent with the skill's slash command and the coding agent's harness expands it. [23] the heartbeat: one run on the queued work a day when nothing moved, the belt for a move the daemon missed. [24] the chain: the queued work firing again as its run ends, because the run's own commits moved the branch; it ends with a run that moves nothing. @@ -75,7 +75,7 @@ See `## Context`. #### Business logic -A project whose branch moved [15] starts one agent [6] told `/work-queue`, the command skill [22] shipped as the `@gemstack/skill-work-queue` package and linked into every checkout the daemon makes (`daemon-runtime.ts`), unattended [16], with its handoff [18] at `merge`, since what it implements has already been triaged onto the queue where a human could have vetoed it. One agent per move, however high the concurrency cap: the next start needs the branch to move again, which the agent's own commits do. The start spends the move and the rotation's turn alike: whether the queue wants refilling is for this run to find out. A commit the daemon wrote itself is not a move: the run's record the daemon writes at teardown, a routine lock [10] it takes or drops, a claim [9] it mints for a plan agent all carry the trailer, on this machine and on every other machine running the daemon, so a record never starts the next run and two daemons on one branch never fire empty runs at each other's records. A person's writes from the dashboard — queue an entry, release a claim — carry no trailer and are moves: a person asking for work is exactly what should start a run. +A project whose branch moved [15] starts one agent [6] told `/work-queue`, the command skill [22] the project tracks as its own file, in every checkout by itself, unattended [16], with its handoff [18] at `merge`, since what it implements has already been triaged onto the queue where a human could have vetoed it. One agent per move, however high the concurrency cap: the next start needs the branch to move again, which the agent's own commits do. The start spends the move and the rotation's turn alike: whether the queue wants refilling is for this run to find out. A commit the daemon wrote itself is not a move: the run's record the daemon writes at teardown, a routine lock [10] it takes or drops, a claim [9] it mints for a plan agent all carry the trailer, on this machine and on every other machine running the daemon, so a record never starts the next run and two daemons on one branch never fire empty runs at each other's records. A person's writes from the dashboard — queue an entry, release a claim — carry no trailer and are moves: a person asking for work is exactly what should start a run. ### The chain, and after an empty run the rotation @@ -280,7 +280,7 @@ Before deciding, every agent this loop started on the project and has not yet be #### Business logic -- The queued work [4] is the command skill [22] `work-queue`: its name is the skill's, its prompt is the slash command `/work-queue`, its label "Work the queue" and its tooltip "Work one queued task off the agent queue, unattended." are written here, and it is declared as working the queue and as auto-merging its pull request. The skill file itself is the `SKILL.md` of the `@gemstack/skill-work-queue` package, which The Framework depends on. +- The queued work [4] is the command skill [22] `work-queue`: its name is the skill's, its prompt is the slash command `/work-queue`, its label "Work the queue" and its tooltip "Work one queued task off the agent queue, unattended." are written here, and it is declared as working the queue and as auto-merging its pull request. The skill file itself is the project's tracked `.claude/skills/work-queue/SKILL.md`; The Framework ships no skill file and depends on no skill package. - Each preset-backed routine carries the preset's stable name, which is what the rotation's position and the switched-off list key on; the prompt rendered from the preset; and the preset's label and one-line tooltip, read off the preset so a relabeled preset relabels its routine and the sentence the launcher shows for a preset and the sentence the routines list shows for its routine are the same sentence. - Only the maintenance routine carries a separate description line, "sweeping the codebase for maintenance work", because "Maintenance" names its preset rather than the work; the other routines' labels read as what they do, so their rows stay one line and their log lines say the label itself. - The planning routine is declared as fanning out [14]; the triage routines each declare their routine lock [10]. diff --git a/packages/framework/src/auto-pm.test.LOGIC.md b/packages/framework/src/auto-pm.test.LOGIC.md index d4d85d87d..6b928ae02 100644 --- a/packages/framework/src/auto-pm.test.LOGIC.md +++ b/packages/framework/src/auto-pm.test.LOGIC.md @@ -27,7 +27,7 @@ What the tests cover, for the Auto PM sweep's policy and loop. The loop is drive - **The routine off, a move** - is the rotation's turn, and nothing works the queue. - **An unreadable branch** - stands the project down naming the branch. - **The log says a stand-down once** - the same stand-down over three looks is one log line; the report carries it every time. -- **The queued-work routine** - its prompt is the slash command of the `work-queue` skill file, which names itself, is invocable only by a person or the daemon, and tells the agent one task, commit without pushing, committed counts as published, say so and stop; it is the only routine that works the queue and the only one that auto-merges. +- **The queued-work routine** - its prompt is the slash command `/work-queue`; it is the only routine that works the queue and the only one that auto-merges. **The rotation** diff --git a/packages/framework/src/auto-pm.test.ts b/packages/framework/src/auto-pm.test.ts index 51c195478..8dca12c9e 100644 --- a/packages/framework/src/auto-pm.test.ts +++ b/packages/framework/src/auto-pm.test.ts @@ -1,8 +1,5 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' -import { readFileSync } from 'node:fs' -import { createRequire } from 'node:module' -import { dirname, join } from 'node:path' import { autoPmDecision, quotaHeadroom, @@ -545,23 +542,10 @@ test('a stand-down is logged when it is news, not once a minute (#1774)', async }) test('AUTO_PM_WORK_JOB fires the command skill by its slash command, and lands its own PRs (#1216/#1774)', () => { - // The prompt is the skill's name as a slash command; the agent's harness expands it. The skill - // file ships as its own package, and only a person or the daemon may invoke it. + // The prompt is the skill's name as a slash command; the agent's harness expands it from the + // project's own tracked skill file. The daemon holds no skill file to check: it names the command. assert.equal(AUTO_PM_WORK_JOB.prompt, `/${WORK_QUEUE_SKILL_NAME}`) assert.equal(AUTO_PM_WORK_JOB.works, true) - const pkg = dirname(createRequire(import.meta.url).resolve('@gemstack/skill-work-queue/package.json')) - const skill = readFileSync(join(pkg, 'SKILL.md'), 'utf8') - assert.match(skill, new RegExp(`^---\\nname: ${WORK_QUEUE_SKILL_NAME}\\n`)) - assert.match(skill, /\ndisable-model-invocation: true\n/) - // What the agent is told: one task, commit but do not push, committed counts as published, - // release what it holds, say so and stop when nothing is queued. - assert.match(skill, /Take one queued task only/) - assert.match(skill, /do not push/) - assert.match(skill, /committed counts as published/) - assert.match(skill, /If nothing is queued, say so and stop/) - // Rom's note (#1774): a command assumes no capability; when it would be broken without one, it says so in capability words, never a skill's name. - assert.match(skill, /no ticketing system or no AI queue, show an error to the user and stop/) - assert.doesNotMatch(skill, /`tickets`|`queue`|npx/) // The queued work implements entries whose triage a human could have vetoed, so its review // happened before the agent. Every other job writes tickets/plans and has nothing to merge. assert.equal(AUTO_PM_WORK_JOB.autoMerge, true) diff --git a/packages/framework/src/auto-pm.ts b/packages/framework/src/auto-pm.ts index 5f9c572ae..f1b9b4148 100644 --- a/packages/framework/src/auto-pm.ts +++ b/packages/framework/src/auto-pm.ts @@ -344,10 +344,10 @@ export const AUTO_PM_JOBS: readonly AutoPmJob[] = [ ] /** - * The command skill the daemon fires on the queued work (#1774): the `SKILL.md` of the - * `@gemstack/skill-work-queue` package, linked into every checkout the daemon makes, marked so - * that only a person or the daemon invokes it. Its prompt is the slash command; the agent's - * harness expands it. + * The command skill the daemon fires on the queued work (#1774): `work-queue`, a skill file the + * project tracks under `.claude/skills/`, marked so that only a person or the daemon + * invokes it. Its prompt is the slash command; the agent's harness expands it. The daemon ships + * no skill file, links none into a checkout and depends on no skill package. */ export const WORK_QUEUE_SKILL_NAME = 'work-queue' diff --git a/packages/framework/src/daemon-runtime.LOGIC.md b/packages/framework/src/daemon-runtime.LOGIC.md index 9afc84227..47d889d50 100644 --- a/packages/framework/src/daemon-runtime.LOGIC.md +++ b/packages/framework/src/daemon-runtime.LOGIC.md @@ -42,7 +42,7 @@ What the daemon does for a project: starting an agent [1] (the project it is for - **The device side of a relay** - a daemon that relayed an agent here may read, steer and hand off that agent against this daemon's home project only, through a fixed list of actions that never includes starting, previewing or deleting. - **Preflight before a checkout is spent** - an agent whose turns run on this machine starts only if its driver's [12] coding agent is installed and logged in; a pass is trusted for 30 seconds, a failure never. - **A continuation reopens the agent's own checkout** - Resume starts from the project's own options, waits out the previous leg's exit, reuses or re-attaches the agent's checkout on its recorded branch, and restores its history so it stays one agent. -- **Every agent gets its own checkout and branch** - a checkout under `.branches/` on branch `agent-` with the `branches` skill [11] and the command skill `work-queue` linked in; a directory that is not a repository gets no checkout and runs in the project's checkout one agent at a time; a repository whose checkout cannot be created refuses the start rather than borrowing the user's checkout. +- **Every agent gets its own checkout and branch** - a checkout under `.branches/` on branch `agent-` with the `branches` skill [11] linked in; a directory that is not a repository gets no checkout and runs in the project's checkout one agent at a time; a repository whose checkout cannot be created refuses the start rather than borrowing the user's checkout. - **One agent per checkout** - a second start aimed at a checkout whose agent is alive or mid-spawn is refused as busy. - **The agent spec, the spawned process and its environment** - the process is handed one spec [4] with its prompt verbatim, runs detached with its stderr in its checkout, sees nothing added to its PATH, its id as `AGENT_ID` and the daemon's address, and is refused and undone if the daemon began closing meanwhile. - **When the process ends** - the spec is removed, the slot freed, and for an agent with its own checkout a chain runs: a failed-start marker if it never reported anything, the teardown, then the transient-death retry. @@ -110,7 +110,7 @@ A continuation start carries only its seed: the agent to continue and, when it h Before the preflight [2], the previous leg is waited out for up to 15 seconds when its slot is still held: a leg that positively reports itself running is a genuine collision and is not waited for, so the busy refusal stands; a leg that reports itself ended, or whose state cannot be read at that instant, is waited for until its slot clears and its queued teardown finishes. A state that cannot be read is asked again rather than taken as running, because the agent's status file is rewritten in place and a single torn read says nothing about the leg. -After the preflight, the agent's checkout is reused when it still exists; otherwise the agent's branch, the one recorded when its checkout went (the agent renames its branch itself to its session name [23], so the recorded name wins over the birth branch `agent-`, which is the only fallback), is checked out again as a fresh checkout with the same two skill links. The agent's archived history is restored into the checkout, so it reopens its own event stream [9] instead of starting empty and stays one row. All of this runs under the agent's lock (`agent-locks.ts`), so a Resume never reuses a checkout that a teardown is removing. When none of this is possible, a new agent is started instead and the daemon logs "[framework] could not continue agent (); starting a new one". +After the preflight, the agent's checkout is reused when it still exists; otherwise the agent's branch, the one recorded when its checkout went (the agent renames its branch itself to its session name [23], so the recorded name wins over the birth branch `agent-`, which is the only fallback), is checked out again as a fresh checkout with the same `branches` link. The agent's archived history is restored into the checkout, so it reopens its own event stream [9] instead of starting empty and stays one row. All of this runs under the agent's lock (`agent-locks.ts`), so a Resume never reuses a checkout that a teardown is removing. When none of this is possible, a new agent is started instead and the daemon logs "[framework] could not continue agent (); starting a new one". ### Every agent gets its own checkout and branch @@ -122,7 +122,7 @@ After the preflight, the agent's checkout is reused when it still exists; otherw #### Business logic -A new agent's id is derived from the moment of the start, unless the caller minted the id first: a sweep [15] that claimed a ticket for the agent wrote the claim [24] under the id it now starts the agent with. The checkout is created by the `branches` skill [11] in one sequence: a git worktree under the project's `.branches/` directory on a fresh branch `agent-`, `.branches/` hidden from git, the project's dependency trees linked in, and the command skill `work-queue` — the skill file of the `@gemstack/skill-work-queue` package that the daemon starts the queued work with — linked in beside `branches` where the coding agent's [18] harness looks for skills. The `tickets`, `queue` and `logs` skills are the project's own tracked files, not the daemon's to link. +A new agent's id is derived from the moment of the start, unless the caller minted the id first: a sweep [15] that claimed a ticket for the agent wrote the claim [24] under the id it now starts the agent with. The checkout is created by the `branches` skill [11] in one sequence: a git worktree under the project's `.branches/` directory on a fresh branch `agent-`, `.branches/` hidden from git, the project's dependency trees linked in, and the `branches` skill linked in where the coding agent's [18] harness looks for skills. The daemon links no other skill: the command skill `work-queue` it starts the queued work with is, like `tickets`, `queue` and `logs`, the project's own tracked file, in every checkout by itself. A project that is not a git repository cannot be given a checkout: its agents run in the project's own checkout, one at a time since they would collide, and the daemon logs "[framework] is not a git repository, so it gets no worktree; running in the main checkout". A project that is a repository but whose checkout could not be created does not fall back to the user's checkout: the start is refused with "could not create a worktree for this run: ", the dashboard shows it, and starting again is the retry, because a failed agent is recoverable and a user's checkout with an agent's edits mixed in is not. When the creation was cut short by its time budget, the partial directory it left is removed; a failure of any other kind leaves the directory alone, since it may be a path that was on disk before this agent asked for it. diff --git a/packages/framework/src/daemon-runtime.test.LOGIC.md b/packages/framework/src/daemon-runtime.test.LOGIC.md index 719f366e6..d4a5f4154 100644 --- a/packages/framework/src/daemon-runtime.test.LOGIC.md +++ b/packages/framework/src/daemon-runtime.test.LOGIC.md @@ -4,4 +4,4 @@ What the tests cover, driving the runtime's waits and its process termination di - **A leg whose state cannot be read** - an unreadable state is asked again rather than taken for a live leg, so the exit is waited out instead of refused; an unreadable leg that then reports running still short-circuits to the busy guard, the asking having continued until the leg committed; a leg that once reported ended is not read again. - **Letting go of the repository at shutdown** - slots nothing has touched return at once; a slot whose process is gone but whose exit has not landed yet is waited for, together with the teardown that appears mid-wait; an agent with no checkout of its own parks no teardown and holds nothing up; a teardown that throws does not fail the shutdown; the wait is bounded, so a wedged teardown costs the grace period and not the exit. - **Terminating an agent's process** - an agent that ignores the graceful stop is ended forcibly together with its whole process group, so the browser it launched dies with it instead of living on unowned. -- **What a spawned agent is handed** - its id as `AGENT_ID`, the daemon's address, and a PATH that is the daemon's own with nothing added; the command skill the daemon links into every checkout is `work-queue`, a skill file whose front matter names it and marks it as invocable only by a person or the daemon. +- **What a spawned agent is handed** - its id as `AGENT_ID`, the daemon's address, and a PATH that is the daemon's own with nothing added. diff --git a/packages/framework/src/daemon-runtime.test.ts b/packages/framework/src/daemon-runtime.test.ts index 283abbc02..0b4a85739 100644 --- a/packages/framework/src/daemon-runtime.test.ts +++ b/packages/framework/src/daemon-runtime.test.ts @@ -1,12 +1,10 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' import { spawn } from 'node:child_process' -import { readFileSync } from 'node:fs' import { join } from 'node:path' -import { childEnv, COMMAND_SKILLS, terminate, waitOutFinishedLeg, waitOutSlots, type FinishedLegState } from './daemon-runtime.js' +import { childEnv, terminate, waitOutFinishedLeg, waitOutSlots, type FinishedLegState } from './daemon-runtime.js' import { isPidAlive } from './store/index.js' import { AGENT_ID_ENV } from './agent-id.js' -import { WORK_QUEUE_SKILL_NAME } from './auto-pm.js' import { DAEMON_URL_ENV } from './dashboard/web-start-endpoints.js' const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)) @@ -237,10 +235,3 @@ test('a spawned agent gets its id as AGENT_ID and no PATH entries of the daemon // No id: the fallback agent in the project checkout is nobody in particular. assert.equal(childEnv(undefined, undefined, base)[AGENT_ID_ENV], undefined) }) - -test('the command skill the daemon links into every checkout is the work-queue skill file (#1774)', () => { - assert.deepEqual(COMMAND_SKILLS.map(s => s.name), [WORK_QUEUE_SKILL_NAME]) - const skill = readFileSync(join(COMMAND_SKILLS[0]!.dir, 'SKILL.md'), 'utf8') - assert.match(skill, /^---\nname: work-queue\n/, 'the front matter names the skill the daemon fires') - assert.match(skill, /\ndisable-model-invocation: true\n/, 'only a person or the daemon invokes it') -}) diff --git a/packages/framework/src/daemon-runtime.ts b/packages/framework/src/daemon-runtime.ts index 132230409..a8d87654a 100644 --- a/packages/framework/src/daemon-runtime.ts +++ b/packages/framework/src/daemon-runtime.ts @@ -1,14 +1,12 @@ import { spawn, type ChildProcess } from 'node:child_process' import { closeSync, mkdirSync, openSync } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' -import { createRequire } from 'node:module' import { appendFile, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import { agentIdFromStartedAt, startedAtFromAgentId, readWorktreeAgent, restoreArchivedAgent, listAgents, findAgent, archivedAgentPaths, readLiveMetas, readLiveMeta, resolveAgentEventsPath, EVENTS_FILE, META_FILE, isPidAlive, toRunCard, diaryOf, fromDiaryLine, type AgentMeta } from './store/index.js' import { isGitRepo, nodeGitRunner, isGitTimeout } from '@gemstack/agent-data' -import { createCheckout, attachCheckout, agentBranchName, worktreePath, worktreeBranch, removeWorktree, pruneWorktrees, agentIdFromWorktreeDir, type SkillLink } from '@gemstack/skill-branches' +import { createCheckout, attachCheckout, agentBranchName, worktreePath, worktreeBranch, removeWorktree, pruneWorktrees, agentIdFromWorktreeDir } from '@gemstack/skill-branches' import { writeRun, type AnyDiaryLine } from '@gemstack/skill-logs' import { AGENT_ID_ENV } from './agent-id.js' -import { WORK_QUEUE_SKILL_NAME } from './auto-pm.js' import { daemonFunnel } from './daemon-writes.js' import { THE_FRAMEWORK_DIR } from './framework-dir.js' import type { FrameworkEvent } from './events.js' @@ -120,16 +118,6 @@ export function childEnv(daemonUrl: string | undefined, agentId: string | undefi return daemonUrl ? { ...env, [DAEMON_URL_ENV]: daemonUrl } : env } -/** - * The command skills the daemon fires (#1774), linked into every checkout it makes beside the - * `branches` skill, through the branches package's caller-given list. Each ships as its own - * package, `@gemstack/skill-`, a `SKILL.md` and no code; the daemon starts the queued - * work with `/work-queue`. The skills an agent composes — tickets, queue, logs — are the - * project's own tracked files, not the daemon's to link. - */ -const WORK_QUEUE_SKILL_DIR = dirname(createRequire(import.meta.url).resolve('@gemstack/skill-work-queue/package.json')) -export const COMMAND_SKILLS: readonly SkillLink[] = [{ name: WORK_QUEUE_SKILL_NAME, dir: WORK_QUEUE_SKILL_DIR }] - /** The daemon's signed write funnel to the data branch (`daemon-writes.ts`): the run's record is its own commit. */ const funnel = daemonFunnel() @@ -498,7 +486,7 @@ export function createProjectRuntime({ cwd, env, binPath, retryDelayMs, driverPr // says which name it got; re-attaching by the birth branch would continue the agent on a // branch without its previous commits. const branch = agentBranchFor(archived ?? { id: agentId }) - await attachCheckout(projectCwd, { agentId, branch, skills: COMMAND_SKILLS }) + await attachCheckout(projectCwd, { agentId, branch }) } await restoreArchivedAgent(projectCwd, path, agentId).catch(() => false) return { cwd: path, agentId } @@ -559,8 +547,10 @@ export function createProjectRuntime({ cwd, env, binPath, retryDelayMs, driverPr ): Promise<{ ok: true; workspace: { cwd: string; agentId?: string } } | { ok: false; error: string }> => { try { // The package's one sequence (#1725): the worktree, the parent's dependencies linked in, the - // routine skill linked in, the branches view (#1580) told now rather than at the next tick. - const worktree = await createCheckout(projectCwd, { agentId, skills: COMMAND_SKILLS }) + // branches view (#1580) told now rather than at the next tick. The daemon links no skill of + // its own (#1774): the command skill it fires, `work-queue`, is a tracked file of the project, + // in every checkout by itself, like `tickets`, `queue` and `logs`. + const worktree = await createCheckout(projectCwd, { agentId }) return { ok: true, workspace: { cwd: worktree.path, agentId } } } catch (err) { if (await isGitRepo(projectCwd)) { diff --git a/packages/framework/src/daemon-workspace.test.LOGIC.md b/packages/framework/src/daemon-workspace.test.LOGIC.md index 5e3aff358..1bb4ef87c 100644 --- a/packages/framework/src/daemon-workspace.test.LOGIC.md +++ b/packages/framework/src/daemon-workspace.test.LOGIC.md @@ -8,4 +8,4 @@ What the tests cover, starting agents through the runtime against real git repos - **What counts as a transient death** - a connection closed mid-response, a reset connection and an overloaded API count as transient; a boot death, an assertion failure and an absent detail do not; the detail is read from the agent's own final end event and only from a failed one, skipping malformed lines, with an empty stream yielding none and a later successful end canceling an earlier failure. - **Retrying a transient death** - an agent that dies to a transient error is continued as the same agent in its retained checkout, at most twice, after which it stays failed; an agent that fails on its own terms is not retried. - **Preflight before anything is spent** - a start on a logged-out coding agent is refused with the fix named ("not logged in", "auth login") and creates no checkout, no branch and no process; an `actions` agent is not probed at all; a passing preflight is probed once for a burst of starts; a failing one is probed again on every start, so logging in is picked up by the very next start. -- **What a spawned agent finds** - its PATH is the daemon's own, with nothing added: a skill's command resolves from the project's dependencies; its checkout links exactly two skills wherever a coding agent's harness looks, the `branches` skill and the daemon's command skill `work-queue`, each pointing at the directory holding its `SKILL.md`. +- **What a spawned agent finds** - its PATH is the daemon's own, with nothing added: a skill's command resolves from the project's dependencies; its checkout links exactly one skill wherever a coding agent's harness looks, the `branches` skill, pointing at the directory holding its `SKILL.md`; the command skill `work-queue` is the project's tracked file, not a link. diff --git a/packages/framework/src/daemon-workspace.test.ts b/packages/framework/src/daemon-workspace.test.ts index f10a70788..05a21e89c 100644 --- a/packages/framework/src/daemon-workspace.test.ts +++ b/packages/framework/src/daemon-workspace.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, mkdir, writeFile, readdir, readFile, readlink, rm, stat, realp import { join, 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, COMMAND_SKILLS } from './daemon-runtime.js' +import { createProjectRuntime, cleanupTimedOutWorktree, markFailedStart, agentStderrPath, isTransientAgentFailure, lastAgentFailureDetail, MAX_TRANSIENT_RETRIES } from './daemon-runtime.js' import type { PreflightResult } from './preflight.js' /** @@ -643,7 +643,7 @@ async function writePathStub(dir: string, log: string): Promise { return stub } -test("a spawned agent gets the daemon's PATH untouched, and its checkout links the branches and work-queue skills (#1725/#1774)", async () => { +test("a spawned agent gets the daemon's PATH untouched, and its checkout links the branches skill and nothing else (#1725/#1774)", async () => { const cwd = await realpath(await mkdtemp(join(tmpdir(), 'framework-agent-path-'))) try { const git = nodeGitRunner() @@ -665,16 +665,15 @@ test("a spawned agent gets the daemon's PATH untouched, and its checkout links t // The daemon puts nothing on the agent's PATH (#1774): a skill's command resolves from the // project's own dependencies, `npx tickets`, as its SKILL.md says. assert.equal(recorded.trim(), process.env['PATH'], "the agent's PATH is the daemon's own") - // Two skills are linked into the checkout, where each harness looks for them: the branches - // package's own (#1739), and the command skill the daemon fires (#1774), from its own package. The skills an agent - // composes — tickets, queue, logs — are the project's tracked files, not links. + // One skill is linked into the checkout, where each harness looks for them: the branches + // package's own (#1739). The daemon links no skill of its own (#1774): the command skill it + // fires, `work-queue`, and the skills an agent composes — tickets, queue, logs — are the + // project's tracked files, in every checkout by themselves. const checkout = worktreePath(cwd, result.agentId!) for (const harnessDir of HARNESS_SKILL_DIRS) { - for (const [name, dir] of [['branches', BRANCHES_SKILL_DIR], ...COMMAND_SKILLS.map(s => [s.name, s.dir] as const)] as const) { - const target = await readlink(join(checkout, harnessDir, name)) - assert.equal(await realpath(resolve(join(checkout, harnessDir), target)), await realpath(dir), `${harnessDir}/${name} links the directory holding its SKILL.md`) - } - assert.deepEqual((await readdir(join(checkout, harnessDir))).sort(), ['branches', 'work-queue'], `${harnessDir} holds exactly those two links`) + const target = await readlink(join(checkout, harnessDir, 'branches')) + assert.equal(await realpath(resolve(join(checkout, harnessDir), target)), await realpath(BRANCHES_SKILL_DIR), `${harnessDir}/branches links the directory holding its SKILL.md`) + assert.deepEqual(await readdir(join(checkout, harnessDir)), ['branches'], `${harnessDir} holds exactly that one link`) } await runtime.dispose() } finally { diff --git a/packages/skill-work-queue/LOGIC.md b/packages/skill-work-queue/LOGIC.md index fccf130f2..88d73c282 100644 --- a/packages/skill-work-queue/LOGIC.md +++ b/packages/skill-work-queue/LOGIC.md @@ -8,4 +8,4 @@ The `@gemstack/skill-work-queue` npm package: the `work-queue` command skill [1] ## Business logic — TL;DR -- **Working the queue** (`SKILL.md`) - one queued task off the agent queue, unattended; the job The Framework's daemon fires when the `agent-data` branch moved (the rules in `packages/framework/src/auto-pm.ts`). +- **Working the queue** (`SKILL.md`) - one queued task off the agent queue, unattended; the job The Framework's daemon fires when the `agent-data` branch moved (the rules in `packages/framework/src/auto-pm.ts`); a project tracks the file as `.claude/skills/work-queue/SKILL.md`, the daemon links nothing. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba1758160..0b7fe567e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,9 +62,6 @@ importers: '@gemstack/skill-tickets': specifier: workspace:* version: link:../skill-tickets - '@gemstack/skill-work-queue': - specifier: workspace:* - version: link:../skill-work-queue '@puppeteer/browsers': specifier: 3.2.1 version: 3.2.1