diff --git a/.changeset/alcode-catchup.md b/.changeset/alcode-catchup.md new file mode 100644 index 00000000..2ca3f826 --- /dev/null +++ b/.changeset/alcode-catchup.md @@ -0,0 +1,5 @@ +--- +"@paleo/alcode": minor +--- + +The `catchup` protocol replaces `read`: it loads the ticket's history and returns a synthesis, and a following `resume` continues the ticket with that history in context. `reserve-side-ticket` now also counts the side tickets archived under `.plans/_archives/`. diff --git a/.changeset/plans-share-archive.md b/.changeset/plans-share-archive.md new file mode 100644 index 00000000..8ebff5fb --- /dev/null +++ b/.changeset/plans-share-archive.md @@ -0,0 +1,5 @@ +--- +"@paleo/plans-share": minor +--- + +Added `archive ` and `auto-archive`, which move ticket directories to `.plans/_archives/`, and the `sync --auto-archive` option. Auto-archiving moves the ticket directories and no-ticket alcode session files untouched for `PLANS_SHARE_ARCHIVE_DAYS` days (default 7). diff --git a/alignfirst-developer-tests/Dockerfile b/alignfirst-developer-tests/Dockerfile index 835fb0ef..e1631403 100644 --- a/alignfirst-developer-tests/Dockerfile +++ b/alignfirst-developer-tests/Dockerfile @@ -91,7 +91,7 @@ RUN cd /opt/alignfirst-developer-tests/fixtures/template && \ RUN npx -y skills add https://github.com/paleo/alignfirst --global --yes \ --agent universal --agent claude-code \ --skill alignfirst --skill al --skill alplan --skill alspec \ - --skill aldescription --skill alreview --skill alread \ + --skill aldescription --skill alreview --skill alcatchup \ --skill almerge --skill alignfirst-setup-guide \ --skill alignfirst-developer-openclaw-playbook diff --git a/alignfirst-developer-tests/scenarios/_lib/coding-session.ts b/alignfirst-developer-tests/scenarios/_lib/coding-session.ts index ca3eef82..f46b1057 100644 --- a/alignfirst-developer-tests/scenarios/_lib/coding-session.ts +++ b/alignfirst-developer-tests/scenarios/_lib/coding-session.ts @@ -79,7 +79,7 @@ export async function waitForCompletionReport( /** * Wait for the investigation findings — the agent relaying, in the thread, the result of a - * `read`-protocol delegation (A03). Judged per message like the ack/completion waits: tolerant of + * no-protocol delegation (A03). Judged per message like the ack/completion waits: tolerant of * phrasing and language, and it judges the earlier launch/ack lines false rather than needing a regex * to exclude them. */ diff --git a/alignfirst-developer-tests/scenarios/_lib/mock-coding-agent.ts b/alignfirst-developer-tests/scenarios/_lib/mock-coding-agent.ts index e0daf187..84fe7499 100644 --- a/alignfirst-developer-tests/scenarios/_lib/mock-coding-agent.ts +++ b/alignfirst-developer-tests/scenarios/_lib/mock-coding-agent.ts @@ -511,7 +511,7 @@ function parseWorktreeRequest( } const CODING_PROTOCOL_RE = - /^Run the _(spec|AAD|plan|description|read|review|merge)_ protocol from the \*alignfirst\* skill\./; + /^Run the _(spec|AAD|plan|description|catchup|review|merge)_ protocol from the \*alignfirst\* skill\./; // The edit each coding result stands behind, applied to the fixture's // `home-page.mjs` with `sed`. A run reports "changes committed on the ticket diff --git a/alignfirst-developer-tests/workspace/AGENTS.md b/alignfirst-developer-tests/workspace/AGENTS.md index cefc64f9..9b7ffb6b 100644 --- a/alignfirst-developer-tests/workspace/AGENTS.md +++ b/alignfirst-developer-tests/workspace/AGENTS.md @@ -8,7 +8,7 @@ When a channel or DM message names a project or a ticket and you are not already Don't investigate the **code** yourself. Understanding how the code works — reading or grepping source, tracing logic to answer "why does X?" / "should we Y?" — is alcode's job. Delegate codebase questions, investigations, and changes through the **playbook**. -Repo and workflow **metadata** is fair game directly: `git` (status, log, branch, diff, fetch), `gh` (PR/issue state), `ls`, the workspace tooling, `DEVELOPERS.md`, the `.plans/` listing. A status request on a ticket ("where does ABC-123 stand?") is ticket work — handle it through the **playbook**: combine that metadata with the ticket's spec/summary history (via alcode `read`), never by reading the source. +Repo and workflow **metadata** is fair game directly: `git` (status, log, branch, diff, fetch), `gh` (PR/issue state), `ls`, the workspace tooling, `DEVELOPERS.md`, the `.plans/` listing. A status request on a ticket ("where does ABC-123 stand?") is ticket work — handle it through the **playbook**: combine that metadata with the ticket's spec/summary history (via alcode `catchup`), never by reading the source. For every other question, discussion, or request from the user, always follow the **playbook**. The playbook is your guide for everything. diff --git a/alignfirst-skills.md b/alignfirst-skills.md index 3380775f..72a71b91 100644 --- a/alignfirst-skills.md +++ b/alignfirst-skills.md @@ -10,7 +10,7 @@ AlignFirst enables AI agents to write the code you would write. It's distributed ## Installation ```bash -npx skills add https://github.com/paleo/alignfirst --global --skill alignfirst --skill al --skill alplan --skill alspec --skill aldescription --skill alreview --skill alread --skill almerge +npx skills add https://github.com/paleo/alignfirst --global --skill alignfirst --skill al --skill alplan --skill alspec --skill aldescription --skill alreview --skill alcatchup --skill almerge ``` > **Note:** We recommend installing these skills globally. @@ -117,12 +117,13 @@ Resolve conflicts after a merge or rebase: The agent investigates both sides of each conflict, resolves them, and writes a `.plans/AB-123/A4-merge.summary.md` file documenting any tricky resolutions. -### Read Task Context +### Catch Up -Load into context all specs and summaries of the current ticket: +Load the history of the current ticket, then continue with your instructions or get a synthesis: ```markdown -/alread +/alcatchup +/alcatchup then start an AAD: … ``` ## Rationale diff --git a/docs/alignfirst-developer/alignfirst-developer.md b/docs/alignfirst-developer/alignfirst-developer.md index 1efcd0d3..427ef7a9 100644 --- a/docs/alignfirst-developer/alignfirst-developer.md +++ b/docs/alignfirst-developer/alignfirst-developer.md @@ -15,9 +15,10 @@ deployment, use the 2. **Operating-instructions playbook** — the [`alignfirst-developer-openclaw-playbook`](../../skills/alignfirst-developer-openclaw-playbook/) skill. `SKILL.md` routes thread sessions to `working-session.md` and channel/DM sessions to - `channel-handling.md`. Its references own working sessions, channel handling, project workspace - setup, project lifecycle, and the `message` tool per surface. Project discovery comes from `alproject --guide`; the delegation - procedure comes from `alcode --openclaw-guide` only when delegation starts. + `channel-handling.md`. Its references own working sessions, channel handling, the `runbooks/` + directory for project workspace setup and project lifecycle, and the `message` tool per surface. + Project discovery comes from `alproject --guide`; the delegation procedure comes from + `alcode --openclaw-guide` only when delegation starts. 3. **Regression-test harness** — [`alignfirst-developer-tests/`](../../alignfirst-developer-tests/). This standalone Dockerised consumer drives the workspace through synthetic Discord and Slack channels and judges the result. @@ -31,8 +32,8 @@ user message → workspace AGENTS.md (auto-loaded) layer 1 → alignfirst-developer-openclaw-playbook/SKILL.md (read first) layer 2 ← procedural dispatcher → references/working-session.md | channel-handling.md layer 2 - → references/project-lifecycle.md (create/remove) layer 2 - → references/project-workspace-setup.md (if the thread gets its workspace) layer 2 + → references/runbooks/project-lifecycle.md (create/onboard/remove) layer 2 + → references/runbooks/project-workspace-setup.md (if the thread gets its workspace) layer 2 → run `alcode --openclaw-guide` (delegation manual, read last), then delegate via alcode ``` @@ -42,7 +43,7 @@ Layer 1 is the only thing OpenClaw injects automatically; everything in layer 2 A channel/DM session runs `alproject list --json` before routing a message that may refer to a project. It resolves filesystem-present projects only, then records the known project paths, ticket, one-line task, and the full text of a detailed request. It opens a thread and ends the turn. Resource URLs, multi-project requests, and requests that may need no project can leave values for the working session to resolve. Duplicate names and missing project paths stay unresolved until the user selects a usable canonical path. The channel session never sets up a workspace, delegates to `alcode`, inspects a codebase, or reports a status — the thread session does all of that, whatever the user asked for and however explicit their green light was. -The cost is one round-trip: a thread session activates on the user's next message in that thread, so the starter ends by bringing the user back. It asks only for a value the channel can establish is required; otherwise it states that the next message launches the working session. Project creation is the exception to the path requirement: the lifecycle procedure establishes the new canonical path. The gain is that everything substantive runs in a session whose plain text auto-streams to the right surface. The previous contract had the channel session finish the setup in-turn, which forced every post through `message`+`threadId` and made a leak to the channel root the standard failure (`alignfirst-developer-tests/artifacts/2026-07-15T10-31-39-655Z/`). +The cost is one round-trip: a thread session activates on the user's next message in that thread, so the starter ends by bringing the user back. It asks only for a value the channel can establish is required; otherwise it states that the next message launches the working session. Project creation and repository onboarding are the exceptions to the path requirement: the lifecycle procedure establishes the new canonical path. The gain is that everything substantive runs in a session whose plain text auto-streams to the right surface. The previous contract had the channel session finish the setup in-turn, which forced every post through `message`+`threadId` and made a leak to the channel root the standard failure (`alignfirst-developer-tests/artifacts/2026-07-15T10-31-39-655Z/`). ## Reading order for maintainers diff --git a/package.json b/package.json index 410cf060..e1aa836b 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "docmap": "docmap", "workspace": "node scripts/workspace.mjs", "plans:setup": "plans-share setup --folder alignfirst", - "plans:sync": "plans-share sync", + "plans:sync": "plans-share sync --auto-archive", "lint": "biome check", "lint:fix": "biome check --write", "build": "npm run build --workspaces --if-present", diff --git a/packages/alcode/src/prompt.ts b/packages/alcode/src/prompt.ts index 2aa2c7e6..5748224e 100644 --- a/packages/alcode/src/prompt.ts +++ b/packages/alcode/src/prompt.ts @@ -1,4 +1,12 @@ -export const PROTOCOLS = ["spec", "plan", "aad", "description", "read", "review", "merge"] as const; +export const PROTOCOLS = [ + "spec", + "plan", + "aad", + "description", + "catchup", + "review", + "merge", +] as const; export type Protocol = (typeof PROTOCOLS)[number]; @@ -7,6 +15,7 @@ export const PROTOCOL_LABELS: Record = { aad: "AAD", plan: "plan", description: "description", + catchup: "catchup", review: "review", merge: "merge", }; @@ -20,16 +29,9 @@ export interface PromptInput { export function buildPrompt(input: PromptInput): string { const { protocol, ticket, message } = input; if (!protocol) return message ?? ""; - if (protocol === "read") return buildReadPrompt(ticket, message); return buildProtocolPrompt(PROTOCOL_LABELS[protocol], ticket, message); } -function buildReadPrompt(ticket?: string, message?: string): string { - const ticketPart = ticket ? ` for ticket ${ticket}` : ""; - const messagePart = message ? `\n\n${message}` : ""; - return `Use the *alignfirst* skill to determine the TASK_DIR${ticketPart}. Then read every \`*spec.md\` and \`*summary.md\` file in the TASK_DIR.${messagePart}`; -} - function buildProtocolPrompt(label: string, ticket?: string, message?: string): string { const ticketPart = ticket ? ` Ticket ID = ${ticket}.` : ""; const messagePart = message ? `\n\n${message}` : ""; diff --git a/packages/alcode/src/session-file.ts b/packages/alcode/src/session-file.ts index 91c7cc08..3029df4b 100644 --- a/packages/alcode/src/session-file.ts +++ b/packages/alcode/src/session-file.ts @@ -218,11 +218,10 @@ export function listSessionRecords(cwd: string): SessionRecord[] { // concurrent reservation took the id, so the loop moves on to the next one. export function reserveSideTicket(cwd: string): string { const plansDir = join(cwd, ".plans"); - let highest = 0; - for (const entry of readEntries(plansDir)) { - const match = entry.isDirectory() ? entry.name.match(/^side-(\d+)$/) : null; - if (match) highest = Math.max(highest, Number(match[1])); - } + const highest = Math.max( + highestSideTicket(plansDir), + highestSideTicket(join(plansDir, "_archives")), + ); for (let n = highest + 1; ; ++n) { const ticket = `side-${n}`; try { @@ -234,6 +233,15 @@ export function reserveSideTicket(cwd: string): string { } } +function highestSideTicket(dir: string): number { + let highest = 0; + for (const entry of readEntries(dir)) { + const match = entry.isDirectory() ? entry.name.match(/^side-(\d+)$/) : null; + if (match) highest = Math.max(highest, Number(match[1])); + } + return highest; +} + function readEntries(dir: string): Dirent[] { try { return readdirSync(dir, { withFileTypes: true }); diff --git a/packages/alcode/templates/cli-reference.md b/packages/alcode/templates/cli-reference.md index c2cc6e3d..0d7b2daf 100644 --- a/packages/alcode/templates/cli-reference.md +++ b/packages/alcode/templates/cli-reference.md @@ -19,7 +19,7 @@ alcode reserve-side-ticket | Option | Description | |--------|-------------| -| `--protocol

` | One of `spec`, `plan`, `aad`, `description`, `read`, `review`, `merge`. Optional. | +| `--protocol

` | One of `spec`, `plan`, `aad`, `description`, `catchup`, `review`, `merge`. Optional. | | `--ticket ` | Ticket ID. `new --protocol` requires it, or `--no-ticket`. | | `--no-ticket` | Work without a ticket: `alcode` reserves the next side ticket `side-N` and passes it to the agent. `new` only, with a protocol. The reserved id is in the session file's path and `ticket:` frontmatter; pass it as `--ticket side-N` in later runs. | | `--message "..."` | Message to send, written in English. `-m` is the short form. Required for `spec`, `aad`, and when no `--protocol`. | @@ -83,14 +83,14 @@ Stop AAD now. Start a spec instead (alignfirst). Two fresh sessions: one reviews, one fixes. 1. **Review** — `alcode new --protocol review --ticket AB-123`. The agent reviews the current branch against the base branch and writes a review file; its path is in the run's result. The base defaults to the repository's default branch; override it via `--message "Base branch: \`develop\`"`. -2. **Fix** (optional, always in a fresh session — never in the review session) — `alcode new --protocol aad --ticket AB-123 --message "Here is a code review: \`.plans/AB-123/B1-review.md\`. What should we fix?"`. Point the message at wherever the review lives: the review file, or the PR/MR whose comments carry it. The agent proposes fixes; decide together what to fix, as in any AAD session, then it implements and writes a summary file. +2. **Fix** (optional, always in a fresh session — never in the review session) — `alcode new --protocol aad --ticket AB-123 --message "Here is a code review: \`.plans/AB-123/B1-review.md\`. What should we fix?"`. Point the message at wherever the review lives: the review file, or the PR/MR whose comments carry it. The agent proposes fixes; decide together what to fix, as in any AAD session. Keep it simple and avoid overengineering. When the agent asks about scope, welcome expansion that cleans things up and refuse expansion that adds complexity; simplicity wins. The agent then implements and writes a summary file. Skip the fix step when the review is informational. ## Other protocols - **description** — `alcode new --protocol description --ticket AB-123`. Writes a PR/MR description for committed work. No discussion. -- **read** — `alcode new --protocol read --ticket AB-123 [--message "..."]`. Loads the ticket's spec and summary files into context; with a message, answers it against that context. +- **catchup** — `alcode new --protocol catchup --ticket AB-123 [--message "..."]`. Loads the ticket's history from its requests, specs, and summaries and returns a synthesis. To continue the ticket with that history in context, run `alcode resume --protocol aad --message "..."` (or `--protocol spec`) in the same session. Runs are sequential, so the one-protocol-at-a-time rule still holds. - **review** — see the review workflow above. - **merge** — `alcode new --protocol merge --ticket AB-123`. Resolves conflicts and summarizes tricky resolutions. Pass the incoming branch via `--message` to start the merge. @@ -105,7 +105,7 @@ alcode resume --message \ 3 - Yes, it should be optional." ``` -**Technical questions** — architecture, patterns, existing behavior, anything answerable by reading the code. Never escalate these to the user. Push the agent to investigate: *"Explore the codebase to find out, and give me your opinion."*, *"Do not rush. Take the time to fully understand the situation first."*, *"What would be the most elegant way to do it?"*, *"Check if a similar pattern is already implemented elsewhere in the codebase."* +**Technical questions** — architecture, patterns, existing behavior, anything answerable by reading the code. Never escalate these to the user. Push the agent to investigate: *"Explore the codebase to find out, and give me your opinion."*, *"Do not rush. Take the time to fully understand the situation first."*, *"What would be the elegant, proper, simple yet robust solution?"*, *"Check if a similar pattern is already implemented elsewhere in the codebase."* **Functional or UX questions** — product behavior, user-facing decisions, business rules. These need human judgement: escalate to your user, then relay the answer. diff --git a/packages/alcode/test/cli.test.ts b/packages/alcode/test/cli.test.ts index 6621be2c..843de066 100644 --- a/packages/alcode/test/cli.test.ts +++ b/packages/alcode/test/cli.test.ts @@ -175,7 +175,7 @@ describe("parseAlcodeArgs", () => { describe("validateSessionArgs", () => { it("rejects an unknown protocol", () => { expect(validate(["new", "--protocol", "bogus", "--ticket", "1"])).toBe( - "Error: --protocol must be one of: spec, plan, aad, description, read, review, merge.", + "Error: --protocol must be one of: spec, plan, aad, description, catchup, review, merge.", ); }); diff --git a/packages/alcode/test/prompt.test.ts b/packages/alcode/test/prompt.test.ts index 6cb89efd..0a4c7662 100644 --- a/packages/alcode/test/prompt.test.ts +++ b/packages/alcode/test/prompt.test.ts @@ -25,17 +25,9 @@ describe("buildPrompt", () => { ); }); - it("builds the special read prompt with a ticket", () => { - expect(buildPrompt({ protocol: "read", ticket: "29" })).toBe( - "Use the *alignfirst* skill to determine the TASK_DIR for ticket 29. " + - "Then read every `*spec.md` and `*summary.md` file in the TASK_DIR.", - ); - }); - - it("appends the message to a read prompt", () => { - expect(buildPrompt({ protocol: "read", ticket: "7", message: "What changed?" })).toBe( - "Use the *alignfirst* skill to determine the TASK_DIR for ticket 7. " + - "Then read every `*spec.md` and `*summary.md` file in the TASK_DIR.\n\nWhat changed?", + it("builds the catchup protocol prompt", () => { + expect(buildPrompt({ protocol: "catchup", ticket: "29", message: "What changed?" })).toBe( + "Run the _catchup_ protocol from the *alignfirst* skill. Ticket ID = 29.\n\nWhat changed?", ); }); }); diff --git a/packages/alcode/test/session-file.test.ts b/packages/alcode/test/session-file.test.ts index 26fdaf79..63c56836 100644 --- a/packages/alcode/test/session-file.test.ts +++ b/packages/alcode/test/session-file.test.ts @@ -242,6 +242,14 @@ describe("reserveSideTicket", () => { expect(reserveSideTicket(dir)).toBe("side-4"); }); + it("takes one above the highest archived side ticket", () => { + mkdirSync(join(dir, ".plans", "side-1")); + mkdirSync(join(dir, ".plans", "_archives", "side-5"), { recursive: true }); + mkdirSync(join(dir, ".plans", "_archives", "side-5-2")); + expect(reserveSideTicket(dir)).toBe("side-6"); + expect(existsSync(join(dir, ".plans", "side-6"))).toBe(true); + }); + it("skips a candidate whose creation loses to an existing entry", () => { mkdirSync(join(dir, ".plans", "side-1")); writeFileSync(join(dir, ".plans", "side-2"), ""); // not a directory: invisible to the scan diff --git a/packages/plans-share/README.md b/packages/plans-share/README.md index b7b553bf..d8de6f11 100644 --- a/packages/plans-share/README.md +++ b/packages/plans-share/README.md @@ -31,7 +31,7 @@ Add the npm scripts, with the project folder baked in: ```json { "plans:setup": "plans-share setup --folder project-a", - "plans:sync": "plans-share sync" + "plans:sync": "plans-share sync --auto-archive" } ``` @@ -55,6 +55,21 @@ npm run plans:sync A project may keep `.plans` as a plain local directory. `sync` then reports local plans mode and exits successfully. +Archive one ticket immediately by id or path: + +```sh +npx --no plans-share archive 250 +npx --no plans-share archive .plans/250 +``` + +Archive stale entries without synchronizing: + +```sh +npx --no plans-share auto-archive +``` + +Pass `--auto-archive` to `sync` to archive stale entries after pulling and before committing. The recommended `plans:sync` script above enables it. + To verify that `.plans` is usable and report its mode: ```sh @@ -72,7 +87,11 @@ Pass `--no` to keep npx off the registry. The bin is `plans-share`, while the pa ## Archiving -To keep `.plans` small, move finished tickets to `_archives/` inside the project folder — anyone, anytime: +Automatic archiving moves stale ticket directories and stale no-ticket session files from `.plans/_alcode/` into `.plans/_archives/`. A ticket's age is the newest modification time among its files. `PLANS_SHARE_ARCHIVE_DAYS` sets the threshold in days and defaults to `7`. + +Existing names gain a numeric suffix, such as `250-2` or `20260101-101010-2.md`. + +Manual moves remain valid: ```sh mv .plans/250 .plans/_archives/ diff --git a/packages/plans-share/src/archive.ts b/packages/plans-share/src/archive.ts new file mode 100644 index 00000000..9a0d1993 --- /dev/null +++ b/packages/plans-share/src/archive.ts @@ -0,0 +1,120 @@ +import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, statSync } from "node:fs"; +import { basename, dirname, extname, join, relative, resolve } from "node:path"; +import { CliError, type CliContext } from "./context.js"; +import { resolvePlansMode } from "./plans-path.js"; + +const DEFAULT_ARCHIVE_DAYS = 7; +const DAY_MS = 86_400_000; + +export function runAutoArchive(ctx: CliContext, args: string[]): void { + rejectAutoArchiveArguments(args); + const mode = resolvePlansMode(ctx); + const archived = autoArchive(join(ctx.cwd, ".plans"), archiveThresholdDays(), ctx.stdout); + if (mode.kind === "shared" && archived) ctx.stdout.write(`Publish with: ${ctx.syncCommand}\n`); +} + +function rejectAutoArchiveArguments(args: string[]): void { + const [unexpected] = args; + if (unexpected !== undefined) throw new CliError(`Unexpected argument: ${unexpected}`); +} + +export function runArchive(ctx: CliContext, args: string[]): void { + const mode = resolvePlansMode(ctx); + const plansDir = join(ctx.cwd, ".plans"); + const target = resolveArchiveTarget(ctx.cwd, plansDir, args); + archiveEntry(plansDir, target, ctx.stdout); + if (mode.kind === "shared") ctx.stdout.write(`Publish with: ${ctx.syncCommand}\n`); +} + +export function archiveThresholdDays(): number { + const value = process.env.PLANS_SHARE_ARCHIVE_DAYS; + if (value === undefined) return DEFAULT_ARCHIVE_DAYS; + const days = Number(value); + if (!Number.isFinite(days) || days <= 0) + throw new CliError("PLANS_SHARE_ARCHIVE_DAYS must be a positive number of days."); + return days; +} + +export function autoArchive( + plansDir: string, + thresholdDays: number, + stdout: { write(s: string): void }, +): boolean { + const cutoff = Date.now() - thresholdDays * DAY_MS; + const candidates = [ + ...staleTicketDirectories(plansDir, cutoff), + ...staleNoTicketSessionFiles(plansDir, cutoff), + ]; + if (candidates.length === 0) { + stdout.write("Nothing to archive.\n"); + return false; + } + for (const candidate of candidates) archiveEntry(plansDir, candidate, stdout); + return true; +} + +function staleTicketDirectories(plansDir: string, cutoff: number): string[] { + return readdirSync(plansDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.name.startsWith("_")) + .map((entry) => join(plansDir, entry.name)) + .filter((ticketDir) => newestFileMtime(ticketDir) < cutoff); +} + +function newestFileMtime(dir: string): number { + const files = readdirSync(dir, { withFileTypes: true, recursive: true }).filter((entry) => + entry.isFile(), + ); + if (files.length === 0) return statSync(dir).mtimeMs; + return Math.max(...files.map((entry) => statSync(join(entry.parentPath, entry.name)).mtimeMs)); +} + +function staleNoTicketSessionFiles(plansDir: string, cutoff: number): string[] { + const sessionDir = join(plansDir, "_alcode"); + if (!existsSync(sessionDir)) return []; + return readdirSync(sessionDir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => join(sessionDir, entry.name)) + .filter((path) => statSync(path).mtimeMs < cutoff); +} + +function archiveEntry( + plansDir: string, + sourcePath: string, + stdout: { write(s: string): void }, +): void { + const rel = relative(plansDir, sourcePath); + const archivesDir = join(plansDir, "_archives"); + const targetDir = join(archivesDir, dirname(rel)); + mkdirSync(targetDir, { recursive: true }); + const target = moveToFreeName(sourcePath, targetDir, statSync(sourcePath).isFile()); + stdout.write(`Archived ${rel} → _archives/${relative(archivesDir, target)}\n`); +} + +function moveToFreeName(sourcePath: string, targetDir: string, isFile: boolean): string { + const name = basename(sourcePath); + const ext = isFile ? extname(name) : ""; + const stem = name.slice(0, name.length - ext.length); + let candidate = join(targetDir, name); + for (let suffix = 2; existsSync(candidate); ++suffix) { + candidate = join(targetDir, `${stem}-${suffix}${ext}`); + } + renameSync(sourcePath, candidate); + return candidate; +} + +function resolveArchiveTarget(cwd: string, plansDir: string, args: string[]): string { + const [argument, unexpected] = args; + if (argument === undefined) throw new CliError("Usage: plans-share archive "); + if (unexpected !== undefined) throw new CliError(`Unexpected argument: ${unexpected}`); + const target = isPathArgument(argument) ? resolve(cwd, argument) : join(plansDir, argument); + const stats = statSync(target, { throwIfNoEntry: false }); + if (!stats?.isDirectory() || realpathSync(dirname(target)) !== realpathSync(plansDir)) + throw new CliError(`${argument} must be an existing directory directly under .plans.`); + if (basename(target).startsWith("_")) + throw new CliError(`${argument}: names starting with _ are not tickets.`); + return target; +} + +function isPathArgument(argument: string): boolean { + return argument.includes("/") || argument.includes("\\"); +} diff --git a/packages/plans-share/src/cli.ts b/packages/plans-share/src/cli.ts index 79d143cf..61cbe8ff 100644 --- a/packages/plans-share/src/cli.ts +++ b/packages/plans-share/src/cli.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { runArchive, runAutoArchive } from "./archive.js"; import { runCheck } from "./check.js"; import { CliError, type CliContext } from "./context.js"; import { runSetup } from "./setup.js"; @@ -8,17 +9,25 @@ const HELP = `plans-share — share the .plans directory through a team plans re Usage: plans-share setup

--folder - plans-share sync + plans-share sync [--auto-archive] + plans-share archive + plans-share auto-archive plans-share check plans-share --help | --version setup Link .plans to //, where is an existing clone of the plans repository, migrating any existing .plans content. Once per machine; re-run with the new location if the clone moves. -sync Pull, commit, and push the plans repository. +sync Pull, commit, and push the plans repository. With --auto-archive, archive + stale plans before committing. +archive Move one ticket directory to .plans/_archives/. +auto-archive + Move stale ticket directories and no-ticket session files to .plans/_archives/. check Report whether .plans is shared through a team plans repository or a plain local directory; exit 1 when it is unusable. For automation, e.g. a workspace preSetup callback. + +PLANS_SHARE_ARCHIVE_DAYS sets the auto-archive threshold in days (default 7). `; export interface MainOptions { @@ -44,7 +53,13 @@ export function main(options?: MainOptions): number { runSetup(ctx, rest); return 0; case "sync": - runSync(ctx); + runSync(ctx, rest); + return 0; + case "archive": + runArchive(ctx, rest); + return 0; + case "auto-archive": + runAutoArchive(ctx, rest); return 0; case "check": runCheck(ctx); diff --git a/packages/plans-share/src/sync.ts b/packages/plans-share/src/sync.ts index 4fd0a94f..57ed8ee9 100644 --- a/packages/plans-share/src/sync.ts +++ b/packages/plans-share/src/sync.ts @@ -1,27 +1,47 @@ -import type { CliContext } from "./context.js"; +import { join } from "node:path"; +import { archiveThresholdDays, autoArchive } from "./archive.js"; +import { CliError, type CliContext } from "./context.js"; import { git, gitOutput, gitSucceeds } from "./git.js"; import { resolvePlansMode } from "./plans-path.js"; -export function runSync(ctx: CliContext): void { +export function runSync(ctx: CliContext, args: string[]): void { + const options = parseSyncArgs(args); + const thresholdDays = options.autoArchive ? archiveThresholdDays() : undefined; const mode = resolvePlansMode(ctx); + const plansDir = join(ctx.cwd, ".plans"); if (mode.kind === "local") { + if (thresholdDays !== undefined) autoArchive(plansDir, thresholdDays, ctx.stdout); ctx.stdout.write("(local plans mode, nothing to sync)\n"); return; } - const plansDir = mode.repoToplevel; + const repoDir = mode.repoToplevel; // A fresh clone of an empty plans repository has no HEAD yet: nothing to rebase onto. - if (hasHead(plansDir)) git(plansDir, "pull", "--rebase", "--autostash"); - git(plansDir, "add", "-A"); - if (hasStagedChanges(plansDir)) git(plansDir, "commit", "--quiet", "-m", "sync"); + if (hasHead(repoDir)) git(repoDir, "pull", "--rebase", "--autostash"); + if (thresholdDays !== undefined) autoArchive(plansDir, thresholdDays, ctx.stdout); + git(repoDir, "add", "-A"); + if (hasStagedChanges(repoDir)) git(repoDir, "commit", "--quiet", "-m", "sync"); // Still no HEAD after the commit step: an empty clone with nothing staged, nothing to push. - if (hasHead(plansDir) && hasCommitsToSend(plansDir)) { - git(plansDir, "push", "--quiet", "-u", "origin", "HEAD"); + if (hasHead(repoDir) && hasCommitsToSend(repoDir)) { + git(repoDir, "push", "--quiet", "-u", "origin", "HEAD"); ctx.stdout.write("Plans synchronized: local changes sent.\n"); } else { ctx.stdout.write("Plans synchronized: nothing to send.\n"); } } +interface SyncOptions { + autoArchive: boolean; +} + +function parseSyncArgs(args: string[]): SyncOptions { + let autoArchive = false; + for (const arg of args) { + if (arg === "--auto-archive") autoArchive = true; + else throw new CliError(`Unknown option: ${arg}`); + } + return { autoArchive }; +} + function hasHead(dir: string): boolean { return gitSucceeds(dir, "rev-parse", "--verify", "-q", "HEAD"); } diff --git a/packages/plans-share/test/plans-share.test.ts b/packages/plans-share/test/plans-share.test.ts index c26ee18f..2c897603 100644 --- a/packages/plans-share/test/plans-share.test.ts +++ b/packages/plans-share/test/plans-share.test.ts @@ -8,6 +8,7 @@ import { renameSync, rmSync, symlinkSync, + utimesSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -90,6 +91,11 @@ function runSetup(fixture: Fixture, dir = join(fixture.root, "team-plans")): Run return run(fixture.product, "setup", dir, "--folder", "myproj"); } +function age(path: string, days: number): void { + const timestamp = new Date(Date.now() - days * 86_400_000); + utimesSync(path, timestamp, timestamp); +} + describe("plans-share setup", () => { it("links .plans to an existing clone", () => { const fixture = makeFixture(); @@ -349,4 +355,245 @@ describe("plans-share sync", () => { expect(result.code).toBe(1); expect(result.stderr).toContain("not a directory"); }); + + it("archives stale plans before publishing when requested", () => { + const fixture = makeFixture(); + runSetup(fixture); + const ticketDir = join(fixture.product, ".plans", "88"); + const spec = join(ticketDir, "A1-spec.md"); + mkdirSync(ticketDir, { recursive: true }); + writeFileSync(spec, "spec\n"); + run(fixture.product, "sync"); + age(spec, 10); + + const result = run(fixture.product, "sync", "--auto-archive"); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("Archived 88 → _archives/88"); + const remoteFiles = execGit(fixture.remoteUrl, "ls-tree", "-r", "HEAD", "--name-only"); + expect(remoteFiles).toContain("myproj/_archives/88/A1-spec.md"); + expect(remoteFiles).not.toContain("myproj/88/A1-spec.md"); + }); + + it("rejects unknown options", () => { + const fixture = makeFixture(); + runSetup(fixture); + const result = run(fixture.product, "sync", "--bogus"); + expect(result.code).toBe(1); + expect(result.stderr).toContain("Unknown option: --bogus"); + }); +}); + +describe("plans-share auto-archive", () => { + it("rejects arguments without archiving", () => { + const fixture = makeFixture(); + runSetup(fixture); + const ticketDir = join(fixture.product, ".plans", "250"); + const spec = join(ticketDir, "A1-spec.md"); + mkdirSync(ticketDir); + writeFileSync(spec, "spec\n"); + age(spec, 10); + + const result = run(fixture.product, "auto-archive", "--dry-run"); + + expect(result.code).toBe(1); + expect(result.stderr).toContain("Unexpected argument: --dry-run"); + expect(existsSync(ticketDir)).toBe(true); + }); + + it("archives a stale ticket directory and prints the shared-mode publish hint", () => { + const fixture = makeFixture(); + runSetup(fixture); + const spec = join(fixture.product, ".plans", "250", "A1-spec.md"); + mkdirSync(join(fixture.product, ".plans", "250")); + writeFileSync(spec, "spec\n"); + age(spec, 10); + + const result = run(fixture.product, "auto-archive"); + + expect(result.code).toBe(0); + expect(existsSync(join(fixture.product, ".plans", "250"))).toBe(false); + expect(existsSync(join(fixture.product, ".plans", "_archives", "250", "A1-spec.md"))).toBe( + true, + ); + expect(result.stdout).toContain("Archived 250 → _archives/250"); + expect(result.stdout).toContain("Publish with: npm run plans:sync"); + }); + + it("keeps a fresh ticket directory", () => { + const fixture = makeFixture(); + runSetup(fixture); + const ticketDir = join(fixture.product, ".plans", "250"); + mkdirSync(ticketDir); + writeFileSync(join(ticketDir, "A1-spec.md"), "spec\n"); + + const result = run(fixture.product, "auto-archive"); + + expect(existsSync(ticketDir)).toBe(true); + expect(result.stdout).toBe("Nothing to archive.\n"); + }); + + it("ignores the archive directory", () => { + const fixture = makeFixture(); + runSetup(fixture); + const archivedSpec = join(fixture.product, ".plans", "_archives", "old", "A1-spec.md"); + mkdirSync(join(fixture.product, ".plans", "_archives", "old"), { recursive: true }); + writeFileSync(archivedSpec, "spec\n"); + age(archivedSpec, 10); + + const result = run(fixture.product, "auto-archive"); + + expect(existsSync(archivedSpec)).toBe(true); + expect(result.stdout).toBe("Nothing to archive.\n"); + }); + + it("archives stale no-ticket sessions and keeps fresh ones", () => { + const fixture = makeFixture(); + runSetup(fixture); + const sessionDir = join(fixture.product, ".plans", "_alcode"); + const stale = join(sessionDir, "20260101-101010.md"); + const fresh = join(sessionDir, "20260102-101010.md"); + mkdirSync(sessionDir); + writeFileSync(stale, "stale\n"); + writeFileSync(fresh, "fresh\n"); + age(stale, 10); + + const result = run(fixture.product, "auto-archive"); + + expect(existsSync(stale)).toBe(false); + expect(existsSync(fresh)).toBe(true); + expect( + existsSync(join(fixture.product, ".plans", "_archives", "_alcode", "20260101-101010.md")), + ).toBe(true); + expect(result.stdout).toContain( + "Archived _alcode/20260101-101010.md → _archives/_alcode/20260101-101010.md", + ); + }); + + it("uses the newest nested file as a ticket's age", () => { + const fixture = makeFixture(); + runSetup(fixture); + const ticketDir = join(fixture.product, ".plans", "250"); + const oldSpec = join(ticketDir, "A1-spec.md"); + const freshSession = join(ticketDir, "_alcode", "x.md"); + mkdirSync(join(ticketDir, "_alcode"), { recursive: true }); + writeFileSync(oldSpec, "spec\n"); + writeFileSync(freshSession, "session\n"); + age(oldSpec, 30); + + const result = run(fixture.product, "auto-archive"); + + expect(existsSync(ticketDir)).toBe(true); + expect(result.stdout).toBe("Nothing to archive.\n"); + }); + + it("honors and validates PLANS_SHARE_ARCHIVE_DAYS", () => { + const fixture = makeFixture(); + runSetup(fixture); + const ticketDir = join(fixture.product, ".plans", "250"); + const spec = join(ticketDir, "A1-spec.md"); + mkdirSync(ticketDir); + writeFileSync(spec, "spec\n"); + age(spec, 2); + const previous = process.env.PLANS_SHARE_ARCHIVE_DAYS; + try { + process.env.PLANS_SHARE_ARCHIVE_DAYS = "1"; + expect(run(fixture.product, "auto-archive").code).toBe(0); + expect(existsSync(join(fixture.product, ".plans", "_archives", "250"))).toBe(true); + process.env.PLANS_SHARE_ARCHIVE_DAYS = "0"; + const result = run(fixture.product, "auto-archive"); + expect(result.code).toBe(1); + expect(result.stderr).toContain( + "PLANS_SHARE_ARCHIVE_DAYS must be a positive number of days.", + ); + } finally { + if (previous === undefined) delete process.env.PLANS_SHARE_ARCHIVE_DAYS; + else process.env.PLANS_SHARE_ARCHIVE_DAYS = previous; + } + }); + + it("suffixes ticket and session file collisions", () => { + const fixture = makeFixture(); + runSetup(fixture); + const plansDir = join(fixture.product, ".plans"); + const ticketSpec = join(plansDir, "250", "A1-spec.md"); + const session = join(plansDir, "_alcode", "20260101-101010.md"); + mkdirSync(join(plansDir, "250")); + mkdirSync(join(plansDir, "_archives", "250"), { recursive: true }); + mkdirSync(join(plansDir, "_alcode")); + mkdirSync(join(plansDir, "_archives", "_alcode")); + writeFileSync(ticketSpec, "new spec\n"); + writeFileSync(join(plansDir, "_archives", "250", "A1-spec.md"), "old spec\n"); + writeFileSync(session, "new session\n"); + writeFileSync(join(plansDir, "_archives", "_alcode", "20260101-101010.md"), "old session\n"); + age(ticketSpec, 10); + age(session, 10); + + const result = run(fixture.product, "auto-archive"); + + expect(existsSync(join(plansDir, "_archives", "250-2", "A1-spec.md"))).toBe(true); + expect(existsSync(join(plansDir, "_archives", "_alcode", "20260101-101010-2.md"))).toBe(true); + expect(result.stdout).toContain("Archived 250 → _archives/250-2"); + expect(result.stdout).toContain( + "Archived _alcode/20260101-101010.md → _archives/_alcode/20260101-101010-2.md", + ); + }); + + it("works in local mode without a publish hint", () => { + const fixture = makeFixture(); + const plansDir = join(fixture.product, ".plans"); + const spec = join(plansDir, "250", "A1-spec.md"); + mkdirSync(join(plansDir, "250"), { recursive: true }); + writeFileSync(spec, "spec\n"); + age(spec, 10); + + const result = run(fixture.product, "auto-archive"); + + expect(result.code).toBe(0); + expect(existsSync(join(plansDir, "_archives", "250", "A1-spec.md"))).toBe(true); + expect(result.stdout).not.toContain("Publish with:"); + }); +}); + +describe("plans-share archive", () => { + it("accepts a ticket id and a path", () => { + const fixture = makeFixture(); + runSetup(fixture); + const plansDir = join(fixture.product, ".plans"); + mkdirSync(join(plansDir, "101")); + mkdirSync(join(plansDir, "102")); + + const idResult = run(fixture.product, "archive", "101"); + const pathResult = run(fixture.product, "archive", ".plans/102"); + + expect(idResult.code).toBe(0); + expect(pathResult.code).toBe(0); + expect(existsSync(join(plansDir, "_archives", "101"))).toBe(true); + expect(existsSync(join(plansDir, "_archives", "102"))).toBe(true); + }); + + it("rejects a missing directory", () => { + const fixture = makeFixture(); + runSetup(fixture); + const result = run(fixture.product, "archive", "missing"); + expect(result.code).toBe(1); + expect(result.stderr).toContain("missing must be an existing directory directly under .plans"); + }); + + it("rejects an underscore-prefixed name", () => { + const fixture = makeFixture(); + runSetup(fixture); + mkdirSync(join(fixture.product, ".plans", "_private")); + const result = run(fixture.product, "archive", "_private"); + expect(result.code).toBe(1); + expect(result.stderr).toContain("names starting with _ are not tickets"); + }); + + it("rejects a missing argument", () => { + const fixture = makeFixture(); + runSetup(fixture); + const result = run(fixture.product, "archive"); + expect(result.code).toBe(1); + expect(result.stderr).toContain("Usage: plans-share archive "); + }); }); diff --git a/skills/alcatchup/SKILL.md b/skills/alcatchup/SKILL.md new file mode 100644 index 00000000..5ef067b8 --- /dev/null +++ b/skills/alcatchup/SKILL.md @@ -0,0 +1,9 @@ +--- +name: alcatchup +description: "Catch up with the current AlignFirst task: load its history, then continue with the user's instructions or summarize it." +disable-model-invocation: true +--- + +Read the *alignfirst* skill (`../alignfirst/SKILL.md`) and its `references/catchup-protocol.md` if not already loaded. + +Execute the _catchup_ protocol from the *alignfirst* skill. Do not use your own plan mode. diff --git a/skills/alignfirst-developer-openclaw-playbook/SKILL.md b/skills/alignfirst-developer-openclaw-playbook/SKILL.md index 462fd2dd..6e2960fe 100644 --- a/skills/alignfirst-developer-openclaw-playbook/SKILL.md +++ b/skills/alignfirst-developer-openclaw-playbook/SKILL.md @@ -4,7 +4,7 @@ description: "Operating-instructions dispatcher for an AlignFirst Developer runn license: CC0 1.0 metadata: author: Paleo - version: "0.31.0" + version: "0.32.0" repository: https://github.com/paleo/alignfirst --- @@ -46,7 +46,7 @@ Thread: recover the values the starter recorded via `message action: "read"`. It A development task owned by one project needs a TICKET_ID. A project's or deployment's instructions define whether you can create or update tickets. When they provide no ticket-system access, skip those external operations and ask the user for an ID. When the user explicitly says there is no ticket, the working session reserves a side ticket `side-N` before workspace setup. Operational maintenance on existing branches and workspaces does not create a new ticket context. -Use AlignFirst protocols only for work owned by one project. Delegate a multi-project request with no main project, workspace cleanup, base-branch refresh, and other operational work to alcode without a protocol. A ticket ID may still identify the project workspaces involved. +Use AlignFirst protocols only for work owned by one project. Delegate project bootstrap (creation and repository onboarding), a multi-project request with no main project, workspace cleanup, base-branch refresh, and other operational work to alcode without a protocol. A ticket ID may still identify the project workspaces involved. ## Who "the user" is depends on where the instruction lives diff --git a/skills/alignfirst-developer-openclaw-playbook/references/channel-handling.md b/skills/alignfirst-developer-openclaw-playbook/references/channel-handling.md index b3852f67..bc95ba4e 100644 --- a/skills/alignfirst-developer-openclaw-playbook/references/channel-handling.md +++ b/skills/alignfirst-developer-openclaw-playbook/references/channel-handling.md @@ -22,12 +22,13 @@ Resolve PROJECT and PROJECT_PATH from that result: - With no mentioned project, infer both values only when the list contains exactly one filesystem-present project. Zero or several filesystem-present projects leave both values unresolved. - A request naming several projects retains every resolved PROJECT and PROJECT_PATH pair. Do not force one of them into the role of main project. - A request to create an absent named project is project-lifecycle intent. Keep the proposed name as PROJECT and leave PROJECT_PATH absent for the lifecycle procedure to establish. +- A request to clone a repository whose name matches no inventory entry is also project-lifecycle intent. The repository name is the proposed PROJECT; PROJECT_PATH stays absent. Never reconstruct PROJECT_PATH from PROJECT. ## Interpreting requests -**First decision: is the message actionable?** A message is actionable when it asks you to do, investigate, change, or advise on something, even when it names no recognized project or ticket. A project or ticket mention, project creation, and project removal are also actionable. +**First decision: is the message actionable?** A message is actionable when it asks you to do, investigate, change, or advise on something, even when it names no recognized project or ticket. A project or ticket mention, project creation, repository onboarding, and project removal are also actionable. - **Not actionable** (greeting, small talk, unrelated chatter) — off-projects chatter. Reply as a colleague, not a service: match the social tone; a reciprocal question is fine. The user knows what you do — no project mentions and no availability offers ("prêt si besoin", "happy to lend a hand"), now or on later small-talk turns. A quiet turn deserves a short reply, never an offer to fill it. On Discord, channel reply; on Slack, normal reply (auto-threaded). - **Actionable** — open a thread and hand off, following the three steps below. Missing PROJECT, PROJECT_PATH, TICKET_ID, or TASK values become questions in the starter when it makes sense. @@ -42,7 +43,7 @@ Everything else waits for the thread session — lifecycle work, workspace, bran From the user's message and the retained inventory result: -- **PROJECT / PROJECT_PATH** — each resolved project name and canonical main-worktree path. A proposed project for creation has no path yet. +- **PROJECT / PROJECT_PATH** — each resolved project name and canonical main-worktree path. A proposed project for creation or repository onboarding has no path yet. - **TICKET_ID** — the ticket the user gave. - **TASK** — a one-line restatement, in your own words, of what the user wants. Preserve every resource URL verbatim in this line so the working session can inspect it. @@ -100,7 +101,7 @@ The `{ask}` is one sentence, and it reflects the first unresolved requirement: - A multi-project request, or a request that may not need a project → ask for no main project; state that the user's next message launches the thread session, which routes the work. - Nothing else needs an answer → state that the user's next message launches the thread session. Do not claim that you are checking or starting the work now. -For project creation, a proposed PROJECT with no PROJECT_PATH is complete enough for handoff. The lifecycle procedure establishes its path. Then end the turn: +For project creation or repository onboarding, a proposed PROJECT with no PROJECT_PATH is complete enough for handoff. The lifecycle procedure establishes its path. Then end the turn: - **Discord** — the starter already went out through `thread-create`, and free-form text auto-streams to the parent channel: your final answer is exactly `NO_REPLY`. - **Slack** — ending the turn on the starter IS its delivery: write it as your final answer and stop. A `message` call to "make sure it posts" fails on this surface and drops a visible ⚠️ failure notice into the thread. diff --git a/skills/alignfirst-developer-openclaw-playbook/references/project-lifecycle.md b/skills/alignfirst-developer-openclaw-playbook/references/runbooks/project-lifecycle.md similarity index 55% rename from skills/alignfirst-developer-openclaw-playbook/references/project-lifecycle.md rename to skills/alignfirst-developer-openclaw-playbook/references/runbooks/project-lifecycle.md index d8940979..afdc76a2 100644 --- a/skills/alignfirst-developer-openclaw-playbook/references/project-lifecycle.md +++ b/skills/alignfirst-developer-openclaw-playbook/references/runbooks/project-lifecycle.md @@ -1,6 +1,6 @@ # Project lifecycle -Use this procedure only to create a project or physically remove one. Project-workspace creation and cleanup follow [`project-workspace-setup.md`](./project-workspace-setup.md) and the project's workspace tooling. +Use this procedure only to create a project, onboard a repository to clone, or physically remove a project. Project-workspace creation and cleanup follow [`project-workspace-setup.md`](./project-workspace-setup.md) and the project's workspace tooling. ## Start with the project guide @@ -25,6 +25,49 @@ Before creating a directory, load the `alignfirst-setup-guide` skill. If the ski The direct main-worktree bootstrap is the creation exception. It ends with the initial commit and the push when a remote destination is known. If the user then requests more changes without a ticket, return to the working-session flow: reserve `side-N`, create a linked workspace, and delegate from it. +## Onboard a repository + +The user hands you a repository URL to clone instead of asking for a new project. Onboarding is bootstrap work like creation. Before the project is prepared, every alcode delegation uses a fresh session with a plain message, never a protocol. + +### Step 1 — Clone and build + +Before any discussion: + +1. Select a parent directory allowed by `alproject --guide`. Ask the user when several qualify. +2. Clone the repository into that parent. PROJECT is the clone's directory name; PROJECT_PATH is its canonical path. +3. Register the main worktree with `alproject`. Request a port allocation when the project's workspace wrapper declares ports. +4. Install dependencies and build, following the repository's own README. + +### Step 2 — Check the AlignFirst Developer contract + +The contract is the one the `alignfirst-setup-guide` lists under "Prepare a Project for an AlignFirst Developer": AlignFirst skills configuration, docmap, the workspace system, and a `DEVELOPERS.md` with a workspaces section. When `DEVELOPERS.md` exists with its workspaces section, the project is prepared. Continue with the normal working-session flow for the user's request. Otherwise, continue to Step 3. + +### Step 3 — Warn and ask + +End the turn on a message that explains the procedure: a branch created in the main worktree, preparation commits by the coding agent, a pull request the user must merge, and work waiting for that merge before the original request resumes. + +Ask the user to approve this procedure and whether `.plans` must be shared through plans-share. If yes, ask for the plans repository URL. If no, `.plans` stays a plain directory. Wait for explicit approval. + +### Step 4 — Prepare the project on a branch + +On approval: + +1. Create `.plans/` in the main worktree. Run `alcode reserve-side-ticket` from PROJECT_PATH, then write `.plans/{TICKET_ID}/A1-request.md` with the recorded request, as in project creation. +2. Create `{TICKET_ID}/alignfirst-setup` in the main worktree. This setup branch is the second main-worktree exception, next to new-project bootstrap. +3. Run `alcode --openclaw-guide`. From PROJECT_PATH, delegate the preparation to alcode without a protocol: use the `alignfirst-setup-guide` skill and prepare the repository for an AlignFirst Developer, with the user's plans-share decision and repository URL. Instruct alcode to commit and push the branch. The setup guide's rule against pushing addresses a human's laptop session, not this procedure. +4. Have alcode create a ready pull request, not a draft. +5. End the turn on the PR link and state that work resumes once the PR is merged. + +### Step 5 — After the merge + +When the user reports the merge, or you observe it while checking the PR: + +1. In the main worktree, switch back to the default branch, pull, and delete the local setup branch. +2. Install dependencies and build. +3. When the user chose plans-share, clone the plans repository under the projects parent if no clone exists there, as allowed by the rendered `alproject-guide.md`. Then run the project's `plans:setup` script against that clone. Otherwise, run `mkdir .plans` when the directory is missing. +4. Run the project's `workspace setup` on the main worktree. Add `--profile remote` when the deployment sets `REMOTE_DEV_DOMAIN`. +5. Continue with the normal working-session flow for the original request through `project-workspace-setup.md`. + ## Remove a project Removal requires the registered PROJECT_PATH selected before the thread opened or supplied by the user. diff --git a/skills/alignfirst-developer-openclaw-playbook/references/project-workspace-setup.md b/skills/alignfirst-developer-openclaw-playbook/references/runbooks/project-workspace-setup.md similarity index 95% rename from skills/alignfirst-developer-openclaw-playbook/references/project-workspace-setup.md rename to skills/alignfirst-developer-openclaw-playbook/references/runbooks/project-workspace-setup.md index 3ca291ad..9efec323 100644 --- a/skills/alignfirst-developer-openclaw-playbook/references/project-workspace-setup.md +++ b/skills/alignfirst-developer-openclaw-playbook/references/runbooks/project-workspace-setup.md @@ -88,9 +88,9 @@ Only for a status request; otherwise skip to Step 7. The Step 4 banner comes fir The `[WORKSPACE]` banner answers "is the env ready", not "where does the work stand". For the work content — what was done, what remains — draw on two complementary sources: - **Repo/workflow metadata**, which you may gather directly: `git log`/`status`/branch state, `gh` PR/issue state, the `.plans/` listing. -- **The ticket's AlignFirst artifacts** via `alcode` (`read` protocol, run from the worktree): it loads the ticket's `*spec.md` / `*summary.md` local files in the agent's session and returns a synthesis of them. +- **The ticket's AlignFirst artifacts** via `alcode` (`catchup` protocol, run from the worktree): it loads the ticket's `*spec.md` / `*summary.md` local files in the agent's session and returns a synthesis of them. -Combine them into the report and post it in the thread; lean on the `read` protocol whenever the spec/summary history matters. What you must **not** do is browse the source to describe how the code works — that's a delegation to alcode, not part of a status report. +Combine them into the report and post it in the thread; lean on the `catchup` protocol whenever the spec/summary history matters. What you must **not** do is browse the source to describe how the code works — that's a delegation to alcode, not part of a status report. ## Step 7 — Start the work diff --git a/skills/alignfirst-developer-openclaw-playbook/references/working-session.md b/skills/alignfirst-developer-openclaw-playbook/references/working-session.md index 008533af..5adbbf09 100644 --- a/skills/alignfirst-developer-openclaw-playbook/references/working-session.md +++ b/skills/alignfirst-developer-openclaw-playbook/references/working-session.md @@ -4,9 +4,12 @@ You're handling project work inside a thread (Slack or Discord). The thread is t Your plain-text replies are your delivery, on Discord and Slack alike — but only the message that **ends your turn** is guaranteed to post. On most model providers, text written between tool calls never leaves the transcript. So the message you end a turn with must carry everything the user needs from that turn — the workspace state, the launch ack, the report. Never call `message` `send`/`thread-reply` targeting this thread: it posts everything twice. The single exception is a rename, which Discord only performs through a post — see "Thread name" below. Otherwise `message` stays for `read`, cross-surface posts, and attachments. -## Prerequisites +## Runbooks -Recover the thread context in Step 1 first. Ordinary project work then loads its delegation and project guides through [`project-workspace-setup.md`](./project-workspace-setup.md). Project creation or removal loads its guides through [`project-lifecycle.md`](./project-lifecycle.md). +A runbook is a procedure you read fully when its situation arises. Step 1 recovers the thread context first. + +- [`runbooks/project-workspace-setup.md`](./runbooks/project-workspace-setup.md) — every single-project request, before any other action. +- [`runbooks/project-lifecycle.md`](./runbooks/project-lifecycle.md) — creating a project, onboarding a repository to clone, physically removing a project. ## Take over a working session @@ -29,7 +32,7 @@ Default rule: When the user asks you to handle or implement an existing ticket a ### Step 3 — Route project lifecycle work -When the request creates or physically removes a project, open [`project-lifecycle.md`](./project-lifecycle.md), read it fully, and follow it before considering a project workspace. Creation may start with a proposed PROJECT and no PROJECT_PATH. Removal requires the registered PROJECT_PATH selected in the starter or supplied by the user. +When the request creates a project, onboards a repository to clone, or physically removes a project, open [`project-lifecycle.md`](./runbooks/project-lifecycle.md), read it fully, and follow it before considering a project workspace. Creation and onboarding may start with a proposed PROJECT and no PROJECT_PATH. Removal requires the registered PROJECT_PATH selected in the starter or supplied by the user. Project-workspace cleanup is not physical project removal; follow "Cleanup requests" below. @@ -51,10 +54,10 @@ The bot owns this reservation and the request capture; the coding agent receives The question on every wake is not a mode but a fact: does this request need a project workspace? -- **The request is single-project work** — require PROJECT, PROJECT_PATH, and TICKET_ID, including for read-only work. Open [`project-workspace-setup.md`](./project-workspace-setup.md), read it fully, and complete its procedure *before any other action* — including before inspecting the codebase. Your first post is its setup signal (Step 2), before any other ack or prose. The procedure attaches the registered workspace or sets one up — it handles the three cases (no branch, branch only, branch + worktree) uniformly — and posts the `[WORKSPACE]` banner. Skipping it and going straight to `git log` or `git branch` is a violation. +- **The request is single-project work** — require PROJECT, PROJECT_PATH, and TICKET_ID, including for read-only work. Open [`project-workspace-setup.md`](./runbooks/project-workspace-setup.md), read it fully, and complete its procedure *before any other action* — including before inspecting the codebase. Your first post is its setup signal (Step 2), before any other ack or prose. The procedure attaches the registered workspace or sets one up — it handles the three cases (no branch, branch only, branch + worktree) uniformly — and posts the `[WORKSPACE]` banner. Skipping it and going straight to `git log` or `git branch` is a violation. - **A required value is missing** — go to Step 6. Resolve or ask for it there. The moment the required values are known, follow the matching path above. -The underlying invariant for an existing project: project work always happens inside a linked workspace. New-project bootstrap through its initial commit is the sole main-worktree exception in `project-lifecycle.md`. +The underlying invariant for an existing project: project work always happens inside a linked workspace. The two main-worktree exceptions in `runbooks/project-lifecycle.md` are new-project bootstrap through its initial commit and the repository-onboarding setup branch. ### Step 6 — Handle the actual request @@ -148,15 +151,15 @@ A project can have documentation files. List them all from PROJECT_PATH, the ful ### Main worktree and base branch -The main worktree at PROJECT_PATH must always stay on the base branch. Never switch it — it is shared across sessions. +The main worktree at PROJECT_PATH stays on the base branch, except for the repository-onboarding setup branch defined in [`project-lifecycle.md`](./runbooks/project-lifecycle.md). It is shared across sessions. -Never edit files while the base branch is checked out, except while bootstrapping a new project before its initial commit as defined in `project-lifecycle.md`. +Never edit files while the base branch is checked out, except while bootstrapping a new project before its initial commit as defined in `runbooks/project-lifecycle.md`. Running the dev-server from the main worktree is fine. ### Linked worktrees and other branches -After a project's initial commit exists, editing the codebase happens on another branch in a linked worktree. If you need one and it doesn't exist yet, follow the [`project-workspace-setup.md`](./project-workspace-setup.md) instructions to set it up. +After a project's initial commit exists, editing the codebase happens on another branch in a linked worktree. If you need one and it doesn't exist yet, follow the [`project-workspace-setup.md`](./runbooks/project-workspace-setup.md) instructions to set it up. Worktrees belong to the workspace tooling. Every creation, reuse, and teardown goes through its commands — run the guide `DEVELOPERS.md` points to (`workspace --guide`) to get them. `git worktree add`/`remove`/`prune` and deleting a worktree directory are out of bounds, and so is a hand-made branch checkout outside a workspace. The registry is what makes a worktree visible to the other sessions and to the dev-server tooling. @@ -183,8 +186,8 @@ Delegate the sequence to alcode. ### Status update -- Check status from the recorded linked-worktree path. The takeover sync in `project-workspace-setup.md` has already fetched and merged the remote branch, so you are reporting the latest state. -- Report where the work stands, drawing on two complementary sources: repo/workflow metadata you gather directly (`git log`/`status`/branch, `gh` PR state), and the ticket's AlignFirst artifacts via alcode (`read` protocol — it synthesizes the `*spec.md`/`*summary.md` history). Don't browse the source to describe the code; that's a separate alcode delegation. +- Check status from the recorded linked-worktree path. The takeover sync in `runbooks/project-workspace-setup.md` has already fetched and merged the remote branch, so you are reporting the latest state. +- Report where the work stands, drawing on two complementary sources: repo/workflow metadata you gather directly (`git log`/`status`/branch, `gh` PR state), and the ticket's AlignFirst artifacts via alcode (`catchup` protocol — it synthesizes the `*spec.md`/`*summary.md` history). Don't browse the source to describe the code; that's a separate alcode delegation. ### Dev-server while working @@ -221,12 +224,17 @@ Clean logs are required for the manual test to pass. When the user brings up acceptance testing, first be sure who runs it — ask when the request leaves a doubt: -- **You run it.** You need to know what to test: the scenarios may already be in your context — the ticket, the thread, the spec artifacts (alcode, `read` protocol). If you can't find them, ask the user rather than inventing them. Once known, test as in "Always test the work manually". +- **You run it.** You need to know what to test: the scenarios may already be in your context — the ticket, the thread, the spec artifacts (alcode, `catchup` protocol). If you can't find them, ask the user rather than inventing them. Once known, test as in "Always test the work manually". - **The user runs it.** They only need the dev-server up with its URL. -### Improving project docs +### Project rules and docs + +Two triggers, both edited through alcode: + +- You learn something non-obvious about how to work in a project — a command, a quirk, a convention not yet written down. Propose capturing it in `DEVELOPERS.md`, ask for confirmation, then have alcode make the edit. +- The user asks to retain a rule for the project. No confirmation needed: the rule goes into both `AGENTS.md` and `DEVELOPERS.md`. When the thread has an active ticket and the rule is simple, add it on the current branch, so the ticket's PR carries it. When the rule is complex or the thread has no ticket, reserve a side ticket (Step 4), set up a workspace on a new branch for the rule, and create a ready pull request. -When you learn something non-obvious about how to work in a project — a command, a quirk, a convention not yet written down — offer to capture it in the project's `DEVELOPERS.md`. Propose the improvement to the user, ask for confirmation, then have alcode make the edit. +A rule that is not about a project has no home: the workspace files are read-only and no memory persists across sessions. Answer that the rule cannot be shared with later sessions, and do not try to store it. ### Commit & push cadence @@ -254,7 +262,7 @@ A code review is the review workflow from the delegation guide: a fresh alcode s The PR/MR review sequence: 1. Read the PR/MR via the platform CLI (`gh`, `glab`). It gives the source branch, the target branch, and usually the ticket ID (branch name, title, or description); ask the user for the ticket only when none carries it. -2. Set up or reuse a workspace on the source branch ([`project-workspace-setup.md`](./project-workspace-setup.md)). +2. Set up or reuse a workspace on the source branch ([`project-workspace-setup.md`](./runbooks/project-workspace-setup.md)). 3. Run the `review` protocol with the target branch as base. Do not fix anything unless the user explicitly asks. 4. Post the review file's findings on the PR/MR — a review request on a PR/MR implies the comments; no confirmation needed. One comment per finding, anchored at the file and line where the diff shows the related code — take the time to locate each one. One general comment for findings with no precise spot. Post yourself via the platform CLI, or delegate to alcode when navigating a huge PR would flood your context. 5. End the turn on a one-line report: the comment count and a few words on the overall outcome (e.g. "Posted 6 comments on the MR — solid branch, two real bugs."). @@ -278,6 +286,10 @@ Whenever you observe that a PR/MR is merged, delegate the post-merge maintenance 2. Refresh the merge target in the main worktree without switching the main worktree away from its base branch. Fetch and fast-forward it, then reinstall dependencies, rebuild, and run new migrations when the project requires them. 3. Report the removed workspace and refreshed branch. +### Protected directories + +When the deployment or the git host refuses a change under a directory — typically `.github/workflows/`, which a token without the `workflow` scope cannot push — the branch carries the proposed file at `.-proposed/` with the rest of the path unchanged: `.github/workflows/ci.yml` becomes `.github-proposed/workflows/ci.yml`. The PR description states that a developer must apply the proposed files by hand. Pass this instruction to alcode, which writes the copy and the description. + ### Cleanup requests When the user asks to tear down one named project workspace (or worktree) from inside a thread: @@ -298,7 +310,7 @@ Run the reset **after** the final reply — it clears the session you're in. ### Project lifecycle requests -Creating or physically removing a project follows [`project-lifecycle.md`](./project-lifecycle.md). Route there before workspace setup. +Creating a project, onboarding a repository to clone, or physically removing a project follows [`project-lifecycle.md`](./runbooks/project-lifecycle.md). Route there before workspace setup. ### Forbidden diff --git a/skills/alignfirst-setup-guide/SKILL.md b/skills/alignfirst-setup-guide/SKILL.md index 83bca003..217d8f7f 100644 --- a/skills/alignfirst-setup-guide/SKILL.md +++ b/skills/alignfirst-setup-guide/SKILL.md @@ -7,7 +7,7 @@ compatibility: Requires git and a Node.js package manager (npm, pnpm, yarn, or b license: CC0 1.0 metadata: author: Paleo - version: "0.31.0" + version: "0.32.0" repository: https://github.com/paleo/alignfirst --- @@ -24,7 +24,7 @@ alone or with its command-alias companions. The core skill and any installed com distinction matters. The `alignfirst` skill contains the protocols. Its seven human-invoked command companions are -`alspec`, `alplan`, `al`, `almerge`, `alreview`, `aldescription`, and `alread`. The command skills +`alspec`, `alplan`, `al`, `almerge`, `alreview`, `aldescription`, and `alcatchup`. The command skills keep `disable-model-invocation: true`, humans invoke them as `/alspec` in Claude Code, GitHub Copilot, Cursor, or `$alspec` in Codex. @@ -50,7 +50,7 @@ When the user asks what the project could adopt, inspect the repository and pres choices: - **AlignFirst skills** add collaborative specification, planning, implementation, merge, review, - description, and task-reading commands. When the team has a plans repository, plans-share can back + description, and catch-up commands. When the team has a plans repository, plans-share can back the project's `.plans` directory. - **docmap** makes the repository's `docs/` tree discoverable to agents and humans. - **workspace** creates isolated git-worktree development environments. @@ -74,7 +74,9 @@ Inspect the repository before changing it. A prepared project has all of these: 1. AlignFirst skills and their project-specific `AGENTS.md` or `CLAUDE.md` section. 2. plans-share when a team plans repository exists. -3. docmap, including project scripts and agent instructions. +3. docmap, including project scripts and agent instructions. When the repository has no `docs/` + directory, bootstrap its documentation through + [docmap-bootstrapping.md](references/docmap-bootstrapping.md) as part of the preparation. 4. workspace, adapted to the project's runtime and development lifecycle, meeting [the AlignFirst Developer contract](references/workspace-setup.md#the-alignfirst-developer-contract). 5. A project-specific `DEVELOPERS.md` for an unfamiliar developer: commands, architecture, diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/gotchas.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/gotchas.md index 84a2d35b..fa2b3ae5 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/gotchas.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/gotchas.md @@ -31,7 +31,7 @@ For every skill it updates, `npx skills update` drops a symlink at `~/.openclaw/ Skills install once, into `~/.agents/skills/`, which OpenClaw and the delegated coding agent both scan. OpenClaw loads only its `agents.defaults.skills` allowlist, including `alignfirst-setup-guide` for project creation; the coding agent loads every skill there. The `al*` -command skills (`al`, `alplan`, `alspec`, `aldescription`, `alreview`, `alread`, `almerge`) sit +command skills (`al`, `alplan`, `alspec`, `aldescription`, `alreview`, `alcatchup`, `almerge`) sit outside OpenClaw's allowlist and look like orphans in its inventory. They are active skills of the coding agent: `skills remove` would delete the canonical copy for both. diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/coding-agents/claude-code/docs/installations/08-coding-agent.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/coding-agents/claude-code/docs/installations/08-coding-agent.md index 1bc2fea9..8601e333 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/coding-agents/claude-code/docs/installations/08-coding-agent.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/coding-agents/claude-code/docs/installations/08-coding-agent.md @@ -83,7 +83,7 @@ npx -y skills add https://github.com/paleo/skills --global --yes \ npx -y skills add https://github.com/paleo/alignfirst --global --yes \ --agent claude-code \ --skill al --skill alplan --skill alspec --skill aldescription \ - --skill alreview --skill alread --skill almerge < /dev/null + --skill alreview --skill alcatchup --skill almerge < /dev/null EOS ``` @@ -143,4 +143,4 @@ sudo -i -u {{SERVICE_USER}} -- bash -lc 'alproject --guide >/dev/null && echo al sudo -i -u {{SERVICE_USER}} -- bash -lc 'npx -y skills list -g --json' # 11 skills: 4 shared, 7 commands ``` -In an interactive session as the service account (`sudo -i -u {{SERVICE_USER}}`, then `claude` in a project), `/al`, `/alplan`, `/alspec`, `/aldescription`, `/alreview`, `/alread` and `/almerge` are offered. The surface smoke test in `07-channel.md` delegates a read-only run from the channel; its session file under `.plans/**/_alcode/*.md` records `agent: claude`. +In an interactive session as the service account (`sudo -i -u {{SERVICE_USER}}`, then `claude` in a project), `/al`, `/alplan`, `/alspec`, `/aldescription`, `/alreview`, `/alcatchup` and `/almerge` are offered. The surface smoke test in `07-channel.md` delegates a read-only run from the channel; its session file under `.plans/**/_alcode/*.md` records `agent: claude`. diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/coding-agents/codex/docs/installations/08-coding-agent.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/coding-agents/codex/docs/installations/08-coding-agent.md index efb39156..7b2e4a51 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/coding-agents/codex/docs/installations/08-coding-agent.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/coding-agents/codex/docs/installations/08-coding-agent.md @@ -76,7 +76,7 @@ npx -y skills add https://github.com/paleo/skills --global --yes \ npx -y skills add https://github.com/paleo/alignfirst --global --yes \ --agent codex \ --skill al --skill alplan --skill alspec --skill aldescription \ - --skill alreview --skill alread --skill almerge < /dev/null + --skill alreview --skill alcatchup --skill almerge < /dev/null EOS ``` @@ -166,4 +166,4 @@ sudo -i -u {{SERVICE_USER}} -- bash -lc 'alproject --guide >/dev/null && echo al sudo -i -u {{SERVICE_USER}} -- bash -lc 'npx -y skills list -g --json' # 11 skills: 4 shared, 7 commands ``` -In an interactive session as the service account (`sudo -i -u {{SERVICE_USER}}`, then `codex` in a project), `$al`, `$alplan`, `$alspec`, `$aldescription`, `$alreview`, `$alread` and `$almerge` are offered. The surface smoke test in `07-channel.md` delegates a read-only run from the channel; its session file under `.plans/**/_alcode/*.md` records `agent: codex`. +In an interactive session as the service account (`sudo -i -u {{SERVICE_USER}}`, then `codex` in a project), `$al`, `$alplan`, `$alspec`, `$aldescription`, `$alreview`, `$alcatchup` and `$almerge` are offered. The surface smoke test in `07-channel.md` delegates a read-only run from the channel; its session file under `.plans/**/_alcode/*.md` records `agent: codex`. diff --git a/skills/alignfirst-setup-guide/references/alignfirst-developer.md b/skills/alignfirst-setup-guide/references/alignfirst-developer.md index 42c5c8f7..7751c611 100644 --- a/skills/alignfirst-setup-guide/references/alignfirst-developer.md +++ b/skills/alignfirst-setup-guide/references/alignfirst-developer.md @@ -91,7 +91,7 @@ On the operator's machine, from the installed skill directory: grep -rlE "$re" . | while read -r f; do awk -v re="$re" '$0 ~ re { skip = !skip; next } !skip' "$f" | cat -s > "$f.tmp" && cat "$f.tmp" > "$f" && rm "$f.tmp"; done ``` -6. Team plans on: delete the `TEAM_PLANS_SECTION` marker lines, then `npm pkg set 'scripts.plans:setup=plans-share setup --folder {{ADMIN_REPOSITORY_NAME}}' 'scripts.plans:sync=plans-share sync'` and `npm install -D @paleo/plans-share`. Off: delete the blocks. +6. Team plans on: delete the `TEAM_PLANS_SECTION` marker lines, then `npm pkg set 'scripts.plans:setup=plans-share setup --folder {{ADMIN_REPOSITORY_NAME}}' 'scripts.plans:sync=plans-share sync --auto-archive'` and `npm install -D @paleo/plans-share`. Off: delete the blocks. 7. Replace every `{{TOKEN}}`, after all overlays are present and the derived tokens are computed. `sed` handles single-line values; the member list needs the editor or a Node one-liner. Dotfiles (`.env.example`, `.alproject.json`) are part of the sweep. 8. `npm install`. 9. Install `sysadmin` project-locally, so the clone carries it: `npx -y skills add https://github.com/paleo/skills --yes --agent --skill sysadmin