diff --git a/.alignfirst.json b/.alignfirst.json new file mode 100644 index 00000000..33f0c5c4 --- /dev/null +++ b/.alignfirst.json @@ -0,0 +1,7 @@ +{ + "schemaVersion": 1, + "ticketPattern": "^\\d+$", + "plans": { + "folder": "alignfirst" + } +} diff --git a/.changeset/alcode-projects-and-alignfirst.md b/.changeset/alcode-projects-and-alignfirst.md new file mode 100644 index 00000000..5f731315 --- /dev/null +++ b/.changeset/alcode-projects-and-alignfirst.md @@ -0,0 +1,5 @@ +--- +"@paleo/alcode": minor +--- + +Added `projects`, replacing `@paleo/alproject`. `new --no-ticket` and the delegated prompt run the `alignfirst` CLI, now a prerequisite. Removed `reserve-side-ticket`. diff --git a/.changeset/alignfirst-initial-release.md b/.changeset/alignfirst-initial-release.md new file mode 100644 index 00000000..df139cf5 --- /dev/null +++ b/.changeset/alignfirst-initial-release.md @@ -0,0 +1,5 @@ +--- +"alignfirst": minor +--- + +Initial release of the AlignFirst CLI. diff --git a/.changeset/docmap-embedded-commands.md b/.changeset/docmap-embedded-commands.md new file mode 100644 index 00000000..69568d86 --- /dev/null +++ b/.changeset/docmap-embedded-commands.md @@ -0,0 +1,5 @@ +--- +"@paleo/docmap": minor +--- + +The CLI can be embedded with an injected command prefix. diff --git a/.changeset/workspace-port-claim.md b/.changeset/workspace-port-claim.md new file mode 100644 index 00000000..42b47384 --- /dev/null +++ b/.changeset/workspace-port-claim.md @@ -0,0 +1,5 @@ +--- +"@paleo/workspace": minor +--- + +The kernel checks `portRange` in `.alignfirst.json` against the port scheme. diff --git a/AGENTS.md b/AGENTS.md index 1ff40810..9ca8cd83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,14 +22,13 @@ This repository is on *GitHub*. ## Packages -- `@paleo/alcode` — coding agent wrapper for the AlignFirst developer. -- `@paleo/alproject` — local project registry for the AlignFirst developer +- `alignfirst` — the AlignFirst CLI: protocols, plans and docs +- `@paleo/alcode` — coding agent wrapper and project discovery for the AlignFirst Developer - `@paleo/docmap` — lightweight documentation system for AI agents and humans - `@paleo/openclaw-channel-mock-core` — shared library for synthetic OpenClaw channel plugins (bus, actions, factories) - `@paleo/openclaw-slack-mock` — Slack-shaped channel plugin for test scenarios - `@paleo/openclaw-discord-mock` — Discord-shaped channel plugin for test scenarios - `@paleo/openclaw-test` — Dockerised regression-test harness (bus, scenario driver, judge, Compose stack) -- `@paleo/plans-share` — share the `.plans` directory through a team plans repository - `@paleo/workspace` — run multiple git-worktree dev environments side by side ## Docmap - Seek Documentation @@ -42,9 +41,7 @@ A **workspace** is a git worktree (with its branch) plus its own dev setup: syml Run `npm run workspace -- --guide` for the full procedures. -## AlignFirst - Ticket ID, Commit Message, Branch Name - -_Ticket ID_: Format is numeric. Use the ticket ID if explicitly provided. Otherwise, deduce it from the current branch name (no confirmation needed). If the branch name is unavailable, get it via `git branch --show-current`. Only ask the user as a last resort. +## AlignFirst - Commit Message, Branch Name Commit message convention: we use conventional commit, e.g., `feat: add new feature`. Do not mention the ticket ID. Do not add a "Co-Authored-By:" line. diff --git a/DEVELOPERS.md b/DEVELOPERS.md index 90bc71c8..fad0e27c 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -21,6 +21,8 @@ Run `npm run workspace -- --guide` to learn the full procedures. ## Everyday commands +The tooling runs through the `alignfirst` CLI built in this workspace, so run `npm run build` first. + | Command | Purpose | |---------|---------| | `npm run build` | Build every package | diff --git a/README.md b/README.md index 2bb1f29f..cc7a3a92 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,13 @@ Companion products for AI-assisted software work. They can be used independently ## AlignFirst skills Collaborative spec/plan/AAD/merge/review protocols. See [alignfirst-skills.md](alignfirst-skills.md). +The protocols run through the `alignfirst` CLI. Install it with `npm install -g alignfirst`; see +[the CLI README](packages/alignfirst/README.md). ### Team plans repository -`@paleo/plans-share` shares the `.plans` directory of the AlignFirst skills among a team, through a dedicated plans repository. See [packages/plans-share/README.md](packages/plans-share/README.md). +`alignfirst plans setup` and `alignfirst sync` share the `.plans` directory among a team through a +dedicated plans repository. See [the CLI README](packages/alignfirst/README.md). ## Docmap - Agent-discoverable documentation @@ -33,6 +36,7 @@ AlignFirst Developer is an AI teammate for software work, currently packaged on Our `alignfirst-setup-guide` skill can help to install these tools. Temporarily install the skill (globally or locally): ```bash +npm install -g alignfirst npx skills add https://github.com/paleo/alignfirst --skill alignfirst-setup-guide ``` diff --git a/alignfirst-developer-tests/.env.local.example b/alignfirst-developer-tests/.env.local.example index d34a378b..aa14caf8 100644 --- a/alignfirst-developer-tests/.env.local.example +++ b/alignfirst-developer-tests/.env.local.example @@ -25,12 +25,9 @@ OPENCLAW_WORKSPACE_DIR=./workspace # gateway's baked copy so playbook edits iterate without a rebuild (relative to this dir). ALIGNFIRST_DEVELOPER_PLAYBOOK_SKILL_DIR=../skills/alignfirst-developer-openclaw-playbook -# Required: host path to the built @paleo/alcode package (the coding-delegation CLI the -# gateway runs; its --guide is the delegation manual). Build it first: -# `npm run build --workspace @paleo/alcode` from the repo root, so packages/alcode/dist -# exists. Live-mounted read-only at /opt/alcode (guide edits in templates/ iterate live); -# a /usr/local/bin/alcode wrapper on PATH resolves to it. -ALIGNFIRST_CODE_DIR=../packages/alcode +# Required: host path to the monorepo root. Run `npm run build` there first so the +# mounted alcode, alignfirst, and docmap packages have built output. +ALIGNFIRST_REPO_DIR=.. # Required: coding agent launched by alcode inside the gateway. Codex is the recommended primary # harness path. This does not select the OpenClaw conversation model. diff --git a/alignfirst-developer-tests/Dockerfile b/alignfirst-developer-tests/Dockerfile index e1631403..39ac9f3f 100644 --- a/alignfirst-developer-tests/Dockerfile +++ b/alignfirst-developer-tests/Dockerfile @@ -12,23 +12,21 @@ USER root # Fixture runtime deps (git for `git init` / worktree, curl for the playbook's # verification step — "confirm the app still serves" — pnpm via Corepack for # the project's dev scripts). Plus the per-command mock-cli shim symlinks -# this consumer wants intercepted. `claude`, `codex`, `gh`, and `alproject` are shimmed. `alcode` runs for real, +# this consumer wants intercepted. `claude`, `codex`, and `gh` are shimmed. `alcode` runs for real, # and its selected coding-agent subprocess resolves back to the shim via PATH order. RUN apk add --no-cache git curl && \ corepack enable && corepack prepare pnpm@latest --activate && \ - for name in claude codex gh alproject; do ln -sf mock-cli-shim "/opt/openclaw-test/mocks/bin/$name"; done && \ - mkdir -p /home/claw/projects /home/claw/external-projects /home/claw/lifecycle-projects && \ - chown claw:claw /home/claw/projects /home/claw/external-projects \ - /home/claw/lifecycle-projects + for name in claude codex gh; do ln -sf mock-cli-shim "/opt/openclaw-test/mocks/bin/$name"; done && \ + mkdir -p /home/claw/projects && \ + chown claw:claw /home/claw/projects -# PATH wrapper for the real `@paleo/alcode` CLI. The package's built output is -# live-mounted at /opt/alcode (`${ALIGNFIRST_CODE_DIR}:/opt/alcode:ro` in the compose -# overlay), so alcode edits iterate without a rebuild — mirroring the skill -# live-mount. `/usr/local/bin` sits after `/opt/openclaw-test/mocks/bin` in -# PATH, so this wrapper is only reached for `alcode` (never shimmed), while -# alcode's own coding-agent subprocess still hits the shim. -RUN printf '#!/bin/sh\nexec node /opt/alcode/bin/alcode.mjs "$@"\n' > /usr/local/bin/alcode && \ - chmod +x /usr/local/bin/alcode +# PATH wrappers for the real CLIs. `/opt/alignfirst` is the read-only monorepo root +# mount, so @paleo/docmap, arktype, and semver resolve through its root node_modules. +# `alcode projects` spawns `alignfirst`. The mocked coding agent never runs the stub +# skills, so no `npx -y alignfirst` resolution is needed. +RUN printf '#!/bin/sh\nexec node /opt/alignfirst/packages/alcode/bin/alcode.mjs "$@"\n' > /usr/local/bin/alcode && \ + printf '#!/bin/sh\nexec node /opt/alignfirst/packages/alignfirst/bin/alignfirst.mjs "$@"\n' > /usr/local/bin/alignfirst && \ + chmod +x /usr/local/bin/alcode /usr/local/bin/alignfirst # PATH wrapper for the `openclaw` CLI. OpenClaw is only a local npm dependency # of /opt/openclaw-test/src (nothing puts its node_modules/.bin on the exec @@ -81,8 +79,8 @@ RUN npm ci --include=dev && \ npm cache clean --force # Single fixture template baked into the image. scripts/reset-fixture.mjs copies -# from here to each configured fixture parent at scenario start, materializing the -# distinct projects (nimbus, lumen, and external-parent orion) with per-name patches. +# from here into the nested fixture tree at scenario start, materializing nimbus, +# lumen, and external-team project orion with per-name patches. COPY --chown=claw:claw projects-fixture/template/ /opt/alignfirst-developer-tests/fixtures/template/ RUN cd /opt/alignfirst-developer-tests/fixtures/template && \ pnpm install --frozen-lockfile --prod=false diff --git a/alignfirst-developer-tests/README.md b/alignfirst-developer-tests/README.md index cc87d309..e91c4b8c 100644 --- a/alignfirst-developer-tests/README.md +++ b/alignfirst-developer-tests/README.md @@ -15,8 +15,8 @@ This README only documents what is specific to this harness. cp .env.local.example .env.local # Edit .env.local — fill ANTHROPIC_API_KEY and select ALIGNFIRST_CODE_AGENT -# Build the real alcode CLI the gateway runs (packages/alcode/dist must exist). -npm run build --workspace @paleo/alcode --prefix .. +# Build the real alcode and alignfirst CLIs the gateway runs. +npm run build --prefix .. npm run vendor # build + pack the local @paleo/openclaw-* into vendor/ (first run only; env:build repeats it) npm install @@ -43,21 +43,21 @@ See the upstream README for all flags. `--parallel K` (or `OPENCLAW_TEST_PARALLE Then set `OPENCLAW_CODEX_HOME` in `.env.local` to `$PWD/.codex-home` with `$PWD` expanded to its absolute value. Repeat the login when the stored access token expires. - `ALIGNFIRST_DEVELOPER_PLAYBOOK_SKILL_DIR` — host path to the `alignfirst-developer-openclaw-playbook` skill, bind-mounted into the gateway. Playbook edits iterate live, no rebuild. -- `ALIGNFIRST_CODE_DIR` — host path to `packages/alcode` (build it first). Live-mounted read-only at `/opt/alcode`; the `/usr/local/bin/alcode` wrapper runs `node /opt/alcode/bin/alcode.mjs`. Alcode runs for real, while both `claude` and `codex` resolve to the mock through PATH. Delegation instructions come from `alcode --openclaw-guide` (rendered from `templates/`, so guide edits iterate live). +- `ALIGNFIRST_REPO_DIR` — host path to the monorepo root (build it first). Live-mounted read-only at `/opt/alignfirst`; the `alcode` and `alignfirst` wrappers run both CLIs from the checkout. Alcode runs for real, while both `claude` and `codex` resolve to the mock through PATH. Delegation instructions come from `alcode --openclaw-guide` (rendered from `packages/alcode/templates/`, so guide edits iterate live). - `ALIGNFIRST_CODE_AGENT=codex|claude` — required selector for alcode's child. It does not affect the OpenClaw conversation model. `ALIGNFIRST_CODE_MODELS` optionally narrows the agent models or pins a full Codex slug. -- [`docker-compose.yml`](docker-compose.yml) — shared fixture volumes on gateway + runner at `/home/claw/projects`, `/home/claw/external-projects`, and `/home/claw/lifecycle-projects`; the skill + alcode bind mounts on `gateway`; `OPENCLAW_TEST_JUDGE_MODEL=anthropic/claude-haiku-4-5` on `runner`. +- [`docker-compose.yml`](docker-compose.yml) — one shared fixture volume on gateway + runner at `/home/claw/projects`; the skill and monorepo bind mounts on `gateway`; `OPENCLAW_TEST_JUDGE_MODEL=anthropic/claude-haiku-4-5` on `runner`. ## Fixtures -Each scenario starts fresh: [`scripts/reset-fixture.mjs`](scripts/reset-fixture.mjs) (run via `ctx.execInGateway(...)`) materializes three Git repositories on `main`, copied from the committed [`projects-fixture/template/`](projects-fixture/template/). `nimbus` and `lumen` live under `/home/claw/projects`; `orion` lives under the second explicit fixture parent `/home/claw/external-projects`. Each carries a project-specific package name, `README.md` and `DEVELOPERS.md` headings, port block (6500, 6520, and 6540), and an untracked `.plans/` directory for alcode's project gate. +Each scenario starts fresh: [`scripts/reset-fixture.mjs`](scripts/reset-fixture.mjs) (run via `ctx.execInGateway(...)`) materializes three Git repositories on `main`, copied from the committed [`projects-fixture/template/`](projects-fixture/template/). `nimbus` and `lumen` live under `/home/claw/projects`; `orion` lives under `/home/claw/projects/external-projects`. Each project has `.alignfirst.json`, a project-specific package name, `README.md` and `DEVELOPERS.md` headings, its own 20-port block (6500, 6520, and 6540), and an untracked `.plans/` directory. -`/home/claw/lifecycle-projects` resets to an empty allowed parent. The creation scenario uses it for `nova`, isolated from the standard projects. Removal scenarios seed a real linked `nimbus` workspace and a sibling additional directory after reset. +The root and its nested `external-projects` and `lifecycle-projects` directories carry `.alignfirst-projects.json` markers with descriptions and port ranges. The lifecycle directory resets empty; the creation scenario uses it for `nova`. Removal scenarios seed a real linked `nimbus` workspace and a sibling additional directory after reset. -The absolute parents are harness storage details. Scenarios obtain canonical main paths from the mocked `alproject list` result and pass those paths through starter, workspace, and coding-agent assertions. The `alproject` shim emits the CLI's labelled list format, supports per-scenario project records and additional-directory groups, and records argv, cwd, and call order. Lifecycle scenarios configure guide, registration, and unregistration responses; successful mutations update subsequent list output. +`alcode projects` runs for real against the fixture tree and calls `alignfirst config --json` in each child. Scenarios assert on the agent's exec calls and on the filesystem. ## Scenarios -Drop `scenarios/.ts`, default-export `async (ctx: ScenarioContext) => void`. Shared helpers under `scenarios/_lib/` (skipped by the runner's discovery). Current scenarios: `A01`–`A26`. +Drop `scenarios/.ts`, default-export `async (ctx: ScenarioContext) => void`. Shared helpers under `scenarios/_lib/` (skipped by the runner's discovery). Current scenarios: `A01`–`A21` and `A23`–`A26`. Almost every one starts with `bootstrapThreadFromChannel` (`_lib/thread-bootstrap.ts`): it sends the channel message, waits for the starter, and asserts the channel session stopped right there — one thread post, no second one, no worktree on disk, no coding-agent call, nothing substantive leaked to the channel root. `sendInThread` then wakes the thread session, which owns the actual work. A scenario that seeds a worktree first passes its absolute path as `seededWorktreePaths` so the check still catches anything the channel session created. @@ -65,14 +65,14 @@ Almost every one starts with `bootstrapThreadFromChannel` (`_lib/thread-bootstra `A06` pins first-turn lookup caching across two off-project messages. `A14` covers sole-project inference, `A15` duplicate-name path selection, and `A16` carries an external canonical path through workspace setup and delegation. -`A17` creates and registers `nova`, bootstraps it on `main` without an AlignFirst protocol, and checks the initial commit. `A18` confirms exact paths before removing a linked workspace and its main worktree. `A19` makes workspace removal fail on an uncommitted file and checks that filesystem and registry state remain intact. +`A17` creates and prepares `nova`, bootstraps it on `main` without an AlignFirst protocol, and checks the initial commit. `A18` confirms exact paths before removing a linked workspace and its main worktree. `A19` makes workspace removal fail on an uncommitted file and checks that the filesystem and project config remain intact. `A23` resolves a PR URL through review and its reported outcome. `A24` carries a multi-project base refresh through one no-protocol delegation per project. `A25` captures a detailed request before workspace setup and coding. `A26` reserves the next side ticket `side-N` before workspace setup for explicit no-ticket work. -Rebuild the alcode package and harness image before focused coverage: +Rebuild the CLIs and harness image before focused coverage: ```sh -npm run build --workspace @paleo/alcode --prefix .. +npm run build --prefix .. npm run env:build ALIGNFIRST_CODE_AGENT=codex npm run e2e -- --channel discord-mock A13-alcode-agent-contract @@ -96,6 +96,6 @@ This harness always tests the **local** `@paleo/openclaw-*` sources, never npmjs - [`openclaw.json`](openclaw.json) · [`docker-compose.yml`](docker-compose.yml) · [`Dockerfile`](Dockerfile) · [`package.json`](package.json) · [`scripts/vendor-packages.mjs`](scripts/vendor-packages.mjs) — committed. - `vendor/` (gitignored) — locally-built `@paleo/openclaw-*` tarballs, regenerated by `npm run vendor`. -- `.env.local` (gitignored) — API keys, workspace/skill/alcode paths, and `ALIGNFIRST_CODE_AGENT`. +- `.env.local` (gitignored) — API keys, workspace/skill/repository paths, and `ALIGNFIRST_CODE_AGENT`. - `artifacts/` (gitignored) — per-run outputs. - `.gateway-logs/` (gitignored) — `raw-stream.jsonl` (opt-in). Session transcripts live in the gateway's SQLite store; each cell's artifact dir archives them as `transcripts.json`. diff --git a/alignfirst-developer-tests/docker-compose.yml b/alignfirst-developer-tests/docker-compose.yml index 009f6ada..1f6c0c88 100644 --- a/alignfirst-developer-tests/docker-compose.yml +++ b/alignfirst-developer-tests/docker-compose.yml @@ -13,8 +13,6 @@ include: volumes: openclaw-codex-home: fixture-projects: - fixture-external-projects: - fixture-lifecycle-projects: services: gateway: @@ -26,17 +24,14 @@ services: volumes: - ${OPENCLAW_CODEX_HOME:-openclaw-codex-home}:/home/claw/.codex:ro - fixture-projects:/home/claw/projects - - fixture-external-projects:/home/claw/external-projects - - fixture-lifecycle-projects:/home/claw/lifecycle-projects # Live-mount the playbook skill over its baked copy so playbook edits # iterate without an image rebuild. Set in .env.local (relative to this # dir, e.g. ../skills/alignfirst-developer-openclaw-playbook). - ${ALIGNFIRST_DEVELOPER_PLAYBOOK_SKILL_DIR}:/home/claw/.agents/skills/alignfirst-developer-openclaw-playbook - # Live-mount the built @paleo/alcode package so the /usr/local/bin/alcode - # wrapper resolves to it. ALIGNFIRST_CODE_DIR is the host path to packages/alcode - # (build it with `npm run build` first). Read-only: the CLI only reads its - # own dist/bin/templates and spawns the selected coding agent from there. - - ${ALIGNFIRST_CODE_DIR}:/opt/alcode:ro + # Live-mount the built monorepo so the alcode and alignfirst wrappers resolve + # to this checkout. Run `npm run build` at the root first so alcode, alignfirst, + # and docmap have built output. + - ${ALIGNFIRST_REPO_DIR:?set ALIGNFIRST_REPO_DIR to the monorepo root}:/opt/alignfirst:ro runner: environment: @@ -44,5 +39,3 @@ services: ALIGNFIRST_CODE_AGENT: ${ALIGNFIRST_CODE_AGENT:?set ALIGNFIRST_CODE_AGENT to codex or claude} volumes: - fixture-projects:/home/claw/projects - - fixture-external-projects:/home/claw/external-projects - - fixture-lifecycle-projects:/home/claw/lifecycle-projects diff --git a/alignfirst-developer-tests/package-lock.json b/alignfirst-developer-tests/package-lock.json index 818d3d19..b467f33e 100644 --- a/alignfirst-developer-tests/package-lock.json +++ b/alignfirst-developer-tests/package-lock.json @@ -847,7 +847,7 @@ "node_modules/@paleo/openclaw-channel-mock-core": { "version": "0.7.0", "resolved": "file:vendor/openclaw-channel-mock-core.tgz", - "integrity": "sha512-TLY1ce8QKnb6SBSwkFN+SkJ7sVHw1ROoLdj2/6SJrviJAAe+nutdldI0duzwJFHUVBsKYP5qUCua+VlQXFCPGg==", + "integrity": "sha512-rDnm0o9qbf4If8AvWYxeuxhu/QyP3dyNvIvwAIokdC3GJh7w09SyJmJuJQ5f/bwww/slPBbYqcdCXo4HyC6XRw==", "license": "MIT", "dependencies": { "typebox": "~1.3.23" @@ -863,7 +863,7 @@ "node_modules/@paleo/openclaw-discord-mock": { "version": "0.3.8", "resolved": "file:vendor/openclaw-discord-mock.tgz", - "integrity": "sha512-5mXJWBScDTOoqcs+xl9UZNP0xj2agka02jx3kAKEHxedLBHPvt8/0I3l3HYCa+GePVOMm+Nyvfd5wkD3/5memg==", + "integrity": "sha512-xNbEhgK3gzSAKuEGIC4/rivwuKl89mn+4zjxpc3cK17BRlppuVMb1IGcQCrxxuQPjwfpuPxNMLTZWSrOVNvIOQ==", "license": "MIT", "dependencies": { "@paleo/openclaw-channel-mock-core": "0.7.0" @@ -878,7 +878,7 @@ "node_modules/@paleo/openclaw-slack-mock": { "version": "0.3.8", "resolved": "file:vendor/openclaw-slack-mock.tgz", - "integrity": "sha512-R25lYn36UpFmpWjrkhSB3PrZYamenAkNeF+I6P187wgqxJFLgpb3O7J8s0uvQUfvWfjrv8OJQq7xH4dQrXx1cw==", + "integrity": "sha512-4X+tyajhTb96zP6tolFK9MNoRNaNfyiRf/3Yo2p97wzqKq/wKTmrPTn3SXQIIVMTMCsg15pDCs0IMiaomNAroQ==", "license": "MIT", "dependencies": { "@paleo/openclaw-channel-mock-core": "0.7.0" @@ -893,7 +893,7 @@ "node_modules/@paleo/openclaw-test": { "version": "0.16.0", "resolved": "file:vendor/openclaw-test.tgz", - "integrity": "sha512-fh2miIdZmPdRFwikCLC56UHXhloOmT3lqh1sBLLNcAFGkeVcVeO2EEYz1TYFJ3hCINhXP5kCoQMm+LCelZwO5Q==", + "integrity": "sha512-+JiIijommZTnZj2JBIfDLNVOA2dONeWpWhjX0UukKqJov2mxkOa7lTA5FRBU3o0ryPNG3Pe3Yc7ECsqH8f+gDQ==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "~0.122.0", diff --git a/alignfirst-developer-tests/scenarios/A01-new-work-to-be-done.ts b/alignfirst-developer-tests/scenarios/A01-new-work-to-be-done.ts index b9378b72..edd667be 100644 --- a/alignfirst-developer-tests/scenarios/A01-new-work-to-be-done.ts +++ b/alignfirst-developer-tests/scenarios/A01-new-work-to-be-done.ts @@ -1,6 +1,6 @@ import type { ScenarioContext } from "@paleo/openclaw-test"; import { NEW_WORK_QUESTION_RUBRIC } from "./_lib/common-constants.ts"; -import { setupAlprojectMock } from "./_lib/mock-alproject.ts"; +import { waitForProjectListing } from "./_lib/project-lifecycle.ts"; import { setupCodingAgentMock } from "./_lib/mock-coding-agent.ts"; import { setupGhMock } from "./_lib/mock-gh.ts"; import { NIMBUS_PROJECT_PATH } from "./_lib/project-fixtures.ts"; @@ -23,7 +23,6 @@ const PROJECT = "nimbus"; export default async function projectDetectionStarter(ctx: ScenarioContext): Promise { ctx.log(`channel: ${ctx.channel}, conversationId: ${ctx.conversationId}`); await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx); const codingAgent = setupCodingAgentMock(ctx); setupGhMock(ctx); @@ -50,7 +49,7 @@ export default async function projectDetectionStarter(ctx: ScenarioContext): Pro prevStep: ack, }); await expectThreadRenamedWithTicket(ctx); - alproject.assertListCallCount(1); + await waitForProjectListing(ctx, "channel session lists the projects"); ctx.log({ attachTo: ack.entry, label: "setup signal received" }); ctx.markScenarioAsEnded("PASS"); diff --git a/alignfirst-developer-tests/scenarios/A02-new-work-with-ticket.ts b/alignfirst-developer-tests/scenarios/A02-new-work-with-ticket.ts index cd8ce964..1fcb93db 100644 --- a/alignfirst-developer-tests/scenarios/A02-new-work-with-ticket.ts +++ b/alignfirst-developer-tests/scenarios/A02-new-work-with-ticket.ts @@ -1,6 +1,6 @@ import type { ScenarioContext } from "@paleo/openclaw-test"; import { HANDOFF_ASK_RUBRIC } from "./_lib/common-constants.ts"; -import { setupAlprojectMock } from "./_lib/mock-alproject.ts"; +import { waitForProjectListing } from "./_lib/project-lifecycle.ts"; import { setupCodingAgentMock } from "./_lib/mock-coding-agent.ts"; import { setupGhMock } from "./_lib/mock-gh.ts"; import { NIMBUS_PROJECT_PATH } from "./_lib/project-fixtures.ts"; @@ -23,7 +23,6 @@ const PROJECT = "nimbus"; export default async function projectDetectionWithTicket(ctx: ScenarioContext): Promise { ctx.log(`channel: ${ctx.channel}, conversationId: ${ctx.conversationId}`); await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx); const codingAgent = setupCodingAgentMock(ctx); setupGhMock(ctx); @@ -50,7 +49,7 @@ export default async function projectDetectionWithTicket(ctx: ScenarioContext): ticketId: TICKET_ID, prevStep: ack, }); - alproject.assertListCallCount(1); + await waitForProjectListing(ctx, "channel session lists the projects"); ctx.log({ attachTo: ack.entry, label: "setup ack received" }); ctx.markScenarioAsEnded("PASS"); diff --git a/alignfirst-developer-tests/scenarios/A03-question.ts b/alignfirst-developer-tests/scenarios/A03-question.ts index ac937e00..070e9f62 100644 --- a/alignfirst-developer-tests/scenarios/A03-question.ts +++ b/alignfirst-developer-tests/scenarios/A03-question.ts @@ -10,7 +10,7 @@ import { escapeRegExp, waitForAnyWorktreeDir, } from "./_lib/fixture-state.ts"; -import { setupAlprojectMock } from "./_lib/mock-alproject.ts"; +import { waitForProjectListing } from "./_lib/project-lifecycle.ts"; import { setupGhMock } from "./_lib/mock-gh.ts"; import { resetFixtures } from "./_lib/reset-fixture.ts"; import { NIMBUS_PROJECT_PATH } from "./_lib/project-fixtures.ts"; @@ -34,7 +34,6 @@ const INVESTIGATION_FINDING = export default async function projectInvestigationQuestion(ctx: ScenarioContext): Promise { ctx.log(`channel: ${ctx.channel}, conversationId: ${ctx.conversationId}`); await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx); // streamDelayMs keeps the mock coding run alive past the launching turn (real runs take // minutes+): an exec that exits mid-turn gets its exit event consumed by the in-flight turn and // the completion wake never fires as its own turn — a fixture artifact, not a product behavior. @@ -62,7 +61,7 @@ export default async function projectInvestigationQuestion(ctx: ScenarioContext) ctx, codingAgent, { - rubric: `The captured invocation is a prompt sent to a coding agent via the alcode CLI, **without** an alignfirst protocol header. Expected: ticket ${TICKET_ID}; an investigation/question delegation that conveys the user's question (export button failure when there are no comparables — paraphrases are fine); and "do not implement / talk first" (or equivalent). Do not judge the project or working directory — that is asserted structurally. Reject if the prompt looks like an alignfirst protocol invocation (\`Run the _spec_ protocol …\` etc.), the ticket is missing, or the question content is missing or unrelated.`, + rubric: `The captured invocation is a prompt sent to a coding agent via the alcode CLI, **without** an AlignFirst protocol header. Expected: ticket ${TICKET_ID}; an investigation/question delegation that conveys the user's question (export button failure when there are no comparables — paraphrases are fine); and "do not implement / talk first" (or equivalent). Do not judge the project or working directory — that is asserted structurally. Reject if the prompt looks like an AlignFirst protocol invocation (\`Run \`alignfirst guide spec\` and follow the protocol.\` etc.), the ticket is missing, or the question content is missing or unrelated.`, label: "coding-agent-investigation-delegation", }, ); @@ -96,7 +95,7 @@ export default async function projectInvestigationQuestion(ctx: ScenarioContext) timeoutMs: 240_000, label: "investigation-summary", }); - alproject.assertListCallCount(1); + await waitForProjectListing(ctx, "channel session lists the projects"); ctx.markScenarioAsEnded("PASS"); ctx.log("PASS"); diff --git a/alignfirst-developer-tests/scenarios/A04-ticket-without-project.ts b/alignfirst-developer-tests/scenarios/A04-ticket-without-project.ts index 4050a6e6..cbc2034b 100644 --- a/alignfirst-developer-tests/scenarios/A04-ticket-without-project.ts +++ b/alignfirst-developer-tests/scenarios/A04-ticket-without-project.ts @@ -1,6 +1,6 @@ import type { ScenarioContext } from "@paleo/openclaw-test"; import { askWhichProjectRubric } from "./_lib/common-constants.ts"; -import { setupAlprojectMock } from "./_lib/mock-alproject.ts"; +import { waitForProjectListing } from "./_lib/project-lifecycle.ts"; import { setupCodingAgentMock } from "./_lib/mock-coding-agent.ts"; import { setupGhMock } from "./_lib/mock-gh.ts"; import { resetFixtures } from "./_lib/reset-fixture.ts"; @@ -16,7 +16,6 @@ const TICKET_ID = "ABC-040"; export default async function ticketWithoutProject(ctx: ScenarioContext): Promise { ctx.log(`channel: ${ctx.channel}, conversationId: ${ctx.conversationId}`); await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx); const codingAgent = setupCodingAgentMock(ctx); setupGhMock(ctx); @@ -31,7 +30,7 @@ export default async function ticketWithoutProject(ctx: ScenarioContext): Promis rubric: askWhichProjectRubric(TICKET_ID), label: "ask-which-project", }); - alproject.assertListCallCount(1); + await waitForProjectListing(ctx, "channel session lists the projects"); ctx.markScenarioAsEnded("PASS"); ctx.log("PASS"); diff --git a/alignfirst-developer-tests/scenarios/A05-wrong-project.ts b/alignfirst-developer-tests/scenarios/A05-wrong-project.ts index f8317801..934cc4b7 100644 --- a/alignfirst-developer-tests/scenarios/A05-wrong-project.ts +++ b/alignfirst-developer-tests/scenarios/A05-wrong-project.ts @@ -1,6 +1,6 @@ import type { ScenarioContext } from "@paleo/openclaw-test"; import { unknownProjectRubric } from "./_lib/common-constants.ts"; -import { setupAlprojectMock } from "./_lib/mock-alproject.ts"; +import { waitForProjectListing } from "./_lib/project-lifecycle.ts"; import { setupCodingAgentMock } from "./_lib/mock-coding-agent.ts"; import { setupGhMock } from "./_lib/mock-gh.ts"; import { resetFixtures } from "./_lib/reset-fixture.ts"; @@ -9,14 +9,13 @@ import { bootstrapThreadFromChannel } from "./_lib/thread-bootstrap.ts"; const WRONG_PROJECT = "aurora"; /** - * A project name absent from the `alproject list` result. The channel session checks the name + * A project name absent from the `alcode projects list` result. The channel session checks the name * while collecting the handoff values, so the starter says the project isn't * there and asks for the right one — then stops. */ export default async function wrongProject(ctx: ScenarioContext): Promise { ctx.log(`channel: ${ctx.channel}, conversationId: ${ctx.conversationId}`); await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx); const codingAgent = setupCodingAgentMock(ctx); setupGhMock(ctx); @@ -31,7 +30,7 @@ export default async function wrongProject(ctx: ScenarioContext): Promise rubric: unknownProjectRubric(WRONG_PROJECT), label: "unknown-project-acknowledgement", }); - alproject.assertListCallCount(1); + await waitForProjectListing(ctx, "channel session lists the projects"); ctx.markScenarioAsEnded("PASS"); ctx.log("PASS"); diff --git a/alignfirst-developer-tests/scenarios/A06-off-projects.ts b/alignfirst-developer-tests/scenarios/A06-off-projects.ts index ff97d128..71ed5ade 100644 --- a/alignfirst-developer-tests/scenarios/A06-off-projects.ts +++ b/alignfirst-developer-tests/scenarios/A06-off-projects.ts @@ -1,5 +1,4 @@ import type { ScenarioContext } from "@paleo/openclaw-test"; -import { setupAlprojectMock } from "./_lib/mock-alproject.ts"; import { setupCodingAgentMock } from "./_lib/mock-coding-agent.ts"; import { setupGhMock } from "./_lib/mock-gh.ts"; import { resetFixtures } from "./_lib/reset-fixture.ts"; @@ -22,14 +21,13 @@ const OFF_PROJECTS_CHAT_PROMPT = '"happy to help") with no concrete work content.'; // The lookup contract is outcome-based: a message with no possible project -// reference needs no `alproject list`, so this scenario asserts only what the +// reference needs no `alcode projects list`, so this scenario asserts only what the // user can observe — social-only replies, no thread, no coding-agent call. The // mock stays installed to serve a lookup if one happens; either count is fine. // The lookup-when-it-matters case is A20-ambiguous-project-mention. export default async function offProjectsChat(ctx: ScenarioContext): Promise { ctx.log(`channel: ${ctx.channel}, conversationId: ${ctx.conversationId}`); await resetFixtures(ctx); - setupAlprojectMock(ctx); const codingAgent = setupCodingAgentMock(ctx); setupGhMock(ctx); diff --git a/alignfirst-developer-tests/scenarios/A07-status-existing-worktree.ts b/alignfirst-developer-tests/scenarios/A07-status-existing-worktree.ts index cce05c2c..720a2a15 100644 --- a/alignfirst-developer-tests/scenarios/A07-status-existing-worktree.ts +++ b/alignfirst-developer-tests/scenarios/A07-status-existing-worktree.ts @@ -2,7 +2,7 @@ import type { ScenarioContext } from "@paleo/openclaw-test"; import { execMatches } from "./_lib/agent-tool-calls.ts"; import { statusExistingWorktreeRubric } from "./_lib/common-constants.ts"; import { seedWorktree, worktreePath } from "./_lib/fixture-state.ts"; -import { setupAlprojectMock } from "./_lib/mock-alproject.ts"; +import { waitForProjectListing } from "./_lib/project-lifecycle.ts"; import { setupCodingAgentMock } from "./_lib/mock-coding-agent.ts"; import { setupGhMock } from "./_lib/mock-gh.ts"; import { assertNoChannelRootLeak, waitForReport } from "./_lib/outbound.ts"; @@ -27,7 +27,6 @@ const BRANCH = `${TICKET_ID}/${BRANCH_DESC}`; export default async function statusExistingWorktree(ctx: ScenarioContext): Promise { ctx.log(`channel: ${ctx.channel}, conversationId: ${ctx.conversationId}`); await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx); const codingAgent = setupCodingAgentMock(ctx); setupGhMock(ctx); @@ -89,7 +88,7 @@ export default async function statusExistingWorktree(ctx: ScenarioContext): Prom // lands in the thread, and leaves no stray worktrees / channel leak. assertWorktreePaths(ctx, [seededWorktreePath]); await assertNoChannelRootLeak(ctx, { sinceCursor: startCursor }); - alproject.assertListCallCount(1); + await waitForProjectListing(ctx, "channel session lists the projects"); ctx.markScenarioAsEnded("PASS"); ctx.log("PASS"); diff --git a/alignfirst-developer-tests/scenarios/A08-status-branch-only.ts b/alignfirst-developer-tests/scenarios/A08-status-branch-only.ts index c56e65ac..ab20422c 100644 --- a/alignfirst-developer-tests/scenarios/A08-status-branch-only.ts +++ b/alignfirst-developer-tests/scenarios/A08-status-branch-only.ts @@ -1,7 +1,7 @@ import type { ScenarioContext } from "@paleo/openclaw-test"; import { execMatches } from "./_lib/agent-tool-calls.ts"; import { assertBranch, seedBranch, waitForWorktreeDir } from "./_lib/fixture-state.ts"; -import { setupAlprojectMock } from "./_lib/mock-alproject.ts"; +import { waitForProjectListing } from "./_lib/project-lifecycle.ts"; import { setupCodingAgentMock } from "./_lib/mock-coding-agent.ts"; import { setupGhMock } from "./_lib/mock-gh.ts"; import { assertNoChannelRootLeak, waitForReport } from "./_lib/outbound.ts"; @@ -22,7 +22,6 @@ const BRANCH = `${TICKET_ID}/${BRANCH_DESC}`; export default async function statusBranchOnly(ctx: ScenarioContext): Promise { ctx.log(`channel: ${ctx.channel}, conversationId: ${ctx.conversationId}`); await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx); const codingAgent = setupCodingAgentMock(ctx); setupGhMock(ctx); @@ -96,7 +95,7 @@ export default async function statusBranchOnly(ctx: ScenarioContext): Promise { ctx.log(`channel: ${ctx.channel}, conversationId: ${ctx.conversationId}`); await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx); const codingAgent = setupCodingAgentMock(ctx); setupGhMock(ctx); @@ -71,7 +70,7 @@ export default async function statusNoBranch(ctx: ScenarioContext): Promise { ctx.log(`channel: ${ctx.channel}, conversationId: ${ctx.conversationId}`); await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx); // Stream delay > exec `yieldMs` (10s default) so OpenClaw auto-backgrounds the alcode exec even if // the agent does not pass `background: true`, letting the "started" ack precede the completion wake. const codingAgent = setupCodingAgentMock(ctx, { streamDelayMs: 12000 }); @@ -112,7 +111,7 @@ export default async function codingSession(ctx: ScenarioContext): Promise // post — the exact shape of the trailing-leak incident — so sweep longer. await assertNoChannelRootLeak(ctx, { sinceCursor: startCursor, withinMs: 15_000 }); await assertNoSelfThreadMessagePost(ctx, threadId, startCursor); - alproject.assertListCallCount(1); + await waitForProjectListing(ctx, "channel session lists the projects"); ctx.markScenarioAsEnded("PASS"); ctx.log("PASS"); diff --git a/alignfirst-developer-tests/scenarios/A11-go-ahead-delegation.ts b/alignfirst-developer-tests/scenarios/A11-go-ahead-delegation.ts index 3d0c5440..c40a96f8 100644 --- a/alignfirst-developer-tests/scenarios/A11-go-ahead-delegation.ts +++ b/alignfirst-developer-tests/scenarios/A11-go-ahead-delegation.ts @@ -6,7 +6,7 @@ import { waitForCompletionReport, } from "./_lib/coding-session.ts"; import { assertBranchForTicket, waitForAnyWorktreeDir } from "./_lib/fixture-state.ts"; -import { setupAlprojectMock } from "./_lib/mock-alproject.ts"; +import { waitForProjectListing } from "./_lib/project-lifecycle.ts"; import { extractCodingPrompt, isCodingProtocolPrompt, @@ -38,7 +38,6 @@ const PROJECT = "nimbus"; export default async function threadSessionDelegation(ctx: ScenarioContext): Promise { ctx.log(`channel: ${ctx.channel}, conversationId: ${ctx.conversationId}`); await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx); // Stream delay > exec `yieldMs` (10s default) so OpenClaw auto-backgrounds the alcode exec even if // the agent does not pass `background: true`, letting the "started" ack precede the completion wake. const codingAgent = setupCodingAgentMock(ctx, { streamDelayMs: 12_000 }); @@ -57,7 +56,7 @@ export default async function threadSessionDelegation(ctx: ScenarioContext): Pro await runSetupPhaseWithoutDelegation(ctx, codingAgent, starter); await runGoAheadPhase(ctx, starter.threadId, startCursor); - alproject.assertListCallCount(1); + await waitForProjectListing(ctx, "channel session lists the projects"); ctx.markScenarioAsEnded("PASS"); ctx.log("PASS"); diff --git a/alignfirst-developer-tests/scenarios/A12-sequential-coding-sessions.ts b/alignfirst-developer-tests/scenarios/A12-sequential-coding-sessions.ts index 4d43dc70..4c0e2cf8 100644 --- a/alignfirst-developer-tests/scenarios/A12-sequential-coding-sessions.ts +++ b/alignfirst-developer-tests/scenarios/A12-sequential-coding-sessions.ts @@ -11,7 +11,7 @@ import { waitForCodingSessionSucceeded, waitForCompletionReport, } from "./_lib/coding-session.ts"; -import { setupAlprojectMock } from "./_lib/mock-alproject.ts"; +import { waitForProjectListing } from "./_lib/project-lifecycle.ts"; import { setupCodingAgentMock, type CodingAgentMockHandle } from "./_lib/mock-coding-agent.ts"; import { setupGhMock } from "./_lib/mock-gh.ts"; import { assertNoChannelRootLeak, assertNoSelfThreadMessagePost } from "./_lib/outbound.ts"; @@ -50,7 +50,6 @@ const isAlcodeLaunch = (call: AgentToolCall): boolean => export default async function sequentialCodingSessions(ctx: ScenarioContext): Promise { ctx.log(`channel: ${ctx.channel}, conversationId: ${ctx.conversationId}`); await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx); // Stream delay > exec `yieldMs` (10s default) so OpenClaw auto-backgrounds the alcode exec even if // the agent does not pass `background: true`, letting the "started" ack precede the completion wake. const codingAgent = setupCodingAgentMock(ctx, { streamDelayMs: 12_000 }); @@ -64,7 +63,7 @@ export default async function sequentialCodingSessions(ctx: ScenarioContext): Pr // post — the exact shape of the trailing-leak incident — so sweep longer. await assertNoChannelRootLeak(ctx, { sinceCursor: startCursor, withinMs: 15_000 }); await assertNoSelfThreadMessagePost(ctx, threadId, startCursor); - alproject.assertListCallCount(1); + await waitForProjectListing(ctx, "channel session lists the projects"); ctx.markScenarioAsEnded("PASS"); ctx.log("PASS"); diff --git a/alignfirst-developer-tests/scenarios/A14-sole-project-inference.ts b/alignfirst-developer-tests/scenarios/A14-sole-project-inference.ts index 514bbe82..89476be7 100644 --- a/alignfirst-developer-tests/scenarios/A14-sole-project-inference.ts +++ b/alignfirst-developer-tests/scenarios/A14-sole-project-inference.ts @@ -1,9 +1,13 @@ import type { ScenarioContext } from "@paleo/openclaw-test"; import { HANDOFF_ASK_RUBRIC } from "./_lib/common-constants.ts"; -import { setupAlprojectMock, registeredProject } from "./_lib/mock-alproject.ts"; import { setupCodingAgentMock } from "./_lib/mock-coding-agent.ts"; import { setupGhMock } from "./_lib/mock-gh.ts"; -import { NIMBUS_PROJECT_PATH, PRIMARY_PROJECT_PARENT } from "./_lib/project-fixtures.ts"; +import { waitForProjectListing } from "./_lib/project-lifecycle.ts"; +import { + LUMEN_PROJECT_PATH, + NIMBUS_PROJECT_PATH, + ORION_PROJECT_PATH, +} from "./_lib/project-fixtures.ts"; import { resetFixtures } from "./_lib/reset-fixture.ts"; import { bootstrapThreadFromChannel } from "./_lib/thread-bootstrap.ts"; @@ -12,9 +16,7 @@ const TICKET_ID = "ABC-0140"; export default async function soleProjectInference(ctx: ScenarioContext): Promise { await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx, { - projects: [registeredProject(PROJECT, NIMBUS_PROJECT_PATH, PRIMARY_PROJECT_PARENT)], - }); + await ctx.execInGateway(["rm", "-rf", LUMEN_PROJECT_PATH, ORION_PROJECT_PATH]); const codingAgent = setupCodingAgentMock(ctx); setupGhMock(ctx); @@ -31,7 +33,7 @@ export default async function soleProjectInference(ctx: ScenarioContext): Promis rubric: HANDOFF_ASK_RUBRIC, label: "sole-project-handoff-ask", }); - alproject.assertListCallCount(1); + await waitForProjectListing(ctx, "channel session lists the projects"); ctx.markScenarioAsEnded("PASS"); ctx.log("PASS"); diff --git a/alignfirst-developer-tests/scenarios/A15-duplicate-project-name.ts b/alignfirst-developer-tests/scenarios/A15-duplicate-project-name.ts index 0ecfa82d..36157fcf 100644 --- a/alignfirst-developer-tests/scenarios/A15-duplicate-project-name.ts +++ b/alignfirst-developer-tests/scenarios/A15-duplicate-project-name.ts @@ -1,13 +1,9 @@ import type { ScenarioContext } from "@paleo/openclaw-test"; import { escapeRe } from "./_lib/common-constants.ts"; -import { setupAlprojectMock, registeredProject } from "./_lib/mock-alproject.ts"; import { setupCodingAgentMock } from "./_lib/mock-coding-agent.ts"; import { setupGhMock } from "./_lib/mock-gh.ts"; -import { - EXTERNAL_PROJECT_PARENT, - NIMBUS_PROJECT_PATH, - PRIMARY_PROJECT_PARENT, -} from "./_lib/project-fixtures.ts"; +import { assertGatewayCommand, waitForProjectListing } from "./_lib/project-lifecycle.ts"; +import { EXTERNAL_PROJECT_PARENT, NIMBUS_PROJECT_PATH } from "./_lib/project-fixtures.ts"; import { resetFixtures } from "./_lib/reset-fixture.ts"; import { bootstrapThreadFromChannel } from "./_lib/thread-bootstrap.ts"; @@ -17,12 +13,7 @@ const DUPLICATE_PATH = `${EXTERNAL_PROJECT_PARENT}/${PROJECT}`; export default async function duplicateProjectName(ctx: ScenarioContext): Promise { await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx, { - projects: [ - registeredProject(PROJECT, NIMBUS_PROJECT_PATH, PRIMARY_PROJECT_PARENT), - registeredProject(PROJECT, DUPLICATE_PATH, EXTERNAL_PROJECT_PARENT), - ], - }); + await seedDuplicateProject(ctx); const codingAgent = setupCodingAgentMock(ctx); setupGhMock(ctx); @@ -51,8 +42,22 @@ export default async function duplicateProjectName(ctx: ScenarioContext): Promis "or coding has started.", label: "duplicate-project-path-choice", }); - alproject.assertListCallCount(1); + await waitForProjectListing(ctx, "channel session lists the projects"); ctx.markScenarioAsEnded("PASS"); ctx.log("PASS"); } + +async function seedDuplicateProject(ctx: ScenarioContext): Promise { + await assertGatewayCommand( + ctx, + ["git", "init", "-q", "-b", "main", DUPLICATE_PATH], + "duplicate project git initialization", + ); + const config = JSON.stringify({ schemaVersion: 1, ticketPattern: "^ABC-\\d+$" }, null, 2); + await assertGatewayCommand( + ctx, + ["sh", "-c", `printf '%s\\n' '${config}' > '${DUPLICATE_PATH}/.alignfirst.json'`], + "duplicate project configuration", + ); +} diff --git a/alignfirst-developer-tests/scenarios/A16-external-project-path.ts b/alignfirst-developer-tests/scenarios/A16-external-project-path.ts index 6ae288c3..34d4557c 100644 --- a/alignfirst-developer-tests/scenarios/A16-external-project-path.ts +++ b/alignfirst-developer-tests/scenarios/A16-external-project-path.ts @@ -5,7 +5,7 @@ import { isCodingProtocolPrompt, setupCodingAgentMock, } from "./_lib/mock-coding-agent.ts"; -import { setupAlprojectMock } from "./_lib/mock-alproject.ts"; +import { waitForProjectListing } from "./_lib/project-lifecycle.ts"; import { setupGhMock } from "./_lib/mock-gh.ts"; import { ORION_PROJECT_PATH } from "./_lib/project-fixtures.ts"; import { resetFixtures } from "./_lib/reset-fixture.ts"; @@ -18,7 +18,6 @@ const TICKET_ID = "ABC-0160"; export default async function externalProjectPath(ctx: ScenarioContext): Promise { await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx); const codingAgent = setupCodingAgentMock(ctx); setupGhMock(ctx); @@ -56,7 +55,7 @@ export default async function externalProjectPath(ctx: ScenarioContext): Promise if (delegation === undefined) { throw new Error(`coding delegation did not run from external worktree ${worktreePath}`); } - alproject.assertListCallCount(1); + await waitForProjectListing(ctx, "channel session lists the projects"); ctx.markScenarioAsEnded("PASS"); ctx.log("PASS"); diff --git a/alignfirst-developer-tests/scenarios/A17-project-creation.ts b/alignfirst-developer-tests/scenarios/A17-project-creation.ts index 9ff2ee05..c3dbf68a 100644 --- a/alignfirst-developer-tests/scenarios/A17-project-creation.ts +++ b/alignfirst-developer-tests/scenarios/A17-project-creation.ts @@ -1,6 +1,5 @@ import { readFile } from "node:fs/promises"; -import type { ScenarioContext } from "@paleo/openclaw-test"; -import { setupAlprojectMock } from "./_lib/mock-alproject.ts"; +import type { AgentToolCall, ScenarioContext } from "@paleo/openclaw-test"; import { extractCodingPrompt, isCodingProtocolPrompt, @@ -8,9 +7,10 @@ import { } from "./_lib/mock-coding-agent.ts"; import { setupGhMock } from "./_lib/mock-gh.ts"; import { - assertAlprojectCallOrder, + assertAgentCommandOrder, assertGatewayCommand, pathExists, + readProjectConfig, waitForLifecycle, } from "./_lib/project-lifecycle.ts"; import { waitForReport } from "./_lib/outbound.ts"; @@ -36,10 +36,6 @@ const REQUEST_PATH = `${NOVA_PROJECT_PATH}/.plans/side-1/A1-request.md`; // artifacts: the defect is more likely here than in the bot. export default async function projectCreation(ctx: ScenarioContext): Promise { await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx, { - guide: lifecycleGuide(), - registerBasePort: 6600, - }); const codingAgent = setupCodingAgentMock(ctx, { onPrompt: async (scenario, cwd, prompt) => { if (cwd !== NOVA_PROJECT_PATH) return; @@ -66,8 +62,8 @@ export default async function projectCreation(ctx: ScenarioContext): Promise - pathExists(`${NOVA_PROJECT_PATH}/.git/refs/heads/main`) && - alproject.projects.some((project) => project.mainPath === NOVA_PROJECT_PATH), - // Creation is the suite's longest flow: guide reads, registration, the + () => { + const portRange = readProjectConfig(NOVA_PROJECT_PATH)?.portRange; + return ( + pathExists(`${NOVA_PROJECT_PATH}/.git/refs/heads/main`) && + portRange?.first === 6600 && + portRange.last === 6607 + ); + }, + // Creation is the suite's longest flow: guide reads, port allocation, the // delegated bootstrap, inspection, and the initial commit. { label: "project creation and initial commit", timeoutMs: 300_000 }, ); - assertCreationCalls(alproject.calls); assertSetupGuideDelegation(codingAgent.codingAgentCalls); await assertCreatedRepository(ctx); const agentCalls = await ctx.getAgentToolCalls(); + assertCreationCalls(agentCalls); const linkedWorkspaceBeforeInitialCommit = agentCalls.some((call) => { const command = call.toolName === "exec" ? JSON.stringify(call.input) : ""; return /workspace\s+setup[^;&|]*(?:\s-c\b|--create\b)/.test(command); @@ -166,7 +166,7 @@ async function waitForCreationReport( message: wait.match.text, prompt: `Does this thread message report that the ${PROJECT} project has been CREATED and is ` + - "ready — the bootstrap or initial commit done, or the project registered — and report " + + "ready — the bootstrap or initial commit done, and its `.alignfirst.json` written — and report " + `the full allocated port range ${ALLOCATED_PORT_RANGE}? Any equivalent range notation ` + 'counts. A launch or in-progress announcement ("the agent is working in the ' + 'background", "je te fais signe") is NOT done.', @@ -182,15 +182,6 @@ async function waitForCreationReport( } } -function lifecycleGuide(): string { - return `# alproject guide - -Allowed parent for new lifecycle fixtures: ${LIFECYCLE_PROJECT_PARENT} - -Create Node.js projects with pnpm. Register only after Git initialization. Keep bootstrap work and the initial commit on main. Request port allocation with both dimensions. -`; -} - async function commitNovaBootstrap(ctx: ScenarioContext, message: string): Promise { const result = await ctx.execInGateway( [ @@ -205,6 +196,11 @@ async function commitNovaBootstrap(ctx: ScenarioContext, message: string): Promi } async function copyBootstrapTemplate(ctx: ScenarioContext): Promise { + const config = JSON.stringify( + { schemaVersion: 1, portRange: { first: 6600, last: 6607 } }, + null, + 2, + ); const result = await ctx.execInGateway( [ "sh", @@ -212,40 +208,36 @@ async function copyBootstrapTemplate(ctx: ScenarioContext): Promise { `cp -R /opt/alignfirst-developer-tests/fixtures/template/. "${NOVA_PROJECT_PATH}/" && ` + `mkdir -p "${NOVA_PROJECT_PATH}/.local" && ` + `sed -i -e 's/base: 6500/base: 6600/' ` + - // Match the registration the bot performed (2 ports × 4 workspaces) — + // Match the project config the bot wrote (2 ports × 4 workspaces) — // a verifying bot treats a mismatched template as a bootstrap defect // and loops on fixing it. `-e 's/maxWorkspaces: 10/maxWorkspaces: ${MAX_WORKSPACES}/' ` + - `"${NOVA_PROJECT_PATH}/scripts/workspace/workspace.mjs"`, + `"${NOVA_PROJECT_PATH}/scripts/workspace/workspace.mjs" && ` + + `printf '%s\\n' '${config}' > "${NOVA_PROJECT_PATH}/.alignfirst.json"`, ], { timeoutMs: 30_000 }, ); if (result.exitCode !== 0) throw new Error(`creation bootstrap failed: ${result.stderr}`); } -function assertCreationCalls(calls: ReturnType["calls"]): void { - // Match the effective registration, not an exploratory probe such as - // `register --help` (harmless — the mock rejects it without mutating). - const register = calls.find( - (call) => call.argv[0] === "register" && call.argv[1] === NOVA_PROJECT_PATH, - ); - if (register === undefined) { - throw new Error(`missing register call for ${NOVA_PROJECT_PATH}: ${JSON.stringify(calls)}`); - } - assertOption(register.argv, "--ports-per-workspace", PORTS_PER_WORKSPACE); - assertOption(register.argv, "--max-workspaces", MAX_WORKSPACES); - assertAlprojectCallOrder( +function assertCreationCalls(calls: AgentToolCall[]): void { + assertAgentCommandOrder( calls, - (call) => call.argv.length === 1 && call.argv[0] === "--guide", - (call) => call.argv[0] === "register" && call.argv[1] === NOVA_PROJECT_PATH, - "alproject guide must precede registration", + /alcode\s+projects\s+--guide\b/, + /git\s+init\b/, + "projects guide must precede git initialization", ); -} - -function assertOption(argv: string[], option: string, expected: string): void { - const index = argv.indexOf(option); - if (index === -1 || argv[index + 1] !== expected) { - throw new Error(`expected ${option} ${expected}: ${JSON.stringify(argv)}`); + const commands = calls + .filter((call) => call.toolName === "exec") + .map((call) => JSON.stringify(call.input)); + const freePortsCommand = commands.find((command) => + /alcode\s+projects\s+free-ports\b/.test(command), + ); + if (freePortsCommand === undefined) { + throw new Error(`missing free-ports call: ${JSON.stringify(commands)}`); + } + if (!/--size(?:\s+|=)8\b/.test(freePortsCommand)) { + throw new Error(`free-ports call did not request size 8: ${JSON.stringify(commands)}`); } } diff --git a/alignfirst-developer-tests/scenarios/A18-project-removal.ts b/alignfirst-developer-tests/scenarios/A18-project-removal.ts index a5fcf1cd..bcd9e9cd 100644 --- a/alignfirst-developer-tests/scenarios/A18-project-removal.ts +++ b/alignfirst-developer-tests/scenarios/A18-project-removal.ts @@ -1,20 +1,13 @@ import type { ScenarioContext } from "@paleo/openclaw-test"; -import { execMatches } from "./_lib/agent-tool-calls.ts"; +import { execMatches, listsProjects, nthMatchingCall } from "./_lib/agent-tool-calls.ts"; import { escapeRe } from "./_lib/common-constants.ts"; -import { registeredProject, setupAlprojectMock } from "./_lib/mock-alproject.ts"; +import { assertAgentCommandOrder, pathExists, waitForLifecycle } from "./_lib/project-lifecycle.ts"; import { - assertAgentCommandOrder, - assertAlprojectCallOrder, - pathExists, - waitForLifecycle, -} from "./_lib/project-lifecycle.ts"; -import { - ADDITIONAL_DIRECTORY_NAME, ADDITIONAL_DIRECTORY_PATH, seedRemovalFixture, waitForPathConfirmation, } from "./_lib/project-removal.ts"; -import { NIMBUS_PROJECT_PATH, PRIMARY_PROJECT_PARENT } from "./_lib/project-fixtures.ts"; +import { NIMBUS_PROJECT_PATH } from "./_lib/project-fixtures.ts"; import { resetFixtures } from "./_lib/reset-fixture.ts"; import { bootstrapThreadFromChannel, sendInThread } from "./_lib/thread-bootstrap.ts"; @@ -24,17 +17,6 @@ const TICKET_ID = "ABC-0180"; export default async function projectRemoval(ctx: ScenarioContext): Promise { await resetFixtures(ctx); const fixture = await seedRemovalFixture(ctx, TICKET_ID); - const alproject = setupAlprojectMock(ctx, { - projects: [ - { - ...registeredProject(PROJECT, NIMBUS_PROJECT_PATH, PRIMARY_PROJECT_PARENT), - workspaces: [fixture.workspaceName], - }, - ], - additionalDirectories: [ - { parent: PRIMARY_PROJECT_PARENT, directories: [ADDITIONAL_DIRECTORY_NAME] }, - ], - }); const starter = await bootstrapThreadFromChannel(ctx, { text: `Supprime physiquement le projet ${PROJECT}.`, @@ -44,7 +26,7 @@ export default async function projectRemoval(ctx: ScenarioContext): Promise - !pathExists(fixture.worktreePath) && - !pathExists(NIMBUS_PROJECT_PATH) && - alproject.projects.length === 0 && - alproject.calls.some( - (call) => call.argv[0] === "list" && call.order > (unregisterOrder(alproject.calls) ?? 999), - ), + () => !pathExists(fixture.worktreePath) && !pathExists(NIMBUS_PROJECT_PATH), { label: "confirmed project removal" }, ); + await ctx.waitForAgentToolCall(nthMatchingCall(listsProjects, 3), { + label: "final project listing after removal", + timeoutMs: 60_000, + }); if (!pathExists(ADDITIONAL_DIRECTORY_PATH)) { throw new Error(`additional directory was removed: ${ADDITIONAL_DIRECTORY_PATH}`); } - assertAlprojectCallOrder( - alproject.calls, - (call) => call.argv[0] === "--guide", - (call) => call.argv[0] === "unregister" && call.argv[1] === NIMBUS_PROJECT_PATH, - "guide must precede unregistration", - ); - assertAlprojectCallOrder( - alproject.calls, - (call) => call.argv[0] === "unregister", - (call) => - call.argv[0] === "list" && - call.order > (unregisterOrder(alproject.calls) ?? Number.POSITIVE_INFINITY), - "unregistration must precede the final inventory", - ); // waitForLifecycle proved the removal on the filesystem, but the trajectory // flushes seconds after the turn — ride it out on the later of the two // ordered commands before the one-shot ordering parse. @@ -89,8 +55,15 @@ export default async function projectRemoval(ctx: ScenarioContext): Promise` relative to the main worktree). + const calls = await ctx.getAgentToolCalls(); assertAgentCommandOrder( - await ctx.getAgentToolCalls(), + calls, + /alcode\s+projects\s+--guide\b/, + /\brm\s+-rf?\s+\S*nimbus|workspace\s+remove/, + "guide must precede removal", + ); + assertAgentCommandOrder( + calls, new RegExp(String.raw`workspace\s+remove[^\n]*${escapeRe(fixture.workspaceName)}`), new RegExp(String.raw`\brm\b[^\n]*${escapeRe(NIMBUS_PROJECT_PATH)}`), "workspace tooling must remove the linked worktree before the main worktree", @@ -100,20 +73,8 @@ export default async function projectRemoval(ctx: ScenarioContext): Promise["calls"], - worktreePath: string, -): void { +function assertRemovalHasNotStarted(worktreePath: string): void { if (!pathExists(worktreePath) || !pathExists(NIMBUS_PROJECT_PATH)) { throw new Error("project removal started before path confirmation"); } - if (calls.some((call) => call.argv[0] === "unregister")) { - throw new Error("project was unregistered before path confirmation"); - } -} - -function unregisterOrder( - calls: ReturnType["calls"], -): number | undefined { - return calls.find((call) => call.argv[0] === "unregister")?.order; } diff --git a/alignfirst-developer-tests/scenarios/A19-project-removal-failure.ts b/alignfirst-developer-tests/scenarios/A19-project-removal-failure.ts index 9dd20645..f4658d14 100644 --- a/alignfirst-developer-tests/scenarios/A19-project-removal-failure.ts +++ b/alignfirst-developer-tests/scenarios/A19-project-removal-failure.ts @@ -1,14 +1,12 @@ import type { ScenarioContext } from "@paleo/openclaw-test"; import { execMatches } from "./_lib/agent-tool-calls.ts"; -import { registeredProject, setupAlprojectMock } from "./_lib/mock-alproject.ts"; -import { pathExists } from "./_lib/project-lifecycle.ts"; +import { pathExists, readProjectConfig } from "./_lib/project-lifecycle.ts"; import { - ADDITIONAL_DIRECTORY_NAME, ADDITIONAL_DIRECTORY_PATH, seedRemovalFixture, waitForPathConfirmation, } from "./_lib/project-removal.ts"; -import { NIMBUS_PROJECT_PATH, PRIMARY_PROJECT_PARENT } from "./_lib/project-fixtures.ts"; +import { NIMBUS_PROJECT_PATH } from "./_lib/project-fixtures.ts"; import { resetFixtures } from "./_lib/reset-fixture.ts"; import { bootstrapThreadFromChannel, sendInThread } from "./_lib/thread-bootstrap.ts"; @@ -18,17 +16,6 @@ const TICKET_ID = "ABC-0190"; export default async function projectRemovalFailure(ctx: ScenarioContext): Promise { await resetFixtures(ctx); const fixture = await seedRemovalFixture(ctx, TICKET_ID, true); - const alproject = setupAlprojectMock(ctx, { - projects: [ - { - ...registeredProject(PROJECT, NIMBUS_PROJECT_PATH, PRIMARY_PROJECT_PARENT), - workspaces: [fixture.workspaceName], - }, - ], - additionalDirectories: [ - { parent: PRIMARY_PROJECT_PARENT, directories: [ADDITIONAL_DIRECTORY_NAME] }, - ], - }); const starter = await bootstrapThreadFromChannel(ctx, { text: `Supprime physiquement le projet ${PROJECT}.`, @@ -61,12 +48,8 @@ export default async function projectRemovalFailure(ctx: ScenarioContext): Promi for (const path of [fixture.worktreePath, NIMBUS_PROJECT_PATH, ADDITIONAL_DIRECTORY_PATH]) { if (!pathExists(path)) throw new Error(`failure recovery removed ${path}`); } - if (!alproject.projects.some((project) => project.mainPath === NIMBUS_PROJECT_PATH)) { - throw new Error("failure recovery changed the registry state"); - } - if (alproject.calls.some((call) => call.argv[0] === "unregister")) { - throw new Error(`unregister ran after workspace failure: ${JSON.stringify(alproject.calls)}`); - } + if (readProjectConfig(NIMBUS_PROJECT_PATH) === undefined) + throw new Error("failure recovery removed the project config"); ctx.markScenarioAsEnded("PASS"); ctx.log("PASS"); diff --git a/alignfirst-developer-tests/scenarios/A20-ambiguous-project-mention.ts b/alignfirst-developer-tests/scenarios/A20-ambiguous-project-mention.ts index b006e970..bef49306 100644 --- a/alignfirst-developer-tests/scenarios/A20-ambiguous-project-mention.ts +++ b/alignfirst-developer-tests/scenarios/A20-ambiguous-project-mention.ts @@ -1,8 +1,8 @@ import type { ScenarioContext } from "@paleo/openclaw-test"; import { HANDOFF_ASK_RUBRIC } from "./_lib/common-constants.ts"; -import { setupAlprojectMock } from "./_lib/mock-alproject.ts"; import { setupCodingAgentMock } from "./_lib/mock-coding-agent.ts"; import { setupGhMock } from "./_lib/mock-gh.ts"; +import { waitForProjectListing } from "./_lib/project-lifecycle.ts"; import { ORION_PROJECT_PATH } from "./_lib/project-fixtures.ts"; import { resetFixtures } from "./_lib/reset-fixture.ts"; import { bootstrapThreadFromChannel } from "./_lib/thread-bootstrap.ts"; @@ -10,17 +10,16 @@ import { bootstrapThreadFromChannel } from "./_lib/thread-bootstrap.ts"; const PROJECT = "orion"; /** - * A casual message naming a registered project with no work framing. The + * A casual message naming a listed project with no work framing. The * off-projects contract exempts only messages with no possible project * reference, and "orion" is exactly the word the bot cannot classify from - * memory: it must consult `alproject list --json`, recognize the project, and open a + * memory: it must consult `alcode projects list --json`, recognize the project, and open a * thread whose starter carries the canonical path. Misclassifying the message * as small talk is the failure this scenario exists to catch. */ export default async function ambiguousProjectMention(ctx: ScenarioContext): Promise { ctx.log(`channel: ${ctx.channel}, conversationId: ${ctx.conversationId}`); await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx); const codingAgent = setupCodingAgentMock(ctx); setupGhMock(ctx); @@ -36,9 +35,7 @@ export default async function ambiguousProjectMention(ctx: ScenarioContext): Pro rubric: HANDOFF_ASK_RUBRIC, label: "ambiguous-mention-handoff-ask", }); - if (!alproject.calls.some((call) => call.argv[0] === "list" && call.argv[1] === "--json")) { - throw new Error("the session routed the project mention without structured inventory lookup"); - } + await waitForProjectListing(ctx, "channel session lists the projects"); ctx.markScenarioAsEnded("PASS"); ctx.log("PASS"); diff --git a/alignfirst-developer-tests/scenarios/A21-action-without-project-or-ticket.ts b/alignfirst-developer-tests/scenarios/A21-action-without-project-or-ticket.ts index ab92e309..f3a81136 100644 --- a/alignfirst-developer-tests/scenarios/A21-action-without-project-or-ticket.ts +++ b/alignfirst-developer-tests/scenarios/A21-action-without-project-or-ticket.ts @@ -1,5 +1,5 @@ import type { ScenarioContext } from "@paleo/openclaw-test"; -import { setupAlprojectMock } from "./_lib/mock-alproject.ts"; +import { waitForProjectListing } from "./_lib/project-lifecycle.ts"; import { setupCodingAgentMock } from "./_lib/mock-coding-agent.ts"; import { setupGhMock } from "./_lib/mock-gh.ts"; import { resetFixtures } from "./_lib/reset-fixture.ts"; @@ -13,7 +13,6 @@ import { bootstrapThreadFromChannel } from "./_lib/thread-bootstrap.ts"; export default async function actionWithoutProjectOrTicket(ctx: ScenarioContext): Promise { ctx.log(`channel: ${ctx.channel}, conversationId: ${ctx.conversationId}`); await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx); const codingAgent = setupCodingAgentMock(ctx); setupGhMock(ctx); @@ -31,7 +30,7 @@ export default async function actionWithoutProjectOrTicket(ctx: ScenarioContext) "setup, or coding has started. The question may be in French.", label: "action-without-project-or-ticket-handoff", }); - alproject.assertListCallCount(1); + await waitForProjectListing(ctx, "channel session lists the projects"); ctx.markScenarioAsEnded("PASS"); ctx.log("PASS"); diff --git a/alignfirst-developer-tests/scenarios/A22-missing-project.ts b/alignfirst-developer-tests/scenarios/A22-missing-project.ts deleted file mode 100644 index 9e4da110..00000000 --- a/alignfirst-developer-tests/scenarios/A22-missing-project.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { ScenarioContext } from "@paleo/openclaw-test"; -import { setupAlprojectMock } from "./_lib/mock-alproject.ts"; -import { setupCodingAgentMock } from "./_lib/mock-coding-agent.ts"; -import { setupGhMock } from "./_lib/mock-gh.ts"; -import { PRIMARY_PROJECT_PARENT } from "./_lib/project-fixtures.ts"; -import { resetFixtures } from "./_lib/reset-fixture.ts"; -import { bootstrapThreadFromChannel } from "./_lib/thread-bootstrap.ts"; - -const PROJECT = "ghost"; -const MISSING_PROJECT_PATH = `${PRIMARY_PROJECT_PARENT}/${PROJECT}`; - -export default async function missingProject(ctx: ScenarioContext): Promise { - await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx, { - projects: [ - { - name: PROJECT, - mainPath: MISSING_PROJECT_PATH, - parent: PRIMARY_PROJECT_PARENT, - status: "missing", - }, - ], - }); - const codingAgent = setupCodingAgentMock(ctx); - setupGhMock(ctx); - - const starter = await bootstrapThreadFromChannel(ctx, { - text: `Sur ${PROJECT}, analyse pourquoi les tests sont lents.`, - project: PROJECT, - codingAgent, - }); - const projectPathLine = starter.match.text - .split(/\r?\n/u) - .filter((line) => line.trim().length > 0)[1]; - if (projectPathLine?.includes(MISSING_PROJECT_PATH)) { - throw new Error(`missing project was routed as usable: ${JSON.stringify(projectPathLine)}`); - } - await ctx.judgeLLM({ - attachTo: starter.entry, - message: starter.match.text, - rubric: - `A thread-opening handoff for investigating slow tests in ${PROJECT}. It explains that ` + - "the registered project is missing from the filesystem or otherwise lacks a usable path, " + - "and asks the user for a usable registered project path. It does not claim that inspection " + - "or work has started.", - label: "missing-project-discrepancy", - }); - alproject.assertListCallCount(1); - - ctx.markScenarioAsEnded("PASS"); - ctx.log("PASS"); -} diff --git a/alignfirst-developer-tests/scenarios/A23-resource-url-handoff.ts b/alignfirst-developer-tests/scenarios/A23-resource-url-handoff.ts index 40b9218b..aab523e6 100644 --- a/alignfirst-developer-tests/scenarios/A23-resource-url-handoff.ts +++ b/alignfirst-developer-tests/scenarios/A23-resource-url-handoff.ts @@ -2,7 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import type { ScenarioContext } from "@paleo/openclaw-test"; import { waitForCodingSessionSucceeded } from "./_lib/coding-session.ts"; import { assertBranchForTicket, seedBranch, waitForAnyWorktreeDir } from "./_lib/fixture-state.ts"; -import { setupAlprojectMock } from "./_lib/mock-alproject.ts"; +import { waitForProjectListing } from "./_lib/project-lifecycle.ts"; import { expectCodingDelegation, extractCodingPrompt, @@ -23,12 +23,11 @@ const REVIEW_RESULT = export default async function resourceUrlHandoff(ctx: ScenarioContext): Promise { await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx); await seedBranch(ctx, NIMBUS_PROJECT_PATH, TICKET_ID, "review-export"); const codingAgent = setupCodingAgentMock(ctx, { streamDelayMs: 12_000, onPrompt: async (_scenario, cwd, prompt) => { - if (!/^Run the _review_ protocol/iu.test(prompt)) return; + if (!/^Run `alignfirst guide review` and follow the protocol\./u.test(prompt)) return; await writeReviewFile(cwd); return REVIEW_RESULT; }, @@ -59,7 +58,7 @@ export default async function resourceUrlHandoff(ctx: ScenarioContext): Promise< "request has already been read.", label: "resource-url-deferred-to-working-session", }); - alproject.assertListCallCount(1); + await waitForProjectListing(ctx, "channel session lists the projects"); const goAheadCursor = await sendInThread(ctx, starter.threadId, "Vas-y."); const { dir: worktreeDir } = await waitForAnyWorktreeDir(NIMBUS_PROJECT_PATH, TICKET_ID, { @@ -69,7 +68,10 @@ export default async function resourceUrlHandoff(ctx: ScenarioContext): Promise< const reviewCall = await expectCodingDelegation(ctx, codingAgent, { ticketId: TICKET_ID, - matches: (call) => /^Run the _review_ protocol/iu.test(extractCodingPrompt(call) ?? ""), + matches: (call) => + /^Run `alignfirst guide review` and follow the protocol\./u.test( + extractCodingPrompt(call) ?? "", + ), rubric: "Grade only the captured alcode delegation text. Pass if it invokes the AlignFirst review " + `protocol for ticket ${TICKET_ID}. Reject only if the ticket is wrong or it invokes a change, ` + diff --git a/alignfirst-developer-tests/scenarios/A24-multi-project-handoff.ts b/alignfirst-developer-tests/scenarios/A24-multi-project-handoff.ts index b2680834..68483522 100644 --- a/alignfirst-developer-tests/scenarios/A24-multi-project-handoff.ts +++ b/alignfirst-developer-tests/scenarios/A24-multi-project-handoff.ts @@ -1,6 +1,5 @@ import type { ScenarioContext } from "@paleo/openclaw-test"; import { escapeRe } from "./_lib/common-constants.ts"; -import { setupAlprojectMock } from "./_lib/mock-alproject.ts"; import { expectNoProtocolDelegation, setupCodingAgentMock } from "./_lib/mock-coding-agent.ts"; import { setupGhMock } from "./_lib/mock-gh.ts"; import { LUMEN_PROJECT_PATH, NIMBUS_PROJECT_PATH } from "./_lib/project-fixtures.ts"; @@ -11,7 +10,6 @@ const TASK = "Rafraîchis les branches de base de nimbus et lumen."; export default async function multiProjectHandoff(ctx: ScenarioContext): Promise { await resetFixtures(ctx); - setupAlprojectMock(ctx); const codingAgent = setupCodingAgentMock(ctx, { defaultResult: "Base branch refreshed from origin/main. Dependencies are current.", }); @@ -67,7 +65,7 @@ async function expectBaseRefreshDelegation( `protocol. Pass when the prompt asks the coding agent, from the ${project} project, to ` + "refresh the base branch from its remote and perform any required dependency, build, or " + "migration refresh. Reject an AlignFirst protocol invocation in the prompt " + - "(`Run the _spec_ protocol …` and similar).", + "(`Run `alignfirst guide spec` and follow the protocol.` and similar).", label: `${project}-base-refresh-delegation`, timeoutMs: 300_000, }); diff --git a/alignfirst-developer-tests/scenarios/A25-detailed-request-handoff.ts b/alignfirst-developer-tests/scenarios/A25-detailed-request-handoff.ts index 4cef1337..1a6294ba 100644 --- a/alignfirst-developer-tests/scenarios/A25-detailed-request-handoff.ts +++ b/alignfirst-developer-tests/scenarios/A25-detailed-request-handoff.ts @@ -1,6 +1,6 @@ import type { ScenarioContext } from "@paleo/openclaw-test"; import { assertBranchForTicket, waitForAnyWorktreeDir } from "./_lib/fixture-state.ts"; -import { setupAlprojectMock } from "./_lib/mock-alproject.ts"; +import { waitForProjectListing } from "./_lib/project-lifecycle.ts"; import { expectCodingDelegation, setupCodingAgentMock } from "./_lib/mock-coding-agent.ts"; import { setupGhMock } from "./_lib/mock-gh.ts"; import { waitForReport } from "./_lib/outbound.ts"; @@ -18,7 +18,6 @@ const REQUEST = `Sur nimbus, réorganise la page d'export. export default async function detailedRequestHandoff(ctx: ScenarioContext): Promise { await resetFixtures(ctx); - const alproject = setupAlprojectMock(ctx); const codingAgent = setupCodingAgentMock(ctx); setupGhMock(ctx); @@ -40,7 +39,7 @@ export default async function detailedRequestHandoff(ctx: ScenarioContext): Prom "the user's next message launches the working session), and claims no work has started.", label: "detailed-request-preserved", }); - alproject.assertListCallCount(1); + await waitForProjectListing(ctx, "channel session lists the projects"); const firstWakeCursor = await sendInThread(ctx, starter.threadId, "Vas-y."); const ticketQuestion = await waitForReport( diff --git a/alignfirst-developer-tests/scenarios/A26-explicit-no-ticket.ts b/alignfirst-developer-tests/scenarios/A26-explicit-no-ticket.ts index f4ed8c4a..c686cc74 100644 --- a/alignfirst-developer-tests/scenarios/A26-explicit-no-ticket.ts +++ b/alignfirst-developer-tests/scenarios/A26-explicit-no-ticket.ts @@ -2,7 +2,7 @@ import { mkdir, readdir, writeFile } from "node:fs/promises"; import { basename, dirname } from "node:path"; import type { ScenarioContext } from "@paleo/openclaw-test"; import { assertBranchForTicket, waitForAnyWorktreeDir } from "./_lib/fixture-state.ts"; -import { setupAlprojectMock } from "./_lib/mock-alproject.ts"; +import { waitForProjectListing } from "./_lib/project-lifecycle.ts"; import { expectCodingDelegation, setupCodingAgentMock } from "./_lib/mock-coding-agent.ts"; import { setupGhMock } from "./_lib/mock-gh.ts"; import { NIMBUS_PROJECT_PATH } from "./_lib/project-fixtures.ts"; @@ -21,7 +21,6 @@ const REQUEST_PATH = `${NIMBUS_PROJECT_PATH}/.plans/${RESERVED_TICKET_ID}/A1-req export default async function explicitNoTicket(ctx: ScenarioContext): Promise { await resetFixtures(ctx); await seedPriorNoTicketWork(); - const alproject = setupAlprojectMock(ctx); const codingAgent = setupCodingAgentMock(ctx); setupGhMock(ctx); @@ -66,7 +65,7 @@ export default async function explicitNoTicket(ctx: ScenarioContext): Promise { return call.input && typeof call.input === "object" ? (call.input as Record) @@ -54,6 +56,11 @@ export function invokesAlcode(call: AgentToolCall): boolean { ); } +export function listsProjects(call: AgentToolCall): boolean { + const command = execCommandOf(call); + return command !== undefined && PROJECT_LIST_JSON_RE.test(command); +} + /** True when the call is an `exec` that invokes Claude or Codex directly. */ export function invokesCodingAgentDirectly(call: AgentToolCall): boolean { const input = inputOf(call); diff --git a/alignfirst-developer-tests/scenarios/_lib/common-constants.ts b/alignfirst-developer-tests/scenarios/_lib/common-constants.ts index cde2c710..a7b709f2 100644 --- a/alignfirst-developer-tests/scenarios/_lib/common-constants.ts +++ b/alignfirst-developer-tests/scenarios/_lib/common-constants.ts @@ -8,7 +8,7 @@ export const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, // The channel session opens the thread and stops there, so its starter may // announce and ask — never act. Judged on every bootstrap. export const STARTER_HANDS_OFF_RUBRIC = - "A thread-opening message from a chat bot, handing the work over to the thread. Judge only what the BOT claims to have ALREADY done or observed — never what the user asked for, and never what the bot promises to do later. Accept: an announcement of the project / project path / ticket; a restatement of the user's request; the full text of a detailed request; a request to the user (a missing ticket id or project, a path choice, a scope question, or an ask for a message in the thread so the work can start); an acknowledgement that a project the user named isn't in the bot's project inventory; mentions of what the alproject inventory shows (known project names, canonical paths, an existing workspace name) — checking that inventory is part of collecting the handoff; a remark that the handoff values are complete / nothing is missing ('le ticket est complet', 'j'ai tout ce qu'il faut') — that is about the collected values, not a repo inspection. Accept every promise about what happens after the user replies — setting up the workspace, launching the code immediately, examining branches / commits / PRs, investigating, reporting back: announcing the thread session's future job IS the handoff, whatever the work is. Tense tie-breaker: the message ends by asking the user for a reply, so nothing is running yet — read any sentence that could be either a present action or a plan ('je lance le travail', 'I'm setting up') as a plan. Reject only a claim of a COMPLETED or IN-PROGRESS act beyond opening the thread — a workspace / worktree / branch / dev server it says it created or has underway, a coding agent it says it launched, or concrete findings that required inspecting the repo (a branch's state, commit contents, PR status) rather than the project inventory."; + "A thread-opening message from a chat bot, handing the work over to the thread. Judge only what the BOT claims to have ALREADY done or observed — never what the user asked for, and never what the bot promises to do later. Accept: an announcement of the project / project path / ticket; a restatement of the user's request; the full text of a detailed request; a request to the user (a missing ticket id or project, a path choice, a scope question, or an ask for a message in the thread so the work can start); an acknowledgement that a project the user named isn't in the bot's project inventory; mentions of what the project inventory (`alcode projects list --json`) shows (known project names, canonical paths, an existing workspace name) — checking that inventory is part of collecting the handoff; a remark that the handoff values are complete / nothing is missing ('le ticket est complet', 'j'ai tout ce qu'il faut') — that is about the collected values, not a repo inspection. Accept every promise about what happens after the user replies — setting up the workspace, launching the code immediately, examining branches / commits / PRs, investigating, reporting back: announcing the thread session's future job IS the handoff, whatever the work is. Tense tie-breaker: the message ends by asking the user for a reply, so nothing is running yet — read any sentence that could be either a present action or a plan ('je lance le travail', 'I'm setting up') as a plan. Reject only a claim of a COMPLETED or IN-PROGRESS act beyond opening the thread — a workspace / worktree / branch / dev server it says it created or has underway, a coding agent it says it launched, or concrete findings that required inspecting the repo (a branch's state, commit contents, PR status) rather than the project inventory."; export const HANDOFF_ASK_RUBRIC = `A message bringing the user back to the thread so the bot can start working. Three forms count: an explicit request for a reply ("réponds ici pour que je démarre", "un message ici et je m'y mets"); a statement that the user's next message launches the work / the thread session ("La session du thread sera lancée par le prochain message"); or a direct question to the user (a missing ticket id, a scope question) — asking is itself the request for a reply. Reject only a message that ends with neither a question nor any mention of the user's next message.`; diff --git a/alignfirst-developer-tests/scenarios/_lib/mock-alproject.ts b/alignfirst-developer-tests/scenarios/_lib/mock-alproject.ts deleted file mode 100644 index 792ffdc0..00000000 --- a/alignfirst-developer-tests/scenarios/_lib/mock-alproject.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { existsSync } from "node:fs"; -import { basename, dirname } from "node:path"; -import type { ScenarioContext } from "@paleo/openclaw-test"; -import { - EXTERNAL_PROJECT_PARENT, - LUMEN_PROJECT_PATH, - NIMBUS_PROJECT_PATH, - ORION_PROJECT_PATH, - PRIMARY_PROJECT_PARENT, -} from "./project-fixtures.ts"; - -const DEFAULT_PROJECTS: AlprojectRecord[] = [ - registeredProject("orion", ORION_PROJECT_PATH, EXTERNAL_PROJECT_PARENT), - registeredProject("lumen", LUMEN_PROJECT_PATH, PRIMARY_PROJECT_PARENT), - registeredProject("nimbus", NIMBUS_PROJECT_PATH, PRIMARY_PROJECT_PARENT), -]; - -export interface AlprojectRecord { - name: string; - mainPath: string; - parent: string; - status?: "registered" | "unregistered" | "missing"; - workspaces?: string[]; - basePort?: number; - endPort?: number; - maxWorkspaces?: number; - portsPerWorkspace?: number; -} - -export interface AdditionalDirectoryGroup { - parent: string; - directories: string[]; -} - -export interface AlprojectMockCall { - argv: string[]; - cwd: string; - order: number; -} - -export interface AlprojectMockHandle { - calls: AlprojectMockCall[]; - projects: AlprojectRecord[]; - assertListCallCount(expected: number): void; -} - -export interface AlprojectCommandResponse { - stdout?: string; - stderr?: string; - exitCode?: number; -} - -export interface SetupAlprojectMockOptions { - projects?: AlprojectRecord[]; - additionalDirectories?: AdditionalDirectoryGroup[]; - guide?: string; - guideResponse?: AlprojectCommandResponse; - registerResponse?: AlprojectCommandResponse; - unregisterResponse?: AlprojectCommandResponse; - registerBasePort?: number; -} - -export function setupAlprojectMock( - ctx: ScenarioContext, - options: SetupAlprojectMockOptions = {}, -): AlprojectMockHandle { - const calls: AlprojectMockCall[] = []; - const projects = (options.projects ?? DEFAULT_PROJECTS).map((project) => ({ ...project })); - const additionalDirectories = options.additionalDirectories ?? []; - - ctx.mockCli("alproject", async ({ argv, cwd, stdout, stderr }) => { - calls.push({ argv: [...argv], cwd, order: calls.length + 1 }); - if (argv.length === 2 && argv[0] === "list" && argv[1] === "--json") { - stdout.write(renderAlprojectJson(projects, additionalDirectories)); - return 0; - } - if (argv.length === 1 && argv[0] === "list") { - stdout.write(renderAlprojectList(projects, additionalDirectories)); - return 0; - } - if (argv.length === 1 && argv[0] === "--guide") { - return writeResponse( - options.guideResponse ?? { stdout: options.guide ?? defaultLifecycleGuide() }, - stdout, - stderr, - ); - } - // Read-only modes the real CLI serves; rejecting them would fail a scenario - // over a harmless orienting call (A17 Discord ran `--help` before `--guide`). - if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) { - stdout.write(helpText()); - return 0; - } - if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "-v")) { - stdout.write("0.1.0\n"); - return 0; - } - if (argv[0] === "register" && argv[1] !== undefined) { - return registerProject(argv, projects, options, stdout, stderr); - } - if (argv.length === 2 && argv[0] === "unregister") { - return unregisterProject(argv[1], projects, options, stdout, stderr); - } - throw new Error(`unexpected alproject invocation: ${JSON.stringify(argv)}`); - }); - - return { - calls, - projects, - assertListCallCount(expected) { - const listCalls = calls.filter((call) => call.argv[0] === "list"); - if (listCalls.length !== expected) { - throw new Error( - `expected ${expected} alproject list call(s), got ${listCalls.length}: ${JSON.stringify(calls)}`, - ); - } - for (const [index, call] of calls.entries()) { - if (call.order !== index + 1) throw new Error("alproject call order is inconsistent"); - } - }, - }; -} - -function registerProject( - argv: string[], - projects: AlprojectRecord[], - options: SetupAlprojectMockOptions, - stdout: { write(chunk: string): void }, - stderr: { write(chunk: string): void }, -): number { - const path = argv[1]; - // The real CLI rejects a global mode or option where belongs - // ("invalid combinations print a concise error and exit non-zero"). - if (path?.startsWith("-")) { - stderr.write( - "alproject: register requires first. " + - "Usage: alproject register [--ports-per-workspace --max-workspaces ]\n", - ); - return 1; - } - if (!existsSync(`${path}/.git`)) { - stderr.write(`mock-alproject: register before .git exists: ${path}\n`); - return 1; - } - const response = options.registerResponse; - if ((response?.exitCode ?? 0) !== 0) return writeResponse(response ?? {}, stdout, stderr); - const portsPerWorkspace = numericOption(argv, "--ports-per-workspace"); - const maxWorkspaces = numericOption(argv, "--max-workspaces"); - const basePort = portsPerWorkspace === undefined ? undefined : (options.registerBasePort ?? 6600); - const reservedPorts = (portsPerWorkspace ?? 1) * (maxWorkspaces ?? 1); - projects.push({ - name: basename(path), - mainPath: path, - parent: dirname(path), - status: "registered", - ...(basePort === undefined - ? {} - : { - basePort, - endPort: basePort + reservedPorts - 1, - maxWorkspaces, - portsPerWorkspace, - }), - }); - const defaultOutput = - basePort === undefined - ? `Registered project: ${JSON.stringify(path)}\n` - : `Registered project: ${JSON.stringify(path)}\nBase port: ${basePort}\n` + - `Port range: ${basePort}..${basePort + reservedPorts - 1}\n`; - return writeResponse({ stdout: response?.stdout ?? defaultOutput }, stdout, stderr); -} - -function unregisterProject( - path: string, - projects: AlprojectRecord[], - options: SetupAlprojectMockOptions, - stdout: { write(chunk: string): void }, - stderr: { write(chunk: string): void }, -): number { - const response = options.unregisterResponse; - if ((response?.exitCode ?? 0) !== 0) return writeResponse(response ?? {}, stdout, stderr); - const index = projects.findIndex((project) => project.mainPath === path); - if (index === -1) { - stderr.write(`mock-alproject: unknown project: ${path}\n`); - return 1; - } - projects.splice(index, 1); - return writeResponse( - { stdout: response?.stdout ?? `Unregistered project: ${JSON.stringify(path)}\n` }, - stdout, - stderr, - ); -} - -function writeResponse( - response: AlprojectCommandResponse, - stdout: { write(chunk: string): void }, - stderr: { write(chunk: string): void }, -): number { - if (response.stdout !== undefined) stdout.write(response.stdout); - if (response.stderr !== undefined) stderr.write(response.stderr); - return response.exitCode ?? 0; -} - -function numericOption(argv: string[], name: string): number | undefined { - const index = argv.indexOf(name); - if (index === -1 || argv[index + 1] === undefined) return; - const value = Number(argv[index + 1]); - return Number.isInteger(value) ? value : undefined; -} - -function helpText(): string { - return `alproject — project registry and port allocator - -Commands: - alproject list [--json] - alproject register [--ports-per-workspace --max-workspaces ] - alproject unregister - -Modes: --guide (complete procedures), --help, -v/--version -`; -} - -function defaultLifecycleGuide(): string { - return `# alproject guide - -Allowed project parents: - -- /home/claw/projects -- /home/claw/external-projects -- /home/claw/lifecycle-projects - -Create Node projects with npm. Keep bootstrap work in the main worktree through the initial commit. -Use the project's documented workspace command for linked-worktree removal. -`; -} - -export function registeredProject(name: string, mainPath: string, parent: string): AlprojectRecord { - return { name, mainPath, parent, status: "registered" }; -} - -function renderAlprojectList( - projects: AlprojectRecord[], - additionalDirectories: AdditionalDirectoryGroup[], -): string { - const lines = ["Projects:"]; - if (projects.length === 0) lines.push(" (none)"); - for (const project of projects) { - lines.push( - `- Name: ${JSON.stringify(project.name)}`, - ` Main path: ${JSON.stringify(project.mainPath)}`, - ` Parent: ${JSON.stringify(project.parent)}`, - ` Status: ${statusLabel(project.status ?? "registered")}`, - ` Workspaces: ${ - project.workspaces === undefined || project.workspaces.length === 0 - ? "(none)" - : project.workspaces.map((workspace) => JSON.stringify(workspace)).join(", ") - }`, - ); - if (project.basePort !== undefined) lines.push(` Base port: ${project.basePort}`); - if (project.endPort !== undefined) - lines.push(` Port range: ${project.basePort}..${project.endPort}`); - } - lines.push("", "Additional directories:"); - if (additionalDirectories.length === 0) lines.push(" (none)"); - for (const group of additionalDirectories) { - lines.push(`- Parent: ${JSON.stringify(group.parent)}`); - for (const directory of group.directories) lines.push(` - ${JSON.stringify(directory)}`); - } - return `${lines.join("\n")}\n`; -} - -function statusLabel(status: NonNullable): string { - if (status === "missing") return "registered but missing from filesystem"; - if (status === "unregistered") return "unregistered on filesystem"; - return "registered"; -} - -function renderAlprojectJson( - projects: AlprojectRecord[], - additionalDirectories: AdditionalDirectoryGroup[], -): string { - return `${JSON.stringify( - { - projects: projects.map((project) => ({ - name: project.name, - parent: project.parent, - path: project.mainPath, - status: project.status ?? "registered", - workspaces: project.workspaces ?? [], - ...(project.basePort === undefined - ? {} - : { - ports: { - basePort: project.basePort, - endPort: project.endPort, - maxWorkspaces: project.maxWorkspaces, - portsPerWorkspace: project.portsPerWorkspace, - }, - }), - })), - additionalDirectories, - }, - undefined, - 2, - )}\n`; -} diff --git a/alignfirst-developer-tests/scenarios/_lib/mock-coding-agent.ts b/alignfirst-developer-tests/scenarios/_lib/mock-coding-agent.ts index 84fe7499..8f0f92ef 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|catchup|review|merge)_ protocol from the \*alignfirst\* skill\./; + /^Run `alignfirst guide (spec|plan|aad|description|catchup|review|merge)` and follow the protocol\./; // 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 @@ -698,5 +698,5 @@ export async function expectCodingDelegation( } function defaultCodingDelegationRubric(ticketId: string): string { - return `The message is a prompt sent to a coding agent via the \`alcode\` CLI. Expected: an alignfirst protocol invocation — \`Run the _spec_ protocol …\`, \`Run the _AAD_ protocol …\`, \`Run the _plan_ protocol …\`, etc. — including ticket id ${ticketId} and a description of the actual task: making the export button bold (paraphrases of "passer le bouton d'export en gras" are fine). Reject if: the ticket id is missing or wrong, the task description is missing or unrelated, or the prompt does not look like an alignfirst protocol invocation.`; + return `The message is a prompt sent to a coding agent via the \`alcode\` CLI. Expected: an AlignFirst protocol invocation — \`Run \`alignfirst guide spec\` and follow the protocol. Ticket ID = …\`, or the equivalent with another lowercase protocol name — including ticket id ${ticketId} and a description of the actual task: making the export button bold (paraphrases of "passer le bouton d'export en gras" are fine). Reject if: the ticket id is missing or wrong, the task description is missing or unrelated, or the prompt does not look like an AlignFirst protocol invocation.`; } diff --git a/alignfirst-developer-tests/scenarios/_lib/project-fixtures.ts b/alignfirst-developer-tests/scenarios/_lib/project-fixtures.ts index dc79a48b..cb133d7f 100644 --- a/alignfirst-developer-tests/scenarios/_lib/project-fixtures.ts +++ b/alignfirst-developer-tests/scenarios/_lib/project-fixtures.ts @@ -1,6 +1,7 @@ export const PRIMARY_PROJECT_PARENT = "/home/claw/projects"; -export const EXTERNAL_PROJECT_PARENT = "/home/claw/external-projects"; -export const LIFECYCLE_PROJECT_PARENT = "/home/claw/lifecycle-projects"; +export const EXTERNAL_PROJECT_PARENT = `${PRIMARY_PROJECT_PARENT}/external-projects`; +export const LIFECYCLE_PROJECT_PARENT = `${PRIMARY_PROJECT_PARENT}/lifecycle-projects`; +export const PROJECT_CONFIG_FILENAME = ".alignfirst.json"; export const NIMBUS_PROJECT_PATH = `${PRIMARY_PROJECT_PARENT}/nimbus`; export const LUMEN_PROJECT_PATH = `${PRIMARY_PROJECT_PARENT}/lumen`; diff --git a/alignfirst-developer-tests/scenarios/_lib/project-lifecycle.ts b/alignfirst-developer-tests/scenarios/_lib/project-lifecycle.ts index 44464417..86b6da1e 100644 --- a/alignfirst-developer-tests/scenarios/_lib/project-lifecycle.ts +++ b/alignfirst-developer-tests/scenarios/_lib/project-lifecycle.ts @@ -1,13 +1,22 @@ -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import type { AgentToolCall, ScenarioContext } from "@paleo/openclaw-test"; -import { execCommandOf } from "./agent-tool-calls.ts"; -import type { AlprojectMockCall } from "./mock-alproject.ts"; +import { execCommandOf, listsProjects } from "./agent-tool-calls.ts"; +import { PROJECT_CONFIG_FILENAME } from "./project-fixtures.ts"; export interface WaitForLifecycleOptions { timeoutMs?: number; label: string; } +export interface ProjectConfig { + portRange?: PortRange; +} + +interface PortRange { + first: number; + last: number; +} + export async function waitForLifecycle( predicate: () => boolean, { timeoutMs = 180_000, label }: WaitForLifecycleOptions, @@ -32,17 +41,29 @@ export async function assertGatewayCommand( return result.stdout.trim(); } -export function assertAlprojectCallOrder( - calls: AlprojectMockCall[], - first: (call: AlprojectMockCall) => boolean, - second: (call: AlprojectMockCall) => boolean, - label: string, -): void { - const firstIndex = calls.findIndex(first); - const secondIndex = calls.findIndex(second); - if (firstIndex === -1 || secondIndex === -1 || firstIndex >= secondIndex) { - throw new Error(`${label}: ${JSON.stringify(calls)}`); +export async function waitForProjectListing(ctx: ScenarioContext, label: string): Promise { + await ctx.waitForAgentToolCall(listsProjects, { label }); +} + +export function readProjectConfig(path: string): ProjectConfig | undefined { + const configPath = `${path}/${PROJECT_CONFIG_FILENAME}`; + if (!existsSync(configPath)) return; + const value: unknown = JSON.parse(readFileSync(configPath, "utf8")); + if (!isRecord(value)) throw new Error(`invalid project config: ${configPath}`); + const portRange = parsePortRange(value.portRange, configPath); + return portRange === undefined ? {} : { portRange }; +} + +function parsePortRange(value: unknown, configPath: string): PortRange | undefined { + if (value === undefined) return; + if (!isRecord(value) || typeof value.first !== "number" || typeof value.last !== "number") { + throw new Error(`invalid port range in project config: ${configPath}`); } + return { first: value.first, last: value.last }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } /** diff --git a/alignfirst-developer-tests/scripts/reset-fixture.mjs b/alignfirst-developer-tests/scripts/reset-fixture.mjs index 8469800d..e3622d49 100755 --- a/alignfirst-developer-tests/scripts/reset-fixture.mjs +++ b/alignfirst-developer-tests/scripts/reset-fixture.mjs @@ -11,16 +11,31 @@ import { } from "node:fs"; const TEMPLATE = "/opt/alignfirst-developer-tests/fixtures/template"; +const PRIMARY = "/home/claw/projects"; +const EXTERNAL = `${PRIMARY}/external-projects`; +const LIFECYCLE = `${PRIMARY}/lifecycle-projects`; +const PRIMARY_MARKER = { + description: + "Managed projects of the AlignFirst Developer test fixture. Adding a project is an operator's decision: ask before creating one.", + portRange: { first: 6500, last: 7700 }, +}; +const EXTERNAL_MARKER = { + description: "Projects hosted for external teams.", + portRange: { first: 6540, last: 6599 }, +}; +const LIFECYCLE_MARKER = { + description: + "Allowed parent for new lifecycle fixtures. Create Node.js projects with pnpm. Keep bootstrap work and the initial commit on main. Claim a port block with free-ports before writing the project config.", + portRange: { first: 6600, last: 6699 }, +}; // Each fixture gets its own port block so two of them can run a dev server at // the same time. The template declares `maxWorkspaces: 10` over `perWorkspace: 2`, // so a fixture spans 20 ports from its base. const FIXTURES = [ - { name: "nimbus", parent: "/home/claw/projects", basePort: 6500 }, - { name: "lumen", parent: "/home/claw/projects", basePort: 6520 }, - { name: "orion", parent: "/home/claw/external-projects", basePort: 6540 }, + { name: "nimbus", parent: PRIMARY, basePort: 6500 }, + { name: "lumen", parent: PRIMARY, basePort: 6520 }, + { name: "orion", parent: EXTERNAL, basePort: 6540 }, ]; -const FIXTURE_PARENTS = [...new Set(FIXTURES.map(({ parent }) => parent))]; -const EMPTY_FIXTURE_PARENTS = ["/home/claw/lifecycle-projects"]; // Each fixture's `origin` is a bare repo alongside its working tree, so // `git fetch` + `git merge --ff-only origin/main` behave like a real up-to-date // clone (a remote-less fixture made the playbook's new-work path — fetch + @@ -39,15 +54,16 @@ async function main() { await runWithTimeout("pnpm", ["-C", dst, "dev", "down", "--all"], 10_000); } } - // Wipe everything under the fixture parents and origins unconditionally. The + // Wipe everything under the fixture root and origins unconditionally. The // fixture template lives in /opt/alignfirst-developer-tests/fixtures/ and is re-copied below. // pnpm's store is pinned to /home/claw/.pnpm-store via ~/.npmrc, so nothing // here is worth keeping. - for (const parent of [...FIXTURE_PARENTS, ...EMPTY_FIXTURE_PARENTS]) { - for (const entry of readdirSync(parent)) { - rmSync(`${parent}/${entry}`, { recursive: true, force: true }); - } + for (const entry of readdirSync(PRIMARY)) { + rmSync(`${PRIMARY}/${entry}`, { recursive: true, force: true }); } + createProjectsDirectory(PRIMARY, PRIMARY_MARKER); + createProjectsDirectory(EXTERNAL, EXTERNAL_MARKER); + createProjectsDirectory(LIFECYCLE, LIFECYCLE_MARKER); rmSync(ORIGINS, { recursive: true, force: true }); mkdirSync(ORIGINS, { recursive: true }); for (const fixture of FIXTURES) { @@ -55,6 +71,11 @@ async function main() { } } +function createProjectsDirectory(path, marker) { + mkdirSync(path, { recursive: true }); + writeFileSync(`${path}/.alignfirst-projects.json`, `${JSON.stringify(marker, null, 2)}\n`); +} + async function resetFixture({ name, parent, basePort }) { const dst = `${parent}/${name}`; cpSync(TEMPLATE, dst, { recursive: true, preserveTimestamps: true }); @@ -104,6 +125,13 @@ function patchFixture(dst, name, basePort) { pkg.name = `@alignfirst-developer-tests/${name}-fixture`; writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + const config = { + schemaVersion: 1, + ticketPattern: "^ABC-\\d+$", + portRange: { first: basePort, last: basePort + 19 }, + }; + writeFileSync(`${dst}/.alignfirst.json`, `${JSON.stringify(config, null, 2)}\n`); + // Both entry points name the project, so the three otherwise identical copies // read distinctly — the app itself stays the "Comparables" product. const capitalized = name.charAt(0).toUpperCase() + name.slice(1); diff --git a/alignfirst-skills.md b/alignfirst-skills.md index 72a71b91..0ba84954 100644 --- a/alignfirst-skills.md +++ b/alignfirst-skills.md @@ -10,9 +10,13 @@ AlignFirst enables AI agents to write the code you would write. It's distributed ## Installation ```bash +npm install -g alignfirst 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 ``` +The skills are stubs that run `npx -y alignfirst guide`. Protocol updates come with the CLI; run +`npm update -g alignfirst` to receive them. + > **Note:** We recommend installing these skills globally. > > After installation, you need to restart your agent (start a new session) for the skills to become available. @@ -133,7 +137,7 @@ Specs, plans, and summaries should be written in well-organized (git-ignored) lo 1. The context window is limited, the compression mechanism is opaque, and we want to be able to continue an unfinished task in a fresh session. 2. It's a way to keep track of what was agreed upon with the agent and what has been done. -## Upgrade from v1 or v2 +## Upgrade from v1, v2 or v3 1. Install the setup-guide skill: @@ -141,7 +145,7 @@ Specs, plans, and summaries should be written in well-organized (git-ignored) lo npx skills add https://github.com/paleo/alignfirst --skill alignfirst-setup-guide ``` -2. Ask your agent to run the upgrade: +2. Ask your agent to follow the setup guide's upgrade route: ```text Use your alignfirst-setup-guide skill. Upgrade AlignFirst in this project. diff --git a/docs/alignfirst-developer/alignfirst-developer.md b/docs/alignfirst-developer/alignfirst-developer.md index 427ef7a9..0fdab09e 100644 --- a/docs/alignfirst-developer/alignfirst-developer.md +++ b/docs/alignfirst-developer/alignfirst-developer.md @@ -17,12 +17,12 @@ deployment, use the 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, 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 + Project discovery comes from `alcode projects --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. - It bind-mounts the workspace, playbook skill, and built `@paleo/alcode` package into the gateway. + It bind-mounts the workspace, playbook skill, and monorepo root into the gateway, so `alcode` and `alignfirst` run from the checkout. The harness intercepts both supported delegated-agent subprocesses. ## How a turn flows @@ -41,7 +41,7 @@ Layer 1 is the only thing OpenClaw injects automatically; everything in layer 2 ## The channel session only bootstraps a thread -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. +A channel/DM session runs `alcode projects list --json` before routing a message that may refer to a project. It resolves listed 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 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/`). diff --git a/docs/alignfirst-developer/openclaw-test-architecture.md b/docs/alignfirst-developer/openclaw-test-architecture.md index b19fe36a..815ee2db 100644 --- a/docs/alignfirst-developer/openclaw-test-architecture.md +++ b/docs/alignfirst-developer/openclaw-test-architecture.md @@ -81,7 +81,7 @@ The CLI injects `OPENCLAW_TEST_PROJECT_DIR`, `OPENCLAW_TEST_PACKAGE_DIR`, `CLAW_ ## Mocked-CLI shim -The gateway's PATH is prepended at runtime with `/opt/openclaw-test/mocks/bin/`, where consumer-created symlinks point at one Node shim. The AlignFirst Developer consumer links `claude`, `codex`, `gh`, and `alproject`: `alcode` runs live and selects its child through gateway `ALIGNFIRST_CODE_AGENT`, while either coding-agent executable remains intercepted. Its Codex handler also serves `debug models --bundled`, so alias resolution requires neither a host Codex installation nor network access. Playbook scenarios mock the structured `alproject list --json` inventory and reject every other invocation. The base image ships only the shim binary; a typical consumer line is `RUN for name in claude codex gh alproject; do ln -sf mock-cli-shim "/opt/openclaw-test/mocks/bin/$name"; done`. The shim POSTs to `http://runner:43124/mock-cli/invoke` with `{ cli, argv, cwd, stdin }` and replays `{ stdout, stderr, exitCode }`. +The gateway's PATH is prepended at runtime with `/opt/openclaw-test/mocks/bin/`, where consumer-created symlinks point at one Node shim. The AlignFirst Developer consumer links `claude`, `codex`, and `gh`; `alcode` and `alignfirst` run live from the mounted checkout. Alcode selects its child through gateway `ALIGNFIRST_CODE_AGENT`, while either coding-agent executable remains intercepted. Its Codex handler also serves `debug models --bundled`, so alias resolution requires neither a host Codex installation nor network access. `alcode projects list --json` reads the real fixture tree, including its markers and project configs, and spawns `alignfirst config --json`. The base image ships only the shim binary; a typical consumer line is `RUN for name in claude codex gh; do ln -sf mock-cli-shim "/opt/openclaw-test/mocks/bin/$name"; done`. The shim POSTs to `http://runner:43124/mock-cli/invoke` with `{ cli, argv, cwd, stdin }` and replays `{ stdout, stderr, exitCode }`. The sh wrapper at `/opt/openclaw-test/mocks/bin/mock-cli-shim` invokes the shim as `node mock-cli-shim.js "$0" "$@"`. The JS reads the symlink name from `argv[2]` (`/opt/openclaw-test/mocks/bin/git` → `git`). Without `"$0"`, the shim would see only the script path and reject every call as `unexpected call to mock-cli-shim.js`. diff --git a/docs/alignfirst-developer/writing-instructions-for-openclaw.md b/docs/alignfirst-developer/writing-instructions-for-openclaw.md index c87d6e66..2196c429 100644 --- a/docs/alignfirst-developer/writing-instructions-for-openclaw.md +++ b/docs/alignfirst-developer/writing-instructions-for-openclaw.md @@ -78,4 +78,4 @@ npm run e2e -- --channel discord-mock --iterations 10 --max-failures 10 A1-new-w ## Watch out for `--iterations` matrix cost -Editing a bind-mounted file propagates live (the workspace dir, the playbook skill, and the `@paleo/alcode` package — including its `templates/` guides — are all mounted into the gateway), but iterations that started before your edit ran against the old text. After a substantive edit, expect to re-run from scratch. Each cell recreates the bus + gateway for fresh state and costs real API tokens (gateway turns + judge), so scope iteration counts deliberately. +Editing a bind-mounted file propagates live (the workspace dir, the playbook skill, and the mounted monorepo — including `packages/alcode/templates/` — are all mounted into the gateway), but iterations that started before your edit ran against the old text. After a substantive edit, expect to re-run from scratch. Each cell recreates the bus + gateway for fresh state and costs real API tokens (gateway turns + judge), so scope iteration counts deliberately. diff --git a/docs/proposals/project-overlays.md b/docs/proposals/project-overlays.md new file mode 100644 index 00000000..e876c6e4 --- /dev/null +++ b/docs/proposals/project-overlays.md @@ -0,0 +1,91 @@ +--- +title: Project Overlays +summary: A future feature of the alignfirst CLI — keeping a project's AlignFirst files outside its repository. Records the design that was implemented on branch 78 and dropped before merge, its contracts, its weaknesses and the seams kept for it. +read_when: + - designing or implementing overlays in the alignfirst CLI + - changing project config resolution, the `config` report or the guide's conventions section, which must stay overlay-compatible + - preparing an AlignFirst Developer for a repository that must stay untouched +--- + +# Project Overlays + +**Status: proposal.** The feature was implemented on branch `78/unified-cli` and dropped before the merge, because it had no user yet and touched most commands. Commit `0cb8cc4` holds the implementation: `git show 0cb8cc4:packages/alignfirst/src/overlay.ts` recovers the core, and `packages/alignfirst/test/overlay.test.ts` and `setup.test.ts` at the same commit hold the tests. + +## Goal + +An AlignFirst Developer sometimes works in a repository that must stay untouched, a client's repository for instance. Today a prepared project carries its AlignFirst files at its root: `.alignfirst.json`, the AlignFirst section of `AGENTS.md`, `DEVELOPERS.md` and `docs/`. An overlay holds these files outside the repository, and every command resolves each file in the project root first, then in the overlay. A prepared project never meets the feature. + +The only footprint left in the repository is the `.plans` symlink, registered in `.git/info/exclude` so it stays invisible to every other clone. Workspaces stay out of scope: `workspace.mjs`, its script and its devDependency remain the one footprint an untouched repository must accept. + +## Layout + +`ALIGNFIRST_OVERLAYS` names the directory holding the overlays. Each overlay is `//_project/`. The underscore keeps it out of the ticket listing, like `_archives`. + +The recommended value is the team plans clone. A project's overlay then sits next to its tickets, is versioned, shared with the team, and travels with `alignfirst sync`. The AlignFirst Developer template set the variable in `environment.d/common.conf` to `~/projects/`. Any other directory works. + +An overlay holds any of: `.alignfirst.json`, `AGENTS.md`, `DEVELOPERS.md`, `docs/`. + +## Matching a repository to its overlay + +The overlay's `.alignfirst.json` carries a `project` key that identifies the repository: + +```json +{ + "schemaVersion": 1, + "project": { "remote": "github.com/org/repo", "paths": ["/abs/path/to/repo"] } +} +``` + +At least one of `remote` and `paths` is required; `paths` holds absolute paths only. The key is meaningful in an overlay only. + +`findOverlay(cwd, env, home)` runs only when `ALIGNFIRST_OVERLAYS` is set. It reads `/*/_project/.alignfirst.json`, expands a leading `~/`, and keeps the overlay whose `project.remote` equals the normalized `origin` URL of the working directory, else whose `project.paths` contains its real path. Remote matches take precedence over path matches. Two matches at the same level is an error naming both directories. + +URL normalization gives `host/org/repo` for both the scp form `git@Host:org/repo.git` and the URL form `https://user@host:8443/org/repo.git`: lowercase host, user, scheme and port removed, trailing slashes and `.git` removed. The rest of the path keeps its case. + +## Resolution + +`resolveProjectConfig(cwd, env, home)` returns the root config when the file exists, else the overlay's config, else nothing. The result carries `source` (`root` or `overlay`) and the matched overlay, even when the root copy wins. The version guard reads the `cli` range from the effective config, so an overlay config guards the CLI version too. + +`resolveProjectFile(cwd, overlay, name)` applies the same rule to `AGENTS.md`, `DEVELOPERS.md` and `docs`: the root copy when it exists, else the overlay's. For `docs/`, the root wins even when its tree is not in docmap format, so the rule stays simple. + +## Command behaviors + +- `guide` appended the overlay's `AGENTS.md` to the core guide under a `## Project conventions` heading, only when the overlay copy was the one in use. This carried the conventions a prepared project keeps in its AGENTS.md section to an agent that never reads an overlay. +- `docmap` appended `--root /docs` to docmap's arguments when the arguments carried no `--root`, the working directory had no `docs/`, and the overlay had one. +- `config` reported `source: overlay` and an `overlay` object `{ dir, matchedBy }`; the overlay line appeared whenever an overlay matched, even with a root config. +- `doctor` had an `Overlay` section: the matched overlay, the matching key, and for each of the four files which copy was in use. +- `DEVELOPERS.md` printed the root file, else the overlay's, else an error listing both paths tried. The command existed only for overlays: in a prepared project the agent reads the file directly. +- `setup --overlay [--plans-folder ] [--ticket-pattern ] [--port-range -]`, run from the main worktree root of the untouched repository, required `ALIGNFIRST_OVERLAYS`. The overlay name was `--plans-folder`, else the repository directory's basename. It created `//_project/`, wrote its `.alignfirst.json` with `project.remote` from the normalized `origin` URL when there was one, `project.paths` with the repository's real path, the given options and no `cli` range; an existing overlay directory was an error. When `ALIGNFIRST_OVERLAYS` was inside a git repository, `.plans` became a relative symlink to `//`, the same link `plans setup` creates, otherwise `.plans` was created as a plain directory. Finally `.plans` was appended to `.git/info/exclude` unless `git check-ignore -q .plans` already succeeded. +- `setup --adopt`, when the team adopts AlignFirst in the repository: moved the overlay's `.alignfirst.json` without its `project` key, then `AGENTS.md`, `DEVELOPERS.md` and `docs/` to the root, each only when the root lacked it, reporting the conflicts it left. It removed the `.plans` line from `.git/info/exclude` and the `_project/` directory when empty, and printed what remained for the agent: the `.plans` ignore rule and, on conflict, the `AGENTS.md` conventions to merge by hand. + +## Touchpoints in `alcode projects` + +Discovery describes each child directory with `alignfirst config --json`. `source: overlay` listed the child as a project in overlay mode with the overlay directory recorded; `status` reported the overlay path as the config source. When `ALIGNFIRST_OVERLAYS` was set, every `/*/_project/` directory that no listed project reported as its overlay was an issue, "unmatched overlay". The projects guide template described a project as a child whose config report finds a root or overlay config. + +## Seams kept in the CLI + +The removal kept the design compatible: + +- Every command reads the project config through one resolution function. Adding the overlay source changes that function alone. +- The `config` report keeps its `source` field, `root` or `null`, so `overlay` can return as a value, and `alcode projects` keeps reading it. +- `guide` keeps one append point for project conventions. + +## Known weaknesses + +Recorded when the feature was designed; none was resolved. + +- **No auto-loaded instructions.** An agent reads a project's `AGENTS.md` on its own and never an overlay's. The agent runs `alignfirst guide`, or now `alignfirst context`, because the user's global instructions say so. This footprint moves from the project to the user, and a developer without that line works as if AlignFirst were absent. +- **Fragile matching.** A fork, a mirror or a renamed remote changes the `origin` URL, and the path fallback is per machine. A wrong match silently serves another project's conventions; `doctor` was the only place showing the match. +- **Two homes per file.** Every command must apply the root-then-overlay rule identically, including `docmap` on a root `docs/` tree that is not in docmap format. +- **Documentation does not travel with the code.** No pull request shows an overlay's docs and no CI checks them. The recommendation remains a `docs/` tree in the repository; the overlay lets the AlignFirst Developer start with less friction and documents the project until its team adopts docmap. + +## Questions for the next design + +The CLI changed since the implementation: conventions became structured fields of `.alignfirst.json` rendered by `alignfirst conventions`, `alignfirst context` chains them with docmap, `setup` disappeared and the setup guide writes the project config itself. + +- With structured conventions, does an overlay still need an `AGENTS.md`? The overlay's `.alignfirst.json` carries the conventions, and `conventions` renders them through the resolution function. The `guide` append point may then be unnecessary. +- The bootstrap for an untouched repository is one line in the AlignFirst Developer's global agent instructions, presumably `alignfirst context`. Decide whether a human developer gets the same line or the feature stays AlignFirst-Developer-only. +- Without `setup`, who creates the overlay? Either the setup guide writes the directory, the `project` key, the `.plans` symlink and the exclude entry by hand, or a single command returns for this one mechanical, multi-step operation. +- Is `project.paths` worth keeping? It is per machine and was only a fallback for a repository without a remote. +- Is `--adopt` needed on day one? It is the exit path from the feature and can come with the first team that adopts. +- `config --json` should report the overlay only when it matched, so `alcode projects` can keep its "unmatched overlay" check. diff --git a/docs/releasing.md b/docs/releasing.md index 4ab37c86..b49ccdc7 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -57,9 +57,9 @@ Done on 2026-08-22. Requires the package owner's npm account and repository admi 1. Register the trusted publisher for each package, with npm CLI ≥ 11.19 and logged in as the owner. Earlier CLIs omit the `permissions` field the registry now requires and fail with `400 Bad Request`: ```bash - for pkg in @paleo/alcode @paleo/docmap @paleo/openclaw-channel-mock-core \ + for pkg in alignfirst @paleo/alcode @paleo/docmap @paleo/openclaw-channel-mock-core \ @paleo/openclaw-discord-mock @paleo/openclaw-slack-mock \ - @paleo/openclaw-test @paleo/plans-share @paleo/workspace; do + @paleo/openclaw-test @paleo/workspace; do npm trust github "$pkg" --repo paleo/alignfirst --file release.yml --env release --allow-publish done npm trust list @paleo/docmap # spot-check @@ -79,6 +79,27 @@ Done on 2026-08-22. Requires the package owner's npm account and repository admi 3. Enable **Allow GitHub Actions to create and approve pull requests** in Settings → Actions → General → Workflow permissions. The `version` job needs it to open the Version Packages PR with the default `GITHUB_TOKEN`. +## Owner steps for the AlignFirst CLI + +The first **release: version packages** PR bumps `alignfirst` to `0.1.0`. Do not let its publish job +run before the manual publish: publish the built tarball from that commit by hand, then approve the +environment. + +1. Publish `alignfirst@0.1.0` once from a machine logged in to npm, because a trusted publisher binds + to an existing package. Then configure trusted publishing and MFA: + + ```bash + npm trust github alignfirst --repo paleo/alignfirst --file release.yml --env release --allow-publish + npm access set mfa=publish alignfirst + ``` + +2. Deprecate the replaced packages: + + ```bash + npm deprecate @paleo/alproject@"*" "Replaced by alcode projects: npm install -g @paleo/alcode" + npm deprecate @paleo/plans-share@"*" "Replaced by the alignfirst package: npm install -g alignfirst" + ``` + ## Two-factor authentication and tokens Every package requires 2FA and disallows tokens, applied on 2026-08-22. This closes the token path; the OIDC flow is unaffected, because trusted publishing satisfies the 2FA requirement. diff --git a/docs/workspace-architecture.md b/docs/workspace-architecture.md index 37c04794..6a74fac9 100644 --- a/docs/workspace-architecture.md +++ b/docs/workspace-architecture.md @@ -97,6 +97,10 @@ The `ports` config group is resolved once per invocation, and a workspace's port Indexes are allocated only when `ports` is configured. A workspace registered while the config was portless carries no `portIndex`, so declaring `ports` later leaves it **stale**: any command needing its ports fails with a message pointing at `workspace setup --force` in that worktree, and `list` shows `?` in its `PORTS` column. The main worktree is never stale — its index is 0 by definition, and never stored. +## Port claim check + +Every workspace command compares `portRange` in the current worktree's `.alignfirst.json` with the whole block reserved by the `ports` scheme. A missing project config skips the check. The `alignfirst` CLI owns the file's schema; the workspace kernel reads only the two range integers. + ## Registry migration `workspace migrate-registry-0.30` converts a pre-`workspaces.json` registry (`slots.json`, keyed by port) in place, from the main worktree only. Every other command fails fast while `slots.json` exists, so an old registry never reads as "no workspaces". Worktrees, their gitignored content and running dev-servers are untouched. @@ -132,4 +136,3 @@ Healing splits by destructiveness: `workspace --guide` prints `templates/guide.md`, expanded by [`guide.ts`](../packages/workspace/src/guide.ts). The prose lives in the template; the command blocks stay in code, so their `#` comments align whatever the package-manager prefix costs (`npm run workspace -- ` against `pnpm workspace `). Three config-driven flags gate the template: `DEV` (a `devServerScript` is declared), `PORTS` (a `ports` group is declared) and `PROFILES` (a non-empty `setupProfiles` map is declared). `{{#NAME}}…{{/NAME}}` keeps a block when the flag is on, `{{^NAME}}…{{/NAME}}` when it is off. Inside the `PROFILES` block, `{{LIST:profiles}}` expands to one `` `name` — description`` line per declared profile. Markers own their line and one regex handles both forms, so a stripped block leaves no stray blank line. A setup-only project therefore reads a guide with no dev-server section and a workspace definition that mentions only symlinks and config files. - diff --git a/docs/writing-a-changeset.md b/docs/writing-a-changeset.md index 5d4802e7..794c09b8 100644 --- a/docs/writing-a-changeset.md +++ b/docs/writing-a-changeset.md @@ -10,25 +10,32 @@ read_when: Write the file directly. `npm run changeset` is the interactive equivalent, for humans. -1. **Identify modified packages.** Map changed file paths to their workspace packages: `packages//` → `@paleo/`. Only include packages with actual source changes. Changes confined to `skills/`, `alignfirst-developer-tests/`, `alignfirst-developer.md`, or `docs/` release nothing and need no changeset. +1. **Identify modified packages.** Map changed file paths to their workspace packages: + `packages//` → `@paleo/`, except `packages/alignfirst/` → `alignfirst`. Only include + packages with actual source changes. Changes confined to `skills/`, + `alignfirst-developer-tests/`, `alignfirst-developer.md`, or `docs/` release nothing and need no + changeset. ```sh git diff main --name-only git status --short # include uncommitted files ``` -2. **Gather context from the plan directory.** Use the `alignfirst` skill to find the plan directory (`.plans//`). Read the summary files (`*-summary.md`) and spec files to write a meaningful description. +2. **Gather context from the plan directory.** Run `alignfirst ticket ` to find the plan + directory. Read the summary files (`*-summary.md`) and spec files to write a meaningful + description. 3. **Determine the bump type** for each package: - `patch` — bug fixes, refactors, internal changes - `minor` — new features, new API surface - `major` — breaking changes -4. **Write the changeset file** in `.changeset/`, with a short kebab-case name (e.g. `.changeset/plans-share-check-mode.md`): +4. **Write the changeset file** in `.changeset/`, with a short kebab-case name (e.g. + `.changeset/alignfirst-new-command.md`): ```markdown --- - "@paleo/plans-share": minor + "alignfirst": minor --- One-line summary of the change (past tense). diff --git a/package-lock.json b/package-lock.json index 68b9ae65..2b1192bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1309,10 +1309,6 @@ "resolved": "packages/alcode", "link": true }, - "node_modules/@paleo/alproject": { - "resolved": "packages/alproject", - "link": true - }, "node_modules/@paleo/docmap": { "resolved": "packages/docmap", "link": true @@ -1333,10 +1329,6 @@ "resolved": "packages/openclaw-test", "link": true }, - "node_modules/@paleo/plans-share": { - "resolved": "packages/plans-share", - "link": true - }, "node_modules/@paleo/workspace": { "resolved": "packages/workspace", "link": true @@ -1914,6 +1906,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -2600,6 +2599,10 @@ } } }, + "node_modules/alignfirst": { + "resolved": "packages/alignfirst", + "link": true + }, "node_modules/arkregex": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/arkregex/-/arkregex-0.0.8.tgz", @@ -6305,7 +6308,6 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7231,6 +7233,9 @@ "name": "@paleo/alcode", "version": "0.12.0", "license": "CC0-1.0", + "dependencies": { + "arktype": "^2.2.3" + }, "bin": { "alcode": "bin/alcode.mjs" }, @@ -7244,18 +7249,20 @@ "node": ">=22.11.0" } }, - "packages/alproject": { - "name": "@paleo/alproject", - "version": "1.1.0", + "packages/alignfirst": { + "version": "0.0.0", "license": "CC0-1.0", "dependencies": { - "arktype": "^2.2.3" + "@paleo/docmap": "~0.9.1", + "arktype": "^2.2.3", + "semver": "^7.8.5" }, "bin": { - "alproject": "bin/alproject.mjs" + "alignfirst": "bin/alignfirst.mjs" }, "devDependencies": { "@types/node": "~24.13.3", + "@types/semver": "~7.8.0", "rimraf": "~6.1.3", "typescript": "~7.0.2", "vitest": "~4.1.11" @@ -7400,23 +7407,6 @@ } } }, - "packages/plans-share": { - "name": "@paleo/plans-share", - "version": "0.7.0", - "license": "CC0-1.0", - "bin": { - "plans-share": "bin/plans-share.mjs" - }, - "devDependencies": { - "@types/node": "~24.13.3", - "rimraf": "~6.1.3", - "typescript": "~7.0.2", - "vitest": "~4.1.11" - }, - "engines": { - "node": ">=22.11.0" - } - }, "packages/workspace": { "name": "@paleo/workspace", "version": "0.32.0", diff --git a/package.json b/package.json index e1aa836b..89b7168a 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,13 @@ "packages/*" ], "scripts": { - "docmap": "docmap", + "docmap": "alignfirst docmap", "workspace": "node scripts/workspace.mjs", - "plans:setup": "plans-share setup --folder alignfirst", - "plans:sync": "plans-share sync --auto-archive", + "plans:setup": "alignfirst plans setup", + "plans:sync": "alignfirst sync --auto-archive", "lint": "biome check", "lint:fix": "biome check --write", - "build": "npm run build --workspaces --if-present", + "build": "npm run build --workspace @paleo/docmap && npm run build --workspaces --if-present", "clear": "npm run clear --workspaces --if-present", "test": "npm run test --workspaces --if-present", "changeset": "changeset", diff --git a/packages/alcode/README.md b/packages/alcode/README.md index 5a3a872e..185bac07 100644 --- a/packages/alcode/README.md +++ b/packages/alcode/README.md @@ -2,6 +2,8 @@ Run a coding agent through [AlignFirst](https://github.com/paleo/alignfirst) protocols from the terminal. `alcode` wraps a coding-agent CLI for non-interactive use: it invokes a protocol (`spec`, `plan`, `aad`, …), streams the run to a per-call session file under `.plans/`, and returns the result. +Prerequisite: install the `alignfirst` CLI on `PATH` with `npm install -g alignfirst`. + Run `alcode --guide` for the full delegation guide. When an OpenClaw agent is the caller, run `alcode --openclaw-guide` instead: the same manual, with the OpenClaw-specific run instructions (`exec` with `background: true` + `timeout: 0`, and the completion-wake procedure). ## Execution model @@ -25,11 +27,14 @@ alcode new --message "Execute the plan: .plans/AB-123/A2-plan.md" alcode new --protocol aad --no-ticket --message "Task description" alcode status .plans/AB-123/_alcode/20260829-135529.md alcode usage +alcode projects list --root ~/projects ``` See `alcode --help` for all commands and options. -A new protocol session needs a ticket. `--no-ticket` makes `alcode` reserve the next side ticket (`side-N` under `.plans/`) and pass it to the agent. +Run `alcode projects --guide` before project lifecycle work. + +A new protocol session needs a ticket. `--no-ticket` makes `alcode` reserve the next side ticket through `alignfirst ticket --side` and pass it to the agent. `alcode status ` reconciles and shows a run's durable status. If a recorded process is gone, the command seals the session file as `status: failed`, `exitReason: terminated`. New Linux records also store the process start time to detect pid reuse. The command accepts session files under the current project's `.plans/**/_alcode/` tree and does not start a coding agent. diff --git a/packages/alcode/package.json b/packages/alcode/package.json index 20f00f45..7f57913e 100644 --- a/packages/alcode/package.json +++ b/packages/alcode/package.json @@ -39,6 +39,9 @@ "lint": "biome check", "test": "vitest run" }, + "dependencies": { + "arktype": "^2.2.3" + }, "devDependencies": { "@types/node": "~24.13.3", "rimraf": "~6.1.3", diff --git a/packages/alcode/src/alignfirst-cli.ts b/packages/alcode/src/alignfirst-cli.ts new file mode 100644 index 00000000..665e67de --- /dev/null +++ b/packages/alcode/src/alignfirst-cli.ts @@ -0,0 +1,51 @@ +import { spawnSync } from "node:child_process"; + +export const DEFAULT_ALIGNFIRST_COMMAND = ["alignfirst"]; + +export interface AlignfirstResult { + status: number; + stdout: string; + stderr: string; +} + +export function runAlignfirst( + command: string[], + args: string[], + cwd: string, + env: NodeJS.ProcessEnv = process.env, +): AlignfirstResult { + const result = spawnSync(command[0], [...command.slice(1), ...args], { + cwd, + env, + encoding: "utf8", + }); + if (result.error && isErrnoException(result.error) && result.error.code === "ENOENT") { + throw new Error("alignfirst is not installed. Install it: npm install -g alignfirst"); + } + if (result.error) throw result.error; + return { + status: result.status ?? 1, + stdout: result.stdout, + stderr: result.stderr, + }; +} + +export function reserveSideTicket(command: string[], cwd: string): string { + const result = runAlignfirst(command, ["ticket", "--side", "--json"], cwd); + if (result.status !== 0) { + throw new Error(result.stderr.trim() || "alignfirst ticket --side failed"); + } + const report: unknown = JSON.parse(result.stdout); + if (!isRecord(report) || typeof report.id !== "string") { + throw new Error("alignfirst ticket --side returned an invalid JSON report"); + } + return report.id; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isErrnoException(error: Error): error is NodeJS.ErrnoException { + return "code" in error; +} diff --git a/packages/alcode/src/cli.ts b/packages/alcode/src/cli.ts index 04aa856d..a72109a0 100644 --- a/packages/alcode/src/cli.ts +++ b/packages/alcode/src/cli.ts @@ -1,11 +1,14 @@ import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from "node:path"; import { parseArgs } from "node:util"; +import { DEFAULT_ALIGNFIRST_COMMAND, reserveSideTicket } from "./alignfirst-cli.js"; import { type CodingAgent, createAgentAdapter, resolveCodingAgent } from "./coding-agent.js"; import { type GuideVariant, renderGuide } from "./guide.js"; import { type ExecutableModelResolver, resolveExecutableModel, resolveModels } from "./models.js"; import { buildPrompt, PROTOCOLS } from "./prompt.js"; +import { runProjects } from "./projects/cli.js"; import { buildAgentEnv, runAgent, type RunConfig, type RunOutput } from "./run-agent.js"; import { applyCompletion, @@ -13,7 +16,6 @@ import { listSessionRecords, readPidStartTime, reconcileSessionFile, - reserveSideTicket, resolveSessionFilePath, type SessionFrontmatter, type SessionRecord, @@ -40,6 +42,8 @@ export interface MainOptions { stderr?: RunOutput; cwd?: string; env?: NodeJS.ProcessEnv; + home?: string; + alignfirstCommand?: string[]; modelResolver?: ExecutableModelResolver; usageReader?: UsageReader; } @@ -50,7 +54,7 @@ export type AlcodeCommand = | { kind: "guide"; variant: GuideVariant } | { kind: "status"; sessionFile: string } | { kind: "usage" } - | { kind: "reserveSideTicket" } + | { kind: "projects"; tokens: string[] } | { kind: "session"; args: SessionArgs }; // `resume` undefined means a new session. @@ -70,6 +74,8 @@ export async function main(options?: MainOptions): Promise { const stderr = options?.stderr ?? process.stderr; const cwd = options?.cwd ?? process.cwd(); const env = options?.env ?? process.env; + const home = options?.home ?? env.HOME ?? env.USERPROFILE ?? homedir(); + const alignfirstCommand = options?.alignfirstCommand ?? DEFAULT_ALIGNFIRST_COMMAND; let command: AlcodeCommand; try { @@ -83,15 +89,6 @@ export async function main(options?: MainOptions): Promise { stdout.write(`${readPackageVersion()}\n`); return 0; } - if (command.kind === "reserveSideTicket") { - const gateError = assertPlansGate(cwd); - if (gateError) { - stderr.write(`${gateError}\n`); - return 1; - } - stdout.write(`${reserveSideTicket(cwd)}\n`); - return 0; - } if (command.kind === "status") { try { const sessionFilePath = resolveStatusSessionFile(cwd, command.sessionFile); @@ -103,6 +100,14 @@ export async function main(options?: MainOptions): Promise { return 1; } } + if (command.kind === "projects") { + try { + return runProjects({ cwd, env, home, stdout, stderr, alignfirstCommand }, command.tokens); + } catch (error) { + stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + return 1; + } + } let agent: CodingAgent; try { @@ -144,6 +149,7 @@ export async function main(options?: MainOptions): Promise { env, stdout, stderr, + alignfirstCommand, modelResolver: options?.modelResolver ?? resolveExecutableModel, }); } @@ -217,8 +223,8 @@ export function parseAlcodeArgs(argv: string[]): AlcodeCommand { return parseStatusCommand(tokens); case "usage": return parseBareCommand(tokens, "usage"); - case "reserve-side-ticket": - return parseBareCommand(tokens, "reserveSideTicket"); + case "projects": + return { kind: "projects", tokens }; default: throw new Error(`Error: unknown command "${command}". Run \`alcode --help\`.`); } @@ -283,7 +289,7 @@ function parseResumeCommand(tokens: string[]): AlcodeCommand { }; } -function parseBareCommand(tokens: string[], kind: "usage" | "reserveSideTicket"): AlcodeCommand { +function parseBareCommand(tokens: string[], kind: "usage"): AlcodeCommand { const { values } = parseArgs({ args: tokens, options: { help: { type: "boolean", short: "h", default: false } }, @@ -340,6 +346,7 @@ interface RunContext { env: NodeJS.ProcessEnv; stdout: RunOutput; stderr: RunOutput; + alignfirstCommand: string[]; modelResolver: ExecutableModelResolver; } @@ -350,7 +357,7 @@ interface RunContext { // session id and status, and the `---- Result ----` block carries the outcome for a waking agent // (or a human). async function runSession(args: SessionArgs, agent: CodingAgent, ctx: RunContext): Promise { - const { cwd, env, stdout, stderr, modelResolver } = ctx; + const { cwd, env, stdout, stderr, alignfirstCommand, modelResolver } = ctx; const gateError = assertPlansGate(cwd); if (gateError) { @@ -367,7 +374,15 @@ async function runSession(args: SessionArgs, agent: CodingAgent, ctx: RunContext } const now = new Date(); - const ticket = args.noTicket ? reserveSideTicket(cwd) : resolveTicket(args, records); + let ticket: string | undefined; + try { + ticket = args.noTicket + ? reserveSideTicket(alignfirstCommand, cwd) + : resolveTicket(args, records); + } catch (error) { + stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + return 1; + } const sessionFilePath = resolveSessionFilePath(cwd, ticket, now); writeInitialSessionFile(sessionFilePath, buildFrontmatter(args, agent, now, realCwd, ticket)); stdout.write(`Session file: ${relative(cwd, sessionFilePath)}\n\n`); @@ -589,7 +604,8 @@ Usage: alcode resume [--protocol ] [--message "..."] alcode status alcode usage - alcode reserve-side-ticket + alcode projects (list | status | init | free-ports --size ) [--root ] + alcode projects --guide [--root ] alcode --guide alcode --openclaw-guide alcode -h, --help @@ -600,14 +616,15 @@ Commands: resume Continue an existing session. status Reconcile and show one run's durable status. Does not start an agent. usage Show the selected coding agent's current usage limits and reset times. - reserve-side-ticket Reserve the next side ticket for work without a ticket: creates - .plans/side-N/ and prints side-N. + projects Discover and manage the projects of a projects directory. Run + \`alcode projects --help\`. Options (new, resume): --protocol

One of: ${PROTOCOLS.join(", ")}. --ticket Ticket ID. \`new --protocol\` requires it, or --no-ticket. - --no-ticket Work without a ticket: reserves the next side ticket (side-N) and passes - it to the agent. new only, requires --protocol. + --no-ticket Work without a ticket: reserves the next side ticket through + \`alignfirst ticket --side\` and passes it to the agent. new only, + requires --protocol. -m, --message "..." Message to send. Required for spec, aad, and when no --protocol. --model Model for a new session: one of ${models.join(", ")}. Omit to use the default model. @@ -615,6 +632,9 @@ Options (new, resume): (\`meta:\`). alcode never interprets it; a later reader of the session file (e.g. the caller reporting the run's outcome) can use it. +Requires: the alignfirst CLI on PATH (npm install -g alignfirst), for side tickets and the +delegated protocols. + Env: ALIGNFIRST_CODE_AGENT Required coding agent: claude or codex (selected: ${agent}). ALIGNFIRST_CODE_MODELS Comma-list overriding the models accepted by --model. diff --git a/packages/alcode/src/guide.ts b/packages/alcode/src/guide.ts index b7b44ca9..9f8304bf 100644 --- a/packages/alcode/src/guide.ts +++ b/packages/alcode/src/guide.ts @@ -33,6 +33,6 @@ export function renderGuide( .trimEnd(); } -function readTemplate(name: string): string { +export function readTemplate(name: string): string { return readFileSync(new URL(`../templates/${name}`, import.meta.url), "utf-8"); } diff --git a/packages/alcode/src/projects/cli.ts b/packages/alcode/src/projects/cli.ts new file mode 100644 index 00000000..b60bef09 --- /dev/null +++ b/packages/alcode/src/projects/cli.ts @@ -0,0 +1,253 @@ +import { realpathSync } from "node:fs"; +import { isAbsolute, join, resolve } from "node:path"; +import { parseArgs } from "node:util"; + +import type { RunOutput } from "../run-agent.js"; +import { buildInventory, type ProjectInventory } from "./discovery.js"; +import { renderProjectsGuide } from "./guide.js"; +import { + assertValidPortRange, + MARKER_FILENAME, + type PortRange, + type ProjectsMarker, + readMarker, + writeMarker, +} from "./markers.js"; +import { findFreeBlock } from "./ports.js"; +import { + renderPortRangeJson, + renderProjectList, + renderProjectListJson, + renderProjectStatus, + renderProjectStatusJson, +} from "./render.js"; +import { getProjectStatus } from "./status.js"; + +const USAGE = `Usage: + alcode projects list [--json] [--root ] + alcode projects status [--json] [--root ] + alcode projects init [--root ] [--description ] [--port-range -] + alcode projects free-ports --size [--json] [--root ] + alcode projects --guide [--root ] +`; + +export interface ProjectsContext { + cwd: string; + env: NodeJS.ProcessEnv; + home: string; + stdout: RunOutput; + stderr: RunOutput; + alignfirstCommand: string[]; +} + +interface ProjectsArgs { + command?: "list" | "status" | "init" | "free-ports"; + path?: string; + root?: string; + json: boolean; + guide: boolean; + help: boolean; + description?: string; + portRange?: PortRange; + size?: number; +} + +export function runProjects(ctx: ProjectsContext, tokens: string[]): number { + const args = parseProjectsArgs(tokens); + if (args.help || (args.command === undefined && !args.guide)) { + ctx.stdout.write(USAGE); + return 0; + } + const root = resolveProjectsRoot(ctx, args.root); + if (args.guide) { + const marker = readMarker(root); + const inventory = marker === undefined ? undefined : inventoryFor(root, marker, ctx); + ctx.stdout.write(`${renderProjectsGuide(inventory)}\n`); + return 0; + } + if (args.command === "init") return initializeProjectsDirectory(root, args, ctx.stdout); + const marker = requireMarker(root); + const inventory = inventoryFor(root, marker, ctx); + if (args.command === "list") { + ctx.stdout.write(args.json ? renderProjectListJson(inventory) : renderProjectList(inventory)); + return 0; + } + if (args.command === "status" && args.path !== undefined) { + const details = getProjectStatus(inventory, args.path); + ctx.stdout.write(args.json ? renderProjectStatusJson(details) : renderProjectStatus(details)); + return 0; + } + if (args.command === "free-ports" && args.size !== undefined) { + const range = findFreeBlock(inventory, args.size); + ctx.stdout.write(args.json ? renderPortRangeJson(range) : `${range.first}..${range.last}\n`); + return 0; + } + throw new Error("Invalid alcode projects command"); +} + +function parseProjectsArgs(tokens: string[]): ProjectsArgs { + const { values, positionals } = parseArgs({ + args: tokens, + options: { + root: { type: "string" }, + json: { type: "boolean", default: false }, + description: { type: "string" }, + "port-range": { type: "string" }, + size: { type: "string" }, + guide: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + strict: true, + allowPositionals: true, + }); + if (values.help) return emptyModeArgs(values.root, true, false); + if (values.guide) { + assertGuideArgs(positionals, values); + return emptyModeArgs(values.root, false, true); + } + const [rawCommand, path, ...extra] = positionals; + if (rawCommand === undefined) { + assertNoCommandOptions(values); + return emptyModeArgs(values.root, false, false); + } + if (!isProjectsCommand(rawCommand)) throw new Error(`Unknown projects command: ${rawCommand}`); + validatePositionals(rawCommand, path, extra); + validateOptionPlacement(rawCommand, values); + return { + command: rawCommand, + ...(path === undefined ? {} : { path }), + ...(values.root === undefined ? {} : { root: values.root }), + json: values.json, + guide: false, + help: false, + ...(values.description === undefined ? {} : { description: values.description }), + ...(values["port-range"] === undefined + ? {} + : { portRange: parsePortRange(values["port-range"]) }), + ...(values.size === undefined ? {} : { size: parsePositiveInteger("--size", values.size) }), + }; +} + +interface ParsedOptionValues { + root?: string; + json: boolean; + description?: string; + "port-range"?: string; + size?: string; + guide: boolean; + help: boolean; +} + +function emptyModeArgs(root: string | undefined, help: boolean, guide: boolean): ProjectsArgs { + return { + ...(root === undefined ? {} : { root }), + json: false, + guide, + help, + }; +} + +function assertGuideArgs(positionals: string[], values: ParsedOptionValues): void { + if (positionals.length > 0) throw new Error("--guide does not accept a command"); + if ( + values.json || + values.description !== undefined || + values["port-range"] !== undefined || + values.size !== undefined + ) { + throw new Error("--guide accepts only --root"); + } +} + +function assertNoCommandOptions(values: ParsedOptionValues): void { + if ( + values.json || + values.description !== undefined || + values["port-range"] !== undefined || + values.size !== undefined + ) { + throw new Error("Command options require a projects command"); + } +} + +function isProjectsCommand(value: string): value is ProjectsArgs["command"] & string { + return value === "list" || value === "status" || value === "init" || value === "free-ports"; +} + +function validatePositionals(command: string, path: string | undefined, extra: string[]): void { + if (command === "status") { + if (path === undefined || extra.length > 0) throw new Error("status requires exactly one path"); + return; + } + if (path !== undefined) throw new Error(`${command} does not accept a path`); +} + +function validateOptionPlacement(command: string, values: ParsedOptionValues): void { + if (values.json && command !== "list" && command !== "status" && command !== "free-ports") { + throw new Error("--json is valid only with list, status, or free-ports"); + } + if ( + command !== "init" && + (values.description !== undefined || values["port-range"] !== undefined) + ) { + throw new Error("--description and --port-range are valid only with init"); + } + if (command === "free-ports") { + if (values.size === undefined) throw new Error("free-ports requires --size "); + } else if (values.size !== undefined) { + throw new Error("--size is valid only with free-ports"); + } +} + +function parsePortRange(value: string): PortRange { + const match = /^(\d+)-(\d+)$/u.exec(value); + if (match === null) throw new Error("--port-range must be -"); + const range = { first: Number(match[1]), last: Number(match[2]) }; + assertValidPortRange(range, "--port-range"); + return range; +} + +function parsePositiveInteger(option: string, value: string): number { + if (!/^[1-9]\d*$/u.test(value)) throw new Error(`${option} must be a positive integer`); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) throw new Error(`${option} must be a positive integer`); + return parsed; +} + +function resolveProjectsRoot(ctx: ProjectsContext, rootOption: string | undefined): string { + const input = rootOption ?? ctx.cwd; + const expanded = input.startsWith("~/") ? join(ctx.home, input.slice(2)) : input; + return realpathSync(isAbsolute(expanded) ? expanded : resolve(ctx.cwd, expanded)); +} + +function initializeProjectsDirectory(root: string, args: ProjectsArgs, stdout: RunOutput): number { + const markerPath = join(root, MARKER_FILENAME); + if (readMarker(root) !== undefined) throw new Error(`${markerPath} already exists.`); + writeMarker(root, { + ...(args.description === undefined ? {} : { description: args.description }), + ...(args.portRange === undefined ? {} : { portRange: args.portRange }), + }); + stdout.write(`Created ${markerPath}\n`); + return 0; +} + +function requireMarker(root: string): ProjectsMarker { + const marker = readMarker(root); + if (marker !== undefined) return marker; + throw new Error( + `${root} is not a projects directory: ${MARKER_FILENAME} is missing. ` + + "Run `alcode projects init` there, or pass --root .", + ); +} + +function inventoryFor( + root: string, + marker: ProjectsMarker, + ctx: ProjectsContext, +): ProjectInventory { + return buildInventory(root, marker, { + env: ctx.env, + home: ctx.home, + alignfirstCommand: ctx.alignfirstCommand, + }); +} diff --git a/packages/alcode/src/projects/discovery.ts b/packages/alcode/src/projects/discovery.ts new file mode 100644 index 00000000..0cacb708 --- /dev/null +++ b/packages/alcode/src/projects/discovery.ts @@ -0,0 +1,473 @@ +import { lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +import { runAlignfirst } from "../alignfirst-cli.js"; +import { type PortRange, type ProjectsMarker, readMarker } from "./markers.js"; +import { containsRange, rangesOverlap } from "./ports.js"; + +export interface ProjectInventory { + root: string; + directories: ProjectsDirectory[]; + projects: DiscoveredProject[]; + issues: InventoryIssue[]; +} + +export interface ProjectsDirectory { + path: string; + description?: string; + portRange?: PortRange; + others: string[]; +} + +export interface DiscoveredProject { + name: string; + path: string; + directory: string; + description: ProjectDescription; + portRange?: PortRange; + workspaces: string[]; + overlay?: string; +} + +export interface ProjectDescription { + source: "root" | "overlay" | null; + overlay: ProjectOverlayDescription | null; + cli: ProjectCliDescription | null; + config: ProjectConfigView | null; +} + +export interface ProjectConfigView { + ticketPattern?: string; + plans?: { folder: string }; + portRange?: PortRange; +} + +export interface InventoryIssue { + path: string; + message: string; +} + +interface ProjectOverlayDescription { + dir: string; + matchedBy: "remote" | "paths"; +} + +interface ProjectCliDescription { + installed: string; + range: string; + satisfied: boolean; +} + +export interface InventoryContext { + env: NodeJS.ProcessEnv; + home: string; + alignfirstCommand: string[]; +} + +interface DirectoryCandidate { + name: string; + directory: string; + path: string; + enclosingRange?: PortRange; +} + +interface MainCandidate extends DirectoryCandidate { + gitDirectory: string; + project: DiscoveredProject; +} + +interface WalkState { + directories: ProjectsDirectory[]; + candidates: DirectoryCandidate[]; + issues: InventoryIssue[]; +} + +interface ProjectError { + error: string; +} + +export function buildInventory( + root: string, + marker: ProjectsMarker, + ctx: InventoryContext, +): ProjectInventory { + const state: WalkState = { directories: [], candidates: [], issues: [] }; + walkProjectsDirectory(root, marker, undefined, state); + const projects = classifyCandidates(state, ctx); + projects.sort((left, right) => left.path.localeCompare(right.path)); + reportOverlappingProjects(projects, state.issues); + reportUnmatchedOverlays(root, projects, state.issues, ctx); + sortInventory(state.directories, projects, state.issues); + return { root, directories: state.directories, projects, issues: state.issues }; +} + +function walkProjectsDirectory( + path: string, + marker: ProjectsMarker, + enclosingRange: PortRange | undefined, + state: WalkState, +): void { + const effectiveRange = marker.portRange ?? enclosingRange; + state.directories.push({ + path, + ...(marker.description === undefined ? {} : { description: marker.description }), + ...(marker.portRange === undefined ? {} : { portRange: marker.portRange }), + others: [], + }); + for (const candidate of readDirectoryCandidates(path)) { + const childMarker = readMarker(candidate.path); + if (childMarker === undefined) { + state.candidates.push({ ...candidate, enclosingRange: effectiveRange }); + continue; + } + reportOutsideRange(candidate.path, childMarker.portRange, effectiveRange, state.issues); + walkProjectsDirectory(candidate.path, childMarker, effectiveRange, state); + } +} + +function readDirectoryCandidates(directory: string): DirectoryCandidate[] { + return readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .flatMap((entry) => readDirectoryCandidate(directory, entry.name)) + .toSorted((left, right) => left.name.localeCompare(right.name)); +} + +function readDirectoryCandidate(directory: string, name: string): DirectoryCandidate[] { + try { + return [{ name, directory, path: realpathSync(join(directory, name)) }]; + } catch (error) { + if (isNodeError(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) return []; + throw error; + } +} + +function classifyCandidates(state: WalkState, ctx: InventoryContext): DiscoveredProject[] { + const linkedCandidates: DirectoryCandidate[] = []; + const ordinaryCandidates: DirectoryCandidate[] = []; + for (const candidate of state.candidates) { + (isLinkedWorktree(candidate.path) ? linkedCandidates : ordinaryCandidates).push(candidate); + } + const projects = ordinaryCandidates.flatMap((candidate) => + classifyCandidate(candidate, state, ctx), + ); + attachLinkedWorktrees(linkedCandidates, projects, state.directories); + return projects; +} + +function classifyCandidate( + candidate: DirectoryCandidate, + state: WalkState, + ctx: InventoryContext, +): DiscoveredProject[] { + const description = describeProject(ctx.alignfirstCommand, candidate.path, { + ...ctx.env, + HOME: ctx.home, + }); + if ("error" in description) { + state.issues.push({ path: candidate.path, message: description.error }); + return []; + } + if (description.source === null) { + addOther(state.directories, candidate.directory, candidate.name); + return []; + } + const project: DiscoveredProject = { + name: candidate.name, + path: candidate.path, + directory: candidate.directory, + description, + ...(description.config?.portRange === undefined + ? {} + : { portRange: description.config.portRange }), + workspaces: [], + ...(description.source === "overlay" && description.overlay !== null + ? { overlay: description.overlay.dir } + : {}), + }; + if (description.source === "root" && mainWorktreeGitDirectory(candidate.path) === undefined) { + state.issues.push({ path: candidate.path, message: "not a git main worktree" }); + } + reportOutsideRange(project.path, project.portRange, candidate.enclosingRange, state.issues); + return [project]; +} + +function describeProject( + command: string[], + path: string, + env: NodeJS.ProcessEnv, +): ProjectDescription | ProjectError { + const result = runAlignfirst(command, ["config", "--json"], path, env); + if (result.status !== 0) { + return { error: firstLine(result.stderr) || "alignfirst config failed" }; + } + let value: unknown; + try { + value = JSON.parse(result.stdout); + } catch (error) { + throw new Error(`Invalid alignfirst config report for ${path}: ${errorMessage(error)}`); + } + return parseProjectDescription(value, path); +} + +function parseProjectDescription(value: unknown, path: string): ProjectDescription { + if (!isRecord(value)) throw invalidDescription(path); + const source = parseSource(value.source, path); + return { + source, + overlay: parseOverlay(value.overlay, path), + cli: parseCli(value.cli, path), + config: parseConfig(value.config, path), + }; +} + +function parseSource(value: unknown, path: string): ProjectDescription["source"] { + if (value === "root" || value === "overlay" || value === null) return value; + throw invalidDescription(path); +} + +function parseOverlay(value: unknown, path: string): ProjectOverlayDescription | null { + if (value === null) return null; + if ( + !isRecord(value) || + typeof value.dir !== "string" || + (value.matchedBy !== "remote" && value.matchedBy !== "paths") + ) { + throw invalidDescription(path); + } + return { dir: value.dir, matchedBy: value.matchedBy }; +} + +function parseCli(value: unknown, path: string): ProjectCliDescription | null { + if (value === null) return null; + if ( + !isRecord(value) || + typeof value.installed !== "string" || + typeof value.range !== "string" || + typeof value.satisfied !== "boolean" + ) { + throw invalidDescription(path); + } + return { installed: value.installed, range: value.range, satisfied: value.satisfied }; +} + +function parseConfig(value: unknown, path: string): ProjectConfigView | null { + if (value === null) return null; + if (!isRecord(value)) throw invalidDescription(path); + const ticketPattern = value.ticketPattern; + const plans = parsePlans(value.plans, path); + const portRange = value.portRange; + if (ticketPattern !== undefined && typeof ticketPattern !== "string") + throw invalidDescription(path); + if (portRange !== undefined && !isPortRange(portRange)) throw invalidDescription(path); + return { + ...(ticketPattern === undefined ? {} : { ticketPattern }), + ...(plans === undefined ? {} : { plans }), + ...(portRange === undefined ? {} : { portRange }), + }; +} + +function parsePlans(value: unknown, path: string): { folder: string } | undefined { + if (value === undefined) return; + if (!isRecord(value) || typeof value.folder !== "string") throw invalidDescription(path); + return { folder: value.folder }; +} + +function invalidDescription(path: string): Error { + return new Error(`Invalid alignfirst config report for ${path}`); +} + +function firstLine(value: string): string { + return value.trim().split("\n", 1)[0] ?? ""; +} + +function isLinkedWorktree(path: string): boolean { + try { + return lstatSync(join(path, ".git")).isFile(); + } catch { + return false; + } +} + +function attachLinkedWorktrees( + candidates: DirectoryCandidate[], + projects: DiscoveredProject[], + directories: ProjectsDirectory[], +): void { + const mainsByGitDirectory = new Map(); + for (const project of projects) { + const gitDirectory = mainWorktreeGitDirectory(project.path); + if (gitDirectory === undefined) continue; + mainsByGitDirectory.set(gitDirectory, { + name: project.name, + directory: project.directory, + path: project.path, + gitDirectory, + project, + }); + } + for (const candidate of candidates) { + const main = linkedWorktreeMain(candidate.path, mainsByGitDirectory); + if (main === undefined) addOther(directories, candidate.directory, candidate.name); + else main.project.workspaces.push(candidate.name); + } +} + +function mainWorktreeGitDirectory(projectPath: string): string | undefined { + const gitPath = join(projectPath, ".git"); + try { + if (!lstatSync(gitPath).isDirectory()) return; + return realpathSync(gitPath); + } catch { + return; + } +} + +function linkedWorktreeMain( + worktreePath: string, + mainsByGitDirectory: ReadonlyMap, +): MainCandidate | undefined { + const worktreeGitFile = join(worktreePath, ".git"); + try { + if (!lstatSync(worktreeGitFile).isFile()) return; + const metadataDirectory = resolveGitdirFile(worktreeGitFile); + const mainGitDirectory = resolveMetadataPath(metadataDirectory, "commondir"); + const main = mainsByGitDirectory.get(mainGitDirectory); + if (main === undefined) return; + if (dirname(metadataDirectory) !== join(mainGitDirectory, "worktrees")) return; + const backlink = resolveMetadataPath(metadataDirectory, "gitdir"); + if (backlink !== realpathSync(worktreeGitFile)) return; + return main; + } catch { + return; + } +} + +function resolveGitdirFile(gitFile: string): string { + const match = /^gitdir:\s*(.+)\s*$/u.exec(readFileSync(gitFile, "utf8")); + if (match === null) throw new Error(`Invalid Git file: ${gitFile}`); + return realpathSync(resolve(dirname(gitFile), match[1])); +} + +function resolveMetadataPath(metadataDirectory: string, filename: string): string { + const target = readFileSync(join(metadataDirectory, filename), "utf8").trim(); + if (target.length === 0) throw new Error(`Empty Git metadata file: ${filename}`); + return realpathSync(resolve(metadataDirectory, target)); +} + +function addOther(directories: ProjectsDirectory[], directoryPath: string, name: string): void { + const directory = directories.find(({ path }) => path === directoryPath); + if (directory === undefined) throw new Error(`Unknown projects directory: ${directoryPath}`); + directory.others.push(name); +} + +function reportOutsideRange( + path: string, + range: PortRange | undefined, + enclosingRange: PortRange | undefined, + issues: InventoryIssue[], +): void { + if (range === undefined || enclosingRange === undefined || containsRange(enclosingRange, range)) { + return; + } + issues.push({ + path, + message: `port range ${formatRange(range)} is outside enclosing range ${formatRange(enclosingRange)}`, + }); +} + +function reportOverlappingProjects(projects: DiscoveredProject[], issues: InventoryIssue[]): void { + const ranged = projects.filter( + (project): project is DiscoveredProject & { portRange: PortRange } => + project.portRange !== undefined, + ); + for (let index = 0; index < ranged.length; ++index) { + const project = ranged[index]; + for (let previous = 0; previous < index; ++previous) { + const other = ranged[previous]; + if (!rangesOverlap(project.portRange, other.portRange)) continue; + issues.push({ + path: project.path, + message: `port range ${formatRange(project.portRange)} overlaps ${other.name}`, + }); + } + } +} + +function reportUnmatchedOverlays( + root: string, + projects: DiscoveredProject[], + issues: InventoryIssue[], + ctx: InventoryContext, +): void { + const configured = ctx.env.ALIGNFIRST_OVERLAYS; + if (configured === undefined || configured === "") return; + const overlaysRoot = realpathOrUndefined(expandHomePath(configured, ctx.home)); + if (overlaysRoot === undefined) return; + const matched = new Set( + projects.flatMap(({ overlay }) => { + const path = overlay === undefined ? undefined : realpathOrUndefined(overlay); + return path === undefined ? [] : [path]; + }), + ); + for (const entry of readdirSync(overlaysRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const overlay = directoryRealpathOrUndefined(join(overlaysRoot, entry.name, "_project")); + if (overlay === undefined || matched.has(overlay)) continue; + issues.push({ path: overlay, message: `unmatched overlay: matches no project under ${root}` }); + } +} + +function expandHomePath(path: string, home: string): string { + return path.startsWith("~/") ? join(home, path.slice(2)) : path; +} + +function realpathOrUndefined(path: string): string | undefined { + try { + return realpathSync(path); + } catch { + return; + } +} + +function directoryRealpathOrUndefined(path: string): string | undefined { + try { + if (!lstatSync(path).isDirectory()) return; + return realpathSync(path); + } catch { + return; + } +} + +function sortInventory( + directories: ProjectsDirectory[], + projects: DiscoveredProject[], + issues: InventoryIssue[], +): void { + directories.sort((left, right) => left.path.localeCompare(right.path)); + projects.sort((left, right) => left.path.localeCompare(right.path)); + issues.sort((left, right) => left.path.localeCompare(right.path)); + for (const directory of directories) + directory.others.sort((left, right) => left.localeCompare(right)); + for (const project of projects) + project.workspaces.sort((left, right) => left.localeCompare(right)); +} + +function formatRange(range: PortRange): string { + return `${range.first}..${range.last}`; +} + +function isPortRange(value: unknown): value is PortRange { + return isRecord(value) && typeof value.first === "number" && typeof value.last === "number"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/alcode/src/projects/guide.ts b/packages/alcode/src/projects/guide.ts new file mode 100644 index 00000000..8e421ccf --- /dev/null +++ b/packages/alcode/src/projects/guide.ts @@ -0,0 +1,40 @@ +import { dirname } from "node:path"; + +import { readTemplate } from "../guide.js"; +import type { ProjectInventory, ProjectsDirectory } from "./discovery.js"; +import type { PortRange } from "./markers.js"; + +export function renderProjectsGuide(inventory?: ProjectInventory): string { + const guide = readTemplate("projects-guide.md").trimEnd(); + if (inventory === undefined) return guide; + const sections = inventory.directories.map((directory) => renderDirectory(inventory, directory)); + return `${guide}\n\n${sections.join("\n\n")}`; +} + +function renderDirectory(inventory: ProjectInventory, directory: ProjectsDirectory): string { + const lines = [`## ${directory.path}`]; + if (directory.description !== undefined) lines.push("", directory.description); + lines.push("", `Port range: ${renderRange(directory.portRange)}`, "", "Projects:"); + const projects = inventory.projects.filter((project) => project.directory === directory.path); + if (projects.length === 0) lines.push("- (none)"); + else { + for (const project of projects) { + lines.push(`- ${project.name} — ${renderRange(project.portRange, "(portless)")}`); + } + } + lines.push("", "Nested directories:"); + const nested = inventory.directories.filter( + (candidate) => candidate.path !== directory.path && dirname(candidate.path) === directory.path, + ); + if (nested.length === 0) lines.push("- (none)"); + else { + for (const child of nested) { + lines.push(`- ${child.path} — ${renderRange(child.portRange)}`); + } + } + return lines.join("\n"); +} + +function renderRange(range: PortRange | undefined, absent = "(none)"): string { + return range === undefined ? absent : `${range.first}..${range.last}`; +} diff --git a/packages/alcode/src/projects/markers.ts b/packages/alcode/src/projects/markers.ts new file mode 100644 index 00000000..f5c271cb --- /dev/null +++ b/packages/alcode/src/projects/markers.ts @@ -0,0 +1,72 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { type } from "arktype"; + +export const MARKER_FILENAME = ".alignfirst-projects.json"; + +const portRangeSchema = type({ + "+": "reject", + first: "1 <= number.integer <= 65535", + last: "1 <= number.integer <= 65535", +}); +const markerSchema = type({ + "+": "reject", + "description?": "string", + "portRange?": portRangeSchema, +}); + +export interface ProjectsMarker { + description?: string; + portRange?: PortRange; +} + +export interface PortRange { + first: number; + last: number; +} + +export function readMarker(dir: string): ProjectsMarker | undefined { + const path = join(dir, MARKER_FILENAME); + if (!existsSync(path)) return; + let value: unknown; + try { + value = JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + throw invalidMarker(path, errorMessage(error)); + } + const marker = markerSchema(value); + if (marker instanceof type.errors) { + throw invalidMarker(path, marker.summary.split("\n", 1)[0]); + } + if (marker.portRange !== undefined) assertValidPortRange(marker.portRange, path); + return marker; +} + +export function writeMarker(dir: string, marker: ProjectsMarker): void { + writeFileSync(join(dir, MARKER_FILENAME), `${JSON.stringify(marker, undefined, 2)}\n`); +} + +export function assertValidPortRange(range: PortRange, label: string): void { + if ( + !Number.isInteger(range.first) || + !Number.isInteger(range.last) || + range.first < 1 || + range.first > 65_535 || + range.last < 1 || + range.last > 65_535 + ) { + throw new Error(`Invalid ${label}: port range endpoints must be integers from 1 to 65535`); + } + if (range.first > range.last) { + throw new Error(`Invalid ${label}: portRange.first must not exceed portRange.last`); + } +} + +function invalidMarker(path: string, detail: string): Error { + return new Error(`Invalid projects marker ${path}: ${detail}`); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/alcode/src/projects/ports.ts b/packages/alcode/src/projects/ports.ts new file mode 100644 index 00000000..1f7d51dc --- /dev/null +++ b/packages/alcode/src/projects/ports.ts @@ -0,0 +1,62 @@ +import type { ProjectInventory } from "./discovery.js"; +import { MARKER_FILENAME, type PortRange } from "./markers.js"; + +interface AllocatedPortRange { + end: number; + start: number; +} + +export function findFreeBlock(inventory: ProjectInventory, size: number): PortRange { + const rootRange = inventory.directories.find(({ path }) => path === inventory.root)?.portRange; + if (rootRange === undefined) { + throw new Error(`${inventory.root}/${MARKER_FILENAME} has no portRange.`); + } + const occupied = [ + ...inventory.projects.flatMap(({ portRange }) => + portRange === undefined ? [] : [allocatedRange(portRange)], + ), + ...inventory.directories.flatMap(({ path, portRange }) => + path === inventory.root || portRange === undefined ? [] : [allocatedRange(portRange)], + ), + ]; + const first = lowestFreeBase(occupied, size, rootRange.first, rootRange.last); + if (first === undefined) { + throw new Error( + `No block of ${size} contiguous free ports in ${rootRange.first}..${rootRange.last}.`, + ); + } + return { first, last: first + size - 1 }; +} + +export function containsRange(available: PortRange, allocation: PortRange): boolean { + return allocation.first >= available.first && allocation.last <= available.last; +} + +export function rangesOverlap(left: PortRange, right: PortRange): boolean { + return left.first <= right.last && right.first <= left.last; +} + +function lowestFreeBase( + ranges: readonly AllocatedPortRange[], + size: number, + firstPort: number, + lastPort: number, +): number | undefined { + let candidate = firstPort; + for (const range of ranges.toSorted((left, right) => left.start - right.start)) { + if (range.end < candidate) continue; + if (range.start > lastPort) break; + if (fitsBefore(candidate, size, range.start - 1)) return candidate; + candidate = Math.max(candidate, range.end + 1); + } + return fitsBefore(candidate, size, lastPort) ? candidate : undefined; +} + +function fitsBefore(basePort: number, size: number, lastPort: number): boolean { + const end = basePort + size - 1; + return Number.isSafeInteger(end) && end <= lastPort; +} + +function allocatedRange(range: PortRange): AllocatedPortRange { + return { start: range.first, end: range.last }; +} diff --git a/packages/alcode/src/projects/render.ts b/packages/alcode/src/projects/render.ts new file mode 100644 index 00000000..6a58858e --- /dev/null +++ b/packages/alcode/src/projects/render.ts @@ -0,0 +1,144 @@ +import type { ProjectInventory } from "./discovery.js"; +import type { PortRange } from "./markers.js"; +import type { ProjectDetails } from "./status.js"; + +export function renderProjectList(inventory: ProjectInventory): string { + const lines = ["Projects:"]; + if (inventory.projects.length === 0) lines.push(" (none)"); + for (const project of inventory.projects) { + lines.push( + `- Name: ${renderOutputValue(project.name)}`, + ` Path: ${renderOutputValue(project.path)}`, + ` Directory: ${renderOutputValue(project.directory)}`, + ` Port range: ${renderRange(project.portRange)}`, + ` Workspaces: ${renderValues(project.workspaces)}`, + ); + if (project.overlay !== undefined) { + lines.push(` Overlay: ${renderOutputValue(project.overlay)}`); + } + } + lines.push("", "Directories:"); + if (inventory.directories.length === 0) lines.push(" (none)"); + for (const directory of inventory.directories) { + lines.push(`- Path: ${renderOutputValue(directory.path)}`); + if (directory.description !== undefined) { + lines.push(` Description: ${renderOutputValue(directory.description)}`); + } + lines.push( + ` Port range: ${renderRange(directory.portRange)}`, + ` Others: ${renderValues(directory.others)}`, + ); + } + lines.push("", "Issues:"); + if (inventory.issues.length === 0) lines.push(" (none)"); + for (const issue of inventory.issues) { + lines.push(`- ${renderOutputValue(issue.path)}: ${escapeControlCharacters(issue.message)}`); + } + return `${lines.join("\n")}\n`; +} + +export function renderProjectListJson(inventory: ProjectInventory): string { + const report = { + root: inventory.root, + directories: inventory.directories.map((directory) => ({ + path: directory.path, + description: directory.description ?? null, + portRange: directory.portRange ?? null, + others: directory.others, + })), + projects: inventory.projects.map((project) => ({ + name: project.name, + path: project.path, + directory: project.directory, + portRange: project.portRange ?? null, + workspaces: project.workspaces, + overlay: project.overlay ?? null, + })), + issues: inventory.issues, + }; + return renderJson(report); +} + +export function renderProjectStatus(details: ProjectDetails): string { + const lines = [ + "Project:", + ` Name: ${renderOutputValue(details.name)}`, + ` Path: ${renderOutputValue(details.path)}`, + ` Directory: ${renderOutputValue(details.directory)}`, + ` Remote host: ${renderNullableValue(details.remoteHost)}`, + ` Config source: ${renderOutputValue(details.configSource)}`, + ` Port range: ${renderRange(details.portRange ?? undefined)}`, + ` Plans folder: ${renderNullableValue(details.plansFolder)}`, + ` Ticket pattern: ${renderNullableValue(details.ticketPattern)}`, + ` Workspaces: ${renderValues(details.workspaces)}`, + " Worktrees:", + ]; + if (details.worktrees.length === 0) lines.push(" (none)"); + for (const worktree of details.worktrees) { + lines.push( + ` - Name: ${renderOutputValue(worktree.name)}`, + ` Path: ${renderOutputValue(worktree.path)}`, + ` Branch: ${worktree.branch === null ? "(detached)" : renderOutputValue(worktree.branch)}`, + ); + } + return `${lines.join("\n")}\n`; +} + +export function renderProjectStatusJson(details: ProjectDetails): string { + return renderJson(details); +} + +export function renderPortRangeJson(range: PortRange): string { + return renderJson(range); +} + +function renderJson(value: unknown): string { + return `${escapeAdditionalJsonCharacters(JSON.stringify(value, undefined, 2))}\n`; +} + +function renderRange(range: PortRange | undefined): string { + return range === undefined ? "(none)" : `${range.first}..${range.last}`; +} + +function renderValues(values: string[]): string { + return values.length === 0 ? "(none)" : values.map(renderOutputValue).join(", "); +} + +function renderNullableValue(value: string | null): string { + return value === null ? "(none)" : renderOutputValue(value); +} + +function renderOutputValue(value: string): string { + return escapeAdditionalJsonCharacters(JSON.stringify(value)); +} + +function escapeControlCharacters(value: string): string { + return Array.from(value, (character) => { + if (!isControlCharacter(character, true)) return character; + const jsonEscape = JSON.stringify(character).slice(1, -1); + return jsonEscape === character ? unicodeEscape(character) : jsonEscape; + }).join(""); +} + +function escapeAdditionalJsonCharacters(value: string): string { + return Array.from(value, (character) => + isControlCharacter(character, false) ? unicodeEscape(character) : character, + ).join(""); +} + +function isControlCharacter(character: string, includeC0: boolean): boolean { + const codePoint = character.codePointAt(0); + if (codePoint === undefined) return false; + return ( + (includeC0 && codePoint <= 0x1f) || + (codePoint >= 0x7f && codePoint <= 0x9f) || + codePoint === 0x2028 || + codePoint === 0x2029 + ); +} + +function unicodeEscape(character: string): string { + const codePoint = character.codePointAt(0); + if (codePoint === undefined) throw new Error("Cannot escape an empty character"); + return `\\u${codePoint.toString(16).padStart(4, "0")}`; +} diff --git a/packages/alcode/src/projects/status.ts b/packages/alcode/src/projects/status.ts new file mode 100644 index 00000000..004c3216 --- /dev/null +++ b/packages/alcode/src/projects/status.ts @@ -0,0 +1,143 @@ +import { execFileSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { basename, isAbsolute, resolve } from "node:path"; + +import type { DiscoveredProject, ProjectInventory } from "./discovery.js"; +import type { PortRange } from "./markers.js"; + +const URL_WITH_AUTHORITY = /^[A-Za-z][A-Za-z\d+.-]*:\/\//u; + +export interface ProjectDetails { + name: string; + path: string; + directory: string; + remoteHost: string | null; + configSource: string; + portRange: PortRange | null; + plansFolder: string | null; + ticketPattern: string | null; + workspaces: string[]; + worktrees: ProjectWorktree[]; +} + +export interface ProjectWorktree { + branch: string | null; + name: string; + path: string; +} + +export function getProjectStatus(inventory: ProjectInventory, inputPath: string): ProjectDetails { + const path = resolveProjectPath(inventory.root, inputPath); + const project = inventory.projects.find((candidate) => candidate.path === path); + if (project === undefined) { + throw new Error( + `${path} is not a project of ${inventory.root}. Pass the main-worktree path of a project ` + + "holding .alignfirst.json or matching an overlay.", + ); + } + return buildProjectDetails(project); +} + +function resolveProjectPath(root: string, inputPath: string): string { + const path = isAbsolute(inputPath) ? inputPath : resolve(root, inputPath); + try { + return realpathSync(path); + } catch (error) { + if (isNodeError(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) return path; + throw error; + } +} + +function buildProjectDetails(project: DiscoveredProject): ProjectDetails { + return { + name: project.name, + path: project.path, + directory: project.directory, + remoteHost: readRemoteHost(project.path), + configSource: project.description.source === "root" ? "root" : (project.overlay ?? "root"), + portRange: project.portRange ?? null, + plansFolder: project.description.config?.plans?.folder ?? null, + ticketPattern: project.description.config?.ticketPattern ?? null, + workspaces: project.workspaces, + worktrees: readWorktrees(project.path), + }; +} + +function readRemoteHost(projectPath: string): string | null { + const remotes = runGit(projectPath, "remote") + .trimEnd() + .split("\n") + .filter((remote) => remote.length > 0) + .toSorted(); + const orderedRemotes = remotes.includes("origin") + ? ["origin", ...remotes.filter((remote) => remote !== "origin")] + : remotes; + for (const remote of orderedRemotes) { + const host = remoteHost(runGit(projectPath, "remote", "get-url", "--", remote).trim()); + if (host !== null) return host; + } + return null; +} + +function remoteHost(remoteUrl: string): string | null { + return URL_WITH_AUTHORITY.test(remoteUrl) ? urlRemoteHost(remoteUrl) : scpRemoteHost(remoteUrl); +} + +function urlRemoteHost(remoteUrl: string): string | null { + try { + const host = new URL(remoteUrl).hostname; + return host.length === 0 ? null : host; + } catch { + return null; + } +} + +function scpRemoteHost(remoteUrl: string): string | null { + if (/^[A-Za-z]:[\\/]/u.test(remoteUrl)) return null; + const match = /^(?:[^@/:\s]+@)?(\[[^\]]+\]|[^/:\s]+):/u.exec(remoteUrl); + return match?.[1] ?? null; +} + +function readWorktrees(projectPath: string): ProjectWorktree[] { + return runGit(projectPath, "worktree", "list", "--porcelain", "-z") + .split("\0\0") + .filter((record) => record.length > 0) + .map(parseWorktree); +} + +function parseWorktree(record: string): ProjectWorktree { + const fields = record.split("\0"); + const pathField = fields.find((field) => field.startsWith("worktree ")); + if (pathField === undefined) throw new Error("Git returned a worktree record without a path"); + const path = realpathSync(pathField.slice("worktree ".length)); + const branchField = fields.find((field) => field.startsWith("branch ")); + return { + branch: branchField === undefined ? null : shortBranch(branchField.slice("branch ".length)), + name: basename(path), + path, + }; +} + +function shortBranch(branch: string): string { + const prefix = "refs/heads/"; + return branch.startsWith(prefix) ? branch.slice(prefix.length) : branch; +} + +function runGit(projectPath: string, ...args: string[]): string { + try { + return execFileSync("git", ["-C", projectPath, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + throw new Error(`Cannot inspect Git project ${projectPath}: ${errorMessage(error)}`); + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} diff --git a/packages/alcode/src/prompt.ts b/packages/alcode/src/prompt.ts index 5748224e..67ffa0f0 100644 --- a/packages/alcode/src/prompt.ts +++ b/packages/alcode/src/prompt.ts @@ -10,16 +10,6 @@ export const PROTOCOLS = [ export type Protocol = (typeof PROTOCOLS)[number]; -export const PROTOCOL_LABELS: Record = { - spec: "spec", - aad: "AAD", - plan: "plan", - description: "description", - catchup: "catchup", - review: "review", - merge: "merge", -}; - export interface PromptInput { protocol?: string; ticket?: string; @@ -28,12 +18,12 @@ export interface PromptInput { export function buildPrompt(input: PromptInput): string { const { protocol, ticket, message } = input; - if (!protocol) return message ?? ""; - return buildProtocolPrompt(PROTOCOL_LABELS[protocol], ticket, message); + if (protocol === undefined) return message ?? ""; + return buildProtocolPrompt(protocol, ticket, message); } -function buildProtocolPrompt(label: string, ticket?: string, message?: string): string { - const ticketPart = ticket ? ` Ticket ID = ${ticket}.` : ""; - const messagePart = message ? `\n\n${message}` : ""; - return `Run the _${label}_ protocol from the *alignfirst* skill.${ticketPart}${messagePart}`; +function buildProtocolPrompt(protocol: string, ticket?: string, message?: string): string { + const ticketPart = ticket === undefined ? "" : ` Ticket ID = ${ticket}.`; + const messagePart = message === undefined ? "" : `\n\n${message}`; + return `Run \`alignfirst guide ${protocol}\` and follow the protocol.${ticketPart}${messagePart}`; } diff --git a/packages/alcode/src/session-file.ts b/packages/alcode/src/session-file.ts index 3029df4b..919f6cde 100644 --- a/packages/alcode/src/session-file.ts +++ b/packages/alcode/src/session-file.ts @@ -188,8 +188,9 @@ export interface SessionRecord { frontmatter: SessionFrontmatter; } -// Lists every session record for a project root: `.plans/_alcode/*.md` plus each ticket's -// `.plans//_alcode/*.md`. The session files are the registry — no separate registry file. +// Lists every active session record for a project root: `.plans/_alcode/*.md` plus each ticket's +// `.plans//_alcode/*.md`. Archived tickets keep their session files but leave the registry. +// The session files are the registry — no separate registry file. // Self-healing: a `running` record whose pid is gone is a stale leftover from an interrupted run; // it gets sealed in passing so the launch guards never block on dead state. The returned records // reflect the post-healing state. @@ -197,7 +198,7 @@ export function listSessionRecords(cwd: string): SessionRecord[] { const plansDir = join(cwd, ".plans"); const sessionDirs = [join(plansDir, "_alcode")]; for (const entry of readEntries(plansDir)) { - if (entry.isDirectory() && entry.name !== "_alcode") { + if (entry.isDirectory() && !entry.name.startsWith("_")) { sessionDirs.push(join(plansDir, entry.name, "_alcode")); } } @@ -213,35 +214,6 @@ export function listSessionRecords(cwd: string): SessionRecord[] { return records; } -// Work without a ticket: reserves the next free `side-N` under `.plans/`, following the alignfirst -// skill's side-ticket convention. The non-recursive mkdir is the reservation: EEXIST means a -// 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"); - const highest = Math.max( - highestSideTicket(plansDir), - highestSideTicket(join(plansDir, "_archives")), - ); - for (let n = highest + 1; ; ++n) { - const ticket = `side-${n}`; - try { - mkdirSync(join(plansDir, ticket)); - return ticket; - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; - } - } -} - -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 0d7b2daf..078ed2e4 100644 --- a/packages/alcode/templates/cli-reference.md +++ b/packages/alcode/templates/cli-reference.md @@ -6,7 +6,7 @@ alcode new --message "..." alcode resume [--protocol ] [--message "..."] alcode status alcode usage -alcode reserve-side-ticket +alcode projects ``` | Command | Description | @@ -15,20 +15,22 @@ alcode reserve-side-ticket | `resume ` | Continue an existing session. | | `status ` | Reconcile and show one run's durable status. The path must be under `.plans/**/_alcode/`. Does not start a coding agent. | | `usage` | Show the selected coding agent's current usage limits and reset times. Takes no option. | -| `reserve-side-ticket` | Reserve the next side ticket for work without a ticket: creates `.plans/side-N/` and prints `side-N`. Takes no option. Use it when you need the ticket ID before delegating; otherwise `new --no-ticket` reserves and delegates in one run. | +| `projects` | Discover and manage the projects of a projects directory; run `alcode projects --guide` before project lifecycle work. | | Option | Description | |--------|-------------| | `--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. | +| `--no-ticket` | Work without a ticket: `alcode` reserves the next side ticket through `alignfirst ticket --side` 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`. | | `--model ` | One of {{MODELS}}. Prefer the default model (omit the flag). | | `--meta "..."` | Opaque handoff string stored verbatim in the session file's `meta:` frontmatter. `alcode` never reads it — it's for you to stash context the run's later reader needs (e.g. where to report the outcome). | The current coding agent is `{{AGENT}}`. `ALIGNFIRST_CODE_MODELS` replaces its displayed allowlist. Codex aliases `sol`, `terra`, and `luna` resolve to the newest bundled matching slug only when selected; a configured full slug passes through unchanged. -`alcode status ` checks that a `running` process still owns its recorded pid. A dead run is sealed as `status: failed`, `exitReason: terminated` before the command reports it. `alcode usage` works without a `.plans` directory and does not start a coding session. Its output follows the selected agent's available account limits. `alcode reserve-side-ticket` needs `.plans/` and no coding agent. +`alcode status ` checks that a `running` process still owns its recorded pid. A dead run is sealed as `status: failed`, `exitReason: terminated` before the command reports it. `alcode usage` works without a `.plans` directory and does not start a coding session. Its output follows the selected agent's available account limits. + +`alcode` requires the `alignfirst` CLI on `PATH`. The delegated agent runs `alignfirst guide ` in the project, so the protocols come from the installed CLI. {{PERMISSIONS}}. diff --git a/packages/alcode/templates/introduction.md b/packages/alcode/templates/introduction.md index 1078c102..6e70012c 100644 --- a/packages/alcode/templates/introduction.md +++ b/packages/alcode/templates/introduction.md @@ -3,3 +3,5 @@ Run a coding agent through AlignFirst protocols with the `alcode` CLI. It wraps **Never implement, investigate, or modify the codebase yourself. Your role is to delegate and guide the agent.** Run `alcode` from the root of the target project, so the agent works in the right repo. The project must contain a `.plans/` directory. + +The project must be prepared for AlignFirst, with the `alignfirst` CLI installed (`npm install -g alignfirst`). diff --git a/packages/alcode/templates/projects-guide.md b/packages/alcode/templates/projects-guide.md new file mode 100644 index 00000000..d9d6a52e --- /dev/null +++ b/packages/alcode/templates/projects-guide.md @@ -0,0 +1,27 @@ +# `alcode projects` guide + +A projects directory groups projects and optional nested projects directories. Its `.alignfirst-projects.json` marker contains an optional description and inclusive `portRange`. A directory without the marker is skipped as a projects directory. Nested markers may claim sub-ranges inside their nearest enclosing range. + +A project is a direct child whose `alignfirst config --json` report finds a root or overlay project config. It either contains `.alignfirst.json` or is a Git main worktree matched by an AlignFirst overlay. Linked Git worktrees are listed as its workspaces. Other child directories appear under `others`. + +## Commands + +```sh +alcode projects list [--json] [--root ] +alcode projects status [--json] [--root ] +alcode projects init [--root ] [--description ] [--port-range -] +alcode projects free-ports --size [--json] [--root ] +alcode projects --guide [--root ] +``` + +`--root` selects the projects directory. It defaults to the working directory. + +## Port claims + +Run `alcode projects free-ports --size ` with the block size required by the project's workspace scheme: `perWorkspace × maxWorkspaces`. Record the returned block as `portRange` in the project's `.alignfirst.json`. For a new project, pass it to `alignfirst setup --port-range -`. + +The project config is its registration. Deleting the project removes it from the listing. The workspace kernel refuses a `workspace` command when the project's `portRange` disagrees with its port scheme. + +## Reported issues + +The listing reports invalid project configs, non-main root projects, project or nested-directory ranges outside their enclosing range, overlapping project ranges, and overlays that match no project under the selected root. diff --git a/packages/alcode/test/cli.test.ts b/packages/alcode/test/cli.test.ts index 843de066..50ccaa46 100644 --- a/packages/alcode/test/cli.test.ts +++ b/packages/alcode/test/cli.test.ts @@ -1,15 +1,8 @@ import { spawnSync } from "node:child_process"; -import { - existsSync, - mkdirSync, - mkdtempSync, - realpathSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -30,6 +23,10 @@ import { writeInitialSessionFile, } from "../src/session-file.js"; +const ALIGNFIRST_BIN = fileURLToPath( + new URL("../../alignfirst/bin/alignfirst.mjs", import.meta.url), +); + function parse(tokens: string[]): SessionArgs { const command = parseAlcodeArgs(["node", "alcode", ...tokens]); if (command.kind !== "session") @@ -83,6 +80,8 @@ describe("coding-agent selection", () => { }), ).toBe(0); expect(stdout.text()).toContain("sol, terra, luna"); + expect(stdout.text()).toContain("alcode projects"); + expect(stdout.text()).not.toContain("reserve-side-ticket"); expect(stdout.text()).not.toContain("fable"); }); }); @@ -106,9 +105,6 @@ describe("parseAlcodeArgs", () => { sessionFile: ".plans/1/_alcode/run.md", }); expect(parseAlcodeArgs(["node", "alcode", "usage"])).toEqual({ kind: "usage" }); - expect(parseAlcodeArgs(["node", "alcode", "reserve-side-ticket"])).toEqual({ - kind: "reserveSideTicket", - }); }); it("reads `new` options into camelCase fields", () => { @@ -147,9 +143,6 @@ describe("parseAlcodeArgs", () => { expect(parseAlcodeArgs(["node", "alcode", "resume", "-h"])).toEqual({ kind: "help" }); expect(parseAlcodeArgs(["node", "alcode", "status", "--help"])).toEqual({ kind: "help" }); expect(parseAlcodeArgs(["node", "alcode", "usage", "--help"])).toEqual({ kind: "help" }); - expect(parseAlcodeArgs(["node", "alcode", "reserve-side-ticket", "-h"])).toEqual({ - kind: "help", - }); }); it("rejects a missing or unknown command", () => { @@ -166,7 +159,6 @@ describe("parseAlcodeArgs", () => { ); expect(() => parse(["status", "--message", "go"])).toThrow(); expect(() => parse(["usage", "extra"])).toThrow(); - expect(() => parse(["reserve-side-ticket", "extra"])).toThrow(); expect(() => parse(["resume", "--message", "go"])).toThrow("exactly one "); expect(() => parse(["resume", "a", "b", "--message", "go"])).toThrow("exactly one "); }); @@ -417,40 +409,6 @@ describe("usage", () => { }); }); -describe("reserve-side-ticket", () => { - let dir: string; - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "alcode-reserve-")); - }); - afterEach(() => rmSync(dir, { recursive: true, force: true })); - - it("prints the reserved id and creates its directory, without a coding agent", async () => { - mkdirSync(join(dir, ".plans", "side-1"), { recursive: true }); - const stdout = makeSink(); - const code = await main({ - argv: ["node", "alcode", "reserve-side-ticket"], - cwd: dir, - env: {}, - stdout, - }); - expect(code).toBe(0); - expect(stdout.text()).toBe("side-2\n"); - expect(existsSync(join(dir, ".plans", "side-2"))).toBe(true); - }); - - it("requires a .plans directory", async () => { - const stderr = makeSink(); - const code = await main({ - argv: ["node", "alcode", "reserve-side-ticket"], - cwd: dir, - env: {}, - stderr, - }); - expect(code).toBe(1); - expect(stderr.text()).toContain("no `.plans/` directory"); - }); -}); - describe("resolveTicket", () => { function record(overrides: Partial): SessionRecord { return { @@ -551,7 +509,7 @@ describe("buildRunConfig", () => { undefined, ); expect(config.prompt).toBe( - "Run the _plan_ protocol from the *alignfirst* skill. Ticket ID = 30.", + "Run `alignfirst guide plan` and follow the protocol. Ticket ID = 30.", ); expect(config.resume).toBe("abc"); }); @@ -701,10 +659,8 @@ describe("launch guards", () => { }); }); - it("reserves the next side ticket for --no-ticket", async () => { + it("reserves the next side ticket through alignfirst for --no-ticket", async () => { mkdirSync(join(dir, ".plans", "side-1")); - mkdirSync(join(dir, ".plans", "side-3")); - mkdirSync(join(dir, ".plans", "side-notes")); const stdout = makeSink(); const code = await main({ argv: ["node", "alcode", "new", "--protocol", "aad", "--no-ticket", "-m", "go"], @@ -712,18 +668,34 @@ describe("launch guards", () => { env: { ALIGNFIRST_CODE_AGENT: "claude" }, stdout, stderr: makeSink(), + alignfirstCommand: ["node", ALIGNFIRST_BIN], modelResolver: async () => { throw new Error("stop before spawning"); }, }); expect(code).toBe(1); - expect(stdout.text()).toContain(`Session file: ${join(".plans", "side-4", "_alcode")}`); + expect(stdout.text()).toContain(`Session file: ${join(".plans", "side-2", "_alcode")}`); const [record] = listSessionRecords(dir); - expect(record.frontmatter.ticket).toBe("side-4"); + expect(record.frontmatter.ticket).toBe("side-2"); expect(record.frontmatter.command).toBe('alcode new --protocol aad --no-ticket --message "go"'); }); + it("reports a missing alignfirst executable before writing a session file", async () => { + const stderr = makeSink(); + const code = await main({ + argv: ["node", "alcode", "new", "--protocol", "aad", "--no-ticket", "-m", "go"], + cwd: dir, + env: { ALIGNFIRST_CODE_AGENT: "claude" }, + stderr, + alignfirstCommand: ["/nonexistent/alignfirst"], + }); + + expect(code).toBe(1); + expect(stderr.text()).toContain("alignfirst is not installed"); + expect(listSessionRecords(dir)).toEqual([]); + }); + it("rejects a protocol run while another run is active in the same worktree", async () => { seedRecord("running.md", { sessionId: "abc" }); const { code, stderr } = await run(["new", "--protocol", "plan", "--ticket", "31"]); diff --git a/packages/alcode/test/guide.test.ts b/packages/alcode/test/guide.test.ts index c1200402..867dc42b 100644 --- a/packages/alcode/test/guide.test.ts +++ b/packages/alcode/test/guide.test.ts @@ -26,6 +26,9 @@ describe("renderGuide", () => { expect(guide).toContain("## CLI reference"); expect(guide).toContain("alcode status "); expect(guide).toContain("alcode usage"); + expect(guide).toContain("alcode projects"); + expect(guide).toContain("alignfirst"); + expect(guide).not.toContain("reserve-side-ticket"); expect(guide).toContain("current usage limits and reset times"); expect(guide).toContain("## Spec-Plan-Execute workflow"); expect(guide).toContain("Stop AAD now. Start a spec instead (alignfirst)."); diff --git a/packages/alcode/test/projects.test.ts b/packages/alcode/test/projects.test.ts new file mode 100644 index 00000000..ec8e4397 --- /dev/null +++ b/packages/alcode/test/projects.test.ts @@ -0,0 +1,477 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { main, parseAlcodeArgs } from "../src/cli.js"; + +const ALIGNFIRST_BIN = fileURLToPath( + new URL("../../alignfirst/bin/alignfirst.mjs", import.meta.url), +); + +let gitConfigDir: string; +let gitConfigPath: string; +let originalGitConfig: string | undefined; +const fixtureDirs: string[] = []; + +beforeAll(() => { + gitConfigDir = mkdtempSync(join(tmpdir(), "alcode-projects-git-")); + gitConfigPath = join(gitConfigDir, "gitconfig"); + writeFileSync(gitConfigPath, ""); + originalGitConfig = process.env.GIT_CONFIG_GLOBAL; + process.env.GIT_CONFIG_GLOBAL = gitConfigPath; +}); + +afterEach(() => { + for (const dir of fixtureDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +afterAll(() => { + if (originalGitConfig === undefined) delete process.env.GIT_CONFIG_GLOBAL; + else process.env.GIT_CONFIG_GLOBAL = originalGitConfig; + rmSync(gitConfigDir, { recursive: true, force: true }); +}); + +describe("projects command surface", () => { + it("dispatches tokens unchanged and renders help without a coding-agent selection", async () => { + expect(parseAlcodeArgs(["node", "alcode", "projects", "list", "--json"])).toEqual({ + kind: "projects", + tokens: ["list", "--json"], + }); + const fixture = makeFixture(); + const result = await runProjects(fixture, ["--help"], { env: {} }); + expect(result.code).toBe(0); + expect(result.stdout).toContain("alcode projects free-ports --size "); + }); + + it("requires a marker and expands --root ~/ against the injected home", async () => { + const fixture = makeFixture(); + const projects = join(fixture.home, "projects"); + mkdirSync(projects); + const result = await runProjects(fixture, ["list", "--root", "~/projects"]); + expect(result.code).toBe(1); + expect(result.stderr).toContain(".alignfirst-projects.json is missing"); + expect(result.stderr).toContain("alcode projects init"); + expect(result.stderr).toContain("--root "); + }); + + it("prints the generic guide without a marker or alignfirst executable", async () => { + const fixture = makeFixture(); + const result = await runProjects(fixture, ["--guide"], { + alignfirstCommand: ["/nonexistent/alignfirst"], + }); + expect(result.code).toBe(0); + expect(result.stdout).toContain("# `alcode projects` guide"); + expect(result.stdout).toContain("alignfirst setup --port-range"); + }); + + it("initializes a marker, refuses overwrite, and validates its range", async () => { + const fixture = makeFixture(); + const created = await runProjects(fixture, [ + "init", + "--description", + "Services", + "--port-range", + "8000-8099", + ]); + expect(created.code).toBe(0); + expect(created.stdout).toContain("Created"); + expect(readJson(join(fixture.root, ".alignfirst-projects.json"))).toEqual({ + description: "Services", + portRange: { first: 8000, last: 8099 }, + }); + + const duplicate = await runProjects(fixture, ["init"]); + expect(duplicate.code).toBe(1); + expect(duplicate.stderr).toContain("already exists"); + + const other = makeFixture(); + const invalid = await runProjects(other, ["init", "--port-range", "9000-8000"]); + expect(invalid.code).toBe(1); + expect(invalid.stderr).toContain("must not exceed"); + }); + + it("rejects unknown marker fields and names the marker file", async () => { + const fixture = makeFixture({ unknown: true }); + const result = await runProjects(fixture, ["list"]); + expect(result.code).toBe(1); + expect(result.stderr).toContain(join(fixture.root, ".alignfirst-projects.json")); + expect(result.stderr).toContain("unknown"); + }); + + it("validates command-specific options", async () => { + const fixture = makeFixture(); + for (const args of [ + ["status"], + ["list", "extra"], + ["init", "--json"], + ["list", "--size", "2"], + ["free-ports"], + ["--guide", "list"], + ]) { + expect((await runProjects(fixture, args)).code).toBe(1); + } + }); +}); + +describe("project discovery", () => { + it("discovers nested projects, portless projects, others, and cross-directory worktrees", async () => { + const fixture = makeFixture({ description: "All projects", portRange: range(8000, 8999) }); + const alpha = makeRepository(fixture.root, "alpha", { + portRange: range(8000, 8099), + }); + const portless = makeRepository(fixture.root, "portless", {}); + const nested = makeProjectsDirectory(fixture.root, "nested", { + description: "Nested", + portRange: range(8500, 8599), + }); + const beta = makeRepository(nested, "beta", { portRange: range(8500, 8549) }); + mkdirSync(join(nested, "notes")); + addWorktree(alpha, join(nested, "alpha-workspace"), "feature"); + + const result = await runProjects(fixture, ["list", "--json"], { env: {} }); + expect(result.code).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.root).toBe(realpathSync(fixture.root)); + expect(report.directories).toEqual([ + { + path: realpathSync(fixture.root), + description: "All projects", + portRange: range(8000, 8999), + others: [], + }, + { + path: realpathSync(nested), + description: "Nested", + portRange: range(8500, 8599), + others: ["notes"], + }, + ]); + expect(report.projects).toEqual([ + { + name: "alpha", + path: alpha, + directory: realpathSync(fixture.root), + portRange: range(8000, 8099), + workspaces: ["alpha-workspace"], + overlay: null, + }, + { + name: "beta", + path: beta, + directory: realpathSync(nested), + portRange: range(8500, 8549), + workspaces: [], + overlay: null, + }, + { + name: "portless", + path: portless, + directory: realpathSync(fixture.root), + portRange: null, + workspaces: [], + overlay: null, + }, + ]); + expect(report.issues).toEqual([]); + }); + + it("reports range, worktree, config, overlap, and unmatched-overlay issues", async () => { + const fixture = makeFixture({ portRange: range(8000, 8099) }); + makeRepository(fixture.root, "a", { portRange: range(8000, 8049) }); + makeRepository(fixture.root, "b", { portRange: range(8030, 8059) }); + makeRepository(fixture.root, "outside", { portRange: range(8200, 8299) }); + const nongit = join(fixture.root, "nongit"); + mkdirSync(nongit); + writeProjectConfig(nongit, {}); + const invalid = join(fixture.root, "invalid"); + mkdirSync(invalid); + writeFileSync(join(invalid, ".alignfirst.json"), "{}\n"); + makeProjectsDirectory(fixture.root, "nested-outside", { portRange: range(9000, 9099) }); + const overlays = join(fixture.base, "overlays"); + mkdirSync(join(overlays, "orphan", "_project"), { recursive: true }); + + const result = await runProjects(fixture, ["list", "--json"], { + env: { ALIGNFIRST_OVERLAYS: overlays }, + }); + expect(result.code).toBe(0); + const report = JSON.parse(result.stdout); + const messages = report.issues.map((issue: { message: string }) => issue.message); + expect(messages).toContain("port range 8030..8059 overlaps a"); + expect(messages).toContain("port range 8200..8299 is outside enclosing range 8000..8099"); + expect(messages).toContain("port range 9000..9099 is outside enclosing range 8000..8099"); + expect(messages).toContain("not a git main worktree"); + expect( + messages.some( + (message: string) => message.startsWith("Invalid ") && message.includes(".alignfirst.json"), + ), + ).toBe(true); + expect(messages).toContain( + `unmatched overlay: matches no project under ${realpathSync(fixture.root)}`, + ); + expect(report.projects.some((project: { name: string }) => project.name === "invalid")).toBe( + false, + ); + }); + + it("discovers an overlay project and reports unmatched overlays", async () => { + const fixture = makeFixture({ portRange: range(8000, 8999) }); + const project = makeRepository(fixture.root, "overlay-project"); + const overlays = join(fixture.base, "overlays"); + const overlay = makeOverlay(overlays, "matched", project, { + ticketPattern: "^OV-\\d+$", + plans: { folder: "overlay-project" }, + portRange: range(8200, 8299), + }); + mkdirSync(join(overlays, "orphan", "_project"), { recursive: true }); + + const result = await runProjects(fixture, ["list", "--json"], { + env: { ALIGNFIRST_OVERLAYS: overlays }, + }); + expect(result.code).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.projects[0]).toMatchObject({ + name: "overlay-project", + path: project, + overlay: realpathSync(overlay), + portRange: range(8200, 8299), + }); + expect(report.issues).toEqual([ + { + path: realpathSync(join(overlays, "orphan", "_project")), + message: `unmatched overlay: matches no project under ${realpathSync(fixture.root)}`, + }, + ]); + }); + + it("fails the listing when alignfirst is missing", async () => { + const fixture = makeFixture({}); + mkdirSync(join(fixture.root, "candidate")); + const result = await runProjects(fixture, ["list"], { + alignfirstCommand: ["/nonexistent/alignfirst"], + }); + expect(result.code).toBe(1); + expect(result.stderr).toContain("alignfirst is not installed"); + }); +}); + +describe("project status", () => { + it("renders root project details and rejects a linked-worktree path", async () => { + const fixture = makeFixture({ portRange: range(8000, 8999) }); + const project = makeRepository(fixture.root, "project", { + ticketPattern: "^P-\\d+$", + plans: { folder: "project-plans" }, + portRange: range(8000, 8099), + }); + execGit(project, "remote", "add", "backup", "https://gitlab.com/team/project.git"); + execGit(project, "remote", "add", "origin", "git@github.com:team/project.git"); + const workspace = join(fixture.root, "project-workspace"); + addWorktree(project, workspace, "feature"); + + const result = await runProjects(fixture, ["status", "project", "--json"]); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + name: "project", + path: project, + directory: realpathSync(fixture.root), + remoteHost: "github.com", + configSource: "root", + portRange: range(8000, 8099), + plansFolder: "project-plans", + ticketPattern: "^P-\\d+$", + workspaces: ["project-workspace"], + worktrees: [ + { branch: "main", name: "project", path: project }, + { branch: "feature", name: "project-workspace", path: realpathSync(workspace) }, + ], + }); + + const text = await runProjects(fixture, ["status", project]); + expect(text.stdout).toContain("Project:\n"); + expect(text.stdout).toContain(' Remote host: "github.com"'); + expect(text.stdout).toContain(" Port range: 8000..8099"); + + const rejected = await runProjects(fixture, ["status", workspace]); + expect(rejected.code).toBe(1); + expect(rejected.stderr).toContain("is not a project of"); + expect(rejected.stderr).toContain("main-worktree path"); + }); + + it("renders overlay config as the status source", async () => { + const fixture = makeFixture({ portRange: range(8000, 8999) }); + const project = makeRepository(fixture.root, "overlay-project"); + const overlays = join(fixture.base, "overlays"); + const overlay = makeOverlay(overlays, "project", project, { + plans: { folder: "team" }, + portRange: range(8300, 8399), + }); + const result = await runProjects(fixture, ["status", project, "--json"], { + env: { ALIGNFIRST_OVERLAYS: overlays }, + }); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + configSource: realpathSync(overlay), + plansFolder: "team", + portRange: range(8300, 8399), + }); + }); +}); + +describe("project ports and guide", () => { + it("finds the lowest block around project and nested-directory claims", async () => { + const fixture = makeFixture({ portRange: range(8000, 8099) }); + makeRepository(fixture.root, "allocated", { portRange: range(8000, 8009) }); + makeProjectsDirectory(fixture.root, "nested", { portRange: range(8020, 8029) }); + + const text = await runProjects(fixture, ["free-ports", "--size", "10"]); + expect(text.code).toBe(0); + expect(text.stdout).toBe("8010..8019\n"); + const json = await runProjects(fixture, ["free-ports", "--size", "10", "--json"]); + expect(JSON.parse(json.stdout)).toEqual(range(8010, 8019)); + + const exhausted = await runProjects(fixture, ["free-ports", "--size", "80"]); + expect(exhausted.code).toBe(1); + expect(exhausted.stderr).toContain("No block of 80 contiguous free ports in 8000..8099"); + }); + + it("requires a root port range for free-ports", async () => { + const fixture = makeFixture({}); + const result = await runProjects(fixture, ["free-ports", "--size", "1"]); + expect(result.code).toBe(1); + expect(result.stderr).toContain("has no portRange"); + }); + + it("appends root and nested guide sections in path order", async () => { + const fixture = makeFixture({ description: "Root projects", portRange: range(8000, 8999) }); + const z = makeProjectsDirectory(fixture.root, "z", {}); + const a = makeProjectsDirectory(fixture.root, "a", { portRange: range(8100, 8199) }); + const result = await runProjects(fixture, ["--guide"]); + expect(result.code).toBe(0); + const rootHeading = result.stdout.indexOf(`## ${realpathSync(fixture.root)}`); + const aHeading = result.stdout.indexOf(`## ${realpathSync(a)}`); + const zHeading = result.stdout.indexOf(`## ${realpathSync(z)}`); + expect(rootHeading).toBeGreaterThan(0); + expect(rootHeading).toBeLessThan(aHeading); + expect(aHeading).toBeLessThan(zHeading); + expect(result.stdout).toContain("Root projects"); + expect(result.stdout).toContain("Port range: 8100..8199"); + }); +}); + +interface Fixture { + base: string; + root: string; + home: string; +} + +interface RunOverrides { + cwd?: string; + home?: string; + env?: NodeJS.ProcessEnv; + alignfirstCommand?: string[]; +} + +function makeFixture(marker?: object): Fixture { + const base = mkdtempSync(join(tmpdir(), "alcode-projects-")); + fixtureDirs.push(base); + const root = join(base, "projects"); + const home = join(base, "home"); + mkdirSync(root); + mkdirSync(home); + if (marker !== undefined) writeMarker(root, marker); + return { base, root, home }; +} + +async function runProjects( + fixture: Fixture, + args: string[], + overrides: RunOverrides = {}, +): Promise<{ code: number; stdout: string; stderr: string }> { + const stdout = makeSink(); + const stderr = makeSink(); + const env = { ...process.env }; + delete env.ALIGNFIRST_CODE_AGENT; + Object.assign(env, overrides.env); + const code = await main({ + argv: ["node", "alcode", "projects", ...args], + cwd: overrides.cwd ?? fixture.root, + home: overrides.home ?? fixture.home, + env: { ...env, GIT_CONFIG_GLOBAL: gitConfigPath }, + alignfirstCommand: overrides.alignfirstCommand ?? ["node", ALIGNFIRST_BIN], + stdout, + stderr, + }); + return { code, stdout: stdout.text(), stderr: stderr.text() }; +} + +function makeProjectsDirectory(parent: string, name: string, marker: object): string { + const directory = join(parent, name); + mkdirSync(directory); + writeMarker(directory, marker); + return directory; +} + +function makeRepository(parent: string, name: string, config?: object): string { + const repository = join(parent, name); + execGit(parent, "init", "--quiet", "--initial-branch=main", repository); + execGit(repository, "config", "user.name", "Test"); + execGit(repository, "config", "user.email", "test@example.com"); + writeFileSync(join(repository, "README.md"), `${name}\n`); + execGit(repository, "add", "README.md"); + execGit(repository, "commit", "--quiet", "-m", "initial"); + if (config !== undefined) writeProjectConfig(repository, config); + return realpathSync(repository); +} + +function writeProjectConfig(directory: string, config: object): void { + writeFileSync( + join(directory, ".alignfirst.json"), + `${JSON.stringify({ schemaVersion: 1, ...config }, undefined, 2)}\n`, + ); +} + +function makeOverlay(overlays: string, name: string, project: string, config: object): string { + const overlay = join(overlays, name, "_project"); + mkdirSync(overlay, { recursive: true }); + writeProjectConfig(overlay, { ...config, project: { paths: [realpathSync(project)] } }); + return overlay; +} + +function addWorktree(main: string, worktree: string, branch: string): void { + execGit(main, "worktree", "add", "--quiet", "-b", branch, worktree); +} + +function execGit(cwd: string, ...args: string[]): string { + return execFileSync("git", ["-C", cwd, ...args], { + encoding: "utf8", + env: { ...process.env, GIT_CONFIG_GLOBAL: gitConfigPath }, + }).trim(); +} + +function writeMarker(directory: string, marker: object): void { + writeFileSync( + join(directory, ".alignfirst-projects.json"), + `${JSON.stringify(marker, undefined, 2)}\n`, + ); +} + +function readJson(path: string): unknown { + return JSON.parse(readFileSync(path, "utf8")); +} + +function range(first: number, last: number): { first: number; last: number } { + return { first, last }; +} + +function makeSink(): { write(text: string): void; text(): string } { + let buffer = ""; + return { + write(text) { + buffer += text; + }, + text: () => buffer, + }; +} diff --git a/packages/alcode/test/prompt.test.ts b/packages/alcode/test/prompt.test.ts index 0a4c7662..5352e25f 100644 --- a/packages/alcode/test/prompt.test.ts +++ b/packages/alcode/test/prompt.test.ts @@ -8,26 +8,26 @@ describe("buildPrompt", () => { }); it("builds a protocol prompt with ticket and message", () => { - expect(buildPrompt({ protocol: "spec", ticket: "29", message: "Do X" })).toBe( - "Run the _spec_ protocol from the *alignfirst* skill. Ticket ID = 29.\n\nDo X", + expect(buildPrompt({ protocol: "spec", ticket: "1234", message: "m" })).toBe( + "Run `alignfirst guide spec` and follow the protocol. Ticket ID = 1234.\n\nm", ); }); - it("uses the AAD label for the aad protocol", () => { + it("uses the CLI protocol name", () => { expect(buildPrompt({ protocol: "aad", ticket: "1", message: "m" })).toBe( - "Run the _AAD_ protocol from the *alignfirst* skill. Ticket ID = 1.\n\nm", + "Run `alignfirst guide aad` and follow the protocol. Ticket ID = 1.\n\nm", ); }); it("omits the ticket and message parts when absent", () => { expect(buildPrompt({ protocol: "plan" })).toBe( - "Run the _plan_ protocol from the *alignfirst* skill.", + "Run `alignfirst guide plan` and follow the protocol.", ); }); 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?", + "Run `alignfirst guide catchup` and follow the protocol. 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 63c56836..60c35287 100644 --- a/packages/alcode/test/session-file.test.ts +++ b/packages/alcode/test/session-file.test.ts @@ -14,7 +14,6 @@ import { readCompletion, readPidStartTime, reconcileSessionFile, - reserveSideTicket, resolveSessionFilePath, serializeFrontmatter, writeInitialSessionFile, @@ -221,48 +220,6 @@ describe("session file lifecycle", () => { }); }); -describe("reserveSideTicket", () => { - let dir: string; - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "alcode-side-")); - mkdirSync(join(dir, ".plans")); - }); - afterEach(() => rmSync(dir, { recursive: true, force: true })); - - it("starts at side-1 and creates the directory", () => { - expect(reserveSideTicket(dir)).toBe("side-1"); - expect(existsSync(join(dir, ".plans", "side-1"))).toBe(true); - }); - - it("takes one above the highest side-N directory, ignoring other names", () => { - mkdirSync(join(dir, ".plans", "side-1")); - mkdirSync(join(dir, ".plans", "side-3")); - mkdirSync(join(dir, ".plans", "side-notes")); - mkdirSync(join(dir, ".plans", "42")); - 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 - expect(reserveSideTicket(dir)).toBe("side-3"); - expect(existsSync(join(dir, ".plans", "side-3"))).toBe(true); - }); - - it("reserves consecutive ids on successive calls", () => { - expect(reserveSideTicket(dir)).toBe("side-1"); - expect(reserveSideTicket(dir)).toBe("side-2"); - }); -}); - describe("listSessionRecords", () => { let dir: string; beforeEach(() => { @@ -299,6 +256,11 @@ describe("listSessionRecords", () => { expect(records.map((r) => r.path).sort()).toEqual([rootPath, ticketPath].sort()); }); + it("ignores session records under archived tickets", () => { + seedRecord("_archives/29/_alcode", "archived.md", makeFrontmatter({ status: "succeeded" })); + expect(listSessionRecords(dir)).toEqual([]); + }); + it("skips non-md files and files without a frontmatter block", () => { seedRecord("_alcode", "good.md", makeFrontmatter({ status: "succeeded" })); writeFileSync(join(dir, ".plans", "_alcode", "junk.md"), "no frontmatter here"); diff --git a/packages/alignfirst/README.md b/packages/alignfirst/README.md new file mode 100644 index 00000000..729e86df --- /dev/null +++ b/packages/alignfirst/README.md @@ -0,0 +1,31 @@ +# alignfirst + +The AlignFirst CLI provides protocols, shared plans and project documentation in one command. + +Install it globally: + +```sh +npm install -g alignfirst +``` + +Or run the current version without installing it: + +```sh +npx -y alignfirst +``` + +Commands: + +- `guide` — Print an AlignFirst protocol. +- `ticket` — Resolve a ticket directory and its next file. +- `sync` — Synchronize shared plans. +- `plans` — Set up, check and archive plans. +- `docmap` — Browse project documentation. +- `config` — Report the effective project configuration. +- `DEVELOPERS.md` — Print the project developer guide. +- `setup` — Prepare an AlignFirst project. +- `doctor` — Diagnose an AlignFirst setup. + +Run `alignfirst --help` for command usage or `alignfirst guide` for the collaboration guide. + +`@paleo/alcode` is the companion CLI for the AlignFirst Developer. diff --git a/packages/plans-share/bin/plans-share.mjs b/packages/alignfirst/bin/alignfirst.mjs similarity index 67% rename from packages/plans-share/bin/plans-share.mjs rename to packages/alignfirst/bin/alignfirst.mjs index 23c6d8b1..f01c54d5 100755 --- a/packages/plans-share/bin/plans-share.mjs +++ b/packages/alignfirst/bin/alignfirst.mjs @@ -1,3 +1,3 @@ #!/usr/bin/env node import { main } from "../dist/cli.js"; -process.exitCode = main(); +process.exit(await main()); diff --git a/packages/alproject/package.json b/packages/alignfirst/package.json similarity index 66% rename from packages/alproject/package.json rename to packages/alignfirst/package.json index 2c95c41d..84750722 100644 --- a/packages/alproject/package.json +++ b/packages/alignfirst/package.json @@ -1,20 +1,21 @@ { - "name": "@paleo/alproject", - "version": "1.1.0", + "name": "alignfirst", + "version": "0.0.0", "license": "CC0-1.0", "author": "Thomas MUR", - "description": "Discover and manage local Git projects.", + "description": "The AlignFirst CLI: protocols, plans and docs in one command.", "keywords": [ "alignfirst", "cli", - "git", - "projects", - "worktrees" + "ai", + "agent", + "plans", + "docmap" ], "repository": { "type": "git", "url": "git+https://github.com/paleo/alignfirst.git", - "directory": "packages/alproject" + "directory": "packages/alignfirst" }, "engines": { "node": ">=22.11.0" @@ -22,7 +23,7 @@ "packageManager": "npm@11.19.0", "type": "module", "bin": { - "alproject": "bin/alproject.mjs" + "alignfirst": "bin/alignfirst.mjs" }, "files": [ "bin", @@ -38,13 +39,16 @@ "lint": "biome check", "test": "vitest run" }, + "dependencies": { + "@paleo/docmap": "~0.9.1", + "arktype": "^2.2.3", + "semver": "^7.8.5" + }, "devDependencies": { "@types/node": "~24.13.3", + "@types/semver": "~7.8.0", "rimraf": "~6.1.3", "typescript": "~7.0.2", "vitest": "~4.1.11" - }, - "dependencies": { - "arktype": "^2.2.3" } } diff --git a/packages/alignfirst/src/cli-error.ts b/packages/alignfirst/src/cli-error.ts new file mode 100644 index 00000000..703ecd84 --- /dev/null +++ b/packages/alignfirst/src/cli-error.ts @@ -0,0 +1 @@ +export class CliError extends Error {} diff --git a/packages/alignfirst/src/cli.ts b/packages/alignfirst/src/cli.ts new file mode 100644 index 00000000..5b62f51d --- /dev/null +++ b/packages/alignfirst/src/cli.ts @@ -0,0 +1,112 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; + +import { CliError } from "./cli-error.js"; +import { resolveCommandForm } from "./command-form.js"; +import { runConfig } from "./commands/config.js"; +import { runDevelopers } from "./commands/developers.js"; +import { runDoctor } from "./commands/doctor.js"; +import { runDocmap } from "./commands/docmap.js"; +import { runGuide } from "./commands/guide.js"; +import { runPlans } from "./commands/plans.js"; +import { runSetup } from "./commands/setup.js"; +import { runSync } from "./commands/sync.js"; +import { runTicket } from "./commands/ticket.js"; +import type { CommandContext, Output } from "./context.js"; +import { resolveProjectConfig } from "./overlay.js"; +import { checkCliRange } from "./version-guard.js"; + +export interface MainOptions { + argv?: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + home?: string; + stdout?: Output; + stderr?: Output; +} + +export async function main(options?: MainOptions): Promise { + const argv = options?.argv ?? process.argv; + const env = options?.env ?? process.env; + const ctx: CommandContext = { + cwd: options?.cwd ?? process.cwd(), + env, + home: options?.home ?? env.HOME ?? env.USERPROFILE ?? homedir(), + stdout: options?.stdout ?? process.stdout, + stderr: options?.stderr ?? process.stderr, + form: resolveCommandForm(env), + version: readPackageVersion(), + }; + const [command, ...args] = argv.slice(2); + try { + if (command === "--version" || command === "-v") { + ctx.stdout.write(`${ctx.version}\n`); + return 0; + } + if (command === undefined || command === "--help" || command === "-h") { + ctx.stdout.write(renderHelp(ctx)); + return 0; + } + if (command !== "config" && command !== "doctor") { + ctx.projectConfig = resolveProjectConfig(ctx.cwd, ctx.env, ctx.home); + ctx.overlay = ctx.projectConfig?.overlay; + checkCliRange(ctx.projectConfig?.config, ctx.version, [command, ...args]); + } + return dispatch(ctx, command, args); + } catch (error) { + if (!(error instanceof CliError)) throw error; + ctx.stderr.write(`${error.message}\n`); + return 1; + } +} + +function readPackageVersion(): string { + const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")) as { + version?: string; + }; + if (pkg.version === undefined) throw new Error("alignfirst: package.json is missing 'version'"); + return pkg.version; +} + +function renderHelp(ctx: CommandContext): string { + return `alignfirst — protocols, plans and docs in one command. + +Usage: + ${ctx.form} guide [] + ${ctx.form} ticket [] + ${ctx.form} sync [--auto-archive] + ${ctx.form} plans + ${ctx.form} docmap [] + ${ctx.form} config [--json] + ${ctx.form} DEVELOPERS.md + ${ctx.form} setup [] + ${ctx.form} doctor + ${ctx.form} --help + ${ctx.form} --version +`; +} + +function dispatch(ctx: CommandContext, command: string, args: string[]): number | Promise { + switch (command) { + case "guide": + return runGuide(ctx, args); + case "ticket": + return runTicket(ctx, args); + case "sync": + return runSync(ctx, args); + case "plans": + return runPlans(ctx, args); + case "docmap": + return runDocmap(ctx, args); + case "config": + return runConfig(ctx, args); + case "DEVELOPERS.md": + return runDevelopers(ctx, args); + case "setup": + return runSetup(ctx, args); + case "doctor": + return runDoctor(ctx, args); + default: + throw new CliError(`Error: unknown command "${command}".\n\n${renderHelp(ctx)}`); + } +} diff --git a/packages/alignfirst/src/command-form.ts b/packages/alignfirst/src/command-form.ts new file mode 100644 index 00000000..6f08d1b5 --- /dev/null +++ b/packages/alignfirst/src/command-form.ts @@ -0,0 +1,10 @@ +export const CMD_PLACEHOLDER = "{{CMD}}"; + +export function resolveCommandForm(env: NodeJS.ProcessEnv): string { + const userAgent = env.npm_config_user_agent; + return userAgent === undefined || userAgent === "" ? "alignfirst" : "npx -y alignfirst"; +} + +export function renderCommandForm(text: string, form: string): string { + return text.replaceAll(CMD_PLACEHOLDER, form); +} diff --git a/packages/alignfirst/src/commands/config.ts b/packages/alignfirst/src/commands/config.ts new file mode 100644 index 00000000..095d2c87 --- /dev/null +++ b/packages/alignfirst/src/commands/config.ts @@ -0,0 +1,82 @@ +import { CliError } from "../cli-error.js"; +import { parseArgs } from "node:util"; +import type { CommandContext } from "../context.js"; +import { resolveProjectConfig, type ResolvedProjectConfig } from "../overlay.js"; +import { parseCommandArgs } from "../parse-args.js"; +import { cliRangeResult } from "../version-guard.js"; + +interface ConfigReport { + source: "root" | "overlay" | null; + overlay: OverlayReport | null; + cli: CliReport | null; + config: ResolvedProjectConfig["config"] | null; +} + +interface OverlayReport { + dir: string; + matchedBy: "remote" | "paths"; +} + +interface CliReport { + installed: string; + range: string; + satisfied: boolean; +} + +export function runConfig(ctx: CommandContext, args: string[]): number { + const usage = `Usage: ${ctx.form} config [--json]\n`; + const json = parseConfigArgs(ctx, args, usage); + if (json === undefined) return 0; + const resolved = resolveProjectConfig(ctx.cwd, ctx.env, ctx.home); + const report = buildConfigReport(ctx, resolved); + ctx.stdout.write(json ? `${JSON.stringify(report, undefined, 2)}\n` : renderConfigReport(report)); + return 0; +} + +function parseConfigArgs(ctx: CommandContext, args: string[], usage: string): boolean | undefined { + const { values, positionals } = parseCommandArgs(usage, () => + parseArgs({ + args, + options: { + json: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + strict: true, + allowPositionals: true, + } as const), + ); + if (values.help) { + ctx.stdout.write(usage); + return; + } + if (positionals.length > 0) + throw new CliError(`Unexpected argument: ${positionals[0]}\n\n${usage}`); + return values.json; +} + +function buildConfigReport( + ctx: CommandContext, + resolved: ResolvedProjectConfig | undefined, +): ConfigReport { + const overlay = resolved?.overlay; + const cli = cliRangeResult(resolved?.config, ctx.version); + return { + source: resolved?.source ?? null, + overlay: overlay ? { dir: overlay.dir, matchedBy: overlay.matchedBy } : null, + cli: cli ? { installed: ctx.version, range: cli.range, satisfied: cli.satisfied } : null, + config: resolved?.config ?? null, + }; +} + +function renderConfigReport(report: ConfigReport): string { + const lines = [`Source: ${report.source ?? "none"}`]; + if (report.overlay) + lines.push(`Overlay: ${report.overlay.dir} (matched by ${report.overlay.matchedBy})`); + if (report.cli) + lines.push( + `CLI range: ${report.cli.range}, ${report.cli.satisfied ? "satisfied" : "not satisfied"} by ${report.cli.installed}`, + ); + else lines.push("CLI range: none"); + if (report.config) lines.push("Config:", JSON.stringify(report.config, undefined, 2)); + return `${lines.join("\n")}\n`; +} diff --git a/packages/alignfirst/src/commands/developers.ts b/packages/alignfirst/src/commands/developers.ts new file mode 100644 index 00000000..3891f114 --- /dev/null +++ b/packages/alignfirst/src/commands/developers.ts @@ -0,0 +1,39 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parseArgs } from "node:util"; + +import { CliError } from "../cli-error.js"; +import type { CommandContext } from "../context.js"; +import { resolveProjectFile } from "../overlay.js"; +import { parseCommandArgs } from "../parse-args.js"; + +export function runDevelopers(ctx: CommandContext, args: string[]): number { + const usage = `Usage: ${ctx.form} DEVELOPERS.md\n`; + if (parseDevelopersArgs(ctx, args, usage)) return 0; + const file = resolveProjectFile(ctx.cwd, ctx.overlay, "DEVELOPERS.md"); + if (file === undefined) throw missingDevelopersError(ctx); + ctx.stdout.write(readFileSync(file.path, "utf-8")); + return 0; +} + +function parseDevelopersArgs(ctx: CommandContext, args: string[], usage: string): boolean { + const { values, positionals } = parseCommandArgs(usage, () => + parseArgs({ + args, + options: { help: { type: "boolean", short: "h", default: false } }, + strict: true, + allowPositionals: true, + } as const), + ); + if (positionals.length > 0) + throw new CliError(`Unexpected argument: ${positionals[0]}\n\n${usage}`); + if (!values.help) return false; + ctx.stdout.write(usage); + return true; +} + +function missingDevelopersError(ctx: CommandContext): CliError { + const tried = [join(ctx.cwd, "DEVELOPERS.md")]; + if (ctx.overlay !== undefined) tried.push(join(ctx.overlay.dir, "DEVELOPERS.md")); + return new CliError(`No DEVELOPERS.md found. Tried: ${tried.join(", ")}.`); +} diff --git a/packages/alignfirst/src/commands/docmap.ts b/packages/alignfirst/src/commands/docmap.ts new file mode 100644 index 00000000..552b7785 --- /dev/null +++ b/packages/alignfirst/src/commands/docmap.ts @@ -0,0 +1,25 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import { main as docmapMain } from "@paleo/docmap"; + +import type { CommandContext } from "../context.js"; +import { resolveProjectFile } from "../overlay.js"; + +export function runDocmap(ctx: CommandContext, args: string[]): number { + const docmapArgs = withOverlayRoot(ctx, args); + return docmapMain({ + argv: ["node", "docmap", ...docmapArgs], + cwd: ctx.cwd, + stdout: ctx.stdout, + stderr: ctx.stderr, + commands: { base: `${ctx.form} docmap`, withArgs: `${ctx.form} docmap` }, + }); +} + +function withOverlayRoot(ctx: CommandContext, args: string[]): string[] { + if (args.includes("--root") || existsSync(join(ctx.cwd, "docs"))) return args; + const docs = resolveProjectFile(ctx.cwd, ctx.overlay, "docs"); + if (docs?.source !== "overlay") return args; + return [...args, "--root", docs.path]; +} diff --git a/packages/alignfirst/src/commands/doctor.ts b/packages/alignfirst/src/commands/doctor.ts new file mode 100644 index 00000000..6f34a62f --- /dev/null +++ b/packages/alignfirst/src/commands/doctor.ts @@ -0,0 +1,190 @@ +import { execFileSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +import semver from "semver"; + +import { CliError } from "../cli-error.js"; +import type { CommandContext } from "../context.js"; +import { errorMessage } from "../errors.js"; +import { findExecutable } from "../executables.js"; +import { + findOverlay, + resolveProjectConfig, + resolveProjectFile, + type ResolvedProjectConfig, +} from "../overlay.js"; +import { parseCommandArgs } from "../parse-args.js"; +import { resolvePlansMode } from "../plans/mode.js"; +import { findInstalledSkill, STUB_SKILLS } from "../skills.js"; +import { cliRangeResult } from "../version-guard.js"; + +const PROJECT_CONFIG_NAME = ".alignfirst.json"; + +interface DoctorLine { + level: "ok" | "warn" | "error"; + text: string; +} + +export function runDoctor(ctx: CommandContext, args: string[]): number { + const usage = `Usage: ${ctx.form} doctor\n`; + if (parseDoctorArgs(ctx, args, usage)) return 0; + writeSection(ctx, "CLI", () => inspectCli(ctx)); + writeSection(ctx, "Config", () => inspectConfig(ctx)); + writeSection(ctx, "Plans", () => inspectPlans(ctx)); + writeSection(ctx, "Docmap", () => inspectDocmap(ctx)); + writeSection(ctx, "Skills", () => inspectSkills(ctx)); + writeSection(ctx, "Overlay", () => inspectOverlay(ctx)); + writeSection(ctx, "Companion", () => inspectCompanion(ctx)); + return 0; +} + +function parseDoctorArgs(ctx: CommandContext, args: string[], usage: string): boolean { + const { values, positionals } = parseCommandArgs(usage, () => + parseArgs({ + args, + options: { help: { type: "boolean", short: "h", default: false } }, + strict: true, + allowPositionals: true, + } as const), + ); + if (positionals.length > 0) + throw new CliError(`Unexpected argument: ${positionals[0]}\n\n${usage}`); + if (!values.help) return false; + ctx.stdout.write(usage); + return true; +} + +function writeSection(ctx: CommandContext, section: string, inspect: () => DoctorLine[]): void { + let lines: DoctorLine[]; + try { + lines = inspect(); + } catch (error) { + lines = [{ level: "error", text: firstLine(errorMessage(error)) }]; + } + for (const line of lines) ctx.stdout.write(`[${line.level}] ${section}: ${line.text}\n`); +} + +function firstLine(text: string): string { + return text.split("\n", 1)[0]; +} + +function inspectCli(ctx: CommandContext): DoctorLine[] { + const invokedPath = process.argv[1] ?? fileURLToPath(import.meta.url); + return [ + { + level: "ok", + text: `${ctx.version}, ${realpathSync(invokedPath)}, launched as ${ctx.form}`, + }, + ]; +} + +function inspectConfig(ctx: CommandContext): DoctorLine[] { + const resolved = resolveProjectConfig(ctx.cwd, ctx.env, ctx.home); + const lines: DoctorLine[] = [{ level: "ok", text: `source ${configSource(resolved)}` }]; + const result = cliRangeResult(resolved?.config, ctx.version); + if (result === undefined) { + lines.push({ level: "ok", text: "no cli range" }); + return lines; + } + lines.push({ + level: result.satisfied ? "ok" : "error", + text: `${result.satisfied ? "satisfies" : "does not satisfy"} ${result.range}`, + }); + if (semver.gtr(ctx.version, result.range)) + lines.push({ level: "warn", text: `${ctx.version} is ahead of ${result.range}` }); + return lines; +} + +function configSource(resolved: ResolvedProjectConfig | undefined): string { + if (resolved === undefined) return "none"; + return resolved.source === "root" ? "root" : (resolved.overlay?.dir ?? "overlay"); +} + +function inspectPlans(ctx: CommandContext): DoctorLine[] { + const mode = resolvePlansMode(ctx.cwd, ctx.form); + return [ + { + level: "ok", + text: mode.kind === "shared" ? `shared (${mode.repoToplevel})` : "local", + }, + ]; +} + +function inspectDocmap(ctx: CommandContext): DoctorLine[] { + const overlay = findOverlay(ctx.cwd, ctx.env, ctx.home); + const docs = resolveProjectFile(ctx.cwd, overlay, "docs"); + const source = docs?.source ?? "none"; + return [ + { level: docs === undefined ? "warn" : "ok", text: `docs/ source ${source}` }, + { level: "ok", text: `embedded docmap ${readDocmapVersion()}` }, + ]; +} + +function readDocmapVersion(): string { + const require = createRequire(import.meta.url); + const pkg: unknown = require("@paleo/docmap/package.json"); + if (!isRecord(pkg) || typeof pkg.version !== "string") + throw new Error("@paleo/docmap package.json has no version"); + return pkg.version; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function inspectSkills(ctx: CommandContext): DoctorLine[] { + return STUB_SKILLS.map((name) => { + const installed = findInstalledSkill(ctx.home, name); + if (installed === undefined) return { level: "warn", text: `${name} missing` }; + return { + level: "ok", + text: `${name} ${installed.version ?? "unknown"} (${installed.root})`, + }; + }); +} + +function inspectOverlay(ctx: CommandContext): DoctorLine[] { + const configured = ctx.env.ALIGNFIRST_OVERLAYS; + if (configured === undefined || configured === "") + return [{ level: "ok", text: "no overlays directory" }]; + const overlay = findOverlay(ctx.cwd, ctx.env, ctx.home); + if (overlay === undefined) return [{ level: "ok", text: "no overlay matches" }]; + const lines: DoctorLine[] = [ + { level: "ok", text: `${overlay.dir} (matched by ${overlay.matchedBy})` }, + ]; + for (const name of [PROJECT_CONFIG_NAME, "AGENTS.md", "DEVELOPERS.md", "docs"]) + lines.push({ + level: "ok", + text: `${name} ${resolveProjectFile(ctx.cwd, overlay, name)?.source ?? "none"}`, + }); + return lines; +} + +function inspectCompanion(ctx: CommandContext): DoctorLine[] { + const executable = findExecutable(ctx.env, "alcode"); + if (executable === undefined) + return [ + { + level: "warn", + text: "alcode not installed (optional; npm install -g @paleo/alcode)", + }, + ]; + try { + const version = execFileSync(executable, ["--version"], { + encoding: "utf-8", + env: ctx.env, + }).trim(); + return [{ level: "ok", text: `alcode ${version} (${executable})` }]; + } catch (error) { + return [{ level: "error", text: `alcode ${executable}: ${commandError(error)}` }]; + } +} + +function commandError(error: unknown): string { + if (isRecord(error) && typeof error.stderr === "string" && error.stderr !== "") + return firstLine(error.stderr); + return firstLine(errorMessage(error)); +} diff --git a/packages/alignfirst/src/commands/guide.ts b/packages/alignfirst/src/commands/guide.ts new file mode 100644 index 00000000..f0bf8e0b --- /dev/null +++ b/packages/alignfirst/src/commands/guide.ts @@ -0,0 +1,157 @@ +import { readFileSync } from "node:fs"; +import { parseArgs } from "node:util"; + +import { CliError } from "../cli-error.js"; +import { renderCommandForm } from "../command-form.js"; +import type { CommandContext } from "../context.js"; +import { resolveProjectFile } from "../overlay.js"; +import { parseCommandArgs } from "../parse-args.js"; +import { PROTOCOLS, type Protocol } from "../protocols.js"; + +const TICKET_ID_RULE_PLACEHOLDER = "{{TICKET_ID_RULE}}"; +const PERSPECTIVES = ["intent", "correctness", "safety", "quality"] as const; +const MODULES = ["typescript-strict", "javascript", "python"] as const; +const PROTOCOL_LIST = `${PROTOCOLS.join(", ")}, or overview`; +const PERSPECTIVE_LIST = PERSPECTIVES.join(", "); +const MODULE_LIST = MODULES.join(", "); + +interface GuideOptions { + protocol?: Protocol | "overview"; + protocolOnly: boolean; + reviewer?: Perspective; + modules: ReviewModule[]; +} + +type Perspective = (typeof PERSPECTIVES)[number]; +type ReviewModule = (typeof MODULES)[number]; + +export function runGuide(ctx: CommandContext, args: string[]): number { + const usage = renderUsage(ctx); + const options = parseGuideArgs(ctx, args, usage); + if (options === undefined) return 0; + const guide = renderGuide(ctx, options); + ctx.stdout.write(`${renderCommandForm(guide, ctx.form).trimEnd()}\n`); + return 0; +} + +function renderUsage(ctx: CommandContext): string { + return `Usage: + ${ctx.form} guide [] [--protocol-only] + ${ctx.form} guide overview + ${ctx.form} guide review --reviewer [--module ]... +`; +} + +function parseGuideArgs( + ctx: CommandContext, + args: string[], + usage: string, +): GuideOptions | undefined { + const { values, positionals } = parseCommandArgs(usage, () => + parseArgs({ + args, + options: { + "protocol-only": { type: "boolean", default: false }, + reviewer: { type: "string" }, + module: { type: "string", multiple: true }, + help: { type: "boolean", short: "h", default: false }, + }, + strict: true, + allowPositionals: true, + } as const), + ); + if (values.help) { + ctx.stdout.write(usage); + return; + } + if (positionals.length > 1) throw new CliError(`Expected at most one protocol.\n\n${usage}`); + const protocol = parseProtocol(positionals[0]); + const reviewer = parsePerspective(values.reviewer); + const modules = (values.module ?? []).map(parseModule); + validateOptions(protocol, values["protocol-only"], reviewer, modules); + return { protocol, protocolOnly: values["protocol-only"], reviewer, modules }; +} + +function parseProtocol(value: string | undefined): Protocol | "overview" | undefined { + if (value === undefined || value === "overview") return value; + const protocol = PROTOCOLS.find((candidate) => candidate === value); + if (protocol === undefined) + throw new CliError(`Unknown protocol "${value}". Protocols: ${PROTOCOL_LIST}.`); + return protocol; +} + +function parsePerspective(value: string | undefined): Perspective | undefined { + if (value === undefined) return; + const perspective = PERSPECTIVES.find((candidate) => candidate === value); + if (perspective === undefined) + throw new CliError(`Unknown reviewer "${value}". Reviewers: ${PERSPECTIVE_LIST}.`); + return perspective; +} + +function parseModule(value: string): ReviewModule { + const module = MODULES.find((candidate) => candidate === value); + if (module === undefined) + throw new CliError(`Unknown module "${value}". Modules: ${MODULE_LIST}.`); + return module; +} + +function validateOptions( + protocol: GuideOptions["protocol"], + protocolOnly: boolean, + reviewer: Perspective | undefined, + modules: ReviewModule[], +): void { + if (protocolOnly && protocol === undefined) + throw new CliError("--protocol-only requires a protocol."); + if (protocolOnly && protocol === "overview") + throw new CliError("--protocol-only cannot be used with overview."); + if (reviewer !== undefined && protocol !== "review") + throw new CliError("--reviewer can only be used with review."); + if (modules.length > 0 && reviewer === undefined) + throw new CliError("--module requires --reviewer."); +} + +function renderGuide(ctx: CommandContext, options: GuideOptions): string { + if (options.reviewer !== undefined) return renderReviewerGuide(options.reviewer, options.modules); + if (options.protocol === "overview") return readGuideTemplate("overview.md"); + if (options.protocolOnly && options.protocol !== undefined) + return readProtocolTemplate(options.protocol); + const core = renderCoreGuide(ctx); + if (options.protocol === undefined) return core; + return `${core.trimEnd()}\n\n${readProtocolTemplate(options.protocol).trimEnd()}`; +} + +function renderReviewerGuide(perspective: Perspective, modules: ReviewModule[]): string { + const templates = [ + readGuideTemplate("code-review/reviewer-common.md"), + readGuideTemplate(`code-review/${perspective}-reviewer.md`), + ...modules.map((module) => readGuideTemplate(`code-review/module-${module}.md`)), + ]; + return templates.map((template) => template.trimEnd()).join("\n\n"); +} + +function renderCoreGuide(ctx: CommandContext): string { + const ticketRule = renderTicketIdRule(ctx); + const core = readGuideTemplate("core.md").replaceAll( + TICKET_ID_RULE_PLACEHOLDER, + () => ticketRule, + ); + const projectConventions = resolveProjectFile(ctx.cwd, ctx.overlay, "AGENTS.md"); + if (projectConventions?.source !== "overlay") return core; + const content = readFileSync(projectConventions.path, "utf-8").trimEnd(); + return `${core.trimEnd()}\n\n## Project conventions\n\n${content}`; +} + +function renderTicketIdRule(ctx: CommandContext): string { + const pattern = ctx.projectConfig?.config.ticketPattern; + if (pattern === undefined) return "Ask the user for the ticket ID when it is not given."; + return `Ticket IDs match \`${pattern}\`. When the user gives no id, run \`{{CMD}} ticket\` without an id: it deduces the id from the current branch.`; +} + +function readProtocolTemplate(protocol: Protocol): string { + return readGuideTemplate(`protocols/${protocol}.md`); +} + +function readGuideTemplate(path: string): string { + return readFileSync(new URL(`../../templates/guide/${path}`, import.meta.url), "utf-8").trimEnd(); +} diff --git a/packages/alignfirst/src/commands/plans.ts b/packages/alignfirst/src/commands/plans.ts new file mode 100644 index 00000000..f282fc12 --- /dev/null +++ b/packages/alignfirst/src/commands/plans.ts @@ -0,0 +1,184 @@ +import { existsSync, mkdirSync, realpathSync, statSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { parseArgs } from "node:util"; + +import { CliError } from "../cli-error.js"; +import type { CommandContext } from "../context.js"; +import { assertMainWorktreeRoot } from "../git.js"; +import { parseCommandArgs } from "../parse-args.js"; +import { archiveEntry, archiveThresholdDays, autoArchive } from "../plans/archive.js"; +import { linkPlans } from "../plans/link.js"; +import { resolvePlansMode } from "../plans/mode.js"; + +export function runPlans(ctx: CommandContext, args: string[]): number { + const [command, ...rest] = args; + switch (command) { + case "setup": + return runSetup(ctx, rest); + case "check": + return runCheck(ctx, rest); + case "archive": + return runArchive(ctx, rest); + case "auto-archive": + return runAutoArchive(ctx, rest); + case "--help": + case "-h": + ctx.stdout.write(plansUsage(ctx)); + return 0; + default: + throw new CliError(`Unknown or missing plans command.\n\n${plansUsage(ctx)}`); + } +} + +function plansUsage(ctx: CommandContext): string { + return `Usage: + ${ctx.form} plans setup [--folder ] + ${ctx.form} plans check + ${ctx.form} plans archive + ${ctx.form} plans auto-archive +`; +} + +function runSetup(ctx: CommandContext, args: string[]): number { + const usage = `Usage: ${ctx.form} plans setup [--folder ]\n`; + const parsed = parseSetupArgs(ctx, args, usage); + if (parsed === undefined) return 0; + assertMainWorktreeRoot(ctx.cwd); + const cloneDir = resolve(ctx.cwd, parsed.dir); + checkClone(ctx, cloneDir); + const projectDir = join(cloneDir, parsed.folder); + mkdirSync(projectDir, { recursive: true }); + linkPlans(ctx, projectDir); + return 0; +} + +interface SetupOptions { + dir: string; + folder: string; +} + +function parseSetupArgs( + ctx: CommandContext, + args: string[], + usage: string, +): SetupOptions | undefined { + const { values, positionals } = parseCommandArgs(usage, () => + parseArgs({ + args, + options: { + folder: { type: "string" }, + help: { type: "boolean", short: "h", default: false }, + }, + strict: true, + allowPositionals: true, + } as const), + ); + if (values.help) { + ctx.stdout.write(usage); + return; + } + if (positionals.length !== 1) throw new CliError(usage.trimEnd()); + const configFolder = ctx.projectConfig?.config.plans?.folder; + if (values.folder !== undefined && configFolder !== undefined) + throw new CliError(".alignfirst.json already sets plans.folder; drop --folder."); + const folder = values.folder ?? configFolder; + if (folder === undefined) + throw new CliError("Pass --folder or set plans.folder in .alignfirst.json."); + return { dir: positionals[0], folder }; +} + +function checkClone(ctx: CommandContext, cloneDir: string): void { + if (!existsSync(cloneDir)) + throw new CliError( + `${cloneDir} does not exist. Clone the team plans repository there first (see the instruction file).`, + ); + if (!existsSync(join(cloneDir, ".git"))) + throw new CliError( + `${cloneDir} is not a git repository. Point ${ctx.form} plans setup at a clone of the team plans repository.`, + ); + if (realpathSync(cloneDir) === realpathSync(ctx.cwd)) + throw new CliError( + `${cloneDir} is the product repository itself. Point ${ctx.form} plans setup at a clone of the team plans repository.`, + ); +} + +function runCheck(ctx: CommandContext, args: string[]): number { + const usage = `Usage: ${ctx.form} plans check\n`; + if (handleBareHelp(ctx, args, usage)) return 0; + const mode = resolvePlansMode(ctx.cwd, ctx.form); + if (mode.kind === "shared") ctx.stdout.write(".plans is linked to the team plans repository.\n"); + else + ctx.stdout.write( + ".plans is a local directory (local plans mode): synchronization is disabled.\n", + ); + return 0; +} + +function handleBareHelp(ctx: CommandContext, args: string[], usage: string): boolean { + const { values, positionals } = parseCommandArgs(usage, () => + parseArgs({ + args, + options: { help: { type: "boolean", short: "h", default: false } }, + strict: true, + allowPositionals: true, + } as const), + ); + if (positionals.length > 0) + throw new CliError(`Unexpected argument: ${positionals[0]}\n\n${usage}`); + if (!values.help) return false; + ctx.stdout.write(usage); + return true; +} + +function runAutoArchive(ctx: CommandContext, args: string[]): number { + const usage = `Usage: ${ctx.form} plans auto-archive\n`; + if (handleBareHelp(ctx, args, usage)) return 0; + const mode = resolvePlansMode(ctx.cwd, ctx.form); + const archived = autoArchive(join(ctx.cwd, ".plans"), archiveThresholdDays(ctx.env), ctx.stdout); + if (mode.kind === "shared" && archived) ctx.stdout.write(`Publish with: ${ctx.form} sync\n`); + return 0; +} + +function runArchive(ctx: CommandContext, args: string[]): number { + const usage = `Usage: ${ctx.form} plans archive \n`; + const target = resolveArchiveTarget(ctx, args, usage); + if (target === undefined) return 0; + const mode = resolvePlansMode(ctx.cwd, ctx.form); + const root = join(ctx.cwd, ".plans"); + archiveEntry(root, target, ctx.stdout); + if (mode.kind === "shared") ctx.stdout.write(`Publish with: ${ctx.form} sync\n`); + return 0; +} + +function resolveArchiveTarget( + ctx: CommandContext, + args: string[], + usage: string, +): string | undefined { + const { values, positionals } = parseCommandArgs(usage, () => + parseArgs({ + args, + options: { help: { type: "boolean", short: "h", default: false } }, + strict: true, + allowPositionals: true, + } as const), + ); + if (values.help) { + ctx.stdout.write(usage); + return; + } + if (positionals.length !== 1) throw new CliError(usage.trimEnd()); + const argument = positionals[0]; + const plansDir = join(ctx.cwd, ".plans"); + const target = isPathArgument(argument) ? resolve(ctx.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/alignfirst/src/commands/setup.ts b/packages/alignfirst/src/commands/setup.ts new file mode 100644 index 00000000..5dd51cd8 --- /dev/null +++ b/packages/alignfirst/src/commands/setup.ts @@ -0,0 +1,313 @@ +import { + appendFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + renameSync, + rmdirSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { basename, join } from "node:path"; +import { parseArgs } from "node:util"; + +import { CliError } from "../cli-error.js"; +import type { CommandContext } from "../context.js"; +import { assertMainWorktreeRoot, gitOutputOrUndefined, gitSucceeds } from "../git.js"; +import { normalizeRemoteUrl } from "../overlay.js"; +import { parseCommandArgs } from "../parse-args.js"; +import { linkPlans } from "../plans/link.js"; +import { + PROJECT_CONFIG_FILENAME, + type PortRange, + type ProjectConfig, + readProjectConfig, + validateProjectConfig, +} from "../project-config.js"; +import { installStubSkills } from "../skills.js"; +import { defaultCliRange } from "../version-guard.js"; + +const ADOPT_FILES = ["AGENTS.md", "DEVELOPERS.md", "docs"] as const; + +interface SetupOptions { + ticketPattern?: string; + plansFolder?: string; + portRange?: PortRange; + agents: string[]; + overlay: boolean; + adopt: boolean; +} + +export function runSetup(ctx: CommandContext, args: string[]): number { + const usage = setupUsage(ctx); + const options = parseSetupArgs(ctx, args, usage); + if (options === undefined) return 0; + if (options.overlay) return runOverlaySetup(ctx, options); + if (options.adopt) return runAdopt(ctx); + return runDefaultSetup(ctx, options); +} + +function setupUsage(ctx: CommandContext): string { + return `Usage: + ${ctx.form} setup [--ticket-pattern ] [--plans-folder ] [--port-range -] [--agent ]... + ${ctx.form} setup --overlay [--plans-folder ] [--ticket-pattern ] [--port-range -] + ${ctx.form} setup --adopt +`; +} + +function parseSetupArgs( + ctx: CommandContext, + args: string[], + usage: string, +): SetupOptions | undefined { + const { values } = parseCommandArgs(usage, () => + parseArgs({ + args, + options: { + "ticket-pattern": { type: "string" }, + "plans-folder": { type: "string" }, + "port-range": { type: "string" }, + agent: { type: "string", multiple: true, default: [] }, + overlay: { type: "boolean", default: false }, + adopt: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + strict: true, + } as const), + ); + if (values.help) { + ctx.stdout.write(usage); + return; + } + if (values.overlay && values.adopt) + throw new CliError(`--overlay and --adopt are mutually exclusive.\n\n${usage}`); + if ((values.overlay || values.adopt) && values.agent.length > 0) + throw new CliError(`--agent is available only in the default setup mode.\n\n${usage}`); + const portRange = + values["port-range"] === undefined ? undefined : parsePortRange(values["port-range"], usage); + const options: SetupOptions = { + ticketPattern: values["ticket-pattern"], + plansFolder: values["plans-folder"], + portRange, + agents: values.agent, + overlay: values.overlay, + adopt: values.adopt, + }; + validateSetupOptions(options); + return options; +} + +function parsePortRange(value: string, usage: string): PortRange { + const match = /^(\d+)-(\d+)$/.exec(value); + if (!match) throw new CliError(`--port-range must be -.\n\n${usage}`); + return { first: Number(match[1]), last: Number(match[2]) }; +} + +function validateSetupOptions(options: SetupOptions): void { + validateProjectConfig( + { + schemaVersion: 1, + ...(options.ticketPattern === undefined ? {} : { ticketPattern: options.ticketPattern }), + ...(options.plansFolder === undefined ? {} : { plans: { folder: options.plansFolder } }), + ...(options.portRange === undefined ? {} : { portRange: options.portRange }), + }, + PROJECT_CONFIG_FILENAME, + ); +} + +function runDefaultSetup(ctx: CommandContext, options: SetupOptions): number { + assertMainWorktreeRoot(ctx.cwd); + setupProjectConfig(ctx, options); + setupPlansDirectory(ctx); + installStubSkills(ctx, options.agents); + ctx.stdout.write("Installed the AlignFirst skills globally.\n"); + setupReadme(ctx); + return 0; +} + +function setupProjectConfig(ctx: CommandContext, options: SetupOptions): void { + const path = join(ctx.cwd, PROJECT_CONFIG_FILENAME); + if (existsSync(path)) { + readProjectConfig(ctx.cwd); + ctx.stdout.write(`${PROJECT_CONFIG_FILENAME} is valid.\n`); + if (hasProjectOptions(options)) + throw new CliError(`${PROJECT_CONFIG_FILENAME} exists; edit it instead of passing options.`); + return; + } + const cli = defaultCliRange(ctx.version); + const config = validateProjectConfig( + { + schemaVersion: 1, + cli, + ...(options.ticketPattern === undefined ? {} : { ticketPattern: options.ticketPattern }), + ...(options.plansFolder === undefined ? {} : { plans: { folder: options.plansFolder } }), + ...(options.portRange === undefined ? {} : { portRange: options.portRange }), + }, + PROJECT_CONFIG_FILENAME, + ); + writeJson(path, config); + ctx.stdout.write(`Created ${PROJECT_CONFIG_FILENAME} (cli ${cli})\n`); +} + +function hasProjectOptions(options: SetupOptions): boolean { + return ( + options.ticketPattern !== undefined || + options.plansFolder !== undefined || + options.portRange !== undefined + ); +} + +function setupPlansDirectory(ctx: CommandContext): void { + const plansPath = join(ctx.cwd, ".plans"); + if (!existsSync(plansPath)) { + mkdirSync(plansPath); + ctx.stdout.write("Created .plans/\n"); + } + if (gitSucceeds(ctx.cwd, "check-ignore", "-q", ".plans")) return; + appendLine(join(ctx.cwd, ".gitignore"), ".plans"); + ctx.stdout.write("Added .plans to .gitignore.\n"); +} + +function appendLine(path: string, line: string): void { + const content = existsSync(path) ? readFileSync(path, "utf-8") : ""; + const separator = content === "" || content.endsWith("\n") ? "" : "\n"; + appendFileSync(path, `${separator}${line}\n`); +} + +function setupReadme(ctx: CommandContext): void { + const path = join(ctx.cwd, "README.md"); + if (!existsSync(path)) return; + const content = readFileSync(path, "utf-8"); + if (/alignfirst/i.test(content)) return; + appendFileSync( + path, + "\n## Prerequisites\n\nInstall the AlignFirst CLI: `npm install -g alignfirst`.\n", + ); + ctx.stdout.write("Added the CLI prerequisite to README.md.\n"); +} + +function runOverlaySetup(ctx: CommandContext, options: SetupOptions): number { + const overlaysDir = resolveOverlaysDir(ctx); + assertMainWorktreeRoot(ctx.cwd); + const projectPath = realpathSync(ctx.cwd); + const name = options.plansFolder ?? basename(projectPath); + const overlayDir = join(overlaysDir, name, "_project"); + if (existsSync(overlayDir)) throw new CliError(`${overlayDir} already exists.`); + const config = buildOverlayConfig(ctx, options, projectPath); + mkdirSync(overlayDir, { recursive: true }); + writeJson(join(overlayDir, PROJECT_CONFIG_FILENAME), config); + ctx.stdout.write(`Created overlay: ${overlayDir}\n`); + setupOverlayPlans(ctx, overlaysDir, name); + setupGitExclude(ctx); + return 0; +} + +function resolveOverlaysDir(ctx: CommandContext): string { + const value = ctx.env.ALIGNFIRST_OVERLAYS; + if (value === undefined || value === "") throw new CliError("ALIGNFIRST_OVERLAYS is not set."); + return value.startsWith("~/") ? join(ctx.home, value.slice(2)) : value; +} + +function buildOverlayConfig( + ctx: CommandContext, + options: SetupOptions, + projectPath: string, +): ProjectConfig { + const origin = gitOutputOrUndefined(ctx.cwd, "remote", "get-url", "origin"); + return validateProjectConfig( + { + schemaVersion: 1, + project: { + ...(origin === undefined || origin === "" ? {} : { remote: normalizeRemoteUrl(origin) }), + paths: [projectPath], + }, + ...(options.ticketPattern === undefined ? {} : { ticketPattern: options.ticketPattern }), + ...(options.plansFolder === undefined ? {} : { plans: { folder: options.plansFolder } }), + ...(options.portRange === undefined ? {} : { portRange: options.portRange }), + }, + PROJECT_CONFIG_FILENAME, + ); +} + +function setupOverlayPlans(ctx: CommandContext, overlaysDir: string, name: string): void { + if (gitSucceeds(overlaysDir, "rev-parse", "--git-dir")) { + linkPlans(ctx, join(overlaysDir, name)); + return; + } + const plansPath = join(ctx.cwd, ".plans"); + if (!existsSync(plansPath)) mkdirSync(plansPath); + ctx.stdout.write("Using a local .plans directory.\n"); +} + +function setupGitExclude(ctx: CommandContext): void { + if (gitSucceeds(ctx.cwd, "check-ignore", "-q", ".plans")) return; + appendLine(join(ctx.cwd, ".git", "info", "exclude"), ".plans"); + ctx.stdout.write("Added .plans to .git/info/exclude.\n"); +} + +function runAdopt(ctx: CommandContext): number { + const overlay = ctx.overlay; + if (overlay === undefined) { + const value = ctx.env.ALIGNFIRST_OVERLAYS; + throw new CliError( + `No overlay matches this repository (ALIGNFIRST_OVERLAYS=${value === undefined || value === "" ? "unset" : value}).`, + ); + } + adoptConfig(ctx, overlay.dir, overlay.config); + const agentsConflict = adoptFiles(ctx, overlay.dir); + removePlansExclude(ctx); + if (readdirSync(overlay.dir).length === 0) rmdirSync(overlay.dir); + else ctx.stdout.write(`Overlay remains: ${overlay.dir}\n`); + ctx.stdout.write("Next: add .plans to .gitignore.\n"); + if (agentsConflict) ctx.stdout.write("Next: merge the overlay AGENTS.md conventions by hand.\n"); + return 0; +} + +function adoptConfig(ctx: CommandContext, overlayDir: string, config: ProjectConfig): void { + const name = PROJECT_CONFIG_FILENAME; + const source = join(overlayDir, name); + const target = join(ctx.cwd, name); + if (existsSync(target)) { + ctx.stdout.write(`kept in the overlay: ${name} (the root has its own)\n`); + return; + } + const rootConfig = structuredClone(config); + delete rootConfig.project; + writeJson(target, validateProjectConfig(rootConfig, name)); + unlinkSync(source); + ctx.stdout.write(`Adopted ${name}.\n`); +} + +function adoptFiles(ctx: CommandContext, overlayDir: string): boolean { + let agentsConflict = false; + for (const name of ADOPT_FILES) { + const source = join(overlayDir, name); + if (!existsSync(source)) continue; + const target = join(ctx.cwd, name); + if (existsSync(target)) { + ctx.stdout.write(`kept in the overlay: ${name} (the root has its own)\n`); + if (name === "AGENTS.md") agentsConflict = true; + continue; + } + renameSync(source, target); + ctx.stdout.write(`Adopted ${name}.\n`); + } + return agentsConflict; +} + +function removePlansExclude(ctx: CommandContext): void { + const path = join(ctx.cwd, ".git", "info", "exclude"); + if (!existsSync(path)) return; + const content = readFileSync(path, "utf-8"); + const lines = content.split(/\r?\n/); + const filtered = lines.filter((line) => line.trim() !== ".plans"); + if (filtered.length === lines.length) return; + writeFileSync(path, filtered.join("\n")); + ctx.stdout.write("Removed .plans from .git/info/exclude.\n"); +} + +function writeJson(path: string, value: ProjectConfig): void { + writeFileSync(path, `${JSON.stringify(value, undefined, 2)}\n`); +} diff --git a/packages/plans-share/src/sync.ts b/packages/alignfirst/src/commands/sync.ts similarity index 55% rename from packages/plans-share/src/sync.ts rename to packages/alignfirst/src/commands/sync.ts index 57ed8ee9..da262eea 100644 --- a/packages/plans-share/src/sync.ts +++ b/packages/alignfirst/src/commands/sync.ts @@ -1,45 +1,62 @@ 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"; +import { parseArgs } from "node:util"; -export function runSync(ctx: CliContext, args: string[]): void { - const options = parseSyncArgs(args); - const thresholdDays = options.autoArchive ? archiveThresholdDays() : undefined; - const mode = resolvePlansMode(ctx); +import type { CommandContext } from "../context.js"; +import { git, gitOutput, gitSucceeds } from "../git.js"; +import { parseCommandArgs } from "../parse-args.js"; +import { archiveThresholdDays, autoArchive } from "../plans/archive.js"; +import { resolvePlansMode } from "../plans/mode.js"; + +export function runSync(ctx: CommandContext, args: string[]): number { + const usage = `Usage: ${ctx.form} sync [--auto-archive]\n`; + const options = parseSyncArgs(ctx, args, usage); + if (options === undefined) return 0; + const thresholdDays = options.autoArchive ? archiveThresholdDays(ctx.env) : undefined; + const mode = resolvePlansMode(ctx.cwd, ctx.form); 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; + return 0; } const repoDir = mode.repoToplevel; - // A fresh clone of an empty plans repository has no HEAD yet: nothing to rebase onto. 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(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"); } + return 0; } 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}`); +function parseSyncArgs( + ctx: CommandContext, + args: string[], + usage: string, +): SyncOptions | undefined { + const { values } = parseCommandArgs(usage, () => + parseArgs({ + args, + options: { + "auto-archive": { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + strict: true, + } as const), + ); + if (values.help) { + ctx.stdout.write(usage); + return; } - return { autoArchive }; + return { autoArchive: values["auto-archive"] }; } function hasHead(dir: string): boolean { @@ -50,7 +67,6 @@ function hasStagedChanges(dir: string): boolean { return !gitSucceeds(dir, "diff", "--cached", "--quiet"); } -// No upstream yet means the branch was never pushed: everything is to send. function hasCommitsToSend(dir: string): boolean { if (!gitSucceeds(dir, "rev-parse", "--verify", "-q", "@{u}")) return true; return gitOutput(dir, "rev-list", "--count", "@{u}..HEAD") !== "0"; diff --git a/packages/alignfirst/src/commands/ticket.ts b/packages/alignfirst/src/commands/ticket.ts new file mode 100644 index 00000000..ee9b57dd --- /dev/null +++ b/packages/alignfirst/src/commands/ticket.ts @@ -0,0 +1,177 @@ +import { join, relative } from "node:path"; +import { parseArgs } from "node:util"; + +import { CliError } from "../cli-error.js"; +import type { CommandContext } from "../context.js"; +import { parseCommandArgs } from "../parse-args.js"; +import { assertPlansGate } from "../plans/layout.js"; +import { + deduceTicketFromBranch, + nextFileName, + peekSideTicket, + reserveSideTicket, + resolveTicketDir, + type ResolvedTicketDir, + validateTicketId, +} from "../plans/ticket.js"; + +const USAGE = `Usage: + {{FORM}} ticket [] [--next ] [--new-cycle] [--json] [--dry-run] + {{FORM}} ticket --side [--json] [--dry-run] +`; + +interface TicketOptions { + id: string; + branch?: string; + next?: string; + newCycle: boolean; + json: boolean; + dryRun: boolean; + side: boolean; +} + +interface TicketJsonReport { + id: string; + dir: string; + state: ResolvedTicketDir["state"]; + branch?: string; + entries: string[]; + next?: string; +} + +export function runTicket(ctx: CommandContext, args: string[]): number { + assertPlansGate(ctx.cwd, ctx.form); + const usage = renderUsage(ctx); + const parsed = parseTicketArgs(ctx, args, usage); + if (parsed === undefined) return 0; + const result = resolveTicket(ctx, parsed); + const next = + parsed.next === undefined ? undefined : nextFileName(result.dir, parsed.next, parsed.newCycle); + if (parsed.json) + ctx.stdout.write(`${JSON.stringify(jsonReport(ctx, parsed, result, next), undefined, 2)}\n`); + else ctx.stdout.write(renderReport(ctx, parsed, result, next)); + return 0; +} + +function renderUsage(ctx: CommandContext): string { + return USAGE.replaceAll("{{FORM}}", ctx.form); +} + +function parseTicketArgs( + ctx: CommandContext, + args: string[], + usage: string, +): TicketOptions | undefined { + const { values, positionals } = parseCommandArgs(usage, () => + parseArgs({ + args, + options: { + next: { type: "string" }, + "new-cycle": { type: "boolean", default: false }, + json: { type: "boolean", default: false }, + "dry-run": { type: "boolean", default: false }, + side: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + strict: true, + allowPositionals: true, + } as const), + ); + if (values.help) { + ctx.stdout.write(usage); + return; + } + if (positionals.length > 1) throw new CliError(`Expected at most one ticket id.\n\n${usage}`); + if (values.side && positionals.length > 0) + throw new CliError(`A ticket id cannot be combined with --side.\n\n${usage}`); + if (values["new-cycle"] && values.next === undefined) + throw new CliError(`--new-cycle requires --next.\n\n${usage}`); + const resolution = resolveTicketId(ctx, positionals[0], values.side, values["dry-run"]); + return { + ...resolution, + next: values.next, + newCycle: values["new-cycle"], + json: values.json, + dryRun: values["dry-run"], + side: values.side, + }; +} + +interface TicketResolution { + id: string; + branch?: string; +} + +function resolveTicketId( + ctx: CommandContext, + positional: string | undefined, + side: boolean, + dryRun: boolean, +): TicketResolution { + const pattern = ctx.projectConfig?.config.ticketPattern; + if (positional !== undefined) { + validateTicketId(positional, pattern); + return { id: positional }; + } + if (side) return { id: dryRun ? peekSideTicket(ctx.cwd) : reserveSideTicket(ctx.cwd) }; + if (pattern === undefined) + throw new CliError( + "No ticket id given and .alignfirst.json has no ticketPattern: pass the id.", + ); + const deduced = deduceTicketFromBranch(ctx.cwd, pattern); + validateTicketId(deduced.id, pattern); + return deduced; +} + +function resolveTicket(ctx: CommandContext, options: TicketOptions): ResolvedTicketDir { + if (options.side && !options.dryRun) + return { + id: options.id, + dir: join(ctx.cwd, ".plans", options.id), + state: "created", + entries: [], + }; + return resolveTicketDir(ctx.cwd, options.id, { dryRun: options.dryRun }); +} + +function jsonReport( + ctx: CommandContext, + options: TicketOptions, + result: ResolvedTicketDir, + next: string | undefined, +): TicketJsonReport { + return { + id: result.id, + dir: relative(ctx.cwd, result.dir), + state: result.state, + ...(options.branch === undefined ? {} : { branch: options.branch }), + entries: result.entries, + ...(next === undefined ? {} : { next: relative(ctx.cwd, join(result.dir, next)) }), + }; +} + +function renderReport( + ctx: CommandContext, + options: TicketOptions, + result: ResolvedTicketDir, + next: string | undefined, +): string { + const reservation = options.side && options.dryRun ? " (would be reserved)" : ""; + const deduction = options.branch === undefined ? "" : ` (deduced from branch ${options.branch})`; + const directoryState = renderDirectoryState(result.state, options.dryRun); + const directory = `${relative(ctx.cwd, result.dir)}/`; + const lines = [ + `Ticket ${result.id}${reservation}${deduction}`, + `Directory: ${directory}${directoryState}`, + ]; + if (result.entries.length === 0) lines.push("Entries: (none)"); + else lines.push("Entries:", ...result.entries.map((entry) => ` ${entry}`)); + if (next !== undefined) lines.push(`Next file: ${relative(ctx.cwd, join(result.dir, next))}`); + return `${lines.join("\n")}\n`; +} + +function renderDirectoryState(state: ResolvedTicketDir["state"], dryRun: boolean): string { + if (state === "existing") return ""; + if (state === "created") return dryRun ? " (would be created)" : " (created)"; + return dryRun ? " (would be restored from _archives)" : " (restored from _archives)"; +} diff --git a/packages/alignfirst/src/context.ts b/packages/alignfirst/src/context.ts new file mode 100644 index 00000000..d9c53fbf --- /dev/null +++ b/packages/alignfirst/src/context.ts @@ -0,0 +1,17 @@ +import type { Overlay, ResolvedProjectConfig } from "./overlay.js"; + +export interface Output { + write(text: string): void; +} + +export interface CommandContext { + cwd: string; + env: NodeJS.ProcessEnv; + home: string; + stdout: Output; + stderr: Output; + form: string; + version: string; + projectConfig?: ResolvedProjectConfig; + overlay?: Overlay; +} diff --git a/packages/alignfirst/src/errors.ts b/packages/alignfirst/src/errors.ts new file mode 100644 index 00000000..e73bc51f --- /dev/null +++ b/packages/alignfirst/src/errors.ts @@ -0,0 +1,7 @@ +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} diff --git a/packages/alignfirst/src/executables.ts b/packages/alignfirst/src/executables.ts new file mode 100644 index 00000000..4db6acf2 --- /dev/null +++ b/packages/alignfirst/src/executables.ts @@ -0,0 +1,22 @@ +import { accessSync, constants, statSync } from "node:fs"; +import { delimiter, join } from "node:path"; + +export function findExecutable(env: NodeJS.ProcessEnv, name: string): string | undefined { + const path = env.PATH; + if (path === undefined || path === "") return; + for (const dir of path.split(delimiter)) { + const candidate = join(dir, name); + if (isExecutableFile(candidate)) return candidate; + } + return; +} + +function isExecutableFile(path: string): boolean { + try { + if (!statSync(path).isFile()) return false; + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} diff --git a/packages/alignfirst/src/git.ts b/packages/alignfirst/src/git.ts new file mode 100644 index 00000000..9437ce10 --- /dev/null +++ b/packages/alignfirst/src/git.ts @@ -0,0 +1,57 @@ +import { execFileSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; + +import { CliError } from "./cli-error.js"; + +export function git(dir: string, ...args: string[]): void { + try { + execFileSync("git", ["-C", dir, ...args], { stdio: "inherit" }); + } catch { + throw gitFailure(args); + } +} + +function gitFailure(args: string[]): CliError { + return new CliError(`git ${args[0]} failed. See the git output above.`); +} + +export function assertMainWorktreeRoot(cwd: string): void { + const toplevel = gitOutput(cwd, "rev-parse", "--show-toplevel"); + if (realpathSync(toplevel) !== realpathSync(cwd)) + throw new CliError("Run this command from the repository root."); + const gitDir = gitOutput(cwd, "rev-parse", "--absolute-git-dir"); + const commonDir = gitOutput(cwd, "rev-parse", "--git-common-dir"); + if (realpathSync(gitDir) !== realpathSync(resolve(cwd, commonDir))) + throw new CliError( + "Run this command from the main worktree. Linked worktrees reach .plans through it.", + ); +} + +export function gitOutput(dir: string, ...args: string[]): string { + try { + return execFileSync("git", ["-C", dir, ...args], { encoding: "utf-8" }).trim(); + } catch { + throw gitFailure(args); + } +} + +export function gitOutputOrUndefined(dir: string, ...args: string[]): string | undefined { + try { + return execFileSync("git", ["-C", dir, ...args], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return; + } +} + +export function gitSucceeds(dir: string, ...args: string[]): boolean { + try { + execFileSync("git", ["-C", dir, ...args], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} diff --git a/packages/alignfirst/src/overlay.ts b/packages/alignfirst/src/overlay.ts new file mode 100644 index 00000000..e3a4558c --- /dev/null +++ b/packages/alignfirst/src/overlay.ts @@ -0,0 +1,119 @@ +import { existsSync, readdirSync, realpathSync } from "node:fs"; +import { join } from "node:path"; + +import { CliError } from "./cli-error.js"; +import { gitOutputOrUndefined } from "./git.js"; +import { readProjectConfig, type ProjectConfig } from "./project-config.js"; + +export interface Overlay { + dir: string; + config: ProjectConfig; + matchedBy: "remote" | "paths"; +} + +export interface ProjectFile { + path: string; + source: "root" | "overlay"; +} + +export interface ResolvedProjectConfig { + config: ProjectConfig; + source: "root" | "overlay"; + overlay?: Overlay; +} + +export function findOverlay( + cwd: string, + env: NodeJS.ProcessEnv, + home: string, +): Overlay | undefined { + const configuredDir = env.ALIGNFIRST_OVERLAYS; + if (configuredDir === undefined || configuredDir === "") return; + const overlaysDir = expandHome(configuredDir, home); + const candidates = readOverlayCandidates(overlaysDir); + const origin = gitOutputOrUndefined(cwd, "remote", "get-url", "origin"); + const normalizedOrigin = + origin === undefined || origin === "" ? undefined : normalizeRemoteUrl(origin); + const realCwd = realpathSync(cwd); + const remoteMatches = candidates.filter( + ({ config }) => normalizedOrigin !== undefined && config.project?.remote === normalizedOrigin, + ); + if (remoteMatches.length > 0) return selectOverlay(remoteMatches, "remote"); + const pathMatches = candidates.filter(({ config }) => config.project?.paths?.includes(realCwd)); + if (pathMatches.length > 0) return selectOverlay(pathMatches, "paths"); + return; +} + +interface OverlayCandidate { + dir: string; + config: ProjectConfig; +} + +function expandHome(path: string, home: string): string { + return path.startsWith("~/") ? join(home, path.slice(2)) : path; +} + +function readOverlayCandidates(overlaysDir: string): OverlayCandidate[] { + if (!existsSync(overlaysDir)) return []; + return readdirSync(overlaysDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .flatMap((entry) => { + const dir = join(overlaysDir, entry.name, "_project"); + const config = readProjectConfig(dir); + return config === undefined ? [] : [{ dir, config }]; + }); +} + +function selectOverlay(candidates: OverlayCandidate[], matchedBy: Overlay["matchedBy"]): Overlay { + if (candidates.length > 1) + throw new CliError( + `Multiple AlignFirst overlays match this project: ${candidates + .map(({ dir }) => dir) + .join(", ")}`, + ); + const candidate = candidates[0]; + return { ...candidate, matchedBy }; +} + +export function normalizeRemoteUrl(url: string): string { + const value = url + .trim() + .replace(/\/+$/, "") + .replace(/\.git$/, ""); + const scpMatch = value.includes("://") ? null : /^(?:[^@]+@)?([^:/]+):(.+)$/.exec(value); + if (scpMatch) return `${scpMatch[1].toLowerCase()}/${scpMatch[2].replace(/^\/+/, "")}`; + try { + const parsed = new URL(value.includes("://") ? value : `https://${value}`); + return `${parsed.hostname.toLowerCase()}${parsed.pathname}` + .replace(/\/+$/, "") + .replace(/\.git$/, "") + .replace(/^\/+/, ""); + } catch { + return value; + } +} + +export function resolveProjectFile( + cwd: string, + overlay: Overlay | undefined, + name: string, +): ProjectFile | undefined { + const rootPath = join(cwd, name); + if (existsSync(rootPath)) return { path: rootPath, source: "root" }; + if (overlay === undefined) return; + const overlayPath = join(overlay.dir, name); + if (existsSync(overlayPath)) return { path: overlayPath, source: "overlay" }; + return; +} + +export function resolveProjectConfig( + cwd: string, + env: NodeJS.ProcessEnv, + home: string, +): ResolvedProjectConfig | undefined { + const overlay = findOverlay(cwd, env, home); + const rootConfig = readProjectConfig(cwd); + if (rootConfig !== undefined) return { config: rootConfig, source: "root", overlay }; + if (overlay !== undefined) return { config: overlay.config, source: "overlay", overlay }; + return; +} diff --git a/packages/alignfirst/src/parse-args.ts b/packages/alignfirst/src/parse-args.ts new file mode 100644 index 00000000..d3064803 --- /dev/null +++ b/packages/alignfirst/src/parse-args.ts @@ -0,0 +1,11 @@ +import { CliError } from "./cli-error.js"; +import { errorMessage } from "./errors.js"; + +export function parseCommandArgs(usage: string, parse: () => T): T { + try { + return parse(); + } catch (error) { + const detail = errorMessage(error).split("\n", 1)[0]; + throw new CliError(`${detail}\n\n${usage}`); + } +} diff --git a/packages/alignfirst/src/plans/archive.ts b/packages/alignfirst/src/plans/archive.ts new file mode 100644 index 00000000..aa423c2f --- /dev/null +++ b/packages/alignfirst/src/plans/archive.ts @@ -0,0 +1,76 @@ +import { existsSync, mkdirSync, readdirSync, renameSync, statSync } from "node:fs"; +import { basename, dirname, extname, join, relative } from "node:path"; + +import { CliError } from "../cli-error.js"; +import type { Output } from "../context.js"; +import { isTicketName } from "./layout.js"; + +const DEFAULT_ARCHIVE_DAYS = 7; +const DAY_MS = 86_400_000; + +export function archiveThresholdDays(env: NodeJS.ProcessEnv): number { + const value = env.ALIGNFIRST_ARCHIVE_DAYS; + if (value === undefined) return DEFAULT_ARCHIVE_DAYS; + const days = Number(value); + if (!Number.isFinite(days) || days <= 0) + throw new CliError("ALIGNFIRST_ARCHIVE_DAYS must be a positive number of days."); + return days; +} + +export function autoArchive(plansDir: string, thresholdDays: number, stdout: Output): 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() && isTicketName(entry.name)) + .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); +} + +export function archiveEntry(plansDir: string, sourcePath: string, stdout: Output): 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; +} diff --git a/packages/alignfirst/src/plans/layout.ts b/packages/alignfirst/src/plans/layout.ts new file mode 100644 index 00000000..affe3df8 --- /dev/null +++ b/packages/alignfirst/src/plans/layout.ts @@ -0,0 +1,26 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import { CliError } from "../cli-error.js"; + +export const PLANS_DIR = ".plans"; +export const ARCHIVES_DIR = "_archives"; + +export function isTicketName(name: string): boolean { + return !name.startsWith("_"); +} + +export function plansDir(cwd: string): string { + return join(cwd, PLANS_DIR); +} + +export function archivesDir(cwd: string): string { + return join(plansDir(cwd), ARCHIVES_DIR); +} + +export function assertPlansGate(cwd: string, form: string): void { + if (existsSync(plansDir(cwd))) return; + throw new CliError( + `Error: no \`.plans/\` directory found in the current directory. Run \`${form}\` from the root of an AlignFirst-managed project.`, + ); +} diff --git a/packages/alignfirst/src/plans/link.ts b/packages/alignfirst/src/plans/link.ts new file mode 100644 index 00000000..f5118d08 --- /dev/null +++ b/packages/alignfirst/src/plans/link.ts @@ -0,0 +1,48 @@ +import { + cpSync, + existsSync, + lstatSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, +} from "node:fs"; +import { join, relative } from "node:path"; + +import { CliError } from "../cli-error.js"; +import type { CommandContext } from "../context.js"; + +export function linkPlans(ctx: CommandContext, targetDir: string): void { + const plansPath = join(ctx.cwd, ".plans"); + const stats = lstatSync(plansPath, { throwIfNoEntry: false }); + if (stats?.isSymbolicLink()) { + if (existsSync(plansPath) && realpathSync(plansPath) === realpathSync(targetDir)) { + ctx.stdout.write(".plans already links to the plans repository.\n"); + return; + } + rmSync(plansPath); + } else if (stats?.isDirectory()) { + migratePlansContent(ctx, plansPath, targetDir); + } else if (stats) { + throw new CliError(".plans exists and is not a directory."); + } + const target = relative(ctx.cwd, targetDir); + symlinkSync(target, plansPath); + ctx.stdout.write(`Linked .plans → ${target}\n`); + ctx.stdout.write(`Publish with: ${ctx.form} sync\n`); +} + +function migratePlansContent(ctx: CommandContext, plansPath: string, targetDir: string): void { + const entries = readdirSync(plansPath); + const collisions = entries.filter((entry) => existsSync(join(targetDir, entry))); + if (collisions.length > 0) + throw new CliError( + `Cannot migrate .plans: already in ${targetDir}: ${collisions.join(", ")}. ` + + "Merge them manually, then re-run.", + ); + for (const entry of entries) + cpSync(join(plansPath, entry), join(targetDir, entry), { recursive: true }); + rmSync(plansPath, { recursive: true }); + if (entries.length > 0) + ctx.stdout.write(`Migrated ${entries.length} entries from the local .plans directory.\n`); +} diff --git a/packages/alignfirst/src/plans/mode.ts b/packages/alignfirst/src/plans/mode.ts new file mode 100644 index 00000000..56ddaac2 --- /dev/null +++ b/packages/alignfirst/src/plans/mode.ts @@ -0,0 +1,55 @@ +import { existsSync, lstatSync, realpathSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { CliError } from "../cli-error.js"; +import { gitOutput } from "../git.js"; + +export type PlansMode = SharedPlans | LocalPlans; + +export interface SharedPlans { + kind: "shared"; + repoToplevel: string; +} + +export interface LocalPlans { + kind: "local"; +} + +export function resolvePlansMode(cwd: string, form: string): PlansMode { + const plansPath = join(cwd, ".plans"); + const stats = lstatSync(plansPath, { throwIfNoEntry: false }); + if (!stats) + throw new CliError( + `.plans is missing. Clone the team plans repository, then run ${form} plans setup ` + + "(see the project documentation) — or create a plain .plans directory to keep plans local.", + ); + if (stats.isSymbolicLink() && !existsSync(plansPath)) + throw new CliError( + `The .plans symlink is broken. Re-run ${form} plans setup with the clone location.`, + ); + if (!statSync(plansPath).isDirectory()) + throw new CliError( + `.plans is not a directory. Remove it, then run ${form} plans setup (see the project documentation).`, + ); + if (plansRepositoryId(plansPath, stats.isSymbolicLink(), form) === repositoryId(cwd)) + return { kind: "local" }; + return { kind: "shared", repoToplevel: gitOutput(plansPath, "rev-parse", "--show-toplevel") }; +} + +function plansRepositoryId(plansPath: string, isSymlink: boolean, form: string): string { + try { + return repositoryId(plansPath); + } catch { + if (isSymlink) + throw new CliError( + `.plans points outside any git repository. Re-run ${form} plans setup with the clone location.`, + ); + throw new CliError( + ".plans is not inside a git repository. Run this command from a worktree root.", + ); + } +} + +function repositoryId(dir: string): string { + return realpathSync(gitOutput(dir, "rev-parse", "--path-format=absolute", "--git-common-dir")); +} diff --git a/packages/alignfirst/src/plans/ticket.ts b/packages/alignfirst/src/plans/ticket.ts new file mode 100644 index 00000000..d01a3b4c --- /dev/null +++ b/packages/alignfirst/src/plans/ticket.ts @@ -0,0 +1,131 @@ +import { type Dirent, existsSync, mkdirSync, readdirSync, renameSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { CliError } from "../cli-error.js"; +import { isNodeError } from "../errors.js"; +import { gitOutputOrUndefined } from "../git.js"; +import { archivesDir, plansDir } from "./layout.js"; + +const FILE_PREFIX = /^([A-Z])(\d+)-/; +const SIDE_TICKET = /^side-(\d+)$/; +const PATH_SAFE_TICKET = /^[A-Za-z0-9._-]+$/; + +export interface ResolvedTicketDir { + id: string; + dir: string; + state: "existing" | "created" | "restored"; + entries: string[]; +} + +export interface ResolveTicketOptions { + dryRun: boolean; +} + +export interface DeducedTicket { + id: string; + branch: string; +} + +export function resolveTicketDir( + cwd: string, + id: string, + { dryRun }: ResolveTicketOptions, +): ResolvedTicketDir { + const dir = join(plansDir(cwd), id); + if (existsSync(dir)) return { id, dir, state: "existing", entries: listEntries(dir) }; + const archivedDir = join(archivesDir(cwd), id); + if (existsSync(archivedDir)) { + const entries = listEntries(archivedDir); + if (!dryRun) renameSync(archivedDir, dir); + return { id, dir, state: "restored", entries }; + } + if (!dryRun) mkdirSync(dir); + return { id, dir, state: "created", entries: [] }; +} + +export function reserveSideTicket(cwd: string): string { + const root = plansDir(cwd); + const highest = Math.max(highestSideTicket(root), highestSideTicket(archivesDir(cwd))); + for (let number = highest + 1; ; ++number) { + const ticket = `side-${number}`; + try { + mkdirSync(join(root, ticket)); + return ticket; + } catch (error) { + if (!isNodeError(error) || error.code !== "EEXIST") throw error; + } + } +} + +export function peekSideTicket(cwd: string): string { + const root = plansDir(cwd); + const highest = Math.max(highestSideTicket(root), highestSideTicket(archivesDir(cwd))); + for (let number = highest + 1; ; ++number) { + const ticket = `side-${number}`; + if (!existsSync(join(root, ticket))) return ticket; + } +} + +function highestSideTicket(dir: string): number { + let highest = 0; + for (const entry of readEntries(dir)) { + const match = entry.isDirectory() ? SIDE_TICKET.exec(entry.name) : null; + if (match) highest = Math.max(highest, Number(match[1])); + } + return highest; +} + +function readEntries(dir: string): Dirent[] { + try { + return readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } +} + +export function nextFileName(dir: string, filename: string, newCycle: boolean): string { + const prefixes = readEntries(dir).flatMap((entry) => { + const match = FILE_PREFIX.exec(entry.name); + return match ? [{ cycle: match[1], number: Number(match[2]) }] : []; + }); + if (prefixes.length === 0) return `A1-${filename}`; + const highestCycle = prefixes.reduce( + (highest, prefix) => (prefix.cycle > highest ? prefix.cycle : highest), + "A", + ); + if (newCycle) return `${String.fromCharCode(highestCycle.charCodeAt(0) + 1)}1-${filename}`; + const highestNumber = Math.max( + ...prefixes.filter(({ cycle }) => cycle === highestCycle).map(({ number }) => number), + ); + return `${highestCycle}${highestNumber + 1}-${filename}`; +} + +export function listEntries(dir: string): string[] { + if (!existsSync(dir) || !statSync(dir).isDirectory()) return []; + return readdirSync(dir, { withFileTypes: true }) + .map((entry) => `${entry.name}${entry.isDirectory() ? "/" : ""}`) + .toSorted(); +} + +export function isPathSafeTicketId(id: string): boolean { + return id !== "." && !id.includes("..") && PATH_SAFE_TICKET.test(id); +} + +export function validateTicketId(id: string, pattern?: string): void { + if (!isPathSafeTicketId(id)) throw new CliError(`Invalid ticket id: ${id}`); + if (pattern !== undefined && !new RegExp(pattern).test(id) && !SIDE_TICKET.test(id)) + throw new CliError(`Ticket id "${id}" does not match ticketPattern "${pattern}".`); +} + +export function deduceTicketFromBranch(cwd: string, pattern: string): DeducedTicket { + const branch = gitOutputOrUndefined(cwd, "branch", "--show-current"); + if (branch === undefined || branch === "") + throw new CliError("Cannot deduce a ticket id from a detached HEAD."); + const unanchored = pattern.replace(/^\^/, "").replace(/\$$/, ""); + const match = new RegExp(unanchored).exec(branch); + if (!match) + throw new CliError( + `Cannot deduce a ticket id from branch "${branch}" with pattern "${pattern}".`, + ); + return { id: match[0], branch }; +} diff --git a/packages/alignfirst/src/project-config.ts b/packages/alignfirst/src/project-config.ts new file mode 100644 index 00000000..315df6fb --- /dev/null +++ b/packages/alignfirst/src/project-config.ts @@ -0,0 +1,109 @@ +import { existsSync, readFileSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; + +import { type } from "arktype"; +import semver from "semver"; + +import { CliError } from "./cli-error.js"; +import { errorMessage } from "./errors.js"; + +export const PROJECT_CONFIG_FILENAME = ".alignfirst.json"; + +export const portRangeSchema = type({ + "+": "reject", + first: "1 <= number.integer <= 65535", + last: "1 <= number.integer <= 65535", +}); + +const plansSchema = type({ + "+": "reject", + folder: "string > 0", +}); +const projectSchema = type({ + "+": "reject", + "remote?": "string > 0", + "paths?": "string[]", +}); +const projectConfigSchema = type({ + "+": "reject", + schemaVersion: "1", + "cli?": "string > 0", + "ticketPattern?": "string > 0", + "plans?": plansSchema, + "portRange?": portRangeSchema, + "project?": projectSchema, +}); + +export interface ProjectConfig { + schemaVersion: 1; + cli?: string; + ticketPattern?: string; + plans?: PlansConfig; + portRange?: PortRange; + project?: ProjectIdentity; +} + +export interface PlansConfig { + folder: string; +} + +export interface PortRange { + first: number; + last: number; +} + +export interface ProjectIdentity { + remote?: string; + paths?: string[]; +} + +export function validateProjectConfig(value: unknown, label: string): ProjectConfig { + const config = projectConfigSchema(value); + if (config instanceof type.errors) throw invalidConfig(label, config.summary.split("\n", 1)[0]); + if (config.cli !== undefined && semver.validRange(config.cli) === null) + throw invalidConfig(label, `cli is not a valid semver range: ${config.cli}`); + if (config.ticketPattern !== undefined) assertValidPattern(config.ticketPattern, label); + if (config.portRange !== undefined) assertValidPortRange(config.portRange, label); + if (config.project !== undefined) assertValidProjectIdentity(config.project, label); + return config; +} + +function invalidConfig(label: string, detail: string): CliError { + return new CliError(`Invalid ${label}: ${detail}`); +} + +function assertValidPattern(pattern: string, label: string): void { + try { + new RegExp(pattern); + } catch (error) { + throw invalidConfig( + label, + `ticketPattern is not a valid regular expression: ${errorMessage(error)}`, + ); + } +} + +export function assertValidPortRange(range: PortRange, label: string): void { + if (range.first > range.last) + throw invalidConfig(label, "portRange.first must not exceed portRange.last"); +} + +function assertValidProjectIdentity(project: ProjectIdentity, label: string): void { + if (project.remote === undefined && project.paths === undefined) + throw invalidConfig(label, "project must contain remote or paths"); + const relativePath = project.paths?.find((path) => !isAbsolute(path)); + if (relativePath !== undefined) + throw invalidConfig(label, `project.paths must contain only absolute paths: ${relativePath}`); +} + +export function readProjectConfig(dir: string): ProjectConfig | undefined { + const path = join(dir, PROJECT_CONFIG_FILENAME); + if (!existsSync(path)) return; + let value: unknown; + try { + value = JSON.parse(readFileSync(path, "utf-8")); + } catch (error) { + throw invalidConfig(path, errorMessage(error)); + } + return validateProjectConfig(value, path); +} diff --git a/packages/alignfirst/src/protocols.ts b/packages/alignfirst/src/protocols.ts new file mode 100644 index 00000000..60bb7c51 --- /dev/null +++ b/packages/alignfirst/src/protocols.ts @@ -0,0 +1,11 @@ +export const PROTOCOLS = [ + "spec", + "plan", + "aad", + "catchup", + "merge", + "review", + "description", +] as const; + +export type Protocol = (typeof PROTOCOLS)[number]; diff --git a/packages/alignfirst/src/skills.ts b/packages/alignfirst/src/skills.ts new file mode 100644 index 00000000..56af38b4 --- /dev/null +++ b/packages/alignfirst/src/skills.ts @@ -0,0 +1,66 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { CliError } from "./cli-error.js"; +import type { CommandContext } from "./context.js"; + +export const STUB_SKILLS = [ + "alignfirst", + "alspec", + "alplan", + "al", + "alcatchup", + "almerge", + "alreview", + "aldescription", +] as const; + +export const SKILL_ROOTS = [".agents/skills", ".claude/skills", ".codex/skills"] as const; + +export interface InstalledSkill { + root: string; + version: string | undefined; +} + +export function findInstalledSkill(home: string, name: string): InstalledSkill | undefined { + for (const relativeRoot of SKILL_ROOTS) { + const root = join(home, relativeRoot); + const skillFile = join(root, name, "SKILL.md"); + if (!existsSync(skillFile)) continue; + return { root, version: readSkillVersion(skillFile) }; + } + return; +} + +function readSkillVersion(path: string): string | undefined { + const content = readFileSync(path, "utf-8"); + const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content)?.[1]; + if (frontmatter === undefined) return; + const metadata = /^metadata:\s*\r?\n((?:^[ \t]+.*(?:\r?\n|$))*)/m.exec(frontmatter)?.[1]; + if (metadata === undefined) return; + return /^\s+version:\s*"?([^"\r\n]+)"?\s*$/m.exec(metadata)?.[1]?.trim(); +} + +export function installStubSkills(ctx: CommandContext, agents: string[]): void { + const skillArgs = STUB_SKILLS.flatMap((skill) => ["--skill", skill]); + const agentArgs = agents.flatMap((agent) => ["--agent", agent]); + try { + execFileSync( + "npx", + [ + "-y", + "skills", + "add", + "https://github.com/paleo/alignfirst", + "--global", + "--yes", + ...skillArgs, + ...agentArgs, + ], + { cwd: ctx.cwd, env: ctx.env, stdio: "inherit" }, + ); + } catch { + throw new CliError("Failed to install the AlignFirst skills globally."); + } +} diff --git a/packages/alignfirst/src/version-guard.ts b/packages/alignfirst/src/version-guard.ts new file mode 100644 index 00000000..02e9d8f5 --- /dev/null +++ b/packages/alignfirst/src/version-guard.ts @@ -0,0 +1,40 @@ +import semver from "semver"; + +import { CliError } from "./cli-error.js"; +import type { ProjectConfig } from "./project-config.js"; + +export interface CliRangeResult { + range: string; + satisfied: boolean; +} + +export function defaultCliRange(version: string): string { + const parsed = semver.parse(version); + if (parsed === null) throw new Error(`Invalid installed version: ${version}`); + const upper = parsed.major === 0 ? `0.${parsed.minor + 1}.0` : `${parsed.major + 1}.0.0`; + return `>=${version} <${upper}`; +} + +export function checkCliRange( + config: ProjectConfig | undefined, + installedVersion: string, + commandArgs: string[], +): void { + const result = cliRangeResult(config, installedVersion); + if (result === undefined || result.satisfied) return; + const command = commandArgs.join(" "); + throw new CliError( + `alignfirst ${installedVersion} is installed; this project requires ${result.range}.\n` + + `Run a matching version: npx -y alignfirst@"${result.range}" ${command}\n` + + `Or install it globally: npm install -g alignfirst@"${result.range}"`, + ); +} + +export function cliRangeResult( + config: ProjectConfig | undefined, + installedVersion: string, +): CliRangeResult | undefined { + const range = config?.cli; + if (range === undefined) return; + return { range, satisfied: semver.satisfies(installedVersion, range) }; +} diff --git a/skills/alignfirst/code-review/correctness-reviewer.md b/packages/alignfirst/templates/guide/code-review/correctness-reviewer.md similarity index 100% rename from skills/alignfirst/code-review/correctness-reviewer.md rename to packages/alignfirst/templates/guide/code-review/correctness-reviewer.md diff --git a/skills/alignfirst/code-review/intent-reviewer.md b/packages/alignfirst/templates/guide/code-review/intent-reviewer.md similarity index 100% rename from skills/alignfirst/code-review/intent-reviewer.md rename to packages/alignfirst/templates/guide/code-review/intent-reviewer.md diff --git a/skills/alignfirst/code-review/module-javascript.md b/packages/alignfirst/templates/guide/code-review/module-javascript.md similarity index 100% rename from skills/alignfirst/code-review/module-javascript.md rename to packages/alignfirst/templates/guide/code-review/module-javascript.md diff --git a/skills/alignfirst/code-review/module-python.md b/packages/alignfirst/templates/guide/code-review/module-python.md similarity index 100% rename from skills/alignfirst/code-review/module-python.md rename to packages/alignfirst/templates/guide/code-review/module-python.md diff --git a/skills/alignfirst/code-review/module-typescript-strict.md b/packages/alignfirst/templates/guide/code-review/module-typescript-strict.md similarity index 100% rename from skills/alignfirst/code-review/module-typescript-strict.md rename to packages/alignfirst/templates/guide/code-review/module-typescript-strict.md diff --git a/skills/alignfirst/code-review/quality-reviewer.md b/packages/alignfirst/templates/guide/code-review/quality-reviewer.md similarity index 100% rename from skills/alignfirst/code-review/quality-reviewer.md rename to packages/alignfirst/templates/guide/code-review/quality-reviewer.md diff --git a/skills/alignfirst/code-review/reviewer-common.md b/packages/alignfirst/templates/guide/code-review/reviewer-common.md similarity index 100% rename from skills/alignfirst/code-review/reviewer-common.md rename to packages/alignfirst/templates/guide/code-review/reviewer-common.md diff --git a/skills/alignfirst/code-review/safety-reviewer.md b/packages/alignfirst/templates/guide/code-review/safety-reviewer.md similarity index 100% rename from skills/alignfirst/code-review/safety-reviewer.md rename to packages/alignfirst/templates/guide/code-review/safety-reviewer.md diff --git a/packages/alignfirst/templates/guide/core.md b/packages/alignfirst/templates/guide/core.md new file mode 100644 index 00000000..1f42bde8 --- /dev/null +++ b/packages/alignfirst/templates/guide/core.md @@ -0,0 +1,54 @@ +# AlignFirst Guide + +An agent that does not know which protocol to use runs `{{CMD}} guide overview`. + +## Protocols + +- **Technical Specification** (_spec_, or _alspec_): `{{CMD}} guide spec --protocol-only` +- **Implementation Plans** (_plan_, or _alplan_): `{{CMD}} guide plan --protocol-only` +- **Align-and-Do Protocol** (_AAD_): `{{CMD}} guide aad --protocol-only` +- **Catch Up** (_catchup_, or _alcatchup_): `{{CMD}} guide catchup --protocol-only` +- **Merge** (_merge_, or _almerge_): `{{CMD}} guide merge --protocol-only` +- **Code Review** (_alreview_): `{{CMD}} guide review --protocol-only` +- **Description** (_aldescription_): `{{CMD}} guide description --protocol-only` + +## TASK_DIR Location + +**TASK_DIR** is `.plans/{TICKET_ID}/`. Run `{{CMD}} ticket ` and use the directory it prints. The command creates a missing directory, restores an archived one, and lists its entries. + +{{TICKET_ID_RULE}} + +**Work without a ticket:** when the user says there is no ticket, run `{{CMD}} ticket --side`. Reuse an existing `side-N` directory when the user refers to that earlier work. Omit the ticket ID from commit messages. + +## File Naming Convention + +Format: `{CYCLE_LETTER}{FILE_NUMBER}-{FILE_TYPE}.md` + +**Common file types:** + +- `spec` - technical specification +- `plan` - implementation plan +- `AAD.summary` - AAD summary document +- `description` - PR/MR description +- `review` - code review report +- `merge.summary` - merge conflicts resolution summary + +**Example structure:** + +```text +.plans/ +├── 123/ +│ ├── A1-spec.md +│ ├── A2-plan.md +│ └── A3-AAD.summary.md +│ └── B1-spec.md +``` + +## Notes + +- **TICKET_ID** is a unique identifier for the task, often an issue or ticket number. +- `{{CMD}} ticket --next ` prints the next filename in the current cycle, the extension included (`--next spec.md` giving `.plans/123/A2-spec.md`). +- `--new-cycle` starts a new cycle. +- The protocol or the user decides whether to continue the current cycle or start a new one. +- Cycle letters and file numbers are internal. Never discuss them with the user. +- New file types are welcome. diff --git a/skills/alignfirst/references/overview.md b/packages/alignfirst/templates/guide/overview.md similarity index 100% rename from skills/alignfirst/references/overview.md rename to packages/alignfirst/templates/guide/overview.md diff --git a/skills/alignfirst/references/aad-protocol.md b/packages/alignfirst/templates/guide/protocols/aad.md similarity index 92% rename from skills/alignfirst/references/aad-protocol.md rename to packages/alignfirst/templates/guide/protocols/aad.md index 9492d5de..080415d0 100644 --- a/skills/alignfirst/references/aad-protocol.md +++ b/packages/alignfirst/templates/guide/protocols/aad.md @@ -4,8 +4,8 @@ You need: -- the TASK_DIR - if you don't have it, use your instructions for finding the **ticket ID**, or ask the user -- the CYCLE_LETTER and FILE_NUMBER — continue the current cycle (same CYCLE_LETTER, bump FILE_NUMBER) +- the TASK_DIR — run `{{CMD}} ticket ` (`{{CMD}} ticket` alone deduces the id from the branch when the project defines a ticket format; `{{CMD}} ticket --side` when there is no ticket) +- the CYCLE_LETTER and FILE_NUMBER — continue the current cycle: `{{CMD}} ticket --next AAD.summary.md` prints the file to create Identify and state these values before starting the protocol. diff --git a/skills/alignfirst/references/catchup-protocol.md b/packages/alignfirst/templates/guide/protocols/catchup.md similarity index 68% rename from skills/alignfirst/references/catchup-protocol.md rename to packages/alignfirst/templates/guide/protocols/catchup.md index 944da84e..cee9ac65 100644 --- a/skills/alignfirst/references/catchup-protocol.md +++ b/packages/alignfirst/templates/guide/protocols/catchup.md @@ -2,7 +2,7 @@ ## Pre-requisites -You need the TASK_DIR — if you don't have it, use your instructions for finding the **ticket ID**, or ask the user. +You need the TASK_DIR — run `{{CMD}} ticket ` (`{{CMD}} ticket` alone deduces the id from the branch when the project defines a ticket format; `{{CMD}} ticket --side` when there is no ticket). ## Steps diff --git a/skills/alignfirst/references/description-protocol.md b/packages/alignfirst/templates/guide/protocols/description.md similarity index 88% rename from skills/alignfirst/references/description-protocol.md rename to packages/alignfirst/templates/guide/protocols/description.md index d33cc68b..30cd54a9 100644 --- a/skills/alignfirst/references/description-protocol.md +++ b/packages/alignfirst/templates/guide/protocols/description.md @@ -4,8 +4,8 @@ You need: -- the TASK_DIR - if you don't have it, use your instructions for finding the **ticket ID**, or ask the user -- the CYCLE_LETTER and FILE_NUMBER — start a new cycle (bump CYCLE_LETTER, FILE_NUMBER = 1) +- the TASK_DIR — run `{{CMD}} ticket ` (`{{CMD}} ticket` alone deduces the id from the branch when the project defines a ticket format; `{{CMD}} ticket --side` when there is no ticket) +- the CYCLE_LETTER and FILE_NUMBER — start a new cycle: `{{CMD}} ticket --next description.md --new-cycle` prints the file to create Identify and state these values before starting the protocol. diff --git a/skills/alignfirst/references/merge-protocol.md b/packages/alignfirst/templates/guide/protocols/merge.md similarity index 90% rename from skills/alignfirst/references/merge-protocol.md rename to packages/alignfirst/templates/guide/protocols/merge.md index 153017ef..6fb52b25 100644 --- a/skills/alignfirst/references/merge-protocol.md +++ b/packages/alignfirst/templates/guide/protocols/merge.md @@ -4,8 +4,8 @@ You need: -- the TASK_DIR - if you don't have it, use your instructions for finding the **ticket ID**, or ask the user -- the CYCLE_LETTER and FILE_NUMBER — continue the current cycle (same CYCLE_LETTER, bump FILE_NUMBER) +- the TASK_DIR — run `{{CMD}} ticket ` (`{{CMD}} ticket` alone deduces the id from the branch when the project defines a ticket format; `{{CMD}} ticket --side` when there is no ticket) +- the CYCLE_LETTER and FILE_NUMBER — continue the current cycle: `{{CMD}} ticket --next merge.summary.md` prints the file to create Identify and state these values before starting the protocol. diff --git a/skills/alignfirst/references/plan-protocol.md b/packages/alignfirst/templates/guide/protocols/plan.md similarity index 96% rename from skills/alignfirst/references/plan-protocol.md rename to packages/alignfirst/templates/guide/protocols/plan.md index e5e0e5a6..5438cee1 100644 --- a/skills/alignfirst/references/plan-protocol.md +++ b/packages/alignfirst/templates/guide/protocols/plan.md @@ -6,8 +6,8 @@ You need: -- the TASK_DIR - if you don't have it, use your instructions for finding the **ticket ID**, or ask the user -- the CYCLE_LETTER and FILE_NUMBER — continue the current cycle (same CYCLE_LETTER, bump FILE_NUMBER) +- the TASK_DIR — run `{{CMD}} ticket ` (`{{CMD}} ticket` alone deduces the id from the branch when the project defines a ticket format; `{{CMD}} ticket --side` when there is no ticket) +- the CYCLE_LETTER and FILE_NUMBER — continue the current cycle: `{{CMD}} ticket --next plan.md` or `{{CMD}} ticket --next main-plan.md` prints the file to create - a **spec file** in the TASK_DIR Identify and state these values before starting the protocol. If any of these pieces of information is missing, STOP AND ASK THE USER. @@ -210,6 +210,8 @@ Note: Write the plan file(s) according to the determined structure: +Use `{{CMD}} ticket --next plan.md`, `{{CMD}} ticket --next main-plan.md`, or `{{CMD}} ticket --next plan-.md` to get the next number. Run one command per file. + **Single Plan**: - **Single plan**: `{TASK_DIR}/{CYCLE_LETTER}{FILE_NUMBER}-plan.md` diff --git a/skills/alignfirst/references/review-protocol.md b/packages/alignfirst/templates/guide/protocols/review.md similarity index 72% rename from skills/alignfirst/references/review-protocol.md rename to packages/alignfirst/templates/guide/protocols/review.md index 72a63e83..c4708a17 100644 --- a/skills/alignfirst/references/review-protocol.md +++ b/packages/alignfirst/templates/guide/protocols/review.md @@ -4,8 +4,8 @@ You need: -- the TASK_DIR - if you don't have it, use your instructions for finding the **ticket ID**, or ask the user -- the CYCLE_LETTER and FILE_NUMBER — start a new cycle (bump CYCLE_LETTER, FILE_NUMBER = 1) +- the TASK_DIR — run `{{CMD}} ticket ` (`{{CMD}} ticket` alone deduces the id from the branch when the project defines a ticket format; `{{CMD}} ticket --side` when there is no ticket) +- the CYCLE_LETTER and FILE_NUMBER — start a new cycle: `{{CMD}} ticket --next review.md --new-cycle` prints the file to create - the **base branch** to compare against - use the branch provided by the user, or fall back to the default branch. Identify and state these values before starting the protocol. @@ -16,14 +16,6 @@ We need a code review for this branch, compared to the base branch. A code revie You are the orchestrator: you scope the work, run one reviewer subagent per perspective, then merge their findings into a single report. Reviewers work with fresh eyes — they derive intent from the code and the diff. Neither you nor the reviewers read specs, plans, summaries, or any file content in TASK_DIR. -The reviewer instructions live in the `code-review/` directory of this skill: - -- [reviewer-common.md](../code-review/reviewer-common.md) — rules shared by all reviewers -- Perspectives: [intent-reviewer.md](../code-review/intent-reviewer.md), [correctness-reviewer.md](../code-review/correctness-reviewer.md), [safety-reviewer.md](../code-review/safety-reviewer.md), [quality-reviewer.md](../code-review/quality-reviewer.md) -- Ecosystem modules: [module-typescript-strict.md](../code-review/module-typescript-strict.md), [module-javascript.md](../code-review/module-javascript.md), [module-python.md](../code-review/module-python.md) - -They are prompts for the reviewers; read them yourself only when Phase 2 has you execute the perspectives without subagents. - Before starting, create your report as a new file `{CYCLE_LETTER}1-review.md` in the TASK_DIR, containing just the header — this reserves the filename. Write the report into it at the end. ## Phase 1. Scoping @@ -31,12 +23,12 @@ Before starting, create your report as a new file `{CYCLE_LETTER}1-review.md` in 1. Find the merge-base: `git merge-base HEAD`, then get the change overview: `git diff --stat HEAD`. The review target is the branch as committed. 2. Select the **ecosystem modules** from the changed files and the repo configuration: - | Changed files | Condition | Module | + | Changed files | Condition | `--module` value | | --- | --- | --- | - | TypeScript | `strict` enabled in the applicable tsconfig | `module-typescript-strict.md` | - | TypeScript | `strict` disabled | `module-javascript.md` | - | JavaScript | — | `module-javascript.md` | - | Python | — | `module-python.md` | + | TypeScript | `strict` enabled in the applicable tsconfig | `typescript-strict` | + | TypeScript | `strict` disabled | `javascript` | + | JavaScript | — | `javascript` | + | Python | — | `python` | | Other stacks | — | no module; the perspectives cover them | A diff spanning several ecosystems gets all the applicable modules. @@ -50,22 +42,22 @@ Before starting, create your report as a new file `{CYCLE_LETTER}1-review.md` in Otherwise, launch four reviewer subagents in parallel: -| Reviewer | Perspective file | Ecosystem modules | +| Reviewer | `--reviewer` value | `--module` values from Phase 1 | | --- | --- | --- | -| Intent | `intent-reviewer.md` | none | -| Correctness | `correctness-reviewer.md` | from Phase 1 | -| Change safety | `safety-reviewer.md` | from Phase 1 | -| Quality | `quality-reviewer.md` | from Phase 1 | +| Intent | `intent` | none | +| Correctness | `correctness` | from Phase 1 | +| Change safety | `safety` | from Phase 1 | +| Quality | `quality` | from Phase 1 | Each subagent prompt must contain: -- the absolute paths of the files to read, in order: `reviewer-common.md`, its perspective file, its ecosystem module(s) — provide the paths, do not reproduce the content +- the command `{{CMD}} guide review --reviewer --module ...` to run from the project root, with the modules selected in Phase 1, and the instruction to read its output before anything else - the base branch and the merge-base - the tooling notes from Phase 1 - for the intent and quality reviewers: the convention paths from Phase 1 -- the instruction that its final message is its report, in the format defined in `reviewer-common.md` +- the instruction that its final message is its report, in the format the reviewer rules define -_If your environment has no subagent tool, follow the small-diff procedure regardless of the diff size._ +_If your environment has no subagent tool, or a subagent cannot run commands, follow the small-diff procedure regardless of the diff size._ ## Phase 3. Merge and Verify diff --git a/skills/alignfirst/references/spec-protocol.md b/packages/alignfirst/templates/guide/protocols/spec.md similarity index 93% rename from skills/alignfirst/references/spec-protocol.md rename to packages/alignfirst/templates/guide/protocols/spec.md index c27e73d6..59b2d741 100644 --- a/skills/alignfirst/references/spec-protocol.md +++ b/packages/alignfirst/templates/guide/protocols/spec.md @@ -4,8 +4,8 @@ You need: -- the TASK_DIR - if you don't have it, use your instructions for finding the **ticket ID**, or ask the user -- the CYCLE_LETTER and FILE_NUMBER — start a new cycle (bump CYCLE_LETTER, FILE_NUMBER = 1) +- the TASK_DIR — run `{{CMD}} ticket ` (`{{CMD}} ticket` alone deduces the id from the branch when the project defines a ticket format; `{{CMD}} ticket --side` when there is no ticket) +- the CYCLE_LETTER and FILE_NUMBER — start a new cycle: `{{CMD}} ticket --next spec.md --new-cycle` prints the file to create Identify and state these values before starting the protocol. diff --git a/packages/alignfirst/test/cli.test.ts b/packages/alignfirst/test/cli.test.ts new file mode 100644 index 00000000..705db26a --- /dev/null +++ b/packages/alignfirst/test/cli.test.ts @@ -0,0 +1,62 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { makeTempDir, packageVersion, runMain } from "./helpers.js"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("alignfirst CLI", () => { + it("prints help and version", async () => { + const cwd = temp(); + const help = await runMain([], { cwd }); + expect(help).toMatchObject({ code: 0, stderr: "" }); + expect(help.stdout).toContain("alignfirst guide"); + expect(help.stdout).toContain("alignfirst doctor"); + const version = await runMain(["--version"], { cwd }); + expect(version.stdout).toBe(`${packageVersion}\n`); + }); + + it("reports an unknown command with help", async () => { + const result = await runMain(["unknown"], { cwd: temp() }); + expect(result.code).toBe(1); + expect(result.stderr).toContain('Error: unknown command "unknown".'); + expect(result.stderr).toContain("alignfirst ticket"); + }); + + it("guards project commands but exempts help, version, config and doctor", async () => { + const cwd = temp(); + writeFileSync( + join(cwd, ".alignfirst.json"), + JSON.stringify({ schemaVersion: 1, cli: ">=1.0.0" }), + ); + mkdirSync(join(cwd, ".plans")); + const guarded = await runMain(["ticket", "78"], { cwd }); + expect(guarded.stderr).toBe( + `alignfirst ${packageVersion} is installed; this project requires >=1.0.0.\n` + + 'Run a matching version: npx -y alignfirst@">=1.0.0" ticket 78\n' + + 'Or install it globally: npm install -g alignfirst@">=1.0.0"\n', + ); + expect((await runMain(["--help"], { cwd })).code).toBe(0); + expect((await runMain(["--version"], { cwd })).code).toBe(0); + expect((await runMain(["config"], { cwd })).code).toBe(0); + expect((await runMain(["doctor"], { cwd, env: { PATH: "" }, home: cwd })).code).toBe(0); + }); + + it("lets doctor report an invalid config", async () => { + const cwd = temp(); + writeFileSync(join(cwd, ".alignfirst.json"), "{"); + expect((await runMain(["doctor"], { cwd, env: { PATH: "" }, home: cwd })).code).toBe(0); + }); +}); + +function temp(): string { + const dir = makeTempDir(); + dirs.push(dir); + return dir; +} diff --git a/packages/alignfirst/test/command-form.test.ts b/packages/alignfirst/test/command-form.test.ts new file mode 100644 index 00000000..ad3411dd --- /dev/null +++ b/packages/alignfirst/test/command-form.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { renderCommandForm, resolveCommandForm } from "../src/command-form.js"; + +describe("command form", () => { + it("uses the installed binary outside a package runner", () => { + expect(resolveCommandForm({})).toBe("alignfirst"); + expect(resolveCommandForm({ npm_config_user_agent: "" })).toBe("alignfirst"); + }); + + it("uses npx inside a package runner", () => { + expect(resolveCommandForm({ npm_config_user_agent: "npm/11" })).toBe("npx -y alignfirst"); + }); + + it("renders every placeholder", () => { + expect(renderCommandForm("{{CMD}} guide; {{CMD}} ticket", "alignfirst")).toBe( + "alignfirst guide; alignfirst ticket", + ); + }); +}); diff --git a/packages/alignfirst/test/config.test.ts b/packages/alignfirst/test/config.test.ts new file mode 100644 index 00000000..e7dd283e --- /dev/null +++ b/packages/alignfirst/test/config.test.ts @@ -0,0 +1,73 @@ +import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { makeTempDir, packageVersion, runMain } from "./helpers.js"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("config command", () => { + it("reports no config in text and JSON", async () => { + const cwd = temp(); + expect((await runMain(["config"], { cwd })).stdout).toBe("Source: none\nCLI range: none\n"); + expect(JSON.parse((await runMain(["config", "--json"], { cwd })).stdout)).toEqual({ + source: null, + overlay: null, + cli: null, + config: null, + }); + }); + + it("reports a root config and its unsatisfied range without failing", async () => { + const cwd = temp(); + writeFileSync( + join(cwd, ".alignfirst.json"), + JSON.stringify({ schemaVersion: 1, cli: ">=1.0.0", ticketPattern: "^\\d+$" }), + ); + const result = await runMain(["config", "--json"], { cwd }); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + source: "root", + overlay: null, + cli: { installed: packageVersion, range: ">=1.0.0", satisfied: false }, + config: { schemaVersion: 1, cli: ">=1.0.0", ticketPattern: "^\\d+$" }, + }); + }); + + it("reports the matched overlay even when the root wins", async () => { + const cwd = temp(); + const overlays = join(cwd, "overlays"); + const overlayDir = join(overlays, "project", "_project"); + mkdirSync(overlayDir, { recursive: true }); + writeFileSync( + join(overlayDir, ".alignfirst.json"), + JSON.stringify({ schemaVersion: 1, project: { paths: [realpathSync(cwd)] } }), + ); + writeFileSync(join(cwd, ".alignfirst.json"), '{"schemaVersion":1}'); + const result = await runMain(["config"], { + cwd, + env: { ALIGNFIRST_OVERLAYS: overlays }, + }); + expect(result.stdout).toContain("Source: root"); + expect(result.stdout).toContain(`Overlay: ${overlayDir} (matched by paths)`); + }); + + it("reports an invalid config as a CLI error", async () => { + const cwd = temp(); + writeFileSync(join(cwd, ".alignfirst.json"), "{"); + const result = await runMain(["config"], { cwd }); + expect(result.code).toBe(1); + expect(result.stderr).toContain(`Invalid ${join(cwd, ".alignfirst.json")}`); + }); +}); + +function temp(): string { + const dir = makeTempDir(); + dirs.push(dir); + return dir; +} diff --git a/packages/alignfirst/test/developers.test.ts b/packages/alignfirst/test/developers.test.ts new file mode 100644 index 00000000..f9403463 --- /dev/null +++ b/packages/alignfirst/test/developers.test.ts @@ -0,0 +1,44 @@ +import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { makeTempDir, runMain } from "./helpers.js"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("DEVELOPERS.md command", () => { + it("prints the root file", async () => { + const cwd = temp(); + writeFileSync(join(cwd, "DEVELOPERS.md"), "root guide\n"); + expect((await runMain(["DEVELOPERS.md"], { cwd })).stdout).toBe("root guide\n"); + }); + + it("prints an overlay file and reports every tried path when absent", async () => { + const cwd = temp(); + const overlays = join(cwd, "overlays"); + const overlayDir = join(overlays, "project", "_project"); + mkdirSync(overlayDir, { recursive: true }); + writeFileSync( + join(overlayDir, ".alignfirst.json"), + JSON.stringify({ schemaVersion: 1, project: { paths: [realpathSync(cwd)] } }), + ); + const env = { ALIGNFIRST_OVERLAYS: overlays }; + writeFileSync(join(overlayDir, "DEVELOPERS.md"), "overlay guide\n"); + expect((await runMain(["DEVELOPERS.md"], { cwd, env })).stdout).toBe("overlay guide\n"); + rmSync(join(overlayDir, "DEVELOPERS.md")); + const missing = await runMain(["DEVELOPERS.md"], { cwd, env }); + expect(missing.stderr).toContain(join(cwd, "DEVELOPERS.md")); + expect(missing.stderr).toContain(join(overlayDir, "DEVELOPERS.md")); + }); +}); + +function temp(): string { + const dir = makeTempDir(); + dirs.push(dir); + return dir; +} diff --git a/packages/alignfirst/test/docmap.test.ts b/packages/alignfirst/test/docmap.test.ts new file mode 100644 index 00000000..52920776 --- /dev/null +++ b/packages/alignfirst/test/docmap.test.ts @@ -0,0 +1,61 @@ +import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { makeTempDir, runMain } from "./helpers.js"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("docmap command", () => { + it("injects the alignfirst command form", async () => { + const cwd = temp(); + expect((await runMain(["docmap", "--help"], { cwd })).stdout).toContain( + "alignfirst docmap --check", + ); + expect( + ( + await runMain(["docmap", "--guide"], { + cwd, + env: { npm_config_user_agent: "npm/11" }, + }) + ).stdout, + ).toContain("npx -y alignfirst docmap --check"); + }); + + it("falls back to overlay docs while root docs win", async () => { + const cwd = temp(); + const overlays = join(cwd, "overlays"); + const overlayDir = join(overlays, "project", "_project"); + mkdirSync(join(overlayDir, "docs"), { recursive: true }); + writeFileSync(join(overlayDir, "docs", "overlay.md"), "# Overlay\n"); + writeFileSync( + join(overlayDir, ".alignfirst.json"), + JSON.stringify({ schemaVersion: 1, project: { paths: [realpathSync(cwd)] } }), + ); + const env = { ALIGNFIRST_OVERLAYS: overlays }; + expect((await runMain(["docmap", "--recursive"], { cwd, env })).stdout).toContain("overlay.md"); + mkdirSync(join(cwd, "docs")); + writeFileSync(join(cwd, "docs", "root.md"), "# Root\n"); + const root = await runMain(["docmap", "--recursive"], { cwd, env }); + expect(root.stdout).toContain("root.md"); + expect(root.stdout).not.toContain("overlay.md"); + }); + + it("propagates docmap exit codes", async () => { + const cwd = temp(); + mkdirSync(join(cwd, "docs")); + writeFileSync(join(cwd, "docs", "bad name.md"), "# Bad\n"); + expect((await runMain(["docmap", "--check"], { cwd })).code).toBe(1); + }); +}); + +function temp(): string { + const dir = makeTempDir(); + dirs.push(dir); + return dir; +} diff --git a/packages/alignfirst/test/doctor.test.ts b/packages/alignfirst/test/doctor.test.ts new file mode 100644 index 00000000..5eded8c2 --- /dev/null +++ b/packages/alignfirst/test/doctor.test.ts @@ -0,0 +1,80 @@ +import { chmodSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { makeTempDir, runMain } from "./helpers.js"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("doctor command", () => { + it("always reports every section in an empty directory", async () => { + const cwd = temp(); + const result = await runMain(["doctor"], { cwd, env: { PATH: "" }, home: cwd }); + expect(result.code).toBe(0); + for (const section of ["CLI", "Config", "Plans", "Docmap", "Skills", "Overlay", "Companion"]) + expect(result.stdout).toContain(`] ${section}:`); + expect(result.stdout).toContain("[warn] Companion: alcode not installed"); + }); + + it("reports an excluded CLI range without failing", async () => { + const cwd = temp(); + writeFileSync( + join(cwd, ".alignfirst.json"), + JSON.stringify({ schemaVersion: 1, cli: ">=1.0.0" }), + ); + const result = await runMain(["doctor"], { cwd, env: { PATH: "" }, home: cwd }); + expect(result.code).toBe(0); + expect(result.stdout).toContain("[error] Config: does not satisfy >=1.0.0"); + }); + + it("continues after an invalid project config", async () => { + const cwd = temp(); + writeFileSync(join(cwd, ".alignfirst.json"), "{"); + const result = await runMain(["doctor"], { cwd, env: { PATH: "" }, home: cwd }); + expect(result.code).toBe(0); + expect(result.stdout).toContain("[error] Config: Invalid"); + expect(result.stdout).toContain("] Plans:"); + }); + + it("reports a matching overlay and each effective file", async () => { + const cwd = temp(); + const overlays = join(cwd, "overlays"); + const overlayDir = join(overlays, "project", "_project"); + mkdirSync(join(overlayDir, "docs"), { recursive: true }); + writeFileSync( + join(overlayDir, ".alignfirst.json"), + JSON.stringify({ schemaVersion: 1, project: { paths: [realpathSync(cwd)] } }), + ); + writeFileSync(join(overlayDir, "AGENTS.md"), "agents\n"); + const result = await runMain(["doctor"], { + cwd, + env: { ALIGNFIRST_OVERLAYS: overlays, PATH: "" }, + home: cwd, + }); + expect(result.stdout).toContain(`[ok] Overlay: ${overlayDir} (matched by paths)`); + expect(result.stdout).toContain("[ok] Overlay: AGENTS.md overlay"); + expect(result.stdout).toContain("[ok] Overlay: DEVELOPERS.md none"); + }); + + it("reports the alcode version", async () => { + const cwd = temp(); + const bin = join(cwd, "bin"); + mkdirSync(bin); + const alcode = join(bin, "alcode"); + writeFileSync(alcode, "#!/bin/sh\necho 0.13.0\n"); + chmodSync(alcode, 0o755); + const result = await runMain(["doctor"], { cwd, env: { PATH: bin }, home: cwd }); + expect(result.stdout).toContain(`[ok] Companion: alcode 0.13.0 (${alcode})`); + }); +}); + +function temp(): string { + const dir = makeTempDir("alignfirst-doctor-"); + dirs.push(dir); + return dir; +} diff --git a/packages/alignfirst/test/guide.test.ts b/packages/alignfirst/test/guide.test.ts new file mode 100644 index 00000000..d5911d70 --- /dev/null +++ b/packages/alignfirst/test/guide.test.ts @@ -0,0 +1,188 @@ +import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { PROTOCOLS } from "../src/protocols.js"; +import { makeTempDir, runMain } from "./helpers.js"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("guide command", () => { + it("renders the core guide by default", async () => { + const result = await runMain(["guide"], { cwd: temp() }); + expect(result).toMatchObject({ code: 0, stderr: "" }); + expect(result.stdout).toContain("# AlignFirst Guide"); + expect(result.stdout).toContain("alignfirst guide spec --protocol-only"); + expect(result.stdout).not.toContain("# How to Write a Technical Specification"); + expect(result.stdout.endsWith("\n")).toBe(true); + expect(result.stdout.endsWith("\n\n")).toBe(false); + }); + + it("renders a protocol after the core or alone", async () => { + const cwd = temp(); + const combined = await runMain(["guide", "spec"], { cwd }); + const coreIndex = combined.stdout.indexOf("# AlignFirst Guide"); + const protocolIndex = combined.stdout.indexOf("# How to Write a Technical Specification"); + expect(coreIndex).toBeGreaterThanOrEqual(0); + expect(protocolIndex).toBeGreaterThan(coreIndex); + + const protocolOnly = await runMain(["guide", "spec", "--protocol-only"], { cwd }); + expect(protocolOnly.stdout).toContain("# How to Write a Technical Specification"); + expect(protocolOnly.stdout).not.toContain("# AlignFirst Guide"); + }); + + it("renders every protocol and the overview", async () => { + const cwd = temp(); + for (const protocol of PROTOCOLS) { + const result = await runMain(["guide", protocol, "--protocol-only"], { cwd }); + expect(result.code).toBe(0); + expect(result.stdout).not.toBe(""); + } + const overview = await runMain(["guide", "overview"], { cwd }); + expect(overview.stdout).toContain("# AlignFirst Overview"); + expect(overview.stdout).not.toContain("# AlignFirst Guide"); + }); + + it("rejects invalid protocol selections", async () => { + const cwd = temp(); + const overviewOnly = await runMain(["guide", "overview", "--protocol-only"], { cwd }); + expect(overviewOnly.code).toBe(1); + expect(overviewOnly.stderr).toContain("--protocol-only cannot be used with overview"); + + const missing = await runMain(["guide", "--protocol-only"], { cwd }); + expect(missing.code).toBe(1); + expect(missing.stderr).toContain("--protocol-only requires a protocol"); + + const unknown = await runMain(["guide", "unknown"], { cwd }); + expect(unknown.code).toBe(1); + expect(unknown.stderr).toBe( + 'Unknown protocol "unknown". Protocols: spec, plan, aad, catchup, merge, review, description, or overview.\n', + ); + }); + + it("composes reviewer rules in order", async () => { + const result = await runMain( + [ + "guide", + "review", + "--reviewer", + "correctness", + "--module", + "typescript-strict", + "--module", + "javascript", + ], + { cwd: temp() }, + ); + const commonIndex = result.stdout.indexOf("# Code Reviewer — Common Rules"); + const perspectiveIndex = result.stdout.indexOf("# Perspective — Correctness"); + const typescriptIndex = result.stdout.indexOf("# Ecosystem Module — Strict TypeScript"); + const javascriptIndex = result.stdout.indexOf( + "# Ecosystem Module — JavaScript and Non-Strict TypeScript", + ); + expect(commonIndex).toBeGreaterThanOrEqual(0); + expect(perspectiveIndex).toBeGreaterThan(commonIndex); + expect(typescriptIndex).toBeGreaterThan(perspectiveIndex); + expect(javascriptIndex).toBeGreaterThan(typescriptIndex); + expect(result.stdout).not.toContain("# AlignFirst Guide"); + expect(result.stdout).not.toContain("# How to Write a Code Review Report"); + }); + + it("rejects invalid reviewer selections", async () => { + const cwd = temp(); + const wrongProtocol = await runMain(["guide", "spec", "--reviewer", "correctness"], { cwd }); + expect(wrongProtocol.stderr).toContain("--reviewer can only be used with review"); + + const missingReviewer = await runMain(["guide", "review", "--module", "python"], { + cwd, + }); + expect(missingReviewer.stderr).toContain("--module requires --reviewer"); + + const unknownModule = await runMain( + ["guide", "review", "--reviewer", "quality", "--module", "ruby"], + { cwd }, + ); + expect(unknownModule.stderr).toContain( + 'Unknown module "ruby". Modules: typescript-strict, javascript, python.', + ); + + const unknownReviewer = await runMain(["guide", "review", "--reviewer", "style"], { + cwd, + }); + expect(unknownReviewer.stderr).toContain( + 'Unknown reviewer "style". Reviewers: intent, correctness, safety, quality.', + ); + }); + + it("resolves every template placeholder", async () => { + const cwd = temp(); + const argumentSets: string[][] = [["guide"], ["guide", "overview"]]; + for (const protocol of PROTOCOLS) { + argumentSets.push(["guide", protocol], ["guide", protocol, "--protocol-only"]); + } + for (const reviewer of ["intent", "correctness", "safety", "quality"]) { + argumentSets.push(["guide", "review", "--reviewer", reviewer]); + for (const module of ["typescript-strict", "javascript", "python"]) { + argumentSets.push(["guide", "review", "--reviewer", reviewer, "--module", module]); + } + } + for (const args of argumentSets) { + const result = await runMain(args, { cwd }); + expect(result.stdout, args.join(" ")).not.toContain("{{"); + } + }); + + it("renders the npm command form", async () => { + const result = await runMain(["guide"], { + cwd: temp(), + env: { npm_config_user_agent: "npm/11.0.0 node/v22.0.0" }, + }); + expect(result.stdout).toContain("npx -y alignfirst guide spec --protocol-only"); + }); + + it("renders the configured ticket rule", async () => { + const cwd = temp(); + writeFileSync( + join(cwd, ".alignfirst.json"), + JSON.stringify({ schemaVersion: 1, ticketPattern: "^AF-\\d+$" }), + ); + const configured = await runMain(["guide"], { cwd }); + expect(configured.stdout).toContain("Ticket IDs match `^AF-\\d+$`"); + expect(configured.stdout).toContain("alignfirst ticket` without an id"); + + const unconfigured = await runMain(["guide"], { cwd: temp() }); + expect(unconfigured.stdout).toContain("Ask the user for the ticket ID when it is not given."); + }); + + it("appends overlay project conventions unless the root has them", async () => { + const cwd = temp(); + const overlays = join(cwd, "overlays"); + const overlayDir = join(overlays, "project", "_project"); + mkdirSync(overlayDir, { recursive: true }); + writeFileSync( + join(overlayDir, ".alignfirst.json"), + JSON.stringify({ schemaVersion: 1, project: { paths: [realpathSync(cwd)] } }), + ); + writeFileSync(join(overlayDir, "AGENTS.md"), "Overlay convention.\n"); + const env = { ALIGNFIRST_OVERLAYS: overlays }; + + const overlay = await runMain(["guide"], { cwd, env }); + expect(overlay.stdout).toContain("## Project conventions\n\nOverlay convention."); + + writeFileSync(join(cwd, "AGENTS.md"), "Root convention.\n"); + const root = await runMain(["guide"], { cwd, env }); + expect(root.stdout).not.toContain("## Project conventions"); + expect(root.stdout).not.toContain("Overlay convention."); + }); +}); + +function temp(): string { + const dir = makeTempDir(); + dirs.push(dir); + return dir; +} diff --git a/packages/alignfirst/test/helpers.ts b/packages/alignfirst/test/helpers.ts new file mode 100644 index 00000000..6f29e013 --- /dev/null +++ b/packages/alignfirst/test/helpers.ts @@ -0,0 +1,81 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { main } from "../src/cli.js"; +import type { Output } from "../src/context.js"; + +export const packageVersion = readPackageVersion(); + +export interface Sink extends Output { + text(): string; +} + +export interface RunOptions { + cwd: string; + env?: NodeJS.ProcessEnv; + home?: string; +} + +export interface RunResult { + code: number; + stdout: string; + stderr: string; +} + +export function makeSink(): Sink { + let buffer = ""; + return { + write(text: string) { + buffer += text; + }, + text: () => buffer, + }; +} + +export async function runMain(args: string[], options: RunOptions): Promise { + const stdout = makeSink(); + const stderr = makeSink(); + const code = await main({ + argv: ["node", "alignfirst", ...args], + cwd: options.cwd, + env: options.env ?? {}, + home: options.home, + stdout, + stderr, + }); + return { code, stdout: stdout.text(), stderr: stderr.text() }; +} + +export function makeTempDir(prefix = "alignfirst-"): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +export function configureGit(dir: string): void { + const config = join(dir, "gitconfig"); + writeFileSync( + config, + "[user]\n\tname = Test\n\temail = test@example.com\n[init]\n\tdefaultBranch = main\n", + ); + process.env.GIT_CONFIG_GLOBAL = config; + process.env.GIT_CONFIG_SYSTEM = "/dev/null"; +} + +export function git(dir: string, ...args: string[]): string { + return execFileSync("git", ["-C", dir, ...args], { encoding: "utf-8" }).trim(); +} + +function readPackageVersion(): string { + const manifest: unknown = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf-8"), + ); + if ( + typeof manifest !== "object" || + manifest === null || + !("version" in manifest) || + typeof manifest.version !== "string" + ) + throw new Error("alignfirst test: package.json is missing 'version'"); + return manifest.version; +} diff --git a/packages/alignfirst/test/overlay.test.ts b/packages/alignfirst/test/overlay.test.ts new file mode 100644 index 00000000..a3d7e9c5 --- /dev/null +++ b/packages/alignfirst/test/overlay.test.ts @@ -0,0 +1,117 @@ +import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + findOverlay, + normalizeRemoteUrl, + resolveProjectConfig, + resolveProjectFile, +} from "../src/overlay.js"; +import { git, makeTempDir } from "./helpers.js"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("overlays", () => { + it("normalizes scp and URL remotes", () => { + expect(normalizeRemoteUrl("git@GitHub.COM:Org/Repo.git")).toBe("github.com/Org/Repo"); + expect(normalizeRemoteUrl("https://user@GitHub.COM:8443/Org/Repo.git/")).toBe( + "github.com/Org/Repo", + ); + }); + + it("matches remote before paths", () => { + const fixture = makeFixture(); + writeOverlay(fixture.overlays, "by-path", { + schemaVersion: 1, + project: { paths: [realpathSync(fixture.project)] }, + }); + const remoteDir = writeOverlay(fixture.overlays, "by-remote", { + schemaVersion: 1, + project: { remote: "github.com/Org/Repo" }, + }); + expect( + findOverlay(fixture.project, { ALIGNFIRST_OVERLAYS: fixture.overlays }, fixture.home), + ).toMatchObject({ dir: remoteDir, matchedBy: "remote" }); + }); + + it("matches paths, expands ~/ and rejects ambiguity", () => { + const fixture = makeFixture(); + const config = { + schemaVersion: 1 as const, + project: { paths: [realpathSync(fixture.project)] }, + }; + writeOverlay(fixture.overlays, "one", config); + expect( + findOverlay(fixture.project, { ALIGNFIRST_OVERLAYS: "~/overlays" }, fixture.home), + ).toMatchObject({ + matchedBy: "paths", + }); + writeOverlay(fixture.overlays, "two", config); + expect(() => + findOverlay(fixture.project, { ALIGNFIRST_OVERLAYS: fixture.overlays }, fixture.home), + ).toThrow("Multiple AlignFirst overlays"); + }); + + it("resolves root files before overlay files and carries a matched overlay with root config", () => { + const fixture = makeFixture(); + const overlayDir = writeOverlay(fixture.overlays, "project", { + schemaVersion: 1, + project: { paths: [realpathSync(fixture.project)] }, + }); + mkdirSync(join(overlayDir, "docs")); + writeFileSync(join(overlayDir, "DEVELOPERS.md"), "overlay"); + const overlay = findOverlay( + fixture.project, + { ALIGNFIRST_OVERLAYS: fixture.overlays }, + fixture.home, + ); + expect(resolveProjectFile(fixture.project, overlay, "docs")).toEqual({ + path: join(overlayDir, "docs"), + source: "overlay", + }); + writeFileSync(join(fixture.project, "DEVELOPERS.md"), "root"); + expect(resolveProjectFile(fixture.project, overlay, "DEVELOPERS.md")).toEqual({ + path: join(fixture.project, "DEVELOPERS.md"), + source: "root", + }); + writeFileSync(join(fixture.project, ".alignfirst.json"), '{"schemaVersion":1}'); + expect( + resolveProjectConfig( + fixture.project, + { ALIGNFIRST_OVERLAYS: fixture.overlays }, + fixture.home, + ), + ).toMatchObject({ source: "root", overlay: { dir: overlayDir } }); + }); +}); + +interface Fixture { + home: string; + project: string; + overlays: string; +} + +function makeFixture(): Fixture { + const home = makeTempDir("alignfirst-overlay-"); + dirs.push(home); + const project = join(home, "project"); + const overlays = join(home, "overlays"); + mkdirSync(project); + mkdirSync(overlays); + git(project, "init", "--quiet"); + git(project, "remote", "add", "origin", "https://user@GitHub.COM:8443/Org/Repo.git"); + return { home, project, overlays }; +} + +function writeOverlay(overlays: string, name: string, config: object): string { + const dir = join(overlays, name, "_project"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, ".alignfirst.json"), JSON.stringify(config)); + return dir; +} diff --git a/packages/alignfirst/test/plans.test.ts b/packages/alignfirst/test/plans.test.ts new file mode 100644 index 00000000..c04dbdbe --- /dev/null +++ b/packages/alignfirst/test/plans.test.ts @@ -0,0 +1,125 @@ +import { + existsSync, + lstatSync, + mkdirSync, + readlinkSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { configureGit, git, makeTempDir, runMain } from "./helpers.js"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("plans commands", () => { + it("reports local plans mode", async () => { + const fixture = makeFixture(); + mkdirSync(join(fixture.product, ".plans")); + const result = await runMain(["plans", "check"], { cwd: fixture.product }); + expect(result).toMatchObject({ code: 0, stderr: "" }); + expect(result.stdout).toContain("local plans mode"); + }); + + it("sets up the plans link with the configured folder", async () => { + const fixture = makeFixture(); + writeFileSync( + join(fixture.product, ".alignfirst.json"), + JSON.stringify({ schemaVersion: 1, plans: { folder: "product-plans" } }), + ); + const result = await runMain(["plans", "setup", fixture.clone], { cwd: fixture.product }); + expect(result.code).toBe(0); + expect(lstatSync(join(fixture.product, ".plans")).isSymbolicLink()).toBe(true); + expect(readlinkSync(join(fixture.product, ".plans"))).toBe( + join("..", "team-plans", "product-plans"), + ); + expect(result.stdout).toContain("Publish with: alignfirst sync"); + }); + + it("synchronizes shared plans", async () => { + const fixture = makeFixture(); + await runMain(["plans", "setup", fixture.clone, "--folder", "product-plans"], { + cwd: fixture.product, + }); + mkdirSync(join(fixture.product, ".plans", "78")); + writeFileSync(join(fixture.product, ".plans", "78", "A1-spec.md"), "spec\n"); + const result = await runMain(["sync"], { cwd: fixture.product }); + expect(result.code).toBe(0); + expect(result.stdout).toContain("local changes sent"); + expect(git(join(fixture.root, "remote.git"), "ls-tree", "-r", "HEAD", "--name-only")).toContain( + "product-plans/78/A1-spec.md", + ); + }); + + it("rejects conflicting, absent and missing-clone setup inputs", async () => { + const fixture = makeFixture(); + writeFileSync( + join(fixture.product, ".alignfirst.json"), + JSON.stringify({ schemaVersion: 1, plans: { folder: "configured" } }), + ); + expect( + ( + await runMain(["plans", "setup", fixture.clone, "--folder", "argument"], { + cwd: fixture.product, + }) + ).stderr, + ).toContain("already sets plans.folder"); + rmSync(join(fixture.product, ".alignfirst.json")); + expect( + (await runMain(["plans", "setup", fixture.clone], { cwd: fixture.product })).stderr, + ).toContain("Pass --folder"); + expect( + ( + await runMain(["plans", "setup", join(fixture.root, "missing"), "--folder", "p"], { + cwd: fixture.product, + }) + ).stderr, + ).toContain("does not exist"); + }); + + it("archives a ticket and honors ALIGNFIRST_ARCHIVE_DAYS", async () => { + const fixture = makeFixture(); + mkdirSync(join(fixture.product, ".plans", "78"), { recursive: true }); + const archive = await runMain(["plans", "archive", "78"], { cwd: fixture.product }); + expect(archive.code).toBe(0); + expect(existsSync(join(fixture.product, ".plans", "_archives", "78"))).toBe(true); + const stale = join(fixture.product, ".plans", "79"); + mkdirSync(stale); + const date = new Date(Date.now() - 2 * 86_400_000); + utimesSync(stale, date, date); + const automatic = await runMain(["plans", "auto-archive"], { + cwd: fixture.product, + env: { ALIGNFIRST_ARCHIVE_DAYS: "1" }, + }); + expect(automatic.stdout).toContain("Archived 79"); + }); +}); + +interface Fixture { + root: string; + product: string; + clone: string; +} + +function makeFixture(): Fixture { + const root = makeTempDir("alignfirst-plans-"); + dirs.push(root); + configureGit(root); + const remote = join(root, "remote.git"); + git(root, "init", "--quiet", "--bare", remote); + const clone = join(root, "team-plans"); + git(root, "clone", "--quiet", remote, clone); + const product = join(root, "product"); + git(root, "init", "--quiet", product); + writeFileSync(join(product, "README.md"), "product\n"); + git(product, "add", "-A"); + git(product, "commit", "--quiet", "-m", "init"); + return { root, product, clone }; +} diff --git a/packages/alignfirst/test/project-config.test.ts b/packages/alignfirst/test/project-config.test.ts new file mode 100644 index 00000000..748d5f12 --- /dev/null +++ b/packages/alignfirst/test/project-config.test.ts @@ -0,0 +1,56 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { readProjectConfig, validateProjectConfig } from "../src/project-config.js"; +import { makeTempDir } from "./helpers.js"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("project config", () => { + it("accepts the full shape and a minimal config", () => { + const full = { + schemaVersion: 1, + cli: ">=0.1.0 <0.2.0", + ticketPattern: "^\\d+$", + plans: { folder: "project" }, + portRange: { first: 8100, last: 8199 }, + project: { remote: "github.com/org/repo", paths: ["/project"] }, + }; + expect(validateProjectConfig(full, "config")).toEqual(full); + expect(validateProjectConfig({ schemaVersion: 1 }, "config")).toEqual({ schemaVersion: 1 }); + }); + + it.each([ + [{ schemaVersion: 1, extra: true }, "extra"], + [{ schemaVersion: 1, cli: "not a range" }, "semver"], + [{ schemaVersion: 1, ticketPattern: "[" }, "regular expression"], + [{ schemaVersion: 1, portRange: { first: 2, last: 1 } }, "must not exceed"], + [{ schemaVersion: 1, project: {} }, "remote or paths"], + [{ schemaVersion: 1, project: { paths: ["relative"] } }, "absolute paths"], + ])("rejects invalid config %#", (value, message) => { + expect(() => validateProjectConfig(value, "config")).toThrow(message); + }); + + it("reads a file, returns undefined when absent, and reports invalid JSON", () => { + const dir = makeTempDir(); + dirs.push(dir); + expect(readProjectConfig(dir)).toBeUndefined(); + writeFileSync(join(dir, ".alignfirst.json"), '{"schemaVersion":1}'); + expect(readProjectConfig(dir)).toEqual({ schemaVersion: 1 }); + writeFileSync(join(dir, ".alignfirst.json"), "{"); + expect(() => readProjectConfig(dir)).toThrow(`Invalid ${join(dir, ".alignfirst.json")}`); + }); + + it("rejects a directory in place of the config file", () => { + const dir = makeTempDir(); + dirs.push(dir); + mkdirSync(join(dir, ".alignfirst.json")); + expect(() => readProjectConfig(dir)).toThrow("Invalid"); + }); +}); diff --git a/packages/alignfirst/test/setup.test.ts b/packages/alignfirst/test/setup.test.ts new file mode 100644 index 00000000..50a2a321 --- /dev/null +++ b/packages/alignfirst/test/setup.test.ts @@ -0,0 +1,222 @@ +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { defaultCliRange } from "../src/version-guard.js"; +import { configureGit, git, makeTempDir, packageVersion, runMain } from "./helpers.js"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("setup command", () => { + it("prepares a project and remains idempotent", async () => { + const fixture = makeProject(); + const fake = makeFakeNpx(fixture.root); + const env = fakeEnv(fake.bin, fake.log); + const args = [ + "setup", + "--ticket-pattern", + "^\\d+$", + "--plans-folder", + "project", + "--port-range", + "8100-8199", + "--agent", + "codex", + ]; + const result = await runMain(args, { cwd: fixture.project, env }); + expect(result.code).toBe(0); + expect(JSON.parse(readFileSync(join(fixture.project, ".alignfirst.json"), "utf-8"))).toEqual({ + schemaVersion: 1, + cli: defaultCliRange(packageVersion), + ticketPattern: "^\\d+$", + plans: { folder: "project" }, + portRange: { first: 8100, last: 8199 }, + }); + expect(existsSync(join(fixture.project, ".plans"))).toBe(true); + expect(readFileSync(join(fixture.project, ".gitignore"), "utf-8")).toBe(".plans\n"); + expect(readFileSync(join(fixture.project, "README.md"), "utf-8")).toContain( + "npm install -g alignfirst", + ); + const npxArgs = readFileSync(fake.log, "utf-8"); + for (const skill of [ + "alignfirst", + "alspec", + "alplan", + "al", + "alcatchup", + "almerge", + "alreview", + "aldescription", + ]) + expect(npxArgs).toContain(`--skill\n${skill}\n`); + expect(npxArgs).toContain("--agent\ncodex\n"); + + expect((await runMain(["setup"], { cwd: fixture.project, env })).code).toBe(0); + expect(readFileSync(join(fixture.project, ".gitignore"), "utf-8")).toBe(".plans\n"); + expect( + readFileSync(join(fixture.project, "README.md"), "utf-8").match(/Prerequisites/g), + ).toHaveLength(1); + }); + + it("rejects options with an existing config and validates config input", async () => { + const fixture = makeProject(); + const fake = makeFakeNpx(fixture.root); + const env = fakeEnv(fake.bin, fake.log); + writeFileSync(join(fixture.project, ".alignfirst.json"), '{"schemaVersion":1}'); + expect( + ( + await runMain(["setup", "--plans-folder", "other"], { + cwd: fixture.project, + env, + }) + ).stderr, + ).toContain("edit it instead of passing options"); + writeFileSync(join(fixture.project, ".alignfirst.json"), "{"); + expect((await runMain(["setup"], { cwd: fixture.project, env })).stderr).toContain("Invalid"); + rmSync(join(fixture.project, ".alignfirst.json")); + expect( + ( + await runMain(["setup", "--port-range", "9-3"], { + cwd: fixture.project, + env, + }) + ).stderr, + ).toContain("first must not exceed"); + }); + + it("creates an overlay with a shared plans link", async () => { + const fixture = makeProject(); + const overlays = join(fixture.root, "overlays"); + mkdirSync(overlays); + git(overlays, "init", "--quiet"); + const result = await runMain( + ["setup", "--overlay", "--plans-folder", "project", "--port-range", "8100-8199"], + { cwd: fixture.project, env: { ALIGNFIRST_OVERLAYS: overlays } }, + ); + expect(result.code).toBe(0); + const overlayDir = join(overlays, "project", "_project"); + expect(JSON.parse(readFileSync(join(overlayDir, ".alignfirst.json"), "utf-8"))).toEqual({ + schemaVersion: 1, + project: { + remote: "github.com/org/project", + paths: [realpathSync(fixture.project)], + }, + plans: { folder: "project" }, + portRange: { first: 8100, last: 8199 }, + }); + expect(lstatSync(join(fixture.project, ".plans")).isSymbolicLink()).toBe(true); + expect(readlinkSync(join(fixture.project, ".plans"))).toBe(join("..", "overlays", "project")); + expect(readFileSync(join(fixture.project, ".git", "info", "exclude"), "utf-8")).toContain( + ".plans", + ); + expect( + ( + await runMain(["setup", "--overlay", "--plans-folder", "project"], { + cwd: fixture.project, + env: { ALIGNFIRST_OVERLAYS: overlays }, + }) + ).stderr, + ).toContain("already exists"); + }); + + it("uses local plans for an overlay outside a git repository", async () => { + const fixture = makeProject(); + const overlays = join(fixture.root, "plain-overlays"); + mkdirSync(overlays); + const result = await runMain(["setup", "--overlay"], { + cwd: fixture.project, + env: { ALIGNFIRST_OVERLAYS: overlays }, + }); + expect(result.code).toBe(0); + expect(lstatSync(join(fixture.project, ".plans")).isDirectory()).toBe(true); + }); + + it("requires the overlays variable", async () => { + const fixture = makeProject(); + expect((await runMain(["setup", "--overlay"], { cwd: fixture.project })).stderr).toContain( + "ALIGNFIRST_OVERLAYS is not set", + ); + }); + + it("adopts overlay files and removes an empty overlay", async () => { + const fixture = makeProject(); + const overlays = join(fixture.root, "overlays"); + const overlayDir = join(overlays, "project", "_project"); + mkdirSync(join(overlayDir, "docs"), { recursive: true }); + writeFileSync( + join(overlayDir, ".alignfirst.json"), + JSON.stringify({ + schemaVersion: 1, + project: { paths: [realpathSync(fixture.project)] }, + }), + ); + writeFileSync(join(overlayDir, "AGENTS.md"), "agents\n"); + writeFileSync(join(overlayDir, "DEVELOPERS.md"), "developers\n"); + writeFileSync(join(overlayDir, "docs", "guide.md"), "# Guide\n"); + writeFileSync(join(fixture.project, ".git", "info", "exclude"), ".plans\n"); + const result = await runMain(["setup", "--adopt"], { + cwd: fixture.project, + env: { ALIGNFIRST_OVERLAYS: overlays }, + }); + expect(result.code).toBe(0); + expect(JSON.parse(readFileSync(join(fixture.project, ".alignfirst.json"), "utf-8"))).toEqual({ + schemaVersion: 1, + }); + expect(readFileSync(join(fixture.project, "AGENTS.md"), "utf-8")).toBe("agents\n"); + expect(existsSync(join(fixture.project, "docs", "guide.md"))).toBe(true); + expect(existsSync(overlayDir)).toBe(false); + expect(readFileSync(join(fixture.project, ".git", "info", "exclude"), "utf-8")).not.toContain( + ".plans", + ); + }); +}); + +interface ProjectFixture { + root: string; + project: string; +} + +function makeProject(): ProjectFixture { + const root = makeTempDir("alignfirst-setup-"); + dirs.push(root); + configureGit(root); + const project = join(root, "project"); + git(root, "init", "--quiet", project); + git(project, "remote", "add", "origin", "https://github.com/org/project.git"); + writeFileSync(join(project, "README.md"), "# Project\n"); + return { root, project }; +} + +interface FakeCommand { + bin: string; + log: string; +} + +function makeFakeNpx(root: string): FakeCommand { + const bin = join(root, "bin"); + const log = join(root, "npx.log"); + mkdirSync(bin); + const executable = join(bin, "npx"); + writeFileSync(executable, '#!/bin/sh\nprintf \'%s\\n\' "$@" >> "$NPX_LOG"\n'); + chmodSync(executable, 0o755); + return { bin, log }; +} + +function fakeEnv(bin: string, log: string): NodeJS.ProcessEnv { + return { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}`, NPX_LOG: log }; +} diff --git a/packages/alignfirst/test/ticket.test.ts b/packages/alignfirst/test/ticket.test.ts new file mode 100644 index 00000000..4fd6f418 --- /dev/null +++ b/packages/alignfirst/test/ticket.test.ts @@ -0,0 +1,103 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { configureGit, git, makeTempDir, runMain } from "./helpers.js"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("ticket command", () => { + it("creates, lists and restores ticket directories", async () => { + const cwd = makeProject(); + const created = await runMain(["ticket", "78"], { cwd }); + expect(created.stdout).toContain("Directory: .plans/78/ (created)"); + writeFileSync(join(cwd, ".plans", "78", "A1-request.md"), "request"); + expect((await runMain(["ticket", "78", "--next", "spec.md"], { cwd })).stdout).toContain( + "Next file: .plans/78/A2-spec.md", + ); + mkdirSync(join(cwd, ".plans", "_archives")); + mkdirSync(join(cwd, ".plans", "_archives", "99")); + const restored = await runMain(["ticket", "99"], { cwd }); + expect(restored.stdout).toContain("restored from _archives"); + expect(existsSync(join(cwd, ".plans", "99"))).toBe(true); + }); + + it("computes a new cycle and keeps dry runs read-only", async () => { + const cwd = makeProject(); + mkdirSync(join(cwd, ".plans", "78")); + writeFileSync(join(cwd, ".plans", "78", "A2-spec.md"), "spec"); + const next = await runMain( + ["ticket", "78", "--next", "notes.txt", "--new-cycle", "--dry-run"], + { cwd }, + ); + expect(next.stdout).toContain(".plans/78/B1-notes.txt"); + const missing = await runMain(["ticket", "79", "--dry-run"], { cwd }); + expect(missing.stdout).toContain("would be created"); + expect(existsSync(join(cwd, ".plans", "79"))).toBe(false); + }); + + it("reserves side tickets across active and archived entries, including an EEXIST race", async () => { + const cwd = makeProject(); + mkdirSync(join(cwd, ".plans", "_archives", "side-2"), { recursive: true }); + writeFileSync(join(cwd, ".plans", "side-3"), "occupied"); + const result = await runMain(["ticket", "--side", "--json"], { cwd }); + expect(JSON.parse(result.stdout)).toMatchObject({ id: "side-4", state: "created" }); + expect(existsSync(join(cwd, ".plans", "side-4"))).toBe(true); + }); + + it("accounts for occupied names when previewing a side ticket", async () => { + const cwd = makeProject(); + mkdirSync(join(cwd, ".plans", "_archives", "side-2"), { recursive: true }); + writeFileSync(join(cwd, ".plans", "side-3"), "occupied"); + const result = await runMain(["ticket", "--side", "--dry-run", "--json"], { cwd }); + expect(JSON.parse(result.stdout)).toMatchObject({ id: "side-4", state: "created" }); + expect(existsSync(join(cwd, ".plans", "side-4"))).toBe(false); + }); + + it("deduces the ticket from the branch and emits the JSON contract", async () => { + const cwd = makeProject(); + git(cwd, "checkout", "-q", "-b", "78/unified-cli"); + writeFileSync( + join(cwd, ".alignfirst.json"), + JSON.stringify({ schemaVersion: 1, ticketPattern: "^\\d+$" }), + ); + const result = await runMain(["ticket", "--json", "--next", "spec.md"], { cwd }); + expect(JSON.parse(result.stdout)).toEqual({ + id: "78", + dir: ".plans/78", + state: "created", + branch: "78/unified-cli", + entries: [], + next: ".plans/78/A1-spec.md", + }); + }); + + it("requires the plans gate and validates configured ids", async () => { + const cwd = makeProject(false); + expect((await runMain(["ticket", "78"], { cwd })).stderr).toContain("no `.plans/`"); + mkdirSync(join(cwd, ".plans")); + writeFileSync( + join(cwd, ".alignfirst.json"), + JSON.stringify({ schemaVersion: 1, ticketPattern: "^\\d+$" }), + ); + expect((await runMain(["ticket", "abc"], { cwd })).stderr).toContain("does not match"); + expect((await runMain(["ticket", "side-2"], { cwd })).code).toBe(0); + }); +}); + +function makeProject(withPlans = true): string { + const cwd = makeTempDir(); + dirs.push(cwd); + configureGit(cwd); + git(cwd, "init", "--quiet"); + writeFileSync(join(cwd, "README.md"), "project"); + git(cwd, "add", "-A"); + git(cwd, "commit", "--quiet", "-m", "init"); + if (withPlans) mkdirSync(join(cwd, ".plans")); + return cwd; +} diff --git a/packages/alproject/tsconfig.build.json b/packages/alignfirst/tsconfig.build.json similarity index 100% rename from packages/alproject/tsconfig.build.json rename to packages/alignfirst/tsconfig.build.json diff --git a/packages/alproject/tsconfig.json b/packages/alignfirst/tsconfig.json similarity index 100% rename from packages/alproject/tsconfig.json rename to packages/alignfirst/tsconfig.json diff --git a/packages/alproject/CHANGELOG.md b/packages/alproject/CHANGELOG.md deleted file mode 100644 index fc9da994..00000000 --- a/packages/alproject/CHANGELOG.md +++ /dev/null @@ -1,19 +0,0 @@ -# @paleo/alproject - -## 1.1.0 - -### Minor Changes - -- ecf4cee: Added explicit base-port allocations outside configured port ranges. - -## 1.0.0 - -### Major Changes - -- e44a7c8: Added detailed project status, exact base-port registration, and parent-specific port ranges using the new object-based configuration. - -## 0.1.0 - -### Minor Changes - -- 2877daa: Added the alproject CLI for project registration, discovery, and port allocation. diff --git a/packages/alproject/README.md b/packages/alproject/README.md deleted file mode 100644 index 601dcb3b..00000000 --- a/packages/alproject/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# @paleo/alproject - -Local project registry for the AlignFirst developer. Discover local Git projects, track explicit registrations, and allocate non-overlapping port ranges. - -## Installation - -```sh -npm install --global @paleo/alproject -``` - -## Configuration - -Create `~/.alproject.json` before running commands: - -```json -{ - "root": { - "path": "~/projects", - "portRange": { - "first": 8000, - "last": 9999 - } - }, - "projectParents": [ - { - "path": "~/projects" - }, - { - "path": "~/work/projects", - "portRange": { - "first": 9000, - "last": 9999 - } - } - ] -} -``` - -`root.path` is the base for relative command paths and stores `alproject-registry.json`. `root.portRange` is the inclusive global allocation range. `projectParents` lists the directories whose direct children may be discovered and registered. It defaults to the root path. All configured directories must exist. - -An optional parent `portRange` reserves part of the global range for projects under that parent. Parent ranges must be inside the global range and cannot overlap. Projects under parents without a dedicated range share the unreserved ports. - -## Commands - -```text -alproject list [--json] -alproject status [--json] -alproject register [--ports-per-workspace --max-workspaces [--base-port [--allow-outside-port-range]]] -alproject unregister -``` - -`status` reports one discovered or registered project. It includes the canonical main path, registration and filesystem status, optional port allocation, preferred remote host, and every Git worktree with its path and branch. Relative paths resolve from `root`; absolute paths are accepted directly. Pass the main-worktree path. - -Port options reserve `ports-per-workspace * max-workspaces` ports. `--base-port` claims an exact available range. Add `--allow-outside-port-range` to permit that explicit allocation outside the configured root and parent ranges. The complete allocation must remain within ports 1 through 65535 and cannot overlap another registration. - -Run `alproject --guide` for the agent-facing operating guide. When `/alproject-guide.md` exists, the command appends it verbatim after the generic guide — use it to describe how the project parents are organized. An unreadable custom guide is an error. - -## Discovery and statuses - -Discovery inspects direct child directories only. A `.git` directory identifies a main worktree. A linked worktree is associated only when both sides of its Git metadata relationship are valid and its main worktree is under an allowed parent. Other child directories appear as additional directories. - -`list` merges discovery with the registry and reports: - -- `registered` — present on the filesystem and in the registry; -- `unregistered on filesystem` — discovered without a registry entry; -- `registered but missing from filesystem` — retained in the registry after a move or deletion. - -Discrepancies are informational. Listing never changes files or registrations. - -## Registry ownership and recovery - -Alproject owns `/alproject-registry.json` and its short-lived lock and temporary sibling files. Edit project files and immutable configuration through their own workflows. - -The current registry format uses `"schemaVersion": 2`. Alproject reads legacy version-1 registries and rewrites them in the current format on the next registration or unregistration. - -Concurrent mutations wait briefly for a live lock and then fail with an actionable error. Retry after the other command finishes. Alproject reclaims locks whose recorded process identity no longer exists. If registry validation fails, correct the reported field or restore a valid registry before retrying. Failed registration, allocation, locking, and writes preserve the previous registry. diff --git a/packages/alproject/bin/alproject.mjs b/packages/alproject/bin/alproject.mjs deleted file mode 100755 index c25cff67..00000000 --- a/packages/alproject/bin/alproject.mjs +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env node -import { main } from "../dist/cli.js"; - -process.exitCode = await main(); diff --git a/packages/alproject/src/cli.ts b/packages/alproject/src/cli.ts deleted file mode 100644 index 3d3d746d..00000000 --- a/packages/alproject/src/cli.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { parseArgs } from "node:util"; - -import { readConfig, readConfigIfPresent } from "./config.js"; -import { buildProjectList, type ProjectList, type ProjectStatus } from "./discovery.js"; -import { errorMessage } from "./errors.js"; -import { renderGuide } from "./guide.js"; -import { registerProject, unregisterProject } from "./mutations.js"; -import { readRegistry } from "./registry.js"; -import { getProjectStatus, type ProjectDetails } from "./status.js"; - -const statusLabels: Record = { - missing: "registered but missing from filesystem", - registered: "registered", - unregistered: "unregistered on filesystem", -}; - -export interface MainOptions { - argv?: string[]; - env?: NodeJS.ProcessEnv; - home?: string; - stderr?: Output; - stdout?: Output; -} - -export interface Output { - write(text: string): void; -} - -export interface AlprojectArgs { - allowOutsidePortRange: boolean; - basePort?: number; - command?: string; - guide: boolean; - help: boolean; - json: boolean; - maxWorkspaces?: number; - path?: string; - portsPerWorkspace?: number; - version: boolean; -} - -interface PortOptionValues { - "allow-outside-port-range"?: boolean; - "base-port"?: string; - "max-workspaces"?: string; - "ports-per-workspace"?: string; -} - -export async function main(options: MainOptions = {}): Promise { - const argv = options.argv ?? process.argv; - const env = options.env ?? process.env; - const home = options.home ?? env.HOME ?? env.USERPROFILE ?? homedir(); - const stderr = options.stderr ?? process.stderr; - const stdout = options.stdout ?? process.stdout; - - let args: AlprojectArgs; - try { - args = parseAlprojectArgs(argv); - } catch (error) { - stderr.write(`${escapeControlCharacters(errorMessage(error))}\n`); - return 1; - } - - if (args.version) { - stdout.write(`${readPackageVersion()}\n`); - return 0; - } - if (args.help) { - stdout.write(renderHelp()); - return 0; - } - - try { - if (args.guide) { - const config = readConfigIfPresent(home); - stdout.write(ensureTrailingNewline(renderGuide(config?.root.path))); - return 0; - } - if (args.command === undefined) { - stdout.write(renderHelp()); - return 0; - } - const config = readConfig(home); - if (args.command === "list") { - const list = buildProjectList(config, readRegistry(config)); - stdout.write(args.json ? renderProjectListJson(list) : renderProjectList(list)); - return 0; - } - if (args.command === "status" && args.path !== undefined) { - const status = getProjectStatus(config, readRegistry(config), args.path); - stdout.write(args.json ? renderProjectStatusJson(status) : renderProjectStatus(status)); - return 0; - } - if (args.command === "register" && args.path !== undefined) { - const result = await registerProject(config, args.path, { - allowOutsidePortRange: args.allowOutsidePortRange, - basePort: args.basePort, - maxWorkspaces: args.maxWorkspaces, - portsPerWorkspace: args.portsPerWorkspace, - }); - stdout.write(renderRegistration(result)); - return 0; - } - if (args.command === "unregister" && args.path !== undefined) { - const path = await unregisterProject(config, args.path); - stdout.write(`Unregistered project: ${renderOutputValue(path)}\n`); - return 0; - } - throw new Error(`Unknown command: ${args.command}`); - } catch (error) { - stderr.write(`${escapeControlCharacters(errorMessage(error))}\n`); - return 1; - } -} - -export function parseAlprojectArgs(argv: string[]): AlprojectArgs { - const { positionals, values } = parseArgs({ - allowPositionals: true, - args: argv.slice(2), - options: { - "allow-outside-port-range": { default: false, type: "boolean" }, - "base-port": { type: "string" }, - guide: { default: false, type: "boolean" }, - help: { default: false, short: "h", type: "boolean" }, - json: { default: false, type: "boolean" }, - "max-workspaces": { type: "string" }, - "ports-per-workspace": { type: "string" }, - version: { default: false, short: "v", type: "boolean" }, - }, - strict: true, - }); - const selectedModes = [ - values.guide === true ? "--guide" : undefined, - values.help === true ? "--help" : undefined, - values.version === true ? "--version" : undefined, - ].filter((mode) => mode !== undefined); - if (selectedModes.length > 1) { - throw new Error("--guide, --help, and --version are mutually exclusive"); - } - if (selectedModes.length === 1) { - if (positionals.length > 0) throw new Error(`${selectedModes[0]} does not accept a command`); - if (values.json === true || hasPortOptions(values)) { - throw new Error(`${selectedModes[0]} does not accept command options`); - } - return { - allowOutsidePortRange: false, - guide: values.guide === true, - help: values.help === true, - json: false, - version: values.version === true, - }; - } - - const [command, path, ...extraPaths] = positionals; - if (command === undefined) { - if (values.json === true) throw new Error("--json is valid only with list or status"); - if (hasPortOptions(values)) { - throw new Error("Port options are valid only with register"); - } - return { - allowOutsidePortRange: false, - guide: false, - help: false, - json: false, - version: false, - }; - } - if (!isCommand(command)) throw new Error(`Unknown command: ${command}`); - validateCommandPaths(command, path, extraPaths); - validatePortOptionPlacement(command, values); - if (values.json === true && command !== "list" && command !== "status") { - throw new Error("--json is valid only with list or status"); - } - const portsPerWorkspace = parsePositiveInteger( - "--ports-per-workspace", - values["ports-per-workspace"], - ); - const maxWorkspaces = parsePositiveInteger("--max-workspaces", values["max-workspaces"]); - const basePort = parsePositiveInteger("--base-port", values["base-port"]); - const allowOutsidePortRange = values["allow-outside-port-range"] === true; - if ((portsPerWorkspace === undefined) !== (maxWorkspaces === undefined)) { - throw new Error("--ports-per-workspace and --max-workspaces must be provided together"); - } - if (basePort !== undefined && portsPerWorkspace === undefined) { - throw new Error("--base-port requires --ports-per-workspace and --max-workspaces"); - } - if (allowOutsidePortRange && basePort === undefined) { - throw new Error("--allow-outside-port-range requires --base-port"); - } - return { - allowOutsidePortRange, - basePort, - command, - guide: false, - help: false, - json: values.json === true, - maxWorkspaces, - path, - portsPerWorkspace, - version: false, - }; -} - -function hasPortOptions(values: PortOptionValues): boolean { - return ( - values["allow-outside-port-range"] === true || - values["base-port"] !== undefined || - values["ports-per-workspace"] !== undefined || - values["max-workspaces"] !== undefined - ); -} - -function isCommand(value: string): value is "list" | "register" | "status" | "unregister" { - return value === "list" || value === "register" || value === "status" || value === "unregister"; -} - -function validateCommandPaths( - command: string, - path: string | undefined, - extraPaths: string[], -): void { - if (command === "list") { - if (path !== undefined) throw new Error("list does not accept a path"); - return; - } - if (path === undefined) throw new Error(`${command} requires exactly one path`); - if (extraPaths.length > 0) throw new Error(`${command} requires exactly one path`); -} - -function validatePortOptionPlacement(command: string, values: PortOptionValues): void { - if (command !== "register" && hasPortOptions(values)) { - throw new Error("Port options are valid only with register"); - } -} - -function parsePositiveInteger(option: string, value: string | undefined): number | undefined { - if (value === undefined) return; - if (!/^[1-9]\d*$/u.test(value)) throw new Error(`${option} must be a positive integer`); - const parsed = Number(value); - if (!Number.isSafeInteger(parsed)) throw new Error(`${option} must be a positive integer`); - return parsed; -} - -function readPackageVersion(): string { - const packageFile = JSON.parse( - readFileSync(new URL("../package.json", import.meta.url), "utf8"), - ) as { - version?: string; - }; - if (packageFile.version === undefined) { - throw new Error("alproject: package.json is missing 'version'"); - } - return packageFile.version; -} - -function renderHelp(): string { - return `alproject — discover and manage local Git projects. - -Usage: - alproject list [--json] - alproject status [--json] - alproject register [--ports-per-workspace --max-workspaces [--base-port [--allow-outside-port-range]]] - alproject unregister - -Options: - --guide Print the complete guide - -h, --help Print this help - --json Print structured list or status output - --allow-outside-port-range - Permit an explicit allocation outside configured ranges - -v, --version Print the alproject version - -Run \`alproject --guide\` for configuration and operational procedures. -`; -} - -export function renderProjectList(list: ProjectList): string { - const lines = ["Projects:"]; - if (list.projects.length === 0) lines.push(" (none)"); - for (const project of list.projects) { - lines.push( - `- Name: ${renderOutputValue(project.name)}`, - ` Main path: ${renderOutputValue(project.path)}`, - ` Parent: ${renderOutputValue(project.parent)}`, - ` Status: ${statusLabels[project.status]}`, - ` Workspaces: ${ - project.workspaces.length === 0 - ? "(none)" - : project.workspaces.map(renderOutputValue).join(", ") - }`, - ); - if (project.ports !== undefined) { - lines.push( - ` Base port: ${project.ports.basePort}`, - ` Port range: ${project.ports.basePort}..${project.ports.endPort}`, - ); - } - } - lines.push("", "Additional directories:"); - if (list.additionalDirectories.length === 0) lines.push(" (none)"); - for (const group of list.additionalDirectories) { - lines.push(`- Parent: ${renderOutputValue(group.parent)}`); - for (const directory of group.directories) { - lines.push(` - ${renderOutputValue(directory)}`); - } - } - return `${lines.join("\n")}\n`; -} - -export function renderProjectListJson(list: ProjectList): string { - return `${escapeAdditionalJsonCharacters(JSON.stringify(list, undefined, 2))}\n`; -} - -function renderProjectStatus(status: ProjectDetails): string { - const lines = [ - "Project:", - ` Name: ${renderOutputValue(status.name)}`, - ` Main path: ${renderOutputValue(status.path)}`, - ` Status: ${statusLabels[status.status]}`, - ` Remote host: ${status.remoteHost === null ? "(none)" : renderOutputValue(status.remoteHost)}`, - ]; - if (status.ports === null) { - lines.push(" Port allocation: (none)"); - } else { - lines.push( - ` Base port: ${status.ports.basePort}`, - ` Port range: ${status.ports.basePort}..${status.ports.endPort}`, - ` Ports per workspace: ${status.ports.portsPerWorkspace}`, - ` Maximum workspaces: ${status.ports.maxWorkspaces}`, - ); - } - lines.push(" Worktrees:"); - if (status.worktrees.length === 0) lines.push(" (none)"); - for (const worktree of status.worktrees) { - lines.push( - ` - Name: ${renderOutputValue(worktree.name)}`, - ` Path: ${renderOutputValue(worktree.path)}`, - ` Branch: ${worktree.branch === null ? "(detached)" : renderOutputValue(worktree.branch)}`, - ); - } - return `${lines.join("\n")}\n`; -} - -function renderProjectStatusJson(status: ProjectDetails): string { - return `${escapeAdditionalJsonCharacters(JSON.stringify(status, undefined, 2))}\n`; -} - -function renderRegistration(result: { - path: string; - ports?: { basePort: number; endPort: number }; -}): string { - const lines = [`Registered project: ${renderOutputValue(result.path)}`]; - if (result.ports !== undefined) { - lines.push( - `Base port: ${result.ports.basePort}`, - `Port range: ${result.ports.basePort}..${result.ports.endPort}`, - ); - } - return `${lines.join("\n")}\n`; -} - -function ensureTrailingNewline(value: string): string { - return value.endsWith("\n") ? value : `${value}\n`; -} - -function renderOutputValue(value: string): string { - return escapeAdditionalJsonCharacters(JSON.stringify(value)); -} - -function escapeControlCharacters(value: string): string { - return Array.from(value, (character) => { - if (!isControlCharacter(character, true)) return character; - const jsonEscape = JSON.stringify(character).slice(1, -1); - if (jsonEscape !== character) return jsonEscape; - return unicodeEscape(character); - }).join(""); -} - -function escapeAdditionalJsonCharacters(value: string): string { - return Array.from(value, (character) => - isControlCharacter(character, false) ? unicodeEscape(character) : character, - ).join(""); -} - -function isControlCharacter(character: string, includeC0: boolean): boolean { - const codePoint = character.codePointAt(0); - if (codePoint === undefined) return false; - return ( - (includeC0 && codePoint <= 0x1f) || - (codePoint >= 0x7f && codePoint <= 0x9f) || - codePoint === 0x2028 || - codePoint === 0x2029 - ); -} - -function unicodeEscape(character: string): string { - const codePoint = character.codePointAt(0); - if (codePoint === undefined) throw new Error("Cannot escape an empty character"); - return `\\u${codePoint.toString(16).padStart(4, "0")}`; -} diff --git a/packages/alproject/src/config.ts b/packages/alproject/src/config.ts deleted file mode 100644 index 226912a0..00000000 --- a/packages/alproject/src/config.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { accessSync, constants, readFileSync, statSync } from "node:fs"; -import { dirname, join } from "node:path"; - -import { type } from "arktype"; - -import { AlprojectError, errorMessage, isNodeError } from "./errors.js"; -import { resolveConfiguredPath } from "./paths.js"; - -export const CONFIG_FILENAME = ".alproject.json"; - -const portRangeSchema = type({ - "+": "reject", - first: "1 <= number.integer <= 65535", - last: "1 <= number.integer <= 65535", -}); -const rootSchema = type({ - "+": "reject", - path: "string > 0", - portRange: portRangeSchema, -}); -const projectParentSchema = type({ - "+": "reject", - path: "string > 0", - "portRange?": portRangeSchema, -}); -const configSchema = type({ - "+": "reject", - "projectParents?": projectParentSchema.array(), - root: rootSchema, -}); - -type ConfigFile = typeof configSchema.infer; -type ConfiguredProjectParent = typeof projectParentSchema.infer; -type ConfiguredRoot = typeof rootSchema.infer; - -export interface AlprojectConfig { - configPath: string; - projectParents: ProjectParent[]; - root: AlprojectRoot; -} - -export interface AlprojectRoot { - path: string; - portRange: PortRange; -} - -export interface ProjectParent { - path: string; - portRange?: PortRange; -} - -export interface PortRange { - first: number; - last: number; -} - -export function readConfig(home: string): AlprojectConfig { - const configPath = join(home, CONFIG_FILENAME); - const configFile = readConfigFile(configPath); - const root = resolveRoot(configFile.root, home, configPath); - const configuredParents = configFile.projectParents ?? [{ path: configFile.root.path }]; - const projectParents = resolveProjectParents(configuredParents, root.portRange, home, configPath); - - assertAccessibleDirectory(root.path, configPath, "root"); - for (const parent of projectParents) { - assertAccessibleDirectory(parent.path, configPath, "project parent"); - } - - return { configPath, projectParents, root }; -} - -export function availablePortRanges(config: AlprojectConfig, projectPath: string): PortRange[] { - const parent = config.projectParents.find((candidate) => candidate.path === dirname(projectPath)); - if (parent?.portRange !== undefined) return [parent.portRange]; - return unreservedPortRanges(config.root.portRange, config.projectParents); -} - -export function readConfigIfPresent(home: string): AlprojectConfig | undefined { - const configPath = join(home, CONFIG_FILENAME); - try { - statSync(configPath); - } catch (error) { - if (isNodeError(error) && error.code === "ENOENT") return; - throw new AlprojectError( - "configuration", - `Cannot inspect configuration file ${configPath}: ${errorMessage(error)}`, - { cause: error }, - ); - } - return readConfig(home); -} - -function readConfigFile(configPath: string): ConfigFile { - const rawConfig = readJsonFile(configPath, "configuration"); - const config = configSchema(rawConfig); - if (config instanceof type.errors) { - throw configurationError(configPath, config.summary); - } - if (config.projectParents?.length === 0) { - throw configurationError(configPath, "projectParents must contain at least one entry"); - } - return config; -} - -function resolveRoot(root: ConfiguredRoot, home: string, configPath: string): AlprojectRoot { - const portRange = validatePortRange(root.portRange, "root.portRange", configPath); - return { - path: resolveConfigPath(root.path, home, configPath, "root.path"), - portRange, - }; -} - -function resolveProjectParents( - parents: ConfiguredProjectParent[], - rootRange: PortRange, - home: string, - configPath: string, -): ProjectParent[] { - const resolved = parents.map((parent, index) => ({ - path: resolveConfigPath(parent.path, home, configPath, `projectParents[${index}].path`), - ...(parent.portRange === undefined - ? {} - : { - portRange: validateParentPortRange( - parent.portRange, - rootRange, - `projectParents[${index}].portRange`, - configPath, - ), - }), - })); - validateDistinctParents(resolved, configPath); - validateNonOverlappingParentRanges(resolved, configPath); - return resolved; -} - -function resolveConfigPath(path: string, home: string, configPath: string, field: string): string { - try { - return resolveConfiguredPath(path, home); - } catch (error) { - throw configurationError(configPath, `${field}: ${errorMessage(error)}`, error); - } -} - -function validateParentPortRange( - range: PortRange, - rootRange: PortRange, - field: string, - configPath: string, -): PortRange { - const validated = validatePortRange(range, field, configPath); - if (validated.first < rootRange.first || validated.last > rootRange.last) { - throw configurationError( - configPath, - `${field} must be within root.portRange ${formatPortRange(rootRange)}`, - ); - } - return validated; -} - -function validatePortRange(range: PortRange, field: string, configPath: string): PortRange { - if (range.first > range.last) { - throw configurationError(configPath, `${field}.first must not exceed ${field}.last`); - } - return range; -} - -function validateDistinctParents(parents: readonly ProjectParent[], configPath: string): void { - const seen = new Set(); - for (const parent of parents) { - if (seen.has(parent.path)) { - throw configurationError(configPath, `duplicate project parent: ${parent.path}`); - } - seen.add(parent.path); - } -} - -function validateNonOverlappingParentRanges( - parents: readonly ProjectParent[], - configPath: string, -): void { - const ranges = parents.flatMap((parent) => - parent.portRange === undefined ? [] : [{ parent: parent.path, ...parent.portRange }], - ); - const sorted = ranges.toSorted((left, right) => left.first - right.first); - for (let index = 1; index < sorted.length; ++index) { - const previous = sorted[index - 1]; - const current = sorted[index]; - if (current.first <= previous.last) { - throw configurationError( - configPath, - `project parent port ranges overlap for ${previous.parent} and ${current.parent}`, - ); - } - } -} - -function unreservedPortRanges( - rootRange: PortRange, - parents: readonly ProjectParent[], -): PortRange[] { - const reserved = parents - .flatMap((parent) => (parent.portRange === undefined ? [] : [parent.portRange])) - .toSorted((left, right) => left.first - right.first); - const available: PortRange[] = []; - let first = rootRange.first; - for (const range of reserved) { - if (first < range.first) available.push({ first, last: range.first - 1 }); - first = range.last + 1; - } - if (first <= rootRange.last) available.push({ first, last: rootRange.last }); - return available; -} - -function formatPortRange(range: PortRange): string { - return `${range.first}..${range.last}`; -} - -function readJsonFile(path: string, label: string): unknown { - let content: string; - try { - content = readFileSync(path, "utf8"); - } catch (error) { - throw new AlprojectError( - "configuration", - `Cannot read ${label} file ${path}: ${errorMessage(error)}`, - { cause: error }, - ); - } - - try { - return JSON.parse(content); - } catch (error) { - throw new AlprojectError( - "configuration", - `Invalid JSON in ${label} file ${path}: ${errorMessage(error)}`, - { cause: error }, - ); - } -} - -function assertAccessibleDirectory(path: string, configPath: string, field: string): void { - try { - if (!statSync(path).isDirectory()) { - throw new Error("path is not a directory"); - } - accessSync(path, constants.R_OK | constants.X_OK); - } catch (error) { - throw configurationError( - configPath, - `${field} directory is missing or inaccessible: ${path} (${errorMessage(error)})`, - error, - ); - } -} - -function configurationError(path: string, detail: string, cause?: unknown): AlprojectError { - return new AlprojectError("configuration", `Invalid configuration ${path}: ${detail}`, { cause }); -} diff --git a/packages/alproject/src/discovery.ts b/packages/alproject/src/discovery.ts deleted file mode 100644 index ed69f94b..00000000 --- a/packages/alproject/src/discovery.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs"; -import { basename, dirname, join, resolve } from "node:path"; - -import type { AlprojectConfig } from "./config.js"; -import { isNodeError } from "./errors.js"; -import type { PortAllocation, Registry } from "./registry.js"; - -export interface ProjectList { - projects: ListedProject[]; - additionalDirectories: AdditionalDirectoryGroup[]; -} - -export interface ListedProject { - name: string; - parent: string; - path: string; - status: ProjectStatus; - workspaces: string[]; - ports?: ListedPortAllocation; -} - -export type ProjectStatus = "registered" | "unregistered" | "missing"; - -export interface ListedPortAllocation extends PortAllocation { - endPort: number; -} - -export interface ProjectDiscovery { - projects: DiscoveredProject[]; - additionalDirectories: AdditionalDirectoryGroup[]; -} - -export interface DiscoveredProject { - name: string; - parent: string; - path: string; - workspaces: string[]; -} - -export interface AdditionalDirectoryGroup { - parent: string; - directories: string[]; -} - -interface DirectoryCandidate { - name: string; - parent: string; - path: string; -} - -interface MainCandidate extends DirectoryCandidate { - gitDirectory: string; -} - -export function buildProjectList( - config: Pick, - registry: Registry, -): ProjectList { - const discovery = discoverProjects(config); - const registeredByPath = new Map(registry.projects.map((project) => [project.path, project])); - const projects: ListedProject[] = discovery.projects.map((project) => { - const registered = registeredByPath.get(project.path); - registeredByPath.delete(project.path); - return { - ...project, - status: registered === undefined ? "unregistered" : "registered", - ...(registered?.ports === undefined ? {} : { ports: listedPorts(registered.ports) }), - }; - }); - - for (const registered of registeredByPath.values()) { - projects.push({ - name: basename(registered.path), - parent: dirname(registered.path), - path: registered.path, - status: "missing", - workspaces: [], - ...(registered.ports === undefined ? {} : { ports: listedPorts(registered.ports) }), - }); - } - - projects.sort(compareProjects); - return { additionalDirectories: discovery.additionalDirectories, projects }; -} - -export function discoverProjects( - config: Pick, -): ProjectDiscovery { - const candidates = config.projectParents - .map((parent) => parent.path) - .toSorted() - .flatMap(readDirectoryCandidates); - const mainCandidates = candidates.flatMap((candidate) => { - const gitDirectory = mainWorktreeGitDirectory(candidate.path); - return gitDirectory === undefined ? [] : [{ ...candidate, gitDirectory }]; - }); - const mainsByGitDirectory = new Map( - mainCandidates.map((candidate) => [candidate.gitDirectory, candidate]), - ); - const workspaceNamesByMainPath = new Map(); - const classifiedPaths = new Set(mainCandidates.map((candidate) => candidate.path)); - - for (const candidate of candidates) { - if (classifiedPaths.has(candidate.path)) continue; - const main = linkedWorktreeMain(candidate.path, mainsByGitDirectory); - if (main === undefined) continue; - const workspaceNames = workspaceNamesByMainPath.get(main.path) ?? []; - workspaceNames.push(candidate.name); - workspaceNamesByMainPath.set(main.path, workspaceNames); - classifiedPaths.add(candidate.path); - } - - const projects = mainCandidates - .map(({ gitDirectory: _gitDirectory, ...main }) => ({ - ...main, - workspaces: (workspaceNamesByMainPath.get(main.path) ?? []).toSorted(), - })) - .toSorted(compareProjects); - const additionalDirectories = groupAdditionalDirectories(candidates, classifiedPaths); - return { additionalDirectories, projects }; -} - -function readDirectoryCandidates(parent: string): DirectoryCandidate[] { - return readdirSync(parent, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .flatMap((entry) => readDirectoryCandidate(parent, entry.name)) - .toSorted((left, right) => left.name.localeCompare(right.name)); -} - -function readDirectoryCandidate(parent: string, name: string): DirectoryCandidate[] { - try { - return [{ name, parent, path: realpathSync(join(parent, name)) }]; - } catch (error) { - if (isNodeError(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) return []; - throw error; - } -} - -export function mainWorktreeGitDirectory(projectPath: string): string | undefined { - const gitPath = join(projectPath, ".git"); - try { - if (!lstatSync(gitPath).isDirectory()) return; - return realpathSync(gitPath); - } catch { - return; - } -} - -function linkedWorktreeMain( - worktreePath: string, - mainsByGitDirectory: ReadonlyMap, -): MainCandidate | undefined { - const worktreeGitFile = join(worktreePath, ".git"); - try { - if (!lstatSync(worktreeGitFile).isFile()) return; - const metadataDirectory = resolveGitdirFile(worktreeGitFile); - const mainGitDirectory = resolveMetadataPath(metadataDirectory, "commondir"); - const main = mainsByGitDirectory.get(mainGitDirectory); - if (main === undefined) return; - if (dirname(metadataDirectory) !== join(mainGitDirectory, "worktrees")) return; - const backlink = resolveMetadataPath(metadataDirectory, "gitdir"); - if (backlink !== realpathSync(worktreeGitFile)) return; - return main; - } catch { - return; - } -} - -function resolveGitdirFile(gitFile: string): string { - const match = /^gitdir:\s*(.+)\s*$/u.exec(readFileSync(gitFile, "utf8")); - if (match === null) throw new Error(`Invalid Git file: ${gitFile}`); - return realpathSync(resolve(dirname(gitFile), match[1])); -} - -function resolveMetadataPath(metadataDirectory: string, filename: string): string { - const target = readFileSync(join(metadataDirectory, filename), "utf8").trim(); - if (target.length === 0) throw new Error(`Empty Git metadata file: ${filename}`); - return realpathSync(resolve(metadataDirectory, target)); -} - -function groupAdditionalDirectories( - candidates: readonly DirectoryCandidate[], - classifiedPaths: ReadonlySet, -): AdditionalDirectoryGroup[] { - const directoriesByParent = new Map(); - for (const candidate of candidates) { - if (classifiedPaths.has(candidate.path)) continue; - const directories = directoriesByParent.get(candidate.parent) ?? []; - directories.push(candidate.name); - directoriesByParent.set(candidate.parent, directories); - } - return [...directoriesByParent] - .map(([parent, directories]) => ({ parent, directories: directories.toSorted() })) - .toSorted((left, right) => left.parent.localeCompare(right.parent)); -} - -function listedPorts(ports: PortAllocation): ListedPortAllocation { - return { - ...ports, - endPort: ports.basePort + ports.portsPerWorkspace * ports.maxWorkspaces - 1, - }; -} - -function compareProjects( - left: Pick, - right: Pick, -): number { - return left.parent.localeCompare(right.parent) || left.path.localeCompare(right.path); -} diff --git a/packages/alproject/src/errors.ts b/packages/alproject/src/errors.ts deleted file mode 100644 index d6c8651c..00000000 --- a/packages/alproject/src/errors.ts +++ /dev/null @@ -1,19 +0,0 @@ -export class AlprojectError extends Error { - readonly code: AlprojectErrorCode; - - constructor(code: AlprojectErrorCode, message: string, options?: ErrorOptions) { - super(message, options); - this.name = "AlprojectError"; - this.code = code; - } -} - -export type AlprojectErrorCode = "configuration" | "filesystem" | "lock" | "registry"; - -export function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -export function isNodeError(error: unknown): error is NodeJS.ErrnoException { - return error instanceof Error && "code" in error; -} diff --git a/packages/alproject/src/guide.ts b/packages/alproject/src/guide.ts deleted file mode 100644 index 9fb711a4..00000000 --- a/packages/alproject/src/guide.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -import { AlprojectError, errorMessage, isNodeError } from "./errors.js"; - -const CUSTOM_GUIDE_FILENAME = "alproject-guide.md"; - -export function renderGuide(root?: string): string { - const genericGuide = readFileSync(new URL("../templates/guide.md", import.meta.url), "utf8"); - if (root === undefined) return genericGuide; - const customGuide = readCustomGuide(root); - if (customGuide === undefined) return genericGuide; - return `${genericGuide.trimEnd()}\n\n${customGuide}`; -} - -function readCustomGuide(root: string): string | undefined { - const path = join(root, CUSTOM_GUIDE_FILENAME); - try { - return readFileSync(path, "utf8"); - } catch (error) { - if (isNodeError(error) && error.code === "ENOENT") return; - throw new AlprojectError( - "filesystem", - `Cannot read custom guide ${path}: ${errorMessage(error)}`, - { cause: error }, - ); - } -} diff --git a/packages/alproject/src/mutations.ts b/packages/alproject/src/mutations.ts deleted file mode 100644 index 0f5d182a..00000000 --- a/packages/alproject/src/mutations.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { - closeSync, - lstatSync, - openSync, - renameSync, - statSync, - unlinkSync, - writeFileSync, - fsyncSync, -} from "node:fs"; -import { randomUUID } from "node:crypto"; -import { dirname, join } from "node:path"; - -import { availablePortRanges, type AlprojectConfig } from "./config.js"; -import { AlprojectError, errorMessage, isNodeError } from "./errors.js"; -import { resolveProjectPath } from "./paths.js"; -import { - allocateProjectPorts, - allocationEnd, - claimProjectPorts, - projectPortCount, - type PortRequest, -} from "./ports.js"; -import { type PortAllocation, readRegistry, type Registry, registryPath } from "./registry.js"; -import { type RegistryLockOptions, withRegistryLock } from "./registry-lock.js"; - -const atomicWriteOperations: AtomicWriteOperations = { - close: closeSync, - fsync: fsyncSync, - open: openSync, - rename: renameSync, - unlink: unlinkSync, - write: writeFileSync, -}; - -export interface RegistrationOptions { - allowOutsidePortRange?: boolean; - basePort?: number; - maxWorkspaces?: number; - portsPerWorkspace?: number; -} - -export interface RegistrationResult { - path: string; - ports?: RegistrationPorts; -} - -export interface RegistrationPorts extends PortRequest { - allowOutsidePortRange?: true; - basePort: number; - endPort: number; -} - -export interface MutationOptions { - atomicWriteOperations?: Partial; - lock?: RegistryLockOptions; -} - -export interface AtomicWriteOperations { - close: typeof closeSync; - fsync: typeof fsyncSync; - open: typeof openSync; - rename: typeof renameSync; - unlink: typeof unlinkSync; - write: typeof writeFileSync; -} - -export async function registerProject( - config: AlprojectConfig, - inputPath: string, - options: RegistrationOptions = {}, - mutationOptions: MutationOptions = {}, -): Promise { - const path = registrationPath(config, inputPath); - const request = portRequest(options); - - return mutateRegistry(config, mutationOptions, (registry) => { - if (registry.projects.some((project) => project.path === path)) { - throw new AlprojectError("registry", `Project is already registered: ${path}`); - } - const ports = allocateRegistrationPorts( - config, - registry, - path, - request, - options.basePort, - options.allowOutsidePortRange === true, - ); - registry.projects.push(ports === undefined ? { path } : { path, ports }); - return { - path, - ...(ports === undefined ? {} : { ports: { ...ports, endPort: allocationEnd(ports) } }), - }; - }); -} - -export async function unregisterProject( - config: AlprojectConfig, - inputPath: string, - mutationOptions: MutationOptions = {}, -): Promise { - const path = mutationPath(config, inputPath); - return mutateRegistry(config, mutationOptions, (registry) => { - const index = registry.projects.findIndex((project) => project.path === path); - if (index < 0) throw new AlprojectError("registry", `Project is not registered: ${path}`); - registry.projects.splice(index, 1); - return path; - }); -} - -function registrationPath(config: AlprojectConfig, inputPath: string): string { - const path = mutationPath(config, inputPath); - assertMainWorktree(path); - if (!config.projectParents.some((parent) => parent.path === dirname(path))) { - throw new AlprojectError( - "filesystem", - `Project must be a direct child of an allowed project parent: ${path}`, - ); - } - return path; -} - -function mutationPath(config: Pick, inputPath: string): string { - return resolveProjectPath(inputPath, config.root.path); -} - -function assertMainWorktree(path: string): void { - try { - if (!statSync(path).isDirectory()) throw new Error("path is not a directory"); - if (!lstatSync(join(path, ".git")).isDirectory()) { - throw new Error(".git is not a directory"); - } - } catch (error) { - throw new AlprojectError( - "filesystem", - `Project must be an existing Git main worktree: ${path} (${errorMessage(error)})`, - { cause: error }, - ); - } -} - -function portRequest(options: RegistrationOptions): PortRequest | undefined { - if (options.allowOutsidePortRange === true && options.basePort === undefined) { - throw new AlprojectError("registry", "allowOutsidePortRange requires basePort"); - } - const hasPortsPerWorkspace = options.portsPerWorkspace !== undefined; - const hasMaxWorkspaces = options.maxWorkspaces !== undefined; - if (hasPortsPerWorkspace !== hasMaxWorkspaces) { - throw new AlprojectError( - "registry", - "portsPerWorkspace and maxWorkspaces must be provided together", - ); - } - if ( - !hasPortsPerWorkspace || - options.portsPerWorkspace === undefined || - options.maxWorkspaces === undefined - ) { - if (options.basePort !== undefined) { - throw new AlprojectError("registry", "basePort requires portsPerWorkspace and maxWorkspaces"); - } - return; - } - const request = { - maxWorkspaces: options.maxWorkspaces, - portsPerWorkspace: options.portsPerWorkspace, - }; - projectPortCount(request); - if (options.basePort !== undefined) { - allocationEnd({ basePort: options.basePort, ...request }); - } - return request; -} - -function allocateRegistrationPorts( - config: AlprojectConfig, - registry: Registry, - path: string, - request: PortRequest | undefined, - basePort: number | undefined, - allowOutsidePortRange: boolean, -): PortAllocation | undefined { - if (request === undefined) return; - const ranges = availablePortRanges(config, path); - const allocation = - basePort === undefined - ? allocateProjectPorts(registry.projects, request, ranges) - : claimProjectPorts( - registry.projects, - { - basePort, - ...request, - ...(allowOutsidePortRange ? { allowOutsidePortRange: true } : {}), - }, - ranges, - ); - return allocation; -} - -async function mutateRegistry( - config: AlprojectConfig, - options: MutationOptions, - mutation: (registry: Registry) => T, -): Promise { - const path = registryPath(config); - return withRegistryLock( - path, - () => { - const registry = readRegistry(config); - const result = mutation(registry); - writeRegistryAtomically(path, registry, options.atomicWriteOperations); - return result; - }, - options.lock, - ); -} - -function writeRegistryAtomically( - path: string, - registry: Registry, - operationOverrides: Partial = {}, -): void { - const operations = { ...atomicWriteOperations, ...operationOverrides }; - const temporaryPath = `${path}.tmp-${process.pid}-${randomUUID()}`; - let descriptor: number | undefined; - try { - descriptor = operations.open(temporaryPath, "wx", 0o600); - operations.write(descriptor, `${JSON.stringify(registry, undefined, 2)}\n`, "utf8"); - operations.fsync(descriptor); - operations.close(descriptor); - descriptor = undefined; - operations.rename(temporaryPath, path); - syncDirectory(dirname(path), operations); - } catch (error) { - if (descriptor !== undefined) tryClose(descriptor, operations.close); - removeTemporaryFile(temporaryPath, operations.unlink); - throw new AlprojectError( - "registry", - `Cannot atomically replace registry ${path}: ${errorMessage(error)}`, - { cause: error }, - ); - } -} - -function syncDirectory(path: string, operations: AtomicWriteOperations): void { - try { - const descriptor = operations.open(path, "r"); - try { - operations.fsync(descriptor); - } finally { - operations.close(descriptor); - } - } catch { - // Best-effort: the rename has already succeeded, and some platforms cannot - // open (Windows) or fsync (some filesystems) a directory. - } -} - -function tryClose(descriptor: number, close: typeof closeSync): void { - try { - close(descriptor); - } catch { - // Preserve the write error. - } -} - -function removeTemporaryFile(path: string, unlink: typeof unlinkSync): void { - try { - unlink(path); - } catch (error) { - if (isNodeError(error) && error.code === "ENOENT") return; - } -} diff --git a/packages/alproject/src/paths.ts b/packages/alproject/src/paths.ts deleted file mode 100644 index dcae7d52..00000000 --- a/packages/alproject/src/paths.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { realpathSync } from "node:fs"; -import { isAbsolute, join, normalize, resolve } from "node:path"; - -import { AlprojectError, errorMessage, isNodeError } from "./errors.js"; - -export function expandHomePath(path: string, home: string): string { - if (!path.startsWith("~/")) return path; - return join(home, path.slice(2)); -} - -export function normalizeAbsolutePath(path: string, home: string): string { - const expandedPath = expandHomePath(path, home); - if (!isAbsolute(expandedPath)) { - throw new AlprojectError("filesystem", `Path must be absolute: ${path}`); - } - return normalize(expandedPath); -} - -export function canonicalizePath(path: string): string { - const normalizedPath = normalize(path); - try { - return realpathSync(normalizedPath); - } catch (error) { - if (isMissingPathError(error)) return normalizedPath; - throw new AlprojectError( - "filesystem", - `Cannot resolve path ${normalizedPath}: ${errorMessage(error)}`, - { - cause: error, - }, - ); - } -} - -export function resolveProjectPath(path: string, root: string): string { - if (path.length === 0) throw new AlprojectError("filesystem", "Project path is required"); - const absolutePath = isAbsolute(path) ? normalize(path) : resolve(root, path); - return canonicalizePath(absolutePath); -} - -export function resolveConfiguredPath(path: string, home: string): string { - return canonicalizePath(normalizeAbsolutePath(path, home)); -} - -function isMissingPathError(error: unknown): boolean { - return isNodeError(error) && (error.code === "ENOENT" || error.code === "ENOTDIR"); -} diff --git a/packages/alproject/src/ports.ts b/packages/alproject/src/ports.ts deleted file mode 100644 index b52d9372..00000000 --- a/packages/alproject/src/ports.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { AlprojectError } from "./errors.js"; -import type { PortRange } from "./config.js"; -import type { PortAllocation, ProjectEntry } from "./registry.js"; - -const MAX_PORT = 65_535; - -export interface PortRequest { - maxWorkspaces: number; - portsPerWorkspace: number; -} - -interface AllocatedPortRange { - end: number; - start: number; -} - -export function allocateProjectPorts( - projects: readonly ProjectEntry[], - request: PortRequest, - availableRanges: readonly PortRange[], -): PortAllocation { - const size = projectPortCount(request); - const allocations = projects.flatMap((project) => - project.ports === undefined ? [] : [allocationRange(project.ports)], - ); - for (const range of availableRanges.toSorted((left, right) => left.first - right.first)) { - const basePort = lowestFreeBase(allocations, size, range.first, range.last); - if (basePort !== undefined) return { basePort, ...request }; - } - throw new AlprojectError( - "registry", - `No contiguous block of ${size} ports is available within ${formatPortRanges(availableRanges)}`, - ); -} - -export function claimProjectPorts( - projects: readonly ProjectEntry[], - claim: PortAllocation, - availableRanges: readonly PortRange[], -): PortAllocation { - const claimedRange = allocationRange(claim); - if ( - claim.allowOutsidePortRange !== true && - !availableRanges.some((range) => containsRange(range, claimedRange)) - ) { - throw new AlprojectError( - "registry", - `Claimed port range ${formatAllocatedRange(claimedRange)} is outside ${formatPortRanges(availableRanges)}`, - ); - } - const conflict = projects.find( - (project) => - project.ports !== undefined && rangesOverlap(claimedRange, allocationRange(project.ports)), - ); - if (conflict !== undefined) { - throw new AlprojectError( - "registry", - `Claimed port range ${formatAllocatedRange(claimedRange)} is not available because it overlaps ${conflict.path}`, - ); - } - return claim; -} - -export function projectPortCount(request: PortRequest): number { - assertPositiveSafeInteger(request.portsPerWorkspace, "portsPerWorkspace"); - assertPositiveSafeInteger(request.maxWorkspaces, "maxWorkspaces"); - const size = request.portsPerWorkspace * request.maxWorkspaces; - if (!Number.isSafeInteger(size)) { - throw new AlprojectError("registry", "Requested port allocation size exceeds safe arithmetic"); - } - return size; -} - -export function allocationEnd(allocation: PortAllocation): number { - assertPositiveSafeInteger(allocation.basePort, "basePort"); - const size = projectPortCount(allocation); - if (allocation.basePort > Number.MAX_SAFE_INTEGER - size + 1) { - throw new AlprojectError("registry", "Requested port allocation end exceeds safe arithmetic"); - } - const end = allocation.basePort + size - 1; - if (end > MAX_PORT) { - throw new AlprojectError("registry", `Requested port allocation exceeds port ${MAX_PORT}`); - } - return end; -} - -function lowestFreeBase( - ranges: readonly AllocatedPortRange[], - size: number, - firstPort: number, - lastPort: number, -): number | undefined { - let candidate = firstPort; - for (const range of ranges.toSorted((left, right) => left.start - right.start)) { - if (range.end < candidate) continue; - if (range.start > lastPort) break; - if (fitsBefore(candidate, size, range.start - 1)) return candidate; - candidate = range.end + 1; - } - return fitsBefore(candidate, size, lastPort) ? candidate : undefined; -} - -function fitsBefore(basePort: number, size: number, lastPort: number): boolean { - const end = basePort + size - 1; - return Number.isSafeInteger(end) && end <= lastPort; -} - -function allocationRange(allocation: PortAllocation): AllocatedPortRange { - return { end: allocationEnd(allocation), start: allocation.basePort }; -} - -function containsRange(available: PortRange, allocation: AllocatedPortRange): boolean { - return allocation.start >= available.first && allocation.end <= available.last; -} - -function rangesOverlap(left: AllocatedPortRange, right: AllocatedPortRange): boolean { - return left.start <= right.end && right.start <= left.end; -} - -function formatPortRanges(ranges: readonly PortRange[]): string { - if (ranges.length === 0) return "the configured port ranges (none available)"; - return ranges.map((range) => `${range.first}..${range.last}`).join(", "); -} - -function formatAllocatedRange(range: AllocatedPortRange): string { - return `${range.start}..${range.end}`; -} - -function assertPositiveSafeInteger(value: number, field: string): void { - if (!Number.isSafeInteger(value) || value < 1) { - throw new AlprojectError("registry", `${field} must be a positive integer`); - } -} diff --git a/packages/alproject/src/registry-lock.ts b/packages/alproject/src/registry-lock.ts deleted file mode 100644 index 4c71d4ec..00000000 --- a/packages/alproject/src/registry-lock.ts +++ /dev/null @@ -1,413 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { execFileSync } from "node:child_process"; -import { - closeSync, - mkdirSync, - openSync, - readFileSync, - readdirSync, - rmdirSync, - statSync, - unlinkSync, - writeFileSync, -} from "node:fs"; -import { join } from "node:path"; - -import { AlprojectError, isNodeError } from "./errors.js"; - -const DEFAULT_TIMEOUT_MS = 500; -const DEFAULT_RETRY_INTERVAL_MS = 25; -const DEFAULT_INCOMPLETE_GRACE_MS = 250; -const CLAIM_PREFIX = "claim-"; -const CHOOSING_PREFIX = "choosing-"; - -export interface RegistryLockOptions { - incompleteGraceMs?: number; - isProcessAlive?: (pid: number) => boolean; - now?: () => number; - pid?: number; - processStartMarker?: (pid: number) => string | undefined; - retryIntervalMs?: number; - sleep?: (milliseconds: number) => Promise; - timeoutMs?: number; -} - -interface LockContext { - claimPath: string; - choosingPath: string; - incompleteGraceMs: number; - isProcessAlive: (pid: number) => boolean; - lockPath: string; - now: () => number; - owner: LockOwner; - processStartMarker: (pid: number) => string | undefined; - retryIntervalMs: number; - sleep: (milliseconds: number) => Promise; - timeoutMs: number; -} - -interface LockOwner { - pid: number; - startMarker?: string; - token: string; -} - -interface LockClaim extends LockOwner { - ticket: number; -} - -interface ContenderFile { - name: string; - path: string; -} - -export async function withRegistryLock( - registryFile: string, - action: () => T | Promise, - options: RegistryLockOptions = {}, -): Promise { - const context = lockContext(registryFile, options); - await acquireLock(context); - try { - return await action(); - } finally { - releaseLock(context); - } -} - -export function registryLockPath(registryFile: string): string { - return `${registryFile}.lock`; -} - -function lockContext(registryFile: string, options: RegistryLockOptions): LockContext { - const pid = options.pid ?? process.pid; - const processStartMarker = options.processStartMarker ?? readProcessStartMarker; - const startMarker = processStartMarker(pid); - const owner = { - pid, - token: randomUUID(), - ...(startMarker === undefined ? {} : { startMarker }), - }; - const lockPath = registryLockPath(registryFile); - return { - claimPath: join(lockPath, contenderName(CLAIM_PREFIX, owner)), - choosingPath: join(lockPath, contenderName(CHOOSING_PREFIX, owner)), - incompleteGraceMs: options.incompleteGraceMs ?? DEFAULT_INCOMPLETE_GRACE_MS, - isProcessAlive: options.isProcessAlive ?? isProcessAlive, - lockPath, - now: options.now ?? Date.now, - owner, - processStartMarker, - retryIntervalMs: options.retryIntervalMs ?? DEFAULT_RETRY_INTERVAL_MS, - sleep: options.sleep ?? delay, - timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, - }; -} - -async function acquireLock(context: LockContext): Promise { - const deadline = context.now() + context.timeoutMs; - try { - createClaim(context); - while (true) { - const contenders = readContenders(context); - if (!contenders.choosing && isFirstClaim(context, contenders.claims)) return; - if (context.now() >= deadline) throw busyError(context.lockPath); - await context.sleep(context.retryIntervalMs); - } - } catch (error) { - removeContender(context.choosingPath); - removeContender(context.claimPath); - removeLockDirectory(context.lockPath); - throw error; - } -} - -function createClaim(context: LockContext): void { - createLockDirectory(context.lockPath); - try { - writeExclusive(context.choosingPath, context.owner); - } catch (error) { - if (isNodeError(error) && error.code === "ENOENT") { - createLockDirectory(context.lockPath); - writeExclusive(context.choosingPath, context.owner); - } else { - throw error; - } - } - - try { - const ticket = nextTicket(readContenders(context).claims); - writeExclusive(context.claimPath, { ...context.owner, ticket }); - } finally { - removeContender(context.choosingPath); - } -} - -function readContenders(context: LockContext): { claims: LockClaim[]; choosing: boolean } { - let files: string[]; - try { - files = readdirSync(context.lockPath); - } catch (error) { - throw lockError(context.lockPath, "cannot read", error); - } - - const claims: LockClaim[] = []; - let choosing = false; - for (const name of files) { - const contender = { name, path: join(context.lockPath, name) }; - if (name.startsWith(CLAIM_PREFIX)) { - const claim = readClaim(context, contender); - if (claim !== undefined) claims.push(claim); - } else if (name.startsWith(CHOOSING_PREFIX) && contender.path !== context.choosingPath) { - if (contenderIsLive(context, contender)) choosing = true; - } - } - return { claims, choosing }; -} - -function readClaim(context: LockContext, contender: ContenderFile): LockClaim | undefined { - const value = readContenderValue(context, contender); - if (!isLockClaim(value)) return; - return value; -} - -function contenderIsLive(context: LockContext, contender: ContenderFile): boolean { - const value = readContenderValue(context, contender); - return isLockOwner(value); -} - -function readContenderValue(context: LockContext, contender: ContenderFile): unknown { - let value: unknown; - try { - value = JSON.parse(readFileSync(contender.path, "utf8")); - } catch (error) { - if (isNodeError(error) && error.code === "ENOENT") return; - if (!(error instanceof SyntaxError)) throw lockError(contender.path, "cannot read", error); - } - - const owner = isLockOwner(value) ? value : ownerFromFilename(contender.name); - if (owner?.pid === context.owner.pid && owner.token === context.owner.token) { - return value ?? owner; - } - if (owner !== undefined && contenderProcessMatches(context, owner)) { - return isLockOwner(value) ? value : owner; - } - if (owner === undefined && !incompleteGraceElapsed(context, contender.path)) return value; - removeContender(contender.path); - return; -} - -function contenderProcessMatches(context: LockContext, owner: LockOwner): boolean { - if (!context.isProcessAlive(owner.pid)) return false; - if (owner.startMarker === undefined) return true; - const currentMarker = context.processStartMarker(owner.pid); - return currentMarker === undefined || currentMarker === owner.startMarker; -} - -function isFirstClaim(context: LockContext, claims: LockClaim[]): boolean { - const ownClaim = claims.find( - (claim) => claim.pid === context.owner.pid && claim.token === context.owner.token, - ); - if (ownClaim === undefined) { - throw lockError( - context.claimPath, - "was removed before acquisition", - new Error("missing claim"), - ); - } - return claims.every( - (claim) => - claim === ownClaim || - ownClaim.ticket < claim.ticket || - (ownClaim.ticket === claim.ticket && ownClaim.token < claim.token), - ); -} - -function nextTicket(claims: LockClaim[]): number { - const highest = claims.reduce((maximum, claim) => Math.max(maximum, claim.ticket), 0); - if (highest >= Number.MAX_SAFE_INTEGER) { - throw new AlprojectError("lock", "Registry lock ticket exceeds safe arithmetic"); - } - return highest + 1; -} - -function writeExclusive(path: string, value: LockOwner | LockClaim): void { - let descriptor: number; - try { - descriptor = openSync(path, "wx", 0o600); - } catch (error) { - throw lockError(path, "cannot create", error); - } - try { - writeFileSync(descriptor, `${JSON.stringify(value)}\n`, "utf8"); - closeSync(descriptor); - } catch (error) { - tryClose(descriptor); - removeContender(path); - throw lockError(path, "cannot initialize", error); - } -} - -function createLockDirectory(path: string): void { - try { - mkdirSync(path, { mode: 0o700 }); - } catch (error) { - if (isNodeError(error) && error.code === "EEXIST") return; - throw lockError(path, "cannot create", error); - } -} - -function releaseLock(context: LockContext): void { - removeContender(context.claimPath); - removeContender(context.choosingPath); - removeLockDirectory(context.lockPath); -} - -function removeContender(path: string): void { - try { - unlinkSync(path); - } catch (error) { - if (isNodeError(error) && error.code === "ENOENT") return; - throw lockError(path, "cannot remove", error); - } -} - -function removeLockDirectory(path: string): void { - try { - rmdirSync(path); - } catch (error) { - if (isNodeError(error) && (error.code === "ENOENT" || error.code === "ENOTEMPTY")) return; - throw lockError(path, "cannot remove", error); - } -} - -function incompleteGraceElapsed(context: LockContext, path: string): boolean { - try { - return context.now() - statSync(path).mtimeMs >= context.incompleteGraceMs; - } catch (error) { - if (isNodeError(error) && error.code === "ENOENT") return true; - throw lockError(path, "cannot inspect", error); - } -} - -function ownerFromFilename(name: string): LockOwner | undefined { - const match = /^(?:claim|choosing)-(\d+)-(.+)\.json$/u.exec(name); - if (match === null) return; - const pid = Number(match[1]); - if (!Number.isSafeInteger(pid) || pid < 1 || match[2].length === 0) return; - return { pid, token: match[2] }; -} - -function contenderName(prefix: string, owner: LockOwner): string { - return `${prefix}${owner.pid}-${owner.token}.json`; -} - -function isLockOwner(value: unknown): value is LockOwner { - if (typeof value !== "object" || value === null) return false; - if (!("pid" in value) || !("token" in value)) return false; - const validStartMarker = !("startMarker" in value) || typeof value.startMarker === "string"; - return ( - validStartMarker && - Number.isSafeInteger(value.pid) && - Number(value.pid) > 0 && - typeof value.token === "string" && - value.token.length > 0 - ); -} - -function isLockClaim(value: unknown): value is LockClaim { - return ( - isLockOwner(value) && - "ticket" in value && - Number.isSafeInteger(value.ticket) && - Number(value.ticket) > 0 - ); -} - -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - if (isNodeError(error) && error.code === "ESRCH") return false; - if (isNodeError(error) && error.code === "EPERM") return true; - throw new AlprojectError("lock", `Cannot determine whether lock owner PID ${pid} is alive`, { - cause: error, - }); - } -} - -function readProcessStartMarker(pid: number): string | undefined { - return readProcStartMarker(pid) ?? readPsStartMarker(pid) ?? readPowerShellStartMarker(pid); -} - -function readProcStartMarker(pid: number): string | undefined { - try { - const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); - const commandEnd = stat.lastIndexOf(")"); - if (commandEnd < 0) return; - const fieldsAfterCommand = stat - .slice(commandEnd + 1) - .trim() - .split(/\s+/u); - const startTime = fieldsAfterCommand[19]; - return startTime === undefined ? undefined : `linux:${startTime}`; - } catch { - return; - } -} - -function readPsStartMarker(pid: number): string | undefined { - try { - const startTime = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { - encoding: "utf8", - env: { ...process.env, LANG: "C", LC_ALL: "C" }, - stdio: "pipe", - }).trim(); - return startTime === "" ? undefined : `ps:${startTime}`; - } catch { - return; - } -} - -function readPowerShellStartMarker(pid: number): string | undefined { - try { - const startTime = execFileSync( - "powershell.exe", - [ - "-NoProfile", - "-NonInteractive", - "-Command", - `(Get-Process -Id ${pid}).StartTime.ToUniversalTime().Ticks`, - ], - { encoding: "utf8", stdio: "pipe" }, - ).trim(); - return startTime === "" ? undefined : `powershell:${startTime}`; - } catch { - return; - } -} - -function delay(milliseconds: number): Promise { - return new Promise((resolve) => setTimeout(resolve, milliseconds)); -} - -function tryClose(descriptor: number): void { - try { - closeSync(descriptor); - } catch { - // Preserve the initialization error. - } -} - -function busyError(path: string): AlprojectError { - return new AlprojectError( - "lock", - `Registry is busy: timed out waiting for ${path}. Retry the command.`, - ); -} - -function lockError(path: string, action: string, cause: unknown): AlprojectError { - const detail = cause instanceof Error ? cause.message : String(cause); - return new AlprojectError("lock", `Registry lock ${path} ${action}: ${detail}`, { cause }); -} diff --git a/packages/alproject/src/registry.ts b/packages/alproject/src/registry.ts deleted file mode 100644 index c3edc47f..00000000 --- a/packages/alproject/src/registry.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { readFileSync } from "node:fs"; -import { isAbsolute, join, normalize } from "node:path"; - -import { type } from "arktype"; - -import { availablePortRanges, type AlprojectConfig } from "./config.js"; -import { AlprojectError, errorMessage, isNodeError } from "./errors.js"; -import { canonicalizePath } from "./paths.js"; -import { allocationEnd } from "./ports.js"; - -export const REGISTRY_FILENAME = "alproject-registry.json"; - -const integerPortFields = [ - "basePort", - "maxWorkspaces", - "portsPerWorkspace", -] satisfies readonly (keyof PortAllocation)[]; -const portsSchema = type({ - "+": "reject", - "allowOutsidePortRange?": "true", - basePort: "number.integer >= 1", - maxWorkspaces: "number.integer >= 1", - portsPerWorkspace: "number.integer >= 1", -}); -const projectEntrySchema = type({ - "+": "reject", - path: "string > 0", - "ports?": portsSchema, -}); -const registrySchema = type({ - "+": "reject", - projects: projectEntrySchema.array(), - schemaVersion: "2", -}); - -export type PortAllocation = typeof portsSchema.infer; -export type ProjectEntry = typeof projectEntrySchema.infer; -export type Registry = typeof registrySchema.infer; - -interface PortRange { - end: number; - path: string; - start: number; -} - -export function registryPath(config: Pick): string { - return join(config.root.path, REGISTRY_FILENAME); -} - -export function readRegistry(config: AlprojectConfig): Registry { - const path = registryPath(config); - const rawRegistry = readRegistryFile(path); - if (rawRegistry === undefined) return { projects: [], schemaVersion: 2 }; - - const registry = registrySchema(migrateLegacyRegistry(rawRegistry)); - if (registry instanceof type.errors) { - throw registryError(path, registry.summary); - } - validateRegistry(registry, config, path); - return registry; -} - -function migrateLegacyRegistry(rawRegistry: unknown): unknown { - if (!isRecord(rawRegistry) || rawRegistry.version !== 1 || "schemaVersion" in rawRegistry) { - return rawRegistry; - } - const { version: _version, ...registry } = rawRegistry; - return { ...registry, schemaVersion: 2 }; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readRegistryFile(path: string): unknown { - let content: string; - try { - content = readFileSync(path, "utf8"); - } catch (error) { - if (isNodeError(error) && error.code === "ENOENT") return; - throw registryError(path, `cannot read file: ${errorMessage(error)}`, error); - } - - try { - return JSON.parse(content); - } catch (error) { - throw registryError(path, `invalid JSON: ${errorMessage(error)}`, error); - } -} - -function validateRegistry(registry: Registry, config: AlprojectConfig, path: string): void { - validateProjectPaths(registry.projects, path); - const ranges = registry.projects.flatMap((project) => - project.ports === undefined ? [] : [portRange(project, config, path)], - ); - validateNonOverlappingRanges(ranges, path); -} - -function validateProjectPaths(projects: readonly ProjectEntry[], registryFile: string): void { - const seenPaths = new Set(); - for (const project of projects) { - if (!isAbsolute(project.path)) { - throw registryError(registryFile, `project path must be absolute: ${project.path}`); - } - const normalizedPath = normalize(project.path); - if (normalizedPath !== project.path || canonicalizePath(project.path) !== project.path) { - throw registryError(registryFile, `project path must be canonical: ${project.path}`); - } - if (seenPaths.has(project.path)) { - throw registryError(registryFile, `duplicate project path: ${project.path}`); - } - seenPaths.add(project.path); - } -} - -function portRange( - project: ProjectEntry, - config: AlprojectConfig, - registryFile: string, -): PortRange { - const ports = project.ports; - if (ports === undefined) throw new Error("Port allocation is required"); - for (const field of integerPortFields) { - const value = ports[field]; - if (!Number.isSafeInteger(value)) { - throw registryError(registryFile, `${field} must be a safe integer for ${project.path}`); - } - } - - let end: number; - try { - end = allocationEnd(ports); - } catch (error) { - throw registryError(registryFile, errorMessage(error), error); - } - if (ports.allowOutsidePortRange !== true) { - validateConfiguredPortRange(ports, project.path, config, registryFile, end); - } - return { end, path: project.path, start: ports.basePort }; -} - -function validateConfiguredPortRange( - ports: PortAllocation, - projectPath: string, - config: AlprojectConfig, - registryFile: string, - end: number, -): void { - const rootRange = config.root.portRange; - if (ports.basePort < rootRange.first || ports.basePort > rootRange.last) { - throw registryError( - registryFile, - `basePort for ${projectPath} must be within ${rootRange.first}..${rootRange.last}`, - ); - } - if (end > rootRange.last) { - throw registryError( - registryFile, - `port allocation for ${projectPath} exceeds configured range ending at ${rootRange.last}`, - ); - } - const availableRanges = availablePortRanges(config, projectPath); - if (!availableRanges.some((range) => ports.basePort >= range.first && end <= range.last)) { - throw registryError( - registryFile, - `port allocation for ${projectPath} is outside its available parent port range`, - ); - } -} - -function validateNonOverlappingRanges(ranges: PortRange[], registryFile: string): void { - const sortedRanges = ranges.toSorted((left, right) => left.start - right.start); - for (let index = 1; index < sortedRanges.length; ++index) { - const previous = sortedRanges[index - 1]; - const current = sortedRanges[index]; - if (current.start <= previous.end) { - throw registryError( - registryFile, - `port allocations overlap for ${previous.path} and ${current.path}`, - ); - } - } -} - -function registryError(path: string, detail: string, cause?: unknown): AlprojectError { - return new AlprojectError("registry", `Invalid registry ${path}: ${detail}`, { cause }); -} diff --git a/packages/alproject/src/status.ts b/packages/alproject/src/status.ts deleted file mode 100644 index 7cddd835..00000000 --- a/packages/alproject/src/status.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { lstatSync } from "node:fs"; -import { basename, dirname } from "node:path"; - -import type { AlprojectConfig } from "./config.js"; -import { - type ListedPortAllocation, - mainWorktreeGitDirectory, - type ProjectStatus, -} from "./discovery.js"; -import { AlprojectError, errorMessage, isNodeError } from "./errors.js"; -import { canonicalizePath, resolveProjectPath } from "./paths.js"; -import { allocationEnd } from "./ports.js"; -import type { PortAllocation, Registry } from "./registry.js"; - -const URL_WITH_AUTHORITY = /^[A-Za-z][A-Za-z\d+.-]*:\/\//u; - -export interface ProjectDetails { - name: string; - path: string; - ports: ListedPortAllocation | null; - remoteHost: string | null; - status: ProjectStatus; - worktrees: ProjectWorktree[]; -} - -export interface ProjectWorktree { - branch: string | null; - name: string; - path: string; -} - -export function getProjectStatus( - config: AlprojectConfig, - registry: Registry, - inputPath: string, -): ProjectDetails { - const path = resolveProjectPath(inputPath, config.root.path); - const registration = registry.projects.find((candidate) => candidate.path === path); - if (registration !== undefined && isMissingPath(path)) { - return { - name: basename(path), - path, - ports: listedPorts(registration.ports), - remoteHost: null, - status: "missing", - worktrees: [], - }; - } - if (mainWorktreeGitDirectory(path) === undefined) { - throw projectLookupError(path, registration !== undefined); - } - if ( - registration === undefined && - !config.projectParents.some((parent) => parent.path === dirname(path)) - ) { - throw projectLookupError(path, false); - } - return { - name: basename(path), - path, - ports: listedPorts(registration?.ports), - remoteHost: readRemoteHost(path), - status: registration === undefined ? "unregistered" : "registered", - worktrees: readWorktrees(path), - }; -} - -function isMissingPath(path: string): boolean { - try { - lstatSync(path); - return false; - } catch (error) { - if (isNodeError(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) return true; - throw new AlprojectError( - "filesystem", - `Cannot inspect project path ${path}: ${errorMessage(error)}`, - { - cause: error, - }, - ); - } -} - -function listedPorts(ports: PortAllocation | undefined): ListedPortAllocation | null { - return ports === undefined ? null : { ...ports, endPort: allocationEnd(ports) }; -} - -function projectLookupError(path: string, registered: boolean): AlprojectError { - const detail = registered - ? `Registered project is not a Git main worktree: ${path}. Use the canonical main-worktree path.` - : `Project is neither registered nor discovered: ${path}. Use the canonical main-worktree path.`; - return new AlprojectError("filesystem", detail); -} - -function readRemoteHost(projectPath: string): string | null { - const remotes = runGit(projectPath, "remote") - .trimEnd() - .split("\n") - .filter((remote) => remote.length > 0) - .toSorted(); - const orderedRemotes = remotes.includes("origin") - ? ["origin", ...remotes.filter((remote) => remote !== "origin")] - : remotes; - for (const remote of orderedRemotes) { - const host = remoteHost(runGit(projectPath, "remote", "get-url", "--", remote).trim()); - if (host !== null) return host; - } - return null; -} - -function remoteHost(remoteUrl: string): string | null { - return URL_WITH_AUTHORITY.test(remoteUrl) ? urlRemoteHost(remoteUrl) : scpRemoteHost(remoteUrl); -} - -function urlRemoteHost(remoteUrl: string): string | null { - try { - const host = new URL(remoteUrl).hostname; - return host.length === 0 ? null : host; - } catch { - return null; - } -} - -function scpRemoteHost(remoteUrl: string): string | null { - if (/^[A-Za-z]:[\\/]/u.test(remoteUrl)) return null; - const match = /^(?:[^@/:\s]+@)?(\[[^\]]+\]|[^/:\s]+):/u.exec(remoteUrl); - return match?.[1] ?? null; -} - -function readWorktrees(projectPath: string): ProjectWorktree[] { - return runGit(projectPath, "worktree", "list", "--porcelain", "-z") - .split("\0\0") - .filter((record) => record.length > 0) - .map(parseWorktree); -} - -function parseWorktree(record: string): ProjectWorktree { - const fields = record.split("\0"); - const pathField = fields.find((field) => field.startsWith("worktree ")); - if (pathField === undefined) { - throw new AlprojectError("filesystem", "Git returned a worktree record without a path"); - } - const path = canonicalizePath(pathField.slice("worktree ".length)); - const branchField = fields.find((field) => field.startsWith("branch ")); - return { - branch: branchField === undefined ? null : shortBranch(branchField.slice("branch ".length)), - name: basename(path), - path, - }; -} - -function shortBranch(branch: string): string { - const prefix = "refs/heads/"; - return branch.startsWith(prefix) ? branch.slice(prefix.length) : branch; -} - -function runGit(projectPath: string, ...args: string[]): string { - try { - return execFileSync("git", ["-C", projectPath, ...args], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); - } catch (error) { - throw new AlprojectError( - "filesystem", - `Cannot inspect Git project ${projectPath}: ${errorMessage(error)}`, - { cause: error }, - ); - } -} diff --git a/packages/alproject/templates/guide.md b/packages/alproject/templates/guide.md deleted file mode 100644 index 5dc1bdba..00000000 --- a/packages/alproject/templates/guide.md +++ /dev/null @@ -1,69 +0,0 @@ -# alproject guide - -Your project registry. - -## Configuration - -```json -{ - "root": { - "path": "~/projects", - "portRange": { "first": 8000, "last": 9999 } - }, - "projectParents": [ - { "path": "~/projects" }, - { - "path": "~/work/projects", - "portRange": { "first": 9000, "last": 9999 } - } - ] -} -``` - -`root.path` stores the registry and resolves relative command paths. `root.portRange` defines the inclusive global port range. `projectParents` defaults to the root path. - -An optional parent `portRange` reserves part of the global range for that parent's projects. Parent ranges must be inside the global range and cannot overlap. Parents without a dedicated range share the remaining global ports. - -## Commands - -### `alproject list [--json]` - -Print every project with its name, main path, parent, status, workspace names, and optional port allocation. Additional directories are grouped by parent. - -Pass `--json` for structured output consumed by tools and agents. The labelled output quotes every filesystem-derived value so control characters cannot create false fields. - -### `alproject status [--json]` - -Print one discovered or registered project with its canonical main path, status, port allocation, remote host, and Git worktrees. Each worktree includes its name, path, and branch. Missing optional values are explicit. - -Relative paths resolve from `root`; absolute paths are accepted directly. Pass the main-worktree path. Linked-worktree paths and paths that are neither discovered nor registered produce an actionable error. - -Pass `--json` for structured output. Absent port allocations, remote hosts, and detached-worktree branches are `null`. - -### `alproject register ` - -Register an existing Git main worktree that is a direct child of an allowed parent. Relative paths resolve from `root`; absolute paths are accepted directly. - -Use both port options to reserve a range: - -```sh -alproject register --ports-per-workspace --max-workspaces [--base-port [--allow-outside-port-range]] -``` - -The sizing values must be positive integers. `max-workspaces` includes the main worktree. Alproject reserves `ports-per-workspace * max-workspaces` ports and selects the lowest contiguous free block available to the project's parent. It considers registry reservations rather than listening processes. - -Pass `--base-port` to claim an exact range instead. Registration fails when the claimed range is outside the ports available to the project's parent or overlaps an existing reservation. - -Add `--allow-outside-port-range` to permit an explicit allocation outside the configured root and parent ranges. The complete allocation must remain within ports 1 through 65535. Existing reservations remain unavailable. - -To change a registration, unregister it first. - -### `alproject unregister ` - -Remove a registry entry and release its port reservation. A missing project path can still be unregistered. This command does not delete the main worktree, linked worktrees, or any other files. - -### Global options - -- `--guide` — print this guide, followed by `/alproject-guide.md` when it exists; -- `-h`, `--help` — print concise command help; -- `-v`, `--version` — print the installed version. diff --git a/packages/alproject/test/cli.test.ts b/packages/alproject/test/cli.test.ts deleted file mode 100644 index c2cd68b5..00000000 --- a/packages/alproject/test/cli.test.ts +++ /dev/null @@ -1,467 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { main, parseAlprojectArgs, renderProjectList, renderProjectListJson } from "../src/cli.js"; - -let fixtureDir: string | undefined; - -afterEach(() => { - if (fixtureDir !== undefined) rmSync(fixtureDir, { force: true, recursive: true }); - fixtureDir = undefined; -}); - -describe("parseAlprojectArgs", () => { - it("parses every command and global mode", () => { - expect(parse(["list"])).toMatchObject({ command: "list" }); - expect(parse(["list", "--json"])).toMatchObject({ command: "list", json: true }); - expect(parse(["status", "project"])).toMatchObject({ command: "status", path: "project" }); - expect(parse(["status", "project", "--json"])).toMatchObject({ - command: "status", - json: true, - path: "project", - }); - expect(parse(["register", "project"])).toMatchObject({ - command: "register", - path: "project", - }); - expect(parse(["unregister", "project"])).toMatchObject({ - command: "unregister", - path: "project", - }); - expect(parse(["--guide"]).guide).toBe(true); - expect(parse(["-h"]).help).toBe(true); - expect(parse(["--help"]).help).toBe(true); - expect(parse(["-v"]).version).toBe(true); - expect(parse(["--version"]).version).toBe(true); - }); - - it("rejects unknown options and commands", () => { - expect(() => parse(["--unknown"])).toThrow(/Unknown option/); - expect(() => parse(["unknown"])).toThrow("Unknown command: unknown"); - }); - - it("requires exact command path counts", () => { - expect(() => parse(["list", "project"])).toThrow("list does not accept a path"); - expect(() => parse(["register"])).toThrow("register requires exactly one path"); - expect(() => parse(["register", "one", "two"])).toThrow("register requires exactly one path"); - expect(() => parse(["status"])).toThrow("status requires exactly one path"); - expect(() => parse(["status", "one", "two"])).toThrow("status requires exactly one path"); - expect(() => parse(["unregister"])).toThrow("unregister requires exactly one path"); - expect(() => parse(["unregister", "one", "two"])).toThrow( - "unregister requires exactly one path", - ); - }); - - it("requires paired positive integer port options only on register", () => { - expect(() => parse(["--ports-per-workspace", "2", "--max-workspaces", "2"])).toThrow( - /only with register/, - ); - expect(() => parse(["register", "project", "--ports-per-workspace", "2"])).toThrow( - /provided together/, - ); - expect(() => parse(["register", "project", "--base-port", "8000"])).toThrow( - /requires --ports-per-workspace and --max-workspaces/, - ); - expect(() => - parse(["register", "project", "--ports-per-workspace", "0", "--max-workspaces", "2"]), - ).toThrow(/positive integer/); - expect(() => parse(["list", "--ports-per-workspace", "2", "--max-workspaces", "2"])).toThrow( - /only with register/, - ); - expect( - parse([ - "register", - "project", - "--ports-per-workspace", - "5", - "--max-workspaces", - "3", - "--base-port", - "8100", - "--allow-outside-port-range", - ]), - ).toMatchObject({ - allowOutsidePortRange: true, - basePort: 8100, - maxWorkspaces: 3, - portsPerWorkspace: 5, - }); - expect(() => parse(["register", "project", "--allow-outside-port-range"])).toThrow( - /requires --base-port/, - ); - expect(() => parse(["list", "--allow-outside-port-range"])).toThrow(/only with register/); - }); - - it("rejects invalid global-mode combinations", () => { - expect(() => parse(["--guide", "--help"])).toThrow(/mutually exclusive/); - expect(() => parse(["list", "--guide"])).toThrow(/does not accept a command/); - expect(() => parse(["--guide", "--ports-per-workspace", "2"])).toThrow( - /does not accept command options/, - ); - expect(() => parse(["register", "project", "--json"])).toThrow(/only with list or status/); - expect(() => parse(["--json"])).toThrow(/only with list or status/); - }); -}); - -describe("main", () => { - it("prints help for a bare invocation without configuration", async () => { - const stdout = makeSink(); - expect(await run([], { stdout })).toBe(0); - expect(stdout.text()).toContain("alproject register "); - expect(stdout.text()).toContain("--base-port "); - expect(stdout.text()).toContain("--allow-outside-port-range"); - expect(stdout.text()).toContain("alproject status [--json]"); - expect(stdout.text()).toContain("alproject --guide"); - }); - - it("prints help aliases and version aliases without configuration", async () => { - for (const option of ["-h", "--help"]) { - const stdout = makeSink(); - expect(await run([option], { stdout })).toBe(0); - expect(stdout.text()).toContain("Usage:"); - } - for (const option of ["-v", "--version"]) { - const stdout = makeSink(); - expect(await run([option], { stdout })).toBe(0); - expect(stdout.text()).toBe(`${packageVersion()}\n`); - } - }); - - it("prints the guide with optional custom content", async () => { - const fixture = makeFixture(); - writeFileSync(join(fixture.root, "alproject-guide.md"), "# Local rules\n\nPreserve me.\n"); - const stdout = makeSink(); - - expect(await run(["--guide"], { home: fixture.home, stdout })).toBe(0); - expect(stdout.text()).toContain("# alproject guide"); - expect(stdout.text().endsWith("\n\n# Local rules\n\nPreserve me.\n")).toBe(true); - }); - - it("prints the generic guide without configuration", async () => { - const home = makeEmptyHome(); - const stdout = makeSink(); - - expect(await run(["--guide"], { home, stdout })).toBe(0); - expect(stdout.text()).toContain("# alproject guide"); - expect(stdout.text()).toContain("## Commands"); - }); - - it("writes custom-guide read failures only to stderr", async () => { - const fixture = makeFixture(); - mkdirSync(join(fixture.root, "alproject-guide.md")); - const stdout = makeSink(); - const stderr = makeSink(); - - expect(await run(["--guide"], { home: fixture.home, stderr, stdout })).toBe(1); - expect(stdout.text()).toBe(""); - expect(stderr.text()).toMatch(/Cannot read custom guide/); - }); - - it("writes argument failures only to stderr", async () => { - const stdout = makeSink(); - const stderr = makeSink(); - expect(await run(["--unknown"], { stderr, stdout })).toBe(1); - expect(stdout.text()).toBe(""); - expect(stderr.text()).toMatch(/Unknown option/); - }); - - it("keeps control characters in errors on one physical line", async () => { - const stderr = makeSink(); - - expect(await run(["list"], { home: "/missing\nhome", stderr })).toBe(1); - expect(stderr.text()).toContain("/missing\\nhome"); - expect(stderr.text().split("\n")).toHaveLength(2); - }); - - it("registers, lists, and unregisters through the package APIs", async () => { - const fixture = makeFixture(); - const project = makeRepository(fixture.root, "project"); - const registerOutput = makeSink(); - expect( - await run( - [ - "register", - "project", - "--ports-per-workspace", - "5", - "--max-workspaces", - "2", - "--base-port", - "8010", - ], - { home: fixture.home, stdout: registerOutput }, - ), - ).toBe(0); - expect(registerOutput.text()).toBe( - `Registered project: ${JSON.stringify(project)}\nBase port: 8010\nPort range: 8010..8019\n`, - ); - - const listOutput = makeSink(); - expect(await run(["list"], { home: fixture.home, stdout: listOutput })).toBe(0); - expect(listOutput.text()).toContain('- Name: "project"'); - expect(listOutput.text()).toContain(` Main path: ${JSON.stringify(project)}`); - expect(listOutput.text()).toContain(` Parent: ${JSON.stringify(fixture.root)}`); - expect(listOutput.text()).toContain(" Status: registered"); - expect(listOutput.text()).toContain(" Workspaces: (none)"); - expect(listOutput.text()).toContain(" Base port: 8010"); - expect(listOutput.text()).toContain(" Port range: 8010..8019"); - - const unregisterOutput = makeSink(); - expect( - await run(["unregister", project], { - home: fixture.home, - stdout: unregisterOutput, - }), - ).toBe(0); - expect(unregisterOutput.text()).toBe(`Unregistered project: ${JSON.stringify(project)}\n`); - - const jsonOutput = makeSink(); - expect(await run(["list", "--json"], { home: fixture.home, stdout: jsonOutput })).toBe(0); - expect(JSON.parse(jsonOutput.text()).projects[0]).toMatchObject({ - name: "project", - path: project, - status: "unregistered", - }); - }); - - it("registers an explicit allocation outside configured ranges", async () => { - const fixture = makeFixture(8000, 8009); - const project = makeRepository(fixture.root, "external"); - const stdout = makeSink(); - - expect( - await run( - [ - "register", - "external", - "--ports-per-workspace", - "5", - "--max-workspaces", - "2", - "--base-port", - "9000", - "--allow-outside-port-range", - ], - { home: fixture.home, stdout }, - ), - ).toBe(0); - expect(stdout.text()).toContain("Port range: 9000..9009"); - - const jsonOutput = makeSink(); - expect( - await run(["status", project, "--json"], { home: fixture.home, stdout: jsonOutput }), - ).toBe(0); - expect(JSON.parse(jsonOutput.text()).ports).toMatchObject({ - allowOutsidePortRange: true, - basePort: 9000, - endPort: 9009, - }); - }); - - it("prints human and JSON project status", async () => { - const fixture = makeFixture(); - const project = makeRepository(fixture.root, "project"); - expect(await run(["register", "project"], { home: fixture.home, stdout: makeSink() })).toBe(0); - - const labelledOutput = makeSink(); - expect(await run(["status", "project"], { home: fixture.home, stdout: labelledOutput })).toBe( - 0, - ); - expect(labelledOutput.text()).toContain(` Main path: ${JSON.stringify(project)}`); - expect(labelledOutput.text()).toContain(" Status: registered"); - expect(labelledOutput.text()).toContain(" Remote host: (none)"); - expect(labelledOutput.text()).toContain(" Port allocation: (none)"); - expect(labelledOutput.text()).toContain(' - Name: "project"'); - expect(labelledOutput.text()).toContain(' Branch: "main"'); - - const jsonOutput = makeSink(); - expect( - await run(["status", project, "--json"], { home: fixture.home, stdout: jsonOutput }), - ).toBe(0); - expect(JSON.parse(jsonOutput.text())).toMatchObject({ - name: "project", - path: project, - ports: null, - remoteHost: null, - status: "registered", - worktrees: [{ branch: "main", name: "project", path: project }], - }); - }); - - it("writes an actionable status lookup failure only to stderr", async () => { - const fixture = makeFixture(); - mkdirSync(join(fixture.root, "directory")); - const stdout = makeSink(); - const stderr = makeSink(); - - expect(await run(["status", "directory"], { home: fixture.home, stderr, stdout })).toBe(1); - expect(stdout.text()).toBe(""); - expect(stderr.text()).toMatch(/neither registered nor discovered.*main-worktree path/); - }); - - it("reports duplicate and exhaustion errors only to stderr", async () => { - const fixture = makeFixture(8000, 8000); - makeRepository(fixture.root, "one"); - makeRepository(fixture.root, "two"); - expect( - await run(["register", "one", "--ports-per-workspace", "1", "--max-workspaces", "1"], { - home: fixture.home, - stdout: makeSink(), - }), - ).toBe(0); - - const duplicateStdout = makeSink(); - const duplicateStderr = makeSink(); - expect( - await run(["register", "one"], { - home: fixture.home, - stderr: duplicateStderr, - stdout: duplicateStdout, - }), - ).toBe(1); - expect(duplicateStdout.text()).toBe(""); - expect(duplicateStderr.text()).toMatch(/already registered/); - - const exhaustionStdout = makeSink(); - const exhaustionStderr = makeSink(); - expect( - await run(["register", "two", "--ports-per-workspace", "1", "--max-workspaces", "1"], { - home: fixture.home, - stderr: exhaustionStderr, - stdout: exhaustionStdout, - }), - ).toBe(1); - expect(exhaustionStdout.text()).toBe(""); - expect(exhaustionStderr.text()).toMatch(/No contiguous block/); - }); - - it("unregisters a missing path and preserves filesystem content", async () => { - const fixture = makeFixture(); - const project = makeRepository(fixture.root, "project"); - expect(await run(["register", project], { home: fixture.home, stdout: makeSink() })).toBe(0); - const moved = `${project}-moved`; - renameSync(project, moved); - - expect(await run(["unregister", project], { home: fixture.home, stdout: makeSink() })).toBe(0); - expect(readFileSync(join(moved, "README.md"), "utf8")).toBe("project\n"); - }); -}); - -describe("renderProjectList", () => { - it("renders complete labelled records and grouped directories in input order", () => { - const output = renderProjectList({ - additionalDirectories: [{ directories: ["a-extra", "z-extra"], parent: "/parents/b" }], - projects: [ - { - name: "alpha", - parent: "/parents/a", - path: "/parents/a/alpha", - status: "unregistered", - workspaces: ["a-workspace", "z-workspace"], - }, - { - name: "gone", - parent: "/parents/b", - path: "/parents/b/gone", - ports: { basePort: 8010, endPort: 8019, maxWorkspaces: 2, portsPerWorkspace: 5 }, - status: "missing", - workspaces: [], - }, - ], - }); - - expect(output).toContain("Status: unregistered on filesystem"); - expect(output).toContain('Workspaces: "a-workspace", "z-workspace"'); - expect(output).toContain("Status: registered but missing from filesystem"); - expect(output).toContain("Base port: 8010"); - expect(output.indexOf("a-extra")).toBeLessThan(output.indexOf("z-extra")); - }); - - it("escapes control characters in labelled output and preserves them in JSON", () => { - const list = { - additionalDirectories: [], - projects: [ - { - name: "evil\n Main path: /injected", - parent: "/parents/a", - path: "/parents/a/evil\nname", - status: "registered" as const, - workspaces: [], - }, - ], - }; - - const labelled = renderProjectList(list); - expect(labelled).toContain('Name: "evil\\n Main path: /injected"'); - expect(labelled.match(/\n {2}Main path:/gu)).toHaveLength(1); - expect(JSON.parse(renderProjectListJson(list)).projects[0].name).toBe(list.projects[0].name); - }); -}); - -interface Fixture { - home: string; - root: string; -} - -function makeFixture(firstPort = 8000, lastPort = 8999): Fixture { - fixtureDir = mkdtempSync(join(tmpdir(), "alproject-cli-")); - const home = join(fixtureDir, "home"); - const root = join(fixtureDir, "projects"); - mkdirSync(home); - mkdirSync(root); - writeFileSync( - join(home, ".alproject.json"), - `${JSON.stringify({ root: { path: root, portRange: { first: firstPort, last: lastPort } } })}\n`, - ); - return { home, root }; -} - -function makeEmptyHome(): string { - fixtureDir = mkdtempSync(join(tmpdir(), "alproject-cli-")); - const home = join(fixtureDir, "home"); - mkdirSync(home); - return home; -} - -function makeRepository(parent: string, name: string): string { - const project = join(parent, name); - execFileSync("git", ["init", "--quiet", "--initial-branch=main", project]); - writeFileSync(join(project, "README.md"), `${name}\n`); - return project; -} - -function parse(args: string[]) { - return parseAlprojectArgs(["node", "alproject", ...args]); -} - -function makeSink(): { text(): string; write(value: string): void } { - let output = ""; - return { - text: () => output, - write(value) { - output += value; - }, - }; -} - -function run( - args: string[], - options: { - home?: string; - stderr?: ReturnType; - stdout?: ReturnType; - }, -) { - return main({ argv: ["node", "alproject", ...args], ...options }); -} - -function packageVersion(): string { - const packageFile = JSON.parse( - readFileSync(new URL("../package.json", import.meta.url), "utf8"), - ) as { version: string }; - return packageFile.version; -} diff --git a/packages/alproject/test/config.test.ts b/packages/alproject/test/config.test.ts deleted file mode 100644 index e8f648e8..00000000 --- a/packages/alproject/test/config.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { - availablePortRanges, - CONFIG_FILENAME, - readConfig, - readConfigIfPresent, -} from "../src/config.js"; -import type { AlprojectError } from "../src/errors.js"; -import { canonicalizePath, expandHomePath, normalizeAbsolutePath } from "../src/paths.js"; - -let fixtureDir: string | undefined; - -afterEach(() => { - if (fixtureDir !== undefined) rmSync(fixtureDir, { force: true, recursive: true }); - fixtureDir = undefined; -}); - -describe("path resolution", () => { - it("expands only a leading home segment", () => { - expect(expandHomePath("~/projects", "/home/tester")).toBe("/home/tester/projects"); - expect(expandHomePath("~other/projects", "/home/tester")).toBe("~other/projects"); - }); - - it("requires absolute paths and normalizes them lexically", () => { - expect(normalizeAbsolutePath("/srv/../projects", "/home/tester")).toBe("/projects"); - expect(() => normalizeAbsolutePath("projects", "/home/tester")).toThrow("must be absolute"); - }); - - it("canonicalizes existing paths and preserves a normalized missing path", () => { - const fixture = makeFixture(); - const real = join(fixture.home, "real"); - const link = join(fixture.home, "link"); - mkdirSync(real); - symlinkSync(real, link); - - expect(canonicalizePath(link)).toBe(realpathSync(real)); - expect(canonicalizePath(join(fixture.home, "missing", "..", "gone"))).toBe( - join(fixture.home, "gone"), - ); - }); -}); - -describe("readConfig", () => { - it("loads an omitted projectParents field as the canonical root", () => { - const fixture = makeFixture(); - writeConfig(fixture, configuredRoot("~/root", 8000, 9999)); - - expect(readConfig(fixture.home)).toEqual({ - configPath: join(fixture.home, CONFIG_FILENAME), - projectParents: [{ path: realpathSync(fixture.root) }], - root: { - path: realpathSync(fixture.root), - portRange: { first: 8000, last: 9999 }, - }, - }); - }); - - it("uses explicit project parents without adding root", () => { - const fixture = makeFixture(); - const otherParent = join(fixture.home, "other"); - mkdirSync(otherParent); - writeConfig(fixture, { - projectParents: [{ path: "~/other" }], - ...configuredRoot("~/root", 1, 65535), - }); - - const config = readConfig(fixture.home); - expect(config.projectParents).toEqual([{ path: realpathSync(otherParent) }]); - expect(config.projectParents.map((parent) => parent.path)).not.toContain(config.root.path); - }); - - it("rejects canonically duplicate explicit parents", () => { - const fixture = makeFixture(); - const linkedRoot = join(fixture.home, "linked-root"); - symlinkSync(fixture.root, linkedRoot); - writeConfig(fixture, { - projectParents: [{ path: "~/root" }, { path: "~/linked-root" }, { path: "~/root/../root" }], - ...configuredRoot("~/linked-root"), - }); - - expect(() => readConfig(fixture.home)).toThrow(/duplicate project parent/); - }); - - it("reserves non-overlapping parent port ranges from the shared root range", () => { - const fixture = makeFixture(); - const dedicated = join(fixture.home, "dedicated"); - const shared = join(fixture.home, "shared"); - mkdirSync(dedicated); - mkdirSync(shared); - writeConfig(fixture, { - projectParents: [ - { path: dedicated, portRange: { first: 8200, last: 8299 } }, - { path: shared }, - ], - ...configuredRoot("~/root", 8000, 8999), - }); - - const config = readConfig(fixture.home); - expect(availablePortRanges(config, join(dedicated, "project"))).toEqual([ - { first: 8200, last: 8299 }, - ]); - expect(availablePortRanges(config, join(shared, "project"))).toEqual([ - { first: 8000, last: 8199 }, - { first: 8300, last: 8999 }, - ]); - }); - - it("reports a missing configuration with its path", () => { - const fixture = makeFixture(); - expect(() => readConfig(fixture.home)).toThrowError( - expect.objectContaining>({ - code: "configuration", - message: expect.stringContaining(join(fixture.home, CONFIG_FILENAME)), - }), - ); - }); - - it("rejects malformed JSON with its configuration path", () => { - const fixture = makeFixture(); - writeFileSync(join(fixture.home, CONFIG_FILENAME), "{"); - expect(() => readConfig(fixture.home)).toThrow(/Invalid JSON.*\.alproject\.json/); - }); - - it.each([ - ["missing root", {}], - ["string root", { root: "~/root" }], - ["unknown field", { extra: true, ...configuredRoot("~/root") }], - ["empty root path", configuredRoot("")], - ["non-string root path", configuredRoot(42)], - ["empty parents", { projectParents: [], ...configuredRoot("~/root") }], - ["empty parent path", { projectParents: [{ path: "" }], ...configuredRoot("~/root") }], - ["string parent", { projectParents: ["~/root"], ...configuredRoot("~/root") }], - ["non-array parents", { projectParents: "~/root", ...configuredRoot("~/root") }], - [ - "partial parent range", - { - projectParents: [{ path: "~/root", portRange: { first: 8000 } }], - ...configuredRoot("~/root"), - }, - ], - ["missing first port", configuredRange({ last: 9000 })], - ["missing last port", configuredRange({ first: 8000 })], - ["non-number first port", configuredRange({ first: "8000", last: 9000 })], - ["non-integer first port", configuredRange({ first: 8000.5, last: 9000 })], - ["first port below range", configuredRange({ first: 0, last: 9000 })], - ["non-integer last port", configuredRange({ first: 8000, last: 9000.5 })], - ["last port above range", configuredRange({ first: 8000, last: 65536 })], - ["reversed ports", configuredRange({ first: 9000, last: 8000 })], - ])("rejects %s", (_label, value) => { - const fixture = makeFixture(); - writeConfig(fixture, value); - expect(() => readConfig(fixture.home)).toThrow(/Invalid configuration.*\.alproject\.json/); - }); - - it("rejects relative configured paths", () => { - const fixture = makeFixture(); - writeConfig(fixture, configuredRoot("root")); - expect(() => readConfig(fixture.home)).toThrow(/root\.path: Path must be absolute/); - }); - - it.each(["root", "parent"])("rejects a missing %s directory", (missingField) => { - const fixture = makeFixture(); - const value = - missingField === "root" - ? configuredRoot("~/missing") - : { - projectParents: [{ path: "~/missing" }], - ...configuredRoot("~/root"), - }; - writeConfig(fixture, value); - expect(() => readConfig(fixture.home)).toThrow(/directory is missing or inaccessible/); - }); - - it("rejects a configured file in place of a directory", () => { - const fixture = makeFixture(); - const filePath = join(fixture.home, "file"); - writeFileSync(filePath, "not a directory"); - writeConfig(fixture, configuredRoot(filePath)); - expect(() => readConfig(fixture.home)).toThrow(/path is not a directory/); - }); - - it.each([ - ["outside root", { first: 7000, last: 8000 }, { first: 8000, last: 9000 }], - ["reversed", { first: 8500, last: 8400 }, { first: 8000, last: 9000 }], - ])("rejects a parent port range %s", (_label, portRange, rootRange) => { - const fixture = makeFixture(); - writeConfig(fixture, { - projectParents: [{ path: "~/root", portRange }], - root: { path: "~/root", portRange: rootRange }, - }); - expect(() => readConfig(fixture.home)).toThrow(/Invalid configuration/); - }); - - it("rejects overlapping parent port ranges", () => { - const fixture = makeFixture(); - const other = join(fixture.home, "other"); - mkdirSync(other); - writeConfig(fixture, { - projectParents: [ - { path: "~/root", portRange: { first: 8100, last: 8200 } }, - { path: other, portRange: { first: 8200, last: 8300 } }, - ], - ...configuredRoot("~/root"), - }); - expect(() => readConfig(fixture.home)).toThrow(/port ranges overlap/); - }); -}); - -describe("readConfigIfPresent", () => { - it("returns no configuration when the file is absent", () => { - const fixture = makeFixture(); - - expect(readConfigIfPresent(fixture.home)).toBeUndefined(); - }); - - it("still rejects an invalid configuration file", () => { - const fixture = makeFixture(); - writeFileSync(join(fixture.home, CONFIG_FILENAME), "{"); - - expect(() => readConfigIfPresent(fixture.home)).toThrow(/Invalid JSON.*\.alproject\.json/); - }); -}); - -interface Fixture { - home: string; - root: string; -} - -function makeFixture(): Fixture { - fixtureDir = mkdtempSync(join(tmpdir(), "alproject-config-")); - const home = join(fixtureDir, "home"); - const root = join(home, "root"); - mkdirSync(root, { recursive: true }); - return { home, root }; -} - -function writeConfig(fixture: Fixture, value: object): void { - writeFileSync(join(fixture.home, CONFIG_FILENAME), JSON.stringify(value)); -} - -function configuredRoot(path: unknown, first = 8000, last = 9000): object { - return { root: { path, portRange: { first, last } } }; -} - -function configuredRange(portRange: object): object { - return { root: { path: "~/root", portRange } }; -} diff --git a/packages/alproject/test/discovery.test.ts b/packages/alproject/test/discovery.test.ts deleted file mode 100644 index 1b0767c1..00000000 --- a/packages/alproject/test/discovery.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { - mkdirSync, - mkdtempSync, - readFileSync, - realpathSync, - renameSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { basename, dirname, join } from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import type { AlprojectConfig } from "../src/config.js"; -import { buildProjectList, discoverProjects } from "../src/discovery.js"; -import type { Registry } from "../src/registry.js"; - -let fixtureDir: string | undefined; - -afterEach(() => { - if (fixtureDir !== undefined) rmSync(fixtureDir, { force: true, recursive: true }); - fixtureDir = undefined; -}); - -describe("discoverProjects", () => { - it("discovers only direct-child main worktrees", () => { - const fixture = makeFixture(); - const direct = makeRepository(fixture.parentA, "direct"); - const container = join(fixture.parentA, "container"); - mkdirSync(container); - makeRepository(container, "nested"); - - expect(discoverProjects(fixture.config)).toEqual({ - additionalDirectories: [{ directories: ["container"], parent: fixture.parentA }], - projects: [projectRecord(direct)], - }); - }); - - it("associates Git-created linked worktrees through both metadata sides", () => { - const fixture = makeFixture(); - const main = makeRepository(fixture.parentA, "project"); - addWorktree(main, join(fixture.parentA, "z-workspace"), "z-branch"); - addWorktree(main, join(fixture.parentA, "a-workspace"), "a-branch"); - - expect(discoverProjects(fixture.config)).toEqual({ - additionalDirectories: [], - projects: [projectRecord(main, ["a-workspace", "z-workspace"])], - }); - }); - - it("classifies malformed and one-sided Git relationships as additional directories", () => { - const fixture = makeFixture(); - const main = makeRepository(fixture.parentA, "project"); - const malformed = makeDirectory(fixture.parentA, "malformed"); - writeFileSync(join(malformed, ".git"), "not git metadata\n"); - const missingBacklink = join(fixture.parentA, "missing-backlink"); - addWorktree(main, missingBacklink, "missing-backlink"); - const metadataDirectory = gitMetadataDirectory(missingBacklink); - writeFileSync(join(metadataDirectory, "gitdir"), join(fixture.parentA, "elsewhere", ".git")); - - expect(discoverProjects(fixture.config)).toEqual({ - additionalDirectories: [ - { - directories: ["malformed", "missing-backlink"], - parent: fixture.parentA, - }, - ], - projects: [projectRecord(main)], - }); - }); - - it("rejects metadata outside the main repository worktrees area", () => { - const fixture = makeFixture(); - const main = makeRepository(fixture.parentA, "project"); - const candidate = makeDirectory(fixture.parentA, "candidate"); - const fakeMetadata = join(main, ".git", "fake-metadata"); - mkdirSync(fakeMetadata); - writeFileSync(join(candidate, ".git"), `gitdir: ${fakeMetadata}\n`); - writeFileSync(join(fakeMetadata, "commondir"), "..\n"); - writeFileSync(join(fakeMetadata, "gitdir"), join(candidate, ".git")); - - expect(discoverProjects(fixture.config).additionalDirectories).toEqual([ - { directories: ["candidate"], parent: fixture.parentA }, - ]); - }); - - it("leaves a valid worktree additional when its main is outside allowed parents", () => { - const fixture = makeFixture(); - const externalParent = join(fixture.root, "external"); - mkdirSync(externalParent); - const externalMain = makeRepository(externalParent, "project"); - addWorktree(externalMain, join(fixture.parentA, "external-workspace"), "external-branch"); - - expect(discoverProjects(fixture.config)).toEqual({ - additionalDirectories: [{ directories: ["external-workspace"], parent: fixture.parentA }], - projects: [], - }); - }); - - it("keeps duplicate project names distinct and orders every collection", () => { - const fixture = makeFixture(["parentB", "parentA"]); - const projectB = makeRepository(fixture.parentB, "same"); - const projectA = makeRepository(fixture.parentA, "same"); - makeDirectory(fixture.parentB, "z-extra"); - makeDirectory(fixture.parentB, "a-extra"); - makeDirectory(fixture.parentA, "middle"); - - expect(discoverProjects(fixture.config)).toEqual({ - additionalDirectories: [ - { directories: ["middle"], parent: fixture.parentA }, - { directories: ["a-extra", "z-extra"], parent: fixture.parentB }, - ], - projects: [projectRecord(projectA), projectRecord(projectB)], - }); - }); -}); - -describe("buildProjectList", () => { - it("merges registered, unregistered, and missing projects without writing", () => { - const fixture = makeFixture(); - const registered = makeRepository(fixture.parentA, "registered"); - const unregistered = makeRepository(fixture.parentA, "unregistered"); - const missing = join(fixture.parentB, "missing"); - makeDirectory(fixture.parentB, "additional"); - const registry: Registry = { - projects: [ - { - path: registered, - ports: { basePort: 8000, maxWorkspaces: 2, portsPerWorkspace: 10 }, - }, - { path: missing }, - ], - schemaVersion: 2, - }; - const registryPath = join(fixture.root, "alproject-registry.json"); - writeFileSync(registryPath, `${JSON.stringify(registry, undefined, 2)}\n`); - const before = readFileSync(registryPath, "utf8"); - - expect(buildProjectList(fixture.config, registry)).toEqual({ - additionalDirectories: [{ directories: ["additional"], parent: fixture.parentB }], - projects: [ - { - ...projectRecord(registered), - ports: { - basePort: 8000, - endPort: 8019, - maxWorkspaces: 2, - portsPerWorkspace: 10, - }, - status: "registered", - }, - { ...projectRecord(unregistered), status: "unregistered" }, - { - name: "missing", - parent: fixture.parentB, - path: missing, - status: "missing", - workspaces: [], - }, - ], - }); - expect(readFileSync(registryPath, "utf8")).toBe(before); - }); - - it("reports a moved project as one missing and one unregistered record", () => { - const fixture = makeFixture(); - const original = makeRepository(fixture.parentA, "original"); - const registry: Registry = { projects: [{ path: original }], schemaVersion: 2 }; - const moved = join(fixture.parentA, "moved"); - renameSync(original, moved); - - expect(buildProjectList(fixture.config, registry).projects).toEqual([ - { ...projectRecord(moved), status: "unregistered" }, - { - name: "original", - parent: fixture.parentA, - path: original, - status: "missing", - workspaces: [], - }, - ]); - }); -}); - -interface Fixture { - config: AlprojectConfig; - parentA: string; - parentB: string; - root: string; -} - -function makeFixture(parentOrder = ["parentA", "parentB"]): Fixture { - fixtureDir = mkdtempSync(join(tmpdir(), "alproject-discovery-")); - const root = join(fixtureDir, "root"); - const parentA = join(fixtureDir, "parentA"); - const parentB = join(fixtureDir, "parentB"); - mkdirSync(root); - mkdirSync(parentA); - mkdirSync(parentB); - const parents = { parentA, parentB }; - return { - config: { - configPath: join(fixtureDir, ".alproject.json"), - projectParents: parentOrder.map((name) => ({ - path: parents[name as keyof typeof parents], - })), - root: { path: root, portRange: { first: 8000, last: 9000 } }, - }, - parentA, - parentB, - root, - }; -} - -function makeRepository(parent: string, name: string): string { - const repository = join(parent, name); - execGit(parent, "init", "--quiet", "--initial-branch=main", repository); - execGit(repository, "config", "user.name", "Test"); - execGit(repository, "config", "user.email", "test@example.com"); - writeFileSync(join(repository, "README.md"), `${name}\n`); - execGit(repository, "add", "README.md"); - execGit(repository, "commit", "--quiet", "-m", "initial"); - return realpathSync(repository); -} - -function addWorktree(main: string, worktree: string, branch: string): void { - execGit(main, "worktree", "add", "--quiet", "-b", branch, worktree); -} - -function gitMetadataDirectory(worktree: string): string { - const gitFile = readFileSync(join(worktree, ".git"), "utf8"); - return gitFile.replace(/^gitdir:\s*/u, "").trim(); -} - -function makeDirectory(parent: string, name: string): string { - const directory = join(parent, name); - mkdirSync(directory); - return directory; -} - -function projectRecord(path: string, workspaces: string[] = []) { - return { - name: basename(path), - parent: dirname(path), - path, - workspaces, - }; -} - -function execGit(cwd: string, ...args: string[]): string { - return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8" }).trim(); -} diff --git a/packages/alproject/test/fixtures/hold-registry-lock.mjs b/packages/alproject/test/fixtures/hold-registry-lock.mjs deleted file mode 100644 index a09a17f8..00000000 --- a/packages/alproject/test/fixtures/hold-registry-lock.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -const registryPath = process.argv[2]; -if (registryPath === undefined) throw new Error("Registry path is required"); -const lockPath = `${registryPath}.lock`; -const claimPath = join(lockPath, `claim-${process.pid}-child.json`); -mkdirSync(lockPath, { mode: 0o700 }); -writeFileSync(claimPath, `${JSON.stringify({ pid: process.pid, ticket: 1, token: "child" })}\n`, { - mode: 0o600, -}); -process.send?.("locked"); -process.once("message", () => { - rmSync(lockPath, { recursive: true }); - process.exit(0); -}); diff --git a/packages/alproject/test/guide.test.ts b/packages/alproject/test/guide.test.ts deleted file mode 100644 index 180ccb65..00000000 --- a/packages/alproject/test/guide.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { renderGuide } from "../src/guide.js"; - -let fixtureDir: string | undefined; - -afterEach(() => { - if (fixtureDir !== undefined) { - chmodSync(fixtureDir, 0o700); - rmSync(fixtureDir, { force: true, recursive: true }); - } - fixtureDir = undefined; -}); - -describe("renderGuide", () => { - it("renders the complete generic guide without template markers", () => { - const root = makeRoot(); - const guide = renderGuide(root); - - expect(guide).toMatch(/^# alproject guide\n/); - for (const content of [ - "alproject list", - "alproject status ", - "--json", - "alproject register ", - "alproject unregister ", - "--ports-per-workspace", - "--base-port", - "--allow-outside-port-range", - "does not delete", - "projectParents", - "remote host", - "root.portRange", - ]) { - expect(guide).toContain(content); - } - expect(guide).not.toMatch(/\{\{[^}]+\}\}/u); - }); - - it("appends custom Markdown verbatim after an empty line", () => { - const root = makeRoot(); - const custom = "# Team procedure\n\nKeep {{consumer-marker}} and trailing space. \n"; - writeFileSync(join(root, "alproject-guide.md"), custom); - - const guide = renderGuide(root); - - expect(guide.endsWith(`\n\n${custom}`)).toBe(true); - expect(guide).not.toContain("\n\n\n"); - }); - - it("treats unreadable custom-guide content as an error", () => { - const root = makeRoot(); - mkdirSync(join(root, "alproject-guide.md")); - - expect(() => renderGuide(root)).toThrow(/Cannot read custom guide/); - }); -}); - -function makeRoot(): string { - fixtureDir = mkdtempSync(join(tmpdir(), "alproject-guide-")); - return fixtureDir; -} diff --git a/packages/alproject/test/mutations.test.ts b/packages/alproject/test/mutations.test.ts deleted file mode 100644 index a8f0039d..00000000 --- a/packages/alproject/test/mutations.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { - existsSync, - fsyncSync, - mkdirSync, - mkdtempSync, - openSync, - readFileSync, - readdirSync, - renameSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import type { AlprojectConfig } from "../src/config.js"; -import { registerProject, unregisterProject } from "../src/mutations.js"; -import { readRegistry, registryPath } from "../src/registry.js"; - -let fixtureDir: string | undefined; - -afterEach(() => { - if (fixtureDir !== undefined) rmSync(fixtureDir, { force: true, recursive: true }); - fixtureDir = undefined; -}); - -describe("registerProject", () => { - it("registers root-relative and absolute main worktrees without ports", async () => { - const fixture = makeFixture(); - const relative = makeProject(fixture.root, "relative"); - const absolute = makeProject(fixture.parent, "absolute"); - - await expect(registerProject(fixture.config, "relative")).resolves.toEqual({ path: relative }); - await expect(registerProject(fixture.config, absolute)).resolves.toEqual({ path: absolute }); - expect(readRegistry(fixture.config).projects).toEqual([{ path: relative }, { path: absolute }]); - }); - - it("requires an existing direct-child main worktree under an allowed parent", async () => { - const fixture = makeFixture(); - const nestedParent = join(fixture.root, "container"); - mkdirSync(nestedParent); - const nested = makeProject(nestedParent, "nested"); - const linked = join(fixture.root, "linked"); - mkdirSync(linked); - writeFileSync(join(linked, ".git"), "gitdir: elsewhere\n"); - - await expect(registerProject(fixture.config, "missing")).rejects.toThrow(/existing Git/); - await expect(registerProject(fixture.config, nested)).rejects.toThrow(/direct child/); - await expect(registerProject(fixture.config, linked)).rejects.toThrow(/existing Git/); - expect(existsSync(registryPath(fixture.config))).toBe(false); - }); - - it("rejects duplicate registration and preserves the registry", async () => { - const fixture = makeFixture(); - const project = makeProject(fixture.root, "project"); - await registerProject(fixture.config, project); - const before = readFileSync(registryPath(fixture.config), "utf8"); - - await expect(registerProject(fixture.config, project)).rejects.toThrow(/already registered/); - expect(readFileSync(registryPath(fixture.config), "utf8")).toBe(before); - }); - - it("requires both positive safe-integer port options before locking", async () => { - const fixture = makeFixture(); - const project = makeProject(fixture.root, "project"); - - await expect( - registerProject(fixture.config, project, { portsPerWorkspace: 2 }), - ).rejects.toThrow(/provided together/); - await expect( - registerProject(fixture.config, project, { maxWorkspaces: 2, portsPerWorkspace: 0 }), - ).rejects.toThrow(/positive integer/); - await expect(registerProject(fixture.config, project, { basePort: 8000 })).rejects.toThrow( - /requires portsPerWorkspace and maxWorkspaces/, - ); - await expect( - registerProject(fixture.config, project, { allowOutsidePortRange: true }), - ).rejects.toThrow(/requires basePort/); - expect(existsSync(registryPath(fixture.config))).toBe(false); - }); - - it("allocates the lowest range, reuses a released hole, and reports inclusive ends", async () => { - const fixture = makeFixture(8000, 8029); - const a = makeProject(fixture.root, "a"); - const b = makeProject(fixture.root, "b"); - const c = makeProject(fixture.root, "c"); - const options = { maxWorkspaces: 2, portsPerWorkspace: 5 }; - - expect((await registerProject(fixture.config, a, options)).ports).toEqual({ - basePort: 8000, - endPort: 8009, - ...options, - }); - expect((await registerProject(fixture.config, b, options)).ports?.basePort).toBe(8010); - await unregisterProject(fixture.config, a); - expect((await registerProject(fixture.config, c, options)).ports?.basePort).toBe(8000); - }); - - it("claims an exact available base port and preserves the registry on conflicts", async () => { - const fixture = makeFixture(8000, 8029); - const a = makeProject(fixture.root, "a"); - const b = makeProject(fixture.root, "b"); - const options = { basePort: 8010, maxWorkspaces: 2, portsPerWorkspace: 5 }; - - expect((await registerProject(fixture.config, a, options)).ports).toEqual({ - basePort: 8010, - endPort: 8019, - maxWorkspaces: 2, - portsPerWorkspace: 5, - }); - const before = readFileSync(registryPath(fixture.config), "utf8"); - await expect(registerProject(fixture.config, b, options)).rejects.toThrow(/not available/); - expect(readFileSync(registryPath(fixture.config), "utf8")).toBe(before); - }); - - it("claims and persists an exact range outside configured ranges only with an override", async () => { - const fixture = makeFixture(8000, 8009); - fixture.config.projectParents[1].portRange = { first: 8000, last: 8009 }; - const project = makeProject(fixture.parent, "external"); - const request = { basePort: 9000, maxWorkspaces: 2, portsPerWorkspace: 5 }; - - await expect(registerProject(fixture.config, project, request)).rejects.toThrow(/outside/); - await expect( - registerProject(fixture.config, project, { - allowOutsidePortRange: true, - ...request, - }), - ).resolves.toEqual({ - path: project, - ports: { - allowOutsidePortRange: true, - basePort: 9000, - endPort: 9009, - maxWorkspaces: 2, - portsPerWorkspace: 5, - }, - }); - expect(readRegistry(fixture.config).projects[0].ports?.allowOutsidePortRange).toBe(true); - }); - - it("keeps overlap checks for allocations outside configured ranges", async () => { - const fixture = makeFixture(8000, 8009); - const a = makeProject(fixture.root, "a"); - const b = makeProject(fixture.root, "b"); - const options = { - allowOutsidePortRange: true, - basePort: 9000, - maxWorkspaces: 1, - portsPerWorkspace: 10, - }; - - await registerProject(fixture.config, a, options); - await expect(registerProject(fixture.config, b, options)).rejects.toThrow(/overlaps/); - }); - - it("reserves dedicated parent ranges from the shared allocation pool", async () => { - const fixture = makeFixture(8000, 8029); - fixture.config.projectParents[1].portRange = { first: 8010, last: 8019 }; - const sharedA = makeProject(fixture.root, "shared-a"); - const sharedB = makeProject(fixture.root, "shared-b"); - const dedicated = makeProject(fixture.parent, "dedicated"); - const options = { maxWorkspaces: 2, portsPerWorkspace: 5 }; - - await expect( - registerProject(fixture.config, sharedA, { basePort: 8010, ...options }), - ).rejects.toThrow(/outside/); - await expect( - registerProject(fixture.config, dedicated, { basePort: 8000, ...options }), - ).rejects.toThrow(/outside/); - expect((await registerProject(fixture.config, sharedA, options)).ports?.basePort).toBe(8000); - expect((await registerProject(fixture.config, sharedB, options)).ports?.basePort).toBe(8020); - expect((await registerProject(fixture.config, dedicated, options)).ports?.basePort).toBe(8010); - }); - - it("uses registry reservations only and preserves an exhausted registry", async () => { - const fixture = makeFixture(8000, 8009); - const a = makeProject(fixture.root, "a"); - const b = makeProject(fixture.root, "b"); - await registerProject(fixture.config, a, { maxWorkspaces: 2, portsPerWorkspace: 5 }); - const before = readFileSync(registryPath(fixture.config), "utf8"); - - await expect( - registerProject(fixture.config, b, { maxWorkspaces: 1, portsPerWorkspace: 1 }), - ).rejects.toThrow(/No contiguous block/); - expect(readFileSync(registryPath(fixture.config), "utf8")).toBe(before); - }); - - it("serializes concurrent registrations into distinct ranges", async () => { - const fixture = makeFixture(8000, 8019); - const a = makeProject(fixture.root, "a"); - const b = makeProject(fixture.root, "b"); - const options = { maxWorkspaces: 2, portsPerWorkspace: 5 }; - - const results = await Promise.all([ - registerProject(fixture.config, a, options), - registerProject(fixture.config, b, options), - ]); - expect(results.map((result) => result.ports?.basePort).toSorted()).toEqual([8000, 8010]); - expect(readRegistry(fixture.config).projects).toHaveLength(2); - }); -}); - -describe("unregisterProject", () => { - it("removes only registry state after the project path disappears", async () => { - const fixture = makeFixture(); - const project = makeProject(fixture.root, "project"); - const sibling = join(project, "README.md"); - writeFileSync(sibling, "keep\n"); - await registerProject(fixture.config, project); - renameSync(project, `${project}-moved`); - - await expect(unregisterProject(fixture.config, "project")).resolves.toBe(project); - expect(readRegistry(fixture.config).projects).toEqual([]); - expect(existsSync(`${project}-moved/README.md`)).toBe(true); - }); - - it("rejects unknown paths without creating or changing a registry", async () => { - const fixture = makeFixture(); - await expect(unregisterProject(fixture.config, "unknown")).rejects.toThrow(/not registered/); - expect(existsSync(registryPath(fixture.config))).toBe(false); - - const known = makeProject(fixture.root, "known"); - await registerProject(fixture.config, known); - const before = readFileSync(registryPath(fixture.config), "utf8"); - await expect(unregisterProject(fixture.config, "unknown")).rejects.toThrow(/not registered/); - expect(readFileSync(registryPath(fixture.config), "utf8")).toBe(before); - }); -}); - -describe("atomic registry mutation", () => { - it("writes complete JSON with a trailing newline", async () => { - const fixture = makeFixture(); - const project = makeProject(fixture.root, "project"); - await registerProject(fixture.config, project); - - const content = readFileSync(registryPath(fixture.config), "utf8"); - expect(content.endsWith("\n")).toBe(true); - expect(JSON.parse(content)).toEqual({ projects: [{ path: project }], schemaVersion: 2 }); - }); - - it("rewrites a legacy registry with schema version 2", async () => { - const fixture = makeFixture(); - const legacy = makeProject(fixture.root, "legacy"); - const project = makeProject(fixture.root, "project"); - writeFileSync( - registryPath(fixture.config), - `${JSON.stringify({ projects: [{ path: legacy }], version: 1 })}\n`, - ); - - await registerProject(fixture.config, project); - - expect(JSON.parse(readFileSync(registryPath(fixture.config), "utf8"))).toEqual({ - projects: [{ path: legacy }, { path: project }], - schemaVersion: 2, - }); - }); - - it("syncs the registry file and its parent directory", async () => { - const fixture = makeFixture(); - const project = makeProject(fixture.root, "project"); - const syncedDescriptors: number[] = []; - - await registerProject( - fixture.config, - project, - {}, - { - atomicWriteOperations: { - fsync: (descriptor) => { - syncedDescriptors.push(descriptor); - fsyncSync(descriptor); - }, - }, - }, - ); - - expect(syncedDescriptors).toHaveLength(2); - }); - - it("ignores a directory sync failure after the rename", async () => { - const fixture = makeFixture(); - const project = makeProject(fixture.root, "project"); - - await expect( - registerProject( - fixture.config, - project, - {}, - { - atomicWriteOperations: { - open: (path, flags, mode) => { - if (flags === "r") { - throw Object.assign(new Error("EISDIR: illegal operation on a directory"), { - code: "EISDIR", - }); - } - return openSync(path, flags, mode); - }, - }, - }, - ), - ).resolves.toEqual({ path: project }); - expect(readRegistry(fixture.config).projects).toEqual([{ path: project }]); - }); - - it("cleans temporary state, releases the lock, and preserves the registry on failure", async () => { - const fixture = makeFixture(); - const existing = makeProject(fixture.root, "existing"); - const failed = makeProject(fixture.root, "failed"); - await registerProject(fixture.config, existing); - const before = readFileSync(registryPath(fixture.config), "utf8"); - - await expect( - registerProject( - fixture.config, - failed, - {}, - { - atomicWriteOperations: { - rename: () => { - throw new Error("injected rename failure"); - }, - }, - }, - ), - ).rejects.toThrow(/Cannot atomically replace/); - expect(readFileSync(registryPath(fixture.config), "utf8")).toBe(before); - expect(readdirSync(fixture.root).filter((name) => name.includes(".tmp-"))).toEqual([]); - await expect(registerProject(fixture.config, failed)).resolves.toEqual({ path: failed }); - }); -}); - -interface Fixture { - config: AlprojectConfig; - parent: string; - root: string; -} - -function makeFixture(firstPort = 8000, lastPort = 9000): Fixture { - fixtureDir = mkdtempSync(join(tmpdir(), "alproject-mutations-")); - const root = join(fixtureDir, "root"); - const parent = join(fixtureDir, "parent"); - mkdirSync(root); - mkdirSync(parent); - return { - config: { - configPath: join(fixtureDir, ".alproject.json"), - projectParents: [{ path: root }, { path: parent }], - root: { path: root, portRange: { first: firstPort, last: lastPort } }, - }, - parent, - root, - }; -} - -function makeProject(parent: string, name: string): string { - const project = join(parent, name); - mkdirSync(join(project, ".git"), { recursive: true }); - return project; -} diff --git a/packages/alproject/test/ports.test.ts b/packages/alproject/test/ports.test.ts deleted file mode 100644 index ffad0106..00000000 --- a/packages/alproject/test/ports.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - allocateProjectPorts, - allocationEnd, - claimProjectPorts, - projectPortCount, -} from "../src/ports.js"; -import type { PortAllocation, ProjectEntry } from "../src/registry.js"; - -describe("project port allocation", () => { - it("allocates the lowest free range independent of registry order", () => { - const projects = [allocated("/c", 8040, 10), allocated("/a", 8000, 20)]; - expect(allocateProjectPorts(projects, request(10), [range(8000, 8099)])).toEqual({ - basePort: 8020, - maxWorkspaces: 2, - portsPerWorkspace: 5, - }); - }); - - it("fits exactly at both inclusive boundaries", () => { - expect(allocateProjectPorts([], request(10), [range(8000, 8009)]).basePort).toBe(8000); - expect( - allocateProjectPorts([allocated("/a", 8000, 10)], request(10), [range(8000, 8019)]).basePort, - ).toBe(8010); - }); - - it("ignores portless registry entries", () => { - expect( - allocateProjectPorts([{ path: "/portless" }], request(10), [range(8000, 8009)]).basePort, - ).toBe(8000); - }); - - it("reports exhaustion without returning an overlapping range", () => { - expect(() => - allocateProjectPorts([allocated("/a", 8000, 10)], request(2), [range(8000, 8009)]), - ).toThrow(/No contiguous block/); - }); - - it("allocates across disjoint available ranges", () => { - expect( - allocateProjectPorts([allocated("/a", 8000, 10)], request(10), [ - range(8000, 8009), - range(9000, 9009), - ]).basePort, - ).toBe(9000); - }); - - it("does not allocate beyond the end of the current available range", () => { - expect(() => - allocateProjectPorts([allocated("/later", 9500, 100)], request(500), [range(8000, 8099)]), - ).toThrow(/No contiguous block/); - }); - - it("claims an exact available range and rejects conflicts or excluded ranges", () => { - const claim = { basePort: 8020, ...request(10) }; - expect(claimProjectPorts([allocated("/a", 8000, 10)], claim, [range(8000, 8099)])).toEqual( - claim, - ); - expect(() => - claimProjectPorts([allocated("/a", 8025, 10)], claim, [range(8000, 8099)]), - ).toThrow(/not available/); - expect(() => claimProjectPorts([], claim, [range(8030, 8099)])).toThrow(/outside/); - }); - - it("claims a range outside configured ranges only with an override", () => { - const claim: PortAllocation = { - allowOutsidePortRange: true, - basePort: 9000, - ...request(10), - }; - - expect(claimProjectPorts([], claim, [range(8000, 8099)])).toEqual(claim); - expect(() => - claimProjectPorts([allocated("/a", 9005, 10)], claim, [range(8000, 8099)]), - ).toThrow(/overlaps/); - }); - - it("validates multiplication and inclusive-end arithmetic", () => { - expect(() => - projectPortCount({ maxWorkspaces: 2, portsPerWorkspace: Number.MAX_SAFE_INTEGER }), - ).toThrow(/safe arithmetic/); - expect(() => - allocationEnd({ - basePort: Number.MAX_SAFE_INTEGER, - maxWorkspaces: 1, - portsPerWorkspace: 2, - }), - ).toThrow(/safe arithmetic/); - expect(() => projectPortCount({ maxWorkspaces: 0, portsPerWorkspace: 1 })).toThrow( - /positive integer/, - ); - expect(() => - allocationEnd({ basePort: 65_535, maxWorkspaces: 1, portsPerWorkspace: 2 }), - ).toThrow(/exceeds port 65535/); - }); -}); - -function allocated(path: string, basePort: number, size: number): ProjectEntry { - return { path, ports: { basePort, maxWorkspaces: 1, portsPerWorkspace: size } }; -} - -function request(size: number) { - return { maxWorkspaces: 2, portsPerWorkspace: size / 2 }; -} - -function range(first: number, last: number) { - return { first, last }; -} diff --git a/packages/alproject/test/registry-lock.test.ts b/packages/alproject/test/registry-lock.test.ts deleted file mode 100644 index f6e94d41..00000000 --- a/packages/alproject/test/registry-lock.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { fork } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { registryLockPath, withRegistryLock } from "../src/registry-lock.js"; - -let fixtureDir: string | undefined; - -afterEach(() => { - if (fixtureDir !== undefined) rmSync(fixtureDir, { force: true, recursive: true }); - fixtureDir = undefined; -}); - -describe("withRegistryLock", () => { - it("times out on a live owner through deterministic timing seams", async () => { - const path = makeRegistryPath(); - writeOwner(path, 123); - let now = 0; - - await expect( - withRegistryLock(path, () => undefined, { - isProcessAlive: () => true, - now: () => now, - retryIntervalMs: 5, - sleep: async (milliseconds) => { - now += milliseconds; - }, - timeoutMs: 10, - }), - ).rejects.toThrow(/Registry is busy/); - expect(existsSync(registryLockPath(path))).toBe(true); - }); - - it("reclaims a dead owner and releases after the action", async () => { - const path = makeRegistryPath(); - writeOwner(path, 123); - - await expect( - withRegistryLock(path, () => "done", { isProcessAlive: () => false }), - ).resolves.toBe("done"); - expect(existsSync(registryLockPath(path))).toBe(false); - }); - - it("reclaims an owner whose PID was reused by another process", async () => { - const path = makeRegistryPath(); - writeOwner(path, 123, "original-process"); - - await expect( - withRegistryLock(path, () => "done", { - isProcessAlive: () => true, - processStartMarker: () => "reused-pid-process", - }), - ).resolves.toBe("done"); - expect(existsSync(registryLockPath(path))).toBe(false); - }); - - it("reclaims incomplete metadata only after its grace interval", async () => { - const path = makeRegistryPath(); - const lockPath = registryLockPath(path); - mkdirSync(lockPath); - const incompletePath = join(lockPath, "claim-incomplete"); - writeFileSync(incompletePath, ""); - utimesSync(incompletePath, new Date(0), new Date(0)); - - await withRegistryLock(path, () => undefined, { incompleteGraceMs: 10, now: () => 100 }); - expect(existsSync(lockPath)).toBe(false); - }); - - it("releases the claim after an action exception", async () => { - const path = makeRegistryPath(); - await expect( - withRegistryLock(path, () => { - throw new Error("failure inside lock"); - }), - ).rejects.toThrow(/failure inside lock/); - expect(existsSync(registryLockPath(path))).toBe(false); - }); - - it("serializes concurrent contenders", async () => { - const path = makeRegistryPath(); - const events: string[] = []; - let releaseFirst: (() => void) | undefined; - const gate = new Promise((resolve) => { - releaseFirst = resolve; - }); - const first = withRegistryLock(path, async () => { - events.push("first-start"); - await gate; - events.push("first-end"); - }); - const second = withRegistryLock(path, () => { - events.push("second"); - }); - await new Promise((resolve) => setTimeout(resolve, 30)); - expect(events).toEqual(["first-start"]); - releaseFirst?.(); - await Promise.all([first, second]); - expect(events).toEqual(["first-start", "first-end", "second"]); - }); - - it("serializes concurrent contenders while reclaiming a dead owner", async () => { - const path = makeRegistryPath(); - writeOwner(path, 999_999); - const events: string[] = []; - - await Promise.all( - Array.from({ length: 8 }, (_, index) => - withRegistryLock(path, async () => { - events.push(`start-${index}`); - await new Promise((resolve) => setTimeout(resolve, 5)); - events.push(`end-${index}`); - }), - ), - ); - - let active = 0; - for (const event of events) { - active += event.startsWith("start-") ? 1 : -1; - expect(active).toBeLessThanOrEqual(1); - } - expect(active).toBe(0); - expect(existsSync(registryLockPath(path))).toBe(false); - }); - - it("observes contention from a real child process", async () => { - const path = makeRegistryPath(); - const child = fork(new URL("fixtures/hold-registry-lock.mjs", import.meta.url), [path], { - stdio: ["ignore", "ignore", "ignore", "ipc"], - }); - await waitForMessage(child, "locked"); - await expect(withRegistryLock(path, () => undefined, { timeoutMs: 30 })).rejects.toThrow( - /Registry is busy/, - ); - child.send("release"); - await waitForExit(child); - await expect(withRegistryLock(path, () => "acquired")).resolves.toBe("acquired"); - }); -}); - -function makeRegistryPath(): string { - fixtureDir = mkdtempSync(join(tmpdir(), "alproject-lock-")); - return join(fixtureDir, "registry.json"); -} - -function writeOwner(registryFile: string, pid: number, startMarker?: string): void { - const lockPath = registryLockPath(registryFile); - mkdirSync(lockPath); - writeFileSync( - join(lockPath, `claim-${pid}-owner.json`), - `${JSON.stringify({ pid, ticket: 1, token: "owner", startMarker })}\n`, - ); -} - -function waitForMessage(child: ReturnType, expected: string): Promise { - return new Promise((resolve, reject) => { - child.once("error", reject); - child.once("exit", (code) => reject(new Error(`Lock child exited early with code ${code}`))); - child.on("message", (message) => { - if (message === expected) resolve(); - }); - }); -} - -function waitForExit(child: ReturnType): Promise { - return new Promise((resolve, reject) => { - child.once("error", reject); - child.once("exit", (code) => { - if (code === 0) resolve(); - else reject(new Error(`Lock child exited with code ${code}`)); - }); - }); -} diff --git a/packages/alproject/test/registry.test.ts b/packages/alproject/test/registry.test.ts deleted file mode 100644 index 701b2453..00000000 --- a/packages/alproject/test/registry.test.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import type { AlprojectConfig } from "../src/config.js"; -import { readRegistry, REGISTRY_FILENAME, registryPath } from "../src/registry.js"; - -let fixtureDir: string | undefined; - -afterEach(() => { - if (fixtureDir !== undefined) rmSync(fixtureDir, { force: true, recursive: true }); - fixtureDir = undefined; -}); - -describe("readRegistry", () => { - it("returns an empty schema-version-2 registry when the file is absent", () => { - const fixture = makeFixture(); - expect(readRegistry(fixture.config)).toEqual({ projects: [], schemaVersion: 2 }); - expect(registryPath(fixture.config)).toBe(join(fixture.root, REGISTRY_FILENAME)); - }); - - it("reads valid schema-version-2 projects", () => { - const fixture = makeFixture(); - const projectA = makeProject(fixture, "a"); - const projectB = makeProject(fixture, "b"); - const registry = { - projects: [ - { path: projectA }, - { - path: projectB, - ports: { basePort: 8000, maxWorkspaces: 2, portsPerWorkspace: 10 }, - }, - ], - schemaVersion: 2, - }; - writeRegistry(fixture, registry); - - expect(readRegistry(fixture.config)).toEqual(registry); - }); - - it("migrates a legacy version-1 registry", () => { - const fixture = makeFixture(); - const project = makeProject(fixture, "legacy"); - writeRegistry(fixture, { projects: [{ path: project }], version: 1 }); - - expect(readRegistry(fixture.config)).toEqual({ - projects: [{ path: project }], - schemaVersion: 2, - }); - }); - - it("reports malformed JSON with the registry path", () => { - const fixture = makeFixture(); - writeFileSync(registryPath(fixture.config), "{"); - expect(() => readRegistry(fixture.config)).toThrow(/Invalid registry.*invalid JSON/); - }); - - it.each([ - ["unsupported legacy version", { projects: [], version: 2 }], - ["unsupported schema version", { projects: [], schemaVersion: 1 }], - ["both version fields", { projects: [], schemaVersion: 2, version: 1 }], - ["missing projects", { version: 1 }], - ["non-array projects", { projects: {}, version: 1 }], - ["unknown registry field", { extra: true, projects: [], version: 1 }], - ["unknown project field", { projects: [{ extra: true, path: "/project" }], version: 1 }], - ["empty project path", { projects: [{ path: "" }], version: 1 }], - ["non-string project path", { projects: [{ path: 42 }], version: 1 }], - ["non-object ports", { projects: [{ path: "/project", ports: 42 }], version: 1 }], - [ - "false port-range override", - { - projects: [ - { - path: "/project", - ports: { - allowOutsidePortRange: false, - basePort: 8000, - maxWorkspaces: 1, - portsPerWorkspace: 1, - }, - }, - ], - version: 1, - }, - ], - [ - "unknown port field", - { - projects: [ - { - path: "/project", - ports: { - basePort: 8000, - extra: true, - maxWorkspaces: 1, - portsPerWorkspace: 1, - }, - }, - ], - version: 1, - }, - ], - [ - "missing port field", - { - projects: [{ path: "/project", ports: { basePort: 8000, portsPerWorkspace: 1 } }], - version: 1, - }, - ], - ...invalidPortFields(), - ])("rejects %s", (_label, registry) => { - const fixture = makeFixture(); - writeRegistry(fixture, registry); - expect(() => readRegistry(fixture.config)).toThrow(/Invalid registry/); - }); - - it("rejects relative and non-normalized project paths", () => { - const fixture = makeFixture(); - writeRegistry(fixture, { - projects: [{ path: "relative" }], - schemaVersion: 2, - }); - expect(() => readRegistry(fixture.config)).toThrow(/project path must be absolute/); - - writeRegistry(fixture, { - projects: [{ path: `${fixture.root}/missing/../project` }], - schemaVersion: 2, - }); - expect(() => readRegistry(fixture.config)).toThrow(/project path must be canonical/); - }); - - it("rejects duplicate project paths", () => { - const fixture = makeFixture(); - const project = makeProject(fixture, "project"); - writeRegistry(fixture, { - projects: [{ path: project }, { path: project }], - version: 1, - }); - expect(() => readRegistry(fixture.config)).toThrow(`duplicate project path: ${project}`); - }); - - it.each([ - ["below", 7999, 1, 1], - ["above", 9001, 1, 1], - ["past inclusive end", 8995, 3, 3], - ["unsafe multiplication", 8000, Number.MAX_SAFE_INTEGER, 2], - ])("rejects an allocation %s the configured range", (_label, basePort, count, size) => { - const fixture = makeFixture(); - const project = makeProject(fixture, "project"); - writeRegistry(fixture, { - projects: [ - { path: project, ports: { basePort, maxWorkspaces: count, portsPerWorkspace: size } }, - ], - version: 1, - }); - expect(() => readRegistry(fixture.config)).toThrow(/Invalid registry/); - }); - - it("accepts an allocation ending exactly at lastPort", () => { - const fixture = makeFixture(); - const project = makeProject(fixture, "project"); - const registry = { - projects: [ - { path: project, ports: { basePort: 8991, maxWorkspaces: 2, portsPerWorkspace: 5 } }, - ], - schemaVersion: 2, - }; - writeRegistry(fixture, registry); - expect(readRegistry(fixture.config)).toEqual(registry); - }); - - it("accepts a marked allocation outside configured ranges", () => { - const fixture = makeFixture(); - const project = makeProject(fixture, "external"); - fixture.config.projectParents[0].portRange = { first: 8500, last: 8599 }; - const registry = { - projects: [ - { - path: project, - ports: { - allowOutsidePortRange: true, - basePort: 9001, - maxWorkspaces: 1, - portsPerWorkspace: 10, - }, - }, - ], - schemaVersion: 2, - }; - writeRegistry(fixture, registry); - - expect(readRegistry(fixture.config)).toEqual(registry); - }); - - it("rejects an override allocation beyond the TCP port range", () => { - const fixture = makeFixture(); - const project = makeProject(fixture, "external"); - writeRegistry(fixture, { - projects: [ - { - path: project, - ports: { - allowOutsidePortRange: true, - basePort: 65_535, - maxWorkspaces: 1, - portsPerWorkspace: 2, - }, - }, - ], - version: 1, - }); - - expect(() => readRegistry(fixture.config)).toThrow(/exceeds port 65535/); - }); - - it("rejects an allocation outside its parent-specific port range", () => { - const fixture = makeFixture(); - const project = makeProject(fixture, "project"); - fixture.config.projectParents[0].portRange = { first: 8500, last: 8599 }; - writeRegistry(fixture, { - projects: [ - { path: project, ports: { basePort: 8400, maxWorkspaces: 1, portsPerWorkspace: 10 } }, - ], - version: 1, - }); - - expect(() => readRegistry(fixture.config)).toThrow(/outside its available parent port range/); - }); - - it("rejects an existing shared allocation inside another parent's reserved range", () => { - const fixture = makeFixture(); - const project = makeProject(fixture, "project"); - fixture.config.projectParents.push({ - path: join(dirname(fixture.root), "dedicated"), - portRange: { first: 8500, last: 8599 }, - }); - writeRegistry(fixture, { - projects: [ - { path: project, ports: { basePort: 8500, maxWorkspaces: 1, portsPerWorkspace: 10 } }, - ], - version: 1, - }); - - expect(() => readRegistry(fixture.config)).toThrow(/outside its available parent port range/); - }); - - it("rejects overlapping and duplicate port reservations", () => { - const fixture = makeFixture(); - const projectA = makeProject(fixture, "a"); - const projectB = makeProject(fixture, "b"); - writeRegistry(fixture, { - projects: [ - { - path: projectA, - ports: { basePort: 8000, maxWorkspaces: 2, portsPerWorkspace: 10 }, - }, - { - path: projectB, - ports: { basePort: 8019, maxWorkspaces: 1, portsPerWorkspace: 2 }, - }, - ], - version: 1, - }); - expect(() => readRegistry(fixture.config)).toThrow(/port allocations overlap/); - - writeRegistry(fixture, { - projects: [ - { - path: projectA, - ports: { basePort: 8000, maxWorkspaces: 1, portsPerWorkspace: 1 }, - }, - { - path: projectB, - ports: { basePort: 8000, maxWorkspaces: 1, portsPerWorkspace: 1 }, - }, - ], - version: 1, - }); - expect(() => readRegistry(fixture.config)).toThrow(/port allocations overlap/); - }); -}); - -interface Fixture { - config: AlprojectConfig; - root: string; -} - -function makeFixture(): Fixture { - fixtureDir = mkdtempSync(join(tmpdir(), "alproject-registry-")); - const root = join(fixtureDir, "root"); - mkdirSync(root); - return { - config: { - configPath: join(fixtureDir, ".alproject.json"), - projectParents: [{ path: root }], - root: { path: root, portRange: { first: 8000, last: 9000 } }, - }, - root, - }; -} - -function makeProject(fixture: Fixture, name: string): string { - const project = join(fixture.root, name); - mkdirSync(project); - return project; -} - -function writeRegistry(fixture: Fixture, value: object): void { - writeFileSync(registryPath(fixture.config), JSON.stringify(value)); -} - -function invalidPortFields(): [string, object][] { - return [ - invalidPortField("non-positive basePort", { basePort: 0 }), - invalidPortField("non-integer basePort", { basePort: 8000.5 }), - invalidPortField("non-positive maxWorkspaces", { maxWorkspaces: 0 }), - invalidPortField("non-integer maxWorkspaces", { maxWorkspaces: 1.5 }), - invalidPortField("non-positive portsPerWorkspace", { portsPerWorkspace: 0 }), - invalidPortField("non-integer portsPerWorkspace", { portsPerWorkspace: 1.5 }), - ]; -} - -function invalidPortField(label: string, override: object): [string, object] { - return [ - label, - { - projects: [ - { - path: "/project", - ports: { basePort: 8000, maxWorkspaces: 1, portsPerWorkspace: 1, ...override }, - }, - ], - version: 1, - }, - ]; -} diff --git a/packages/alproject/test/status.test.ts b/packages/alproject/test/status.test.ts deleted file mode 100644 index b13d1a01..00000000 --- a/packages/alproject/test/status.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import type { AlprojectConfig } from "../src/config.js"; -import type { Registry } from "../src/registry.js"; -import { getProjectStatus } from "../src/status.js"; - -let fixtureDir: string | undefined; - -afterEach(() => { - if (fixtureDir !== undefined) rmSync(fixtureDir, { force: true, recursive: true }); - fixtureDir = undefined; -}); - -describe("getProjectStatus", () => { - it("reports registration, ports, the preferred remote host, and every worktree", () => { - const fixture = makeFixture(); - const project = makeRepository(fixture.root, "project"); - const workspace = join(fixture.root, "project-workspace"); - execGit(project, "worktree", "add", "--quiet", "-b", "feature", workspace); - execGit(project, "remote", "add", "backup", "https://gitlab.com/team/project.git"); - execGit(project, "remote", "add", "origin", "git@github.com:team/project.git"); - const registry: Registry = { - projects: [ - { - path: project, - ports: { basePort: 8000, maxWorkspaces: 3, portsPerWorkspace: 4 }, - }, - ], - schemaVersion: 2, - }; - - expect(getProjectStatus(fixture.config, registry, "project")).toEqual({ - name: "project", - path: project, - ports: { basePort: 8000, endPort: 8011, maxWorkspaces: 3, portsPerWorkspace: 4 }, - remoteHost: "github.com", - status: "registered", - worktrees: [ - { branch: "main", name: "project", path: project }, - { - branch: "feature", - name: "project-workspace", - path: realpathSync(workspace), - }, - ], - }); - }); - - it("reports explicit absent values for an unregistered project", () => { - const fixture = makeFixture(); - const project = makeRepository(fixture.root, "project"); - - expect(getProjectStatus(fixture.config, emptyRegistry(), project)).toEqual({ - name: "project", - path: project, - ports: null, - remoteHost: null, - status: "unregistered", - worktrees: [{ branch: "main", name: "project", path: project }], - }); - }); - - it("falls back to another remote with a recognizable host", () => { - const fixture = makeFixture(); - const project = makeRepository(fixture.root, "project"); - execGit(project, "remote", "add", "origin", join(fixture.root, "local.git")); - execGit(project, "remote", "add", "backup", "ssh://git@gitlab.com/team/project.git"); - - expect(getProjectStatus(fixture.config, emptyRegistry(), project).remoteHost).toBe( - "gitlab.com", - ); - }); - - it("does not treat a hostless URL scheme as an SCP host", () => { - const fixture = makeFixture(); - const project = makeRepository(fixture.root, "project"); - execGit(project, "remote", "add", "origin", "file:///srv/project.git"); - execGit(project, "remote", "add", "backup", "https://gitlab.com/team/project.git"); - - expect(getProjectStatus(fixture.config, emptyRegistry(), project).remoteHost).toBe( - "gitlab.com", - ); - }); - - it("reads a remote whose name starts with a hyphen", () => { - const fixture = makeFixture(); - const project = makeRepository(fixture.root, "project"); - execGit(project, "remote", "add", "--", "--push", "https://github.com/team/project.git"); - - expect(getProjectStatus(fixture.config, emptyRegistry(), project).remoteHost).toBe( - "github.com", - ); - }); - - it("reads the host from SCP syntax without a user", () => { - const fixture = makeFixture(); - const project = makeRepository(fixture.root, "project"); - execGit(project, "remote", "add", "origin", "github.com:team/project.git"); - - expect(getProjectStatus(fixture.config, emptyRegistry(), project).remoteHost).toBe( - "github.com", - ); - }); - - it("reports a registered project missing from the filesystem", () => { - const fixture = makeFixture(); - const project = join(fixture.root, "missing"); - const registry: Registry = { projects: [{ path: project }], schemaVersion: 2 }; - - expect(getProjectStatus(fixture.config, registry, "missing")).toEqual({ - name: "missing", - path: project, - ports: null, - remoteHost: null, - status: "missing", - worktrees: [], - }); - }); - - it("inspects a registered project whose parent is no longer configured", () => { - const fixture = makeFixture(); - const project = makeRepository(fixture.root, "project"); - const configuredParent = join(fixture.root, "other-parent"); - mkdirSync(configuredParent); - fixture.config.projectParents = [{ path: configuredParent }]; - execGit(project, "remote", "add", "origin", "https://github.com/team/project.git"); - const registry: Registry = { projects: [{ path: project }], schemaVersion: 2 }; - - expect(getProjectStatus(fixture.config, registry, project)).toMatchObject({ - path: project, - remoteHost: "github.com", - status: "registered", - worktrees: [{ branch: "main", name: "project", path: project }], - }); - }); - - it("rejects an unregistered project outside configured parents", () => { - const fixture = makeFixture(); - const project = makeRepository(fixture.root, "project"); - const configuredParent = join(fixture.root, "other-parent"); - mkdirSync(configuredParent); - fixture.config.projectParents = [{ path: configuredParent }]; - - expect(() => getProjectStatus(fixture.config, emptyRegistry(), project)).toThrow( - /neither registered nor discovered/, - ); - }); - - it("rejects unknown and linked-worktree paths with main-worktree guidance", () => { - const fixture = makeFixture(); - const project = makeRepository(fixture.root, "project"); - const workspace = join(fixture.root, "workspace"); - execGit(project, "worktree", "add", "--quiet", "-b", "feature", workspace); - mkdirSync(join(fixture.root, "directory")); - - expect(() => getProjectStatus(fixture.config, emptyRegistry(), "directory")).toThrow( - /canonical main-worktree path/, - ); - expect(() => getProjectStatus(fixture.config, emptyRegistry(), workspace)).toThrow( - /canonical main-worktree path/, - ); - }); -}); - -interface Fixture { - config: AlprojectConfig; - root: string; -} - -function makeFixture(): Fixture { - fixtureDir = mkdtempSync(join(tmpdir(), "alproject-status-")); - const root = join(fixtureDir, "root"); - mkdirSync(root); - return { - config: { - configPath: join(fixtureDir, ".alproject.json"), - projectParents: [{ path: root }], - root: { path: root, portRange: { first: 8000, last: 9000 } }, - }, - root, - }; -} - -function makeRepository(parent: string, name: string): string { - const repository = join(parent, name); - execGit(parent, "init", "--quiet", "--initial-branch=main", repository); - execGit(repository, "config", "user.name", "Test"); - execGit(repository, "config", "user.email", "test@example.com"); - writeFileSync(join(repository, "README.md"), `${name}\n`); - execGit(repository, "add", "README.md"); - execGit(repository, "commit", "--quiet", "-m", "initial"); - return realpathSync(repository); -} - -function emptyRegistry(): Registry { - return { projects: [], schemaVersion: 2 }; -} - -function execGit(cwd: string, ...args: string[]): string { - return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8" }).trim(); -} diff --git a/packages/docmap/package.json b/packages/docmap/package.json index cbf5fa12..6cf6fa7f 100644 --- a/packages/docmap/package.json +++ b/packages/docmap/package.json @@ -24,6 +24,10 @@ "bin": { "docmap": "bin/docmap.mjs" }, + "exports": { + ".": "./dist/cli.js", + "./package.json": "./package.json" + }, "files": [ "bin", "dist", diff --git a/packages/docmap/src/cli.ts b/packages/docmap/src/cli.ts index c56c0ced..cbda9003 100644 --- a/packages/docmap/src/cli.ts +++ b/packages/docmap/src/cli.ts @@ -26,6 +26,7 @@ export interface MainOptions { stderr?: { write(s: string): void }; cwd?: string; userAgent?: string; + commands?: PackageManagerCommands; } export function main(options?: MainOptions): number { @@ -46,7 +47,7 @@ export function main(options?: MainOptions): number { // Fold an explicit `--root` into every suggested command so each one is copy-pasteable against the // same custom root; `showRootOption` then drops the now-redundant `--root ` help row. - const pm = commandsWithRoot(detectPackageManager(cwd, userAgent), root); + const pm = commandsWithRoot(options?.commands ?? detectPackageManager(cwd, userAgent), root); const showRootOption = root === undefined; // Mode precedence (each prints only its own output, then returns): version → help → guide → @@ -164,7 +165,7 @@ interface HelpOptions { showRootOption: boolean; } -interface PackageManagerCommands { +export interface PackageManagerCommands { base: string; withArgs: string; } diff --git a/packages/docmap/test/docmap.test.ts b/packages/docmap/test/docmap.test.ts index 0cfd5e4e..6205e988 100644 --- a/packages/docmap/test/docmap.test.ts +++ b/packages/docmap/test/docmap.test.ts @@ -1,7 +1,7 @@ import { relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { main } from "../src/cli.js"; +import { main, type PackageManagerCommands } from "../src/cli.js"; import { extractFallbackTitle } from "../src/parser.js"; const __dirname = fileURLToPath(new URL(".", import.meta.url)); @@ -30,7 +30,12 @@ function run(args: string[], fixtureDir: string) { return invoke(["node", "docmap", "--root", fixtureDir, ...args], process.cwd()); } -function invoke(argv: string[], cwd: string, userAgent?: string) { +function invoke( + argv: string[], + cwd: string, + userAgent?: string, + commands?: PackageManagerCommands, +) { const stdout: string[] = []; const stderr: string[] = []; const code = main({ @@ -47,6 +52,7 @@ function invoke(argv: string[], cwd: string, userAgent?: string) { }, cwd, userAgent, + commands, }); return { code, stdout: stdout.join(""), stderr: stderr.join("") }; } @@ -876,3 +882,30 @@ describe("package-manager prefix in help", () => { expect(out).not.toContain("npx @paleo/docmap"); }); }); + +describe("injected command prefix", () => { + const commands = { base: "alignfirst docmap", withArgs: "alignfirst docmap" }; + + it("renders the injected prefix in full and short help", () => { + const full = invoke(["node", "docmap", "--help"], "/", undefined, commands).stdout; + const short = invoke(["node", "docmap"], "/", undefined, commands).stdout; + expect(full).toContain("alignfirst docmap --check"); + expect(full).toContain("alignfirst docmap --guide"); + expect(short).toContain("alignfirst docmap --guide"); + }); + + it("renders the injected prefix in the guide", () => { + const out = invoke(["node", "docmap", "--guide"], "/", undefined, commands).stdout; + expect(out).toContain("alignfirst docmap --check"); + }); + + it("folds --root into the injected prefix", () => { + const out = invoke( + ["node", "docmap", "--root", "my docs", "--help"], + "/", + undefined, + commands, + ).stdout; + expect(out).toContain("alignfirst docmap --root 'my docs' --check"); + }); +}); diff --git a/packages/openclaw-test/test/context-wait-for-outbound.test.ts b/packages/openclaw-test/test/context-wait-for-outbound.test.ts index 08a7d5ca..0bbe65b0 100644 --- a/packages/openclaw-test/test/context-wait-for-outbound.test.ts +++ b/packages/openclaw-test/test/context-wait-for-outbound.test.ts @@ -113,7 +113,7 @@ describe("waitForOutbound fail-fast", () => { it("ignores a cliMock from before the wait started", async () => { // Regression: a completed channel-phase CLI call must not trip a later wait. - const stale = { atMs: 1000, entry: makeCliMockEntry("alproject", ["list"]) }; + const stale = { atMs: 1000, entry: makeCliMockEntry("alcode", ["projects", "list"]) }; let now = 60_000; const deps: WaitForOutboundDeps = { accountId: "ch", diff --git a/packages/plans-share/CHANGELOG.md b/packages/plans-share/CHANGELOG.md deleted file mode 100644 index c576977d..00000000 --- a/packages/plans-share/CHANGELOG.md +++ /dev/null @@ -1,49 +0,0 @@ -# @paleo/plans-share - -## 0.7.0 - -### Minor Changes - -- a873b8b: 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). - -## 0.6.1 - -### Patch Changes - -- 801309f: Published from CI with an npm provenance attestation, verifiable with `npm audit signatures`. - -## 0.6.0 - -### Minor Changes - -- `check` now reports the mode of `.plans` instead of requiring a plans repository. It exits 1 only when `.plans` is unusable. Breaking: `check` used to fail on a local `.plans`. - -## 0.5.0 - -### Minor Changes - -- b40afe9: Local plans mode: `sync` now succeeds when `.plans` is a plain local directory. - -## 0.4.0 - -### Minor Changes - -- Renamed from `@paleo/plans-repo`: the bin is now `plans-share`; update the `plans:setup` and `plans:sync` scripts accordingly. The `sync` command now reports whether local changes were sent. - -## 0.3.0 - -### Minor Changes - -- 21d52a7: New `plans-repo check` command: verifies that `.plans` is linked to a team plans repository, and exits 1 with guidance otherwise. - -## 0.2.0 - -### Minor Changes - -- 6d62bcf: `plans-repo setup` no longer clones the plans repository: point it at an existing clone (clone it yourself, with your own SSH configuration). The `--repo` option is removed — update the `plans:setup` npm script to `plans-repo setup --folder ` and document the repository URL in the instruction file. - -## 0.1.0 - -### Minor Changes - -- 60afd89: New `plans-repo` CLI: share the `.plans` directory through a dedicated team plans repository. `plans-repo setup

--repo --folder ` clones (or reuses) the plans repository and links `.plans` to the project's folder inside it, migrating any existing content. `plans-repo sync` pulls, commits, and pushes the plans repository. diff --git a/packages/plans-share/README.md b/packages/plans-share/README.md deleted file mode 100644 index d8de6f11..00000000 --- a/packages/plans-share/README.md +++ /dev/null @@ -1,98 +0,0 @@ -# @paleo/plans-share - -Share the `.plans` directory of the [AlignFirst skills](https://github.com/paleo/alignfirst) through a dedicated team plans repository. - -## How it works - -A team hosts a plans repository, multi-project — one folder per code repo, ticket directories inside: - -```text -myteam-plans/ - project-a/ - 250/ - A1-spec.md - _archives/ - project-b/ - 103/ -``` - -Each developer clones it once per machine. In each project, `.plans` in the main worktree becomes a symlink to the project's folder inside the clone. Plans never enter the product repository: its `.gitignore` contains `.plans`. - -Plan history has no value, so the plans repository only receives synchronization commits — pull, commit `sync`, push — at each user's discretion. - -## Install - -```sh -npm install -D @paleo/plans-share -``` - -Add the npm scripts, with the project folder baked in: - -```json -{ - "plans:setup": "plans-share setup --folder project-a", - "plans:sync": "plans-share sync --auto-archive" -} -``` - -Document the plans repository URL where developers will find it (e.g. `AGENTS.md`), since cloning is theirs to do — with their own SSH configuration. - -## Commands - -Once per machine: clone the plans repository anywhere (typically next to the other repos), then, from the main worktree root, pass the clone location: - -```sh -npm run plans:setup -- ../myteam-plans -``` - -`setup` migrates any existing `.plans` content into the clone and creates the symlink. Re-run it with the new location if the clone moves. - -To synchronize, from any worktree: - -```sh -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 -npx --no plans-share check -``` - -Two modes exit 0: - -- **shared** — a symlink into the plans repository clone. -- **local** — a plain directory. Synchronization is disabled. - -It exits 1 when `.plans` is unusable: missing, a broken symlink, not a directory, or a symlink leading outside any git repository. - -Pass `--no` to keep npx off the registry. The bin is `plans-share`, while the package is `@paleo/plans-share`. - -## Archiving - -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/package.json b/packages/plans-share/package.json deleted file mode 100644 index 09941fab..00000000 --- a/packages/plans-share/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "@paleo/plans-share", - "version": "0.7.0", - "license": "CC0-1.0", - "author": "Thomas MUR", - "description": "Share AlignFirst plans through a team plans repository.", - "keywords": [ - "alignfirst", - "plans", - "specs", - "repository", - "ai", - "agent" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/paleo/alignfirst.git", - "directory": "packages/plans-share" - }, - "engines": { - "node": ">=22.11.0" - }, - "packageManager": "npm@11.19.0", - "type": "module", - "bin": { - "plans-share": "bin/plans-share.mjs" - }, - "files": [ - "bin", - "dist" - ], - "publishConfig": { - "access": "public" - }, - "scripts": { - "build": "tsc -p tsconfig.build.json", - "clear": "rimraf dist/*", - "lint": "biome check", - "test": "vitest run" - }, - "devDependencies": { - "@types/node": "~24.13.3", - "rimraf": "~6.1.3", - "typescript": "~7.0.2", - "vitest": "~4.1.11" - } -} diff --git a/packages/plans-share/src/archive.ts b/packages/plans-share/src/archive.ts deleted file mode 100644 index 9a0d1993..00000000 --- a/packages/plans-share/src/archive.ts +++ /dev/null @@ -1,120 +0,0 @@ -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/check.ts b/packages/plans-share/src/check.ts deleted file mode 100644 index ce4448b5..00000000 --- a/packages/plans-share/src/check.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { CliContext } from "./context.js"; -import { resolvePlansMode } from "./plans-path.js"; - -/** - * Verifies that `.plans` is usable and reports its mode. Both modes exit 0, since a plain local - * directory is a supported setup. A missing, broken or non-directory `.plans` fails. - */ -export function runCheck(ctx: CliContext): void { - const mode = resolvePlansMode(ctx); - if (mode.kind === "shared") { - ctx.stdout.write(".plans is linked to the team plans repository.\n"); - return; - } - ctx.stdout.write( - ".plans is a local directory (local plans mode): synchronization is disabled.\n", - ); -} diff --git a/packages/plans-share/src/cli.ts b/packages/plans-share/src/cli.ts deleted file mode 100644 index 61cbe8ff..00000000 --- a/packages/plans-share/src/cli.ts +++ /dev/null @@ -1,102 +0,0 @@ -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"; -import { runSync } from "./sync.js"; - -const HELP = `plans-share — share the .plans directory through a team plans repository. - -Usage: - plans-share setup --folder - 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. 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 { - argv?: string[]; - stdout?: { write(s: string): void }; - stderr?: { write(s: string): void }; - cwd?: string; - userAgent?: string; -} - -export function main(options?: MainOptions): number { - const argv = options?.argv ?? process.argv; - const ctx: CliContext = { - cwd: options?.cwd ?? process.cwd(), - stdout: options?.stdout ?? process.stdout, - stderr: options?.stderr ?? process.stderr, - syncCommand: syncCommand(options?.userAgent ?? process.env.npm_config_user_agent ?? ""), - }; - const [command, ...rest] = argv.slice(2); - try { - switch (command) { - case "setup": - runSetup(ctx, rest); - return 0; - case "sync": - runSync(ctx, rest); - return 0; - case "archive": - runArchive(ctx, rest); - return 0; - case "auto-archive": - runAutoArchive(ctx, rest); - return 0; - case "check": - runCheck(ctx); - return 0; - case "--version": - ctx.stdout.write(`${readPackageVersion()}\n`); - return 0; - case "--help": - case "-h": - case undefined: - ctx.stdout.write(HELP); - return 0; - default: - throw new CliError(`Unknown command: ${command}\n\n${HELP}`); - } - } catch (err) { - if (err instanceof CliError) { - ctx.stderr.write(`${err.message}\n`); - return 1; - } - throw err; - } -} - -// The consumer repo wires `plans:sync` to this bin, so suggest the script through the package -// manager that launched us (`npm_config_user_agent` is empty for a bare global binary). -function syncCommand(userAgent: string): string { - if (userAgent.startsWith("pnpm")) return "pnpm plans:sync"; - if (userAgent.startsWith("yarn")) return "yarn plans:sync"; - if (userAgent.startsWith("bun")) return "bun run plans:sync"; - return "npm run plans:sync"; -} - -function readPackageVersion(): string { - const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")) as { - version?: string; - }; - if (pkg.version === undefined) throw new Error("plans-share: package.json is missing 'version'"); - return pkg.version; -} diff --git a/packages/plans-share/src/context.ts b/packages/plans-share/src/context.ts deleted file mode 100644 index c3dc8e9b..00000000 --- a/packages/plans-share/src/context.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface CliContext { - cwd: string; - stdout: { write(s: string): void }; - stderr: { write(s: string): void }; - /** The `plans:sync` script invocation for the package manager that launched us. */ - syncCommand: string; -} - -/** A user-facing failure, reported on stderr with exit code 1. */ -export class CliError extends Error {} diff --git a/packages/plans-share/src/git.ts b/packages/plans-share/src/git.ts deleted file mode 100644 index 6d9e5a4f..00000000 --- a/packages/plans-share/src/git.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { CliError } from "./context.js"; - -export function git(dir: string, ...args: string[]): void { - try { - execFileSync("git", ["-C", dir, ...args], { stdio: "inherit" }); - } catch { - throw gitFailure(args); - } -} - -// Git's own stderr is already on screen: callers let the child write to it. -function gitFailure(args: string[]): CliError { - return new CliError(`git ${args[0]} failed. See the git output above.`); -} - -export function gitOutput(dir: string, ...args: string[]): string { - try { - return execFileSync("git", ["-C", dir, ...args], { encoding: "utf-8" }).trim(); - } catch { - throw gitFailure(args); - } -} - -export function gitSucceeds(dir: string, ...args: string[]): boolean { - try { - execFileSync("git", ["-C", dir, ...args], { stdio: "ignore" }); - return true; - } catch { - return false; - } -} diff --git a/packages/plans-share/src/plans-path.ts b/packages/plans-share/src/plans-path.ts deleted file mode 100644 index aceba32d..00000000 --- a/packages/plans-share/src/plans-path.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { existsSync, lstatSync, realpathSync, statSync } from "node:fs"; -import { join } from "node:path"; -import { CliError, type CliContext } from "./context.js"; -import { gitOutput } from "./git.js"; - -/** - * How `.plans` is wired for this project. - * - * - `shared` — a symlink into a clone of the team plans repository. `sync` publishes there. - * - `local` — a plain directory in the product repository, for a contributor without access to - * the team clone. `sync` has nowhere to publish. - */ -export type PlansMode = SharedPlans | LocalPlans; - -export interface SharedPlans { - kind: "shared"; - /** Toplevel of the plans repository clone. */ - repoToplevel: string; -} - -export interface LocalPlans { - kind: "local"; -} - -/** - * Classifies `.plans`, or throws when it is unusable: missing, broken symlink, not a directory. - * - * Shared means `.plans` resolves into a git repository other than this project's own. A worktree - * root cannot answer that question: every worktree has its own, so a plain local `.plans` would - * read as shared, and `sync` would commit into the product repository. - */ -export function resolvePlansMode(ctx: CliContext): PlansMode { - const plansPath = join(ctx.cwd, ".plans"); - const stats = lstatSync(plansPath, { throwIfNoEntry: false }); - if (!stats) - throw new CliError( - ".plans is missing. Clone the team plans repository, then run the plans:setup script " + - "(see the project documentation) — or create a plain .plans directory to keep plans local.", - ); - if (stats.isSymbolicLink() && !existsSync(plansPath)) - throw new CliError( - "The .plans symlink is broken. Re-run the plans:setup script with the clone location.", - ); - if (!statSync(plansPath).isDirectory()) - throw new CliError( - ".plans is not a directory. Remove it, then run the plans:setup script (see the project documentation).", - ); - // Inside this project's own repository: a plain local directory, kept on this machine. - if (plansRepositoryId(plansPath, stats.isSymbolicLink()) === repositoryId(ctx.cwd)) - return { kind: "local" }; - return { kind: "shared", repoToplevel: gitOutput(plansPath, "rev-parse", "--show-toplevel") }; -} - -/** - * A symlink leading outside any git repository stays an error. Local mode is a plain directory, - * so such a link means the clone moved away, and reading it as local would bury the breakage. - */ -function plansRepositoryId(plansPath: string, isSymlink: boolean): string { - try { - return repositoryId(plansPath); - } catch { - if (isSymlink) - throw new CliError( - ".plans points outside any git repository. Re-run the plans:setup script with the clone location.", - ); - throw new CliError( - ".plans is not inside a git repository. Run this command from a worktree root.", - ); - } -} - -/** Identifies a repository across all its worktrees: they share one git common directory. */ -function repositoryId(dir: string): string { - return realpathSync(gitOutput(dir, "rev-parse", "--path-format=absolute", "--git-common-dir")); -} diff --git a/packages/plans-share/src/setup.ts b/packages/plans-share/src/setup.ts deleted file mode 100644 index d74b0cd8..00000000 --- a/packages/plans-share/src/setup.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { - cpSync, - existsSync, - lstatSync, - mkdirSync, - readdirSync, - realpathSync, - rmSync, - symlinkSync, -} from "node:fs"; -import { join, relative, resolve } from "node:path"; -import { CliError, type CliContext } from "./context.js"; -import { gitOutput } from "./git.js"; - -export function runSetup(ctx: CliContext, args: string[]): void { - const options = parseSetupArgs(args); - checkMainWorktreeRoot(ctx); - const cloneDir = resolve(ctx.cwd, options.dir); - checkClone(ctx, cloneDir); - const projectDir = join(cloneDir, options.folder); - mkdirSync(projectDir, { recursive: true }); - linkPlans(ctx, projectDir); -} - -interface SetupOptions { - dir: string; - folder: string; -} - -function parseSetupArgs(args: string[]): SetupOptions { - let dir: string | undefined; - let folder: string | undefined; - for (let i = 0; i < args.length; ++i) { - const arg = args[i]; - if (arg === "--folder") folder = args[++i]; - else if (arg.startsWith("-")) throw new CliError(`Unknown option: ${arg}`); - else if (dir === undefined) dir = arg; - else throw new CliError(`Unexpected argument: ${arg}`); - } - if (dir === undefined || folder === undefined) - throw new CliError("Usage: plans-share setup --folder "); - return { dir, folder }; -} - -function checkMainWorktreeRoot(ctx: CliContext): void { - const toplevel = gitOutput(ctx.cwd, "rev-parse", "--show-toplevel"); - if (realpathSync(toplevel) !== realpathSync(ctx.cwd)) - throw new CliError("Run this command from the repository root."); - const gitDir = gitOutput(ctx.cwd, "rev-parse", "--absolute-git-dir"); - const commonDir = gitOutput(ctx.cwd, "rev-parse", "--git-common-dir"); - if (realpathSync(gitDir) !== realpathSync(resolve(ctx.cwd, commonDir))) - throw new CliError( - "Run this command from the main worktree. Linked worktrees reach .plans through it.", - ); -} - -function checkClone(ctx: CliContext, cloneDir: string): void { - if (!existsSync(cloneDir)) - throw new CliError( - `${cloneDir} does not exist. Clone the team plans repository there first (see the instruction file).`, - ); - if (!existsSync(join(cloneDir, ".git"))) - throw new CliError( - `${cloneDir} is not a git repository. Point plans:setup at a clone of the team plans repository.`, - ); - if (realpathSync(cloneDir) === realpathSync(ctx.cwd)) - throw new CliError( - `${cloneDir} is the product repository itself. Point plans:setup at a clone of the team plans repository.`, - ); -} - -function linkPlans(ctx: CliContext, projectDir: string): void { - const plansPath = join(ctx.cwd, ".plans"); - const stats = lstatSync(plansPath, { throwIfNoEntry: false }); - if (stats?.isSymbolicLink()) { - if (existsSync(plansPath) && realpathSync(plansPath) === realpathSync(projectDir)) { - ctx.stdout.write(".plans already links to the plans repository.\n"); - return; - } - rmSync(plansPath); - } else if (stats?.isDirectory()) { - migratePlansContent(ctx, plansPath, projectDir); - } else if (stats) { - throw new CliError(".plans exists and is not a directory."); - } - const target = relative(ctx.cwd, projectDir); - symlinkSync(target, plansPath); - ctx.stdout.write(`Linked .plans → ${target}\n`); - ctx.stdout.write(`Publish with: ${ctx.syncCommand}\n`); -} - -function migratePlansContent(ctx: CliContext, plansPath: string, projectDir: string): void { - const entries = readdirSync(plansPath); - const collisions = entries.filter((entry) => existsSync(join(projectDir, entry))); - if (collisions.length > 0) - throw new CliError( - `Cannot migrate .plans: already in ${projectDir}: ${collisions.join(", ")}. ` + - "Merge them manually, then re-run.", - ); - for (const entry of entries) - cpSync(join(plansPath, entry), join(projectDir, entry), { recursive: true }); - rmSync(plansPath, { recursive: true }); - if (entries.length > 0) - ctx.stdout.write(`Migrated ${entries.length} entries from the local .plans directory.\n`); -} diff --git a/packages/plans-share/test/plans-share.test.ts b/packages/plans-share/test/plans-share.test.ts deleted file mode 100644 index 2c897603..00000000 --- a/packages/plans-share/test/plans-share.test.ts +++ /dev/null @@ -1,599 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { - existsSync, - lstatSync, - mkdirSync, - mkdtempSync, - readlinkSync, - renameSync, - rmSync, - symlinkSync, - utimesSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; - -import { main } from "../src/cli.js"; - -let suiteDir: string; -let fixtureDir: string; - -beforeAll(() => { - suiteDir = mkdtempSync(join(tmpdir(), "plans-share-suite-")); - const gitConfig = join(suiteDir, "gitconfig"); - writeFileSync( - gitConfig, - "[user]\n\tname = Test\n\temail = test@example.com\n[init]\n\tdefaultBranch = main\n", - ); - process.env.GIT_CONFIG_GLOBAL = gitConfig; - process.env.GIT_CONFIG_SYSTEM = "/dev/null"; -}); - -afterAll(() => { - rmSync(suiteDir, { recursive: true, force: true }); -}); - -afterEach(() => { - if (fixtureDir) rmSync(fixtureDir, { recursive: true, force: true }); -}); - -interface Fixture { - root: string; - product: string; - remoteUrl: string; -} - -function makeFixture(): Fixture { - fixtureDir = mkdtempSync(join(tmpdir(), "plans-share-")); - const remoteUrl = join(fixtureDir, "remote.git"); - execGit(fixtureDir, "init", "--quiet", "--bare", remoteUrl); - execGit(fixtureDir, "clone", "--quiet", remoteUrl, join(fixtureDir, "team-plans")); - const product = join(fixtureDir, "product"); - execGit(fixtureDir, "init", "--quiet", product); - writeFileSync(join(product, "README.md"), "product\n"); - execGit(product, "add", "-A"); - execGit(product, "commit", "--quiet", "-m", "init"); - return { root: fixtureDir, product, remoteUrl }; -} - -function execGit(dir: string, ...args: string[]): string { - return execFileSync("git", ["-C", dir, ...args], { encoding: "utf-8" }).trim(); -} - -function addWorktree(fixture: Fixture): string { - const worktree = join(fixture.root, "product-feat"); - execGit(fixture.product, "worktree", "add", "--quiet", worktree, "-b", "feat"); - return worktree; -} - -interface RunResult { - code: number; - stdout: string; - stderr: string; -} - -function run(cwd: string, ...args: string[]): RunResult { - let stdout = ""; - let stderr = ""; - const code = main({ - argv: ["node", "plans-share", ...args], - cwd, - stdout: { write: (s) => (stdout += s) }, - stderr: { write: (s) => (stderr += s) }, - }); - return { code, stdout, stderr }; -} - -function runSetup(fixture: Fixture, dir = join(fixture.root, "team-plans")): RunResult { - 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(); - const result = runSetup(fixture); - expect(result.stderr).toBe(""); - expect(result.code).toBe(0); - expect(lstatSync(join(fixture.product, ".plans")).isSymbolicLink()).toBe(true); - expect(readlinkSync(join(fixture.product, ".plans"))).toBe(join("..", "team-plans", "myproj")); - expect(existsSync(join(fixture.root, "team-plans", "myproj"))).toBe(true); - }); - - it("migrates existing .plans content into the clone", () => { - const fixture = makeFixture(); - const ticketDir = join(fixture.product, ".plans", "123"); - mkdirSync(ticketDir, { recursive: true }); - writeFileSync(join(ticketDir, "A1-spec.md"), "spec\n"); - const result = runSetup(fixture); - expect(result.code).toBe(0); - expect(existsSync(join(fixture.root, "team-plans", "myproj", "123", "A1-spec.md"))).toBe(true); - expect(lstatSync(join(fixture.product, ".plans")).isSymbolicLink()).toBe(true); - }); - - it("reports all migration collisions without copying anything", () => { - const fixture = makeFixture(); - const cloneDir = join(fixture.root, "team-plans"); - mkdirSync(join(cloneDir, "myproj", "123"), { recursive: true }); - mkdirSync(join(cloneDir, "myproj", "456"), { recursive: true }); - mkdirSync(join(fixture.product, ".plans", "123"), { recursive: true }); - mkdirSync(join(fixture.product, ".plans", "456"), { recursive: true }); - mkdirSync(join(fixture.product, ".plans", "789"), { recursive: true }); - const result = runSetup(fixture); - expect(result.code).toBe(1); - expect(result.stderr).toContain("123, 456"); - expect(existsSync(join(cloneDir, "myproj", "789"))).toBe(false); - expect(lstatSync(join(fixture.product, ".plans")).isDirectory()).toBe(true); - }); - - it("is idempotent once linked", () => { - const fixture = makeFixture(); - runSetup(fixture); - const result = runSetup(fixture); - expect(result.code).toBe(0); - expect(result.stdout).toContain("already links"); - }); - - it("fails when the directory does not exist", () => { - const fixture = makeFixture(); - const result = runSetup(fixture, join(fixture.root, "nowhere")); - expect(result.code).toBe(1); - expect(result.stderr).toContain("does not exist"); - }); - - it("fails when the directory is not a git repository", () => { - const fixture = makeFixture(); - const plainDir = join(fixture.root, "plain"); - mkdirSync(plainDir); - const result = runSetup(fixture, plainDir); - expect(result.code).toBe(1); - expect(result.stderr).toContain("not a git repository"); - }); - - it("fails when the directory is the product repository itself", () => { - const fixture = makeFixture(); - const result = runSetup(fixture, fixture.product); - expect(result.code).toBe(1); - expect(result.stderr).toContain("product repository itself"); - }); - - it("re-links after the clone moved", () => { - const fixture = makeFixture(); - runSetup(fixture); - const movedDir = join(fixture.root, "moved-plans"); - renameSync(join(fixture.root, "team-plans"), movedDir); - const result = runSetup(fixture, movedDir); - expect(result.code).toBe(0); - expect(readlinkSync(join(fixture.product, ".plans"))).toBe(join("..", "moved-plans", "myproj")); - }); - - it("refuses to run from a linked worktree", () => { - const fixture = makeFixture(); - const worktree = join(fixture.root, "product-feat"); - execGit(fixture.product, "worktree", "add", "--quiet", worktree, "-b", "feat"); - const result = run(worktree, "setup", join(fixture.root, "team-plans"), "--folder", "myproj"); - expect(result.code).toBe(1); - expect(result.stderr).toContain("main worktree"); - }); -}); - -describe("plans-share check", () => { - it("succeeds when .plans is linked to a plans repository", () => { - const fixture = makeFixture(); - runSetup(fixture); - const result = run(fixture.product, "check"); - expect(result.stderr).toBe(""); - expect(result.code).toBe(0); - expect(result.stdout).toContain("linked to the team plans repository"); - }); - - it("fails when .plans is missing", () => { - const fixture = makeFixture(); - const result = run(fixture.product, "check"); - expect(result.code).toBe(1); - expect(result.stderr).toContain("Clone the team plans repository"); - }); - - it("reports local plans mode when .plans is a plain directory", () => { - const fixture = makeFixture(); - mkdirSync(join(fixture.product, ".plans")); - const result = run(fixture.product, "check"); - expect(result.stderr).toBe(""); - expect(result.code).toBe(0); - expect(result.stdout).toContain("local plans mode"); - }); - - it("reports local plans mode identically from a linked worktree", () => { - const fixture = makeFixture(); - mkdirSync(join(fixture.product, ".plans")); - const worktree = join(fixture.root, "product-feat"); - execGit(fixture.product, "worktree", "add", "--quiet", worktree, "-b", "feat"); - symlinkSync(join("..", "product", ".plans"), join(worktree, ".plans")); - const result = run(worktree, "check"); - expect(result.stderr).toBe(""); - expect(result.code).toBe(0); - expect(result.stdout).toContain("local plans mode"); - }); - - it("reports shared mode from a linked worktree", () => { - const fixture = makeFixture(); - runSetup(fixture); - const worktree = join(fixture.root, "product-feat"); - execGit(fixture.product, "worktree", "add", "--quiet", worktree, "-b", "feat"); - symlinkSync(join("..", "product", ".plans"), join(worktree, ".plans")); - const result = run(worktree, "check"); - expect(result.stderr).toBe(""); - expect(result.code).toBe(0); - expect(result.stdout).toContain("team plans repository"); - }); - - it("fails when the .plans symlink is broken", () => { - const fixture = makeFixture(); - runSetup(fixture); - renameSync(join(fixture.root, "team-plans"), join(fixture.root, "moved-plans")); - const result = run(fixture.product, "check"); - expect(result.code).toBe(1); - expect(result.stderr).toContain("broken"); - }); - - it("fails when .plans is a regular file", () => { - const fixture = makeFixture(); - writeFileSync(join(fixture.product, ".plans"), "oops\n"); - const result = run(fixture.product, "check"); - expect(result.code).toBe(1); - expect(result.stderr).toContain("not a directory"); - }); - - // Local mode is a plain directory, so a symlink out of any repository means the clone moved. - // It must stay an error. - it("fails when .plans links to a directory outside any git repository", () => { - const fixture = makeFixture(); - mkdirSync(join(fixture.root, "plain")); - symlinkSync(join("..", "plain"), join(fixture.product, ".plans")); - const result = run(fixture.product, "check"); - expect(result.code).toBe(1); - expect(result.stderr).toContain("outside any git repository"); - }); - - it("reports shared mode when .plans links to another repository", () => { - const fixture = makeFixture(); - runSetup(fixture); - const result = run(fixture.product, "check"); - expect(result.code).toBe(0); - expect(result.stdout).toContain("team plans repository"); - }); -}); - -describe("plans-share sync", () => { - it("publishes plan files to the remote", () => { - const fixture = makeFixture(); - runSetup(fixture); - const ticketDir = join(fixture.product, ".plans", "77"); - mkdirSync(ticketDir, { recursive: true }); - writeFileSync(join(ticketDir, "A1-spec.md"), "spec\n"); - const result = run(fixture.product, "sync"); - expect(result.stderr).toBe(""); - expect(result.code).toBe(0); - expect(result.stdout).toContain("Plans synchronized: local changes sent."); - const remoteFiles = execGit(fixture.remoteUrl, "ls-tree", "-r", "HEAD", "--name-only"); - expect(remoteFiles).toContain("myproj/77/A1-spec.md"); - }); - - it("reports nothing to send when already synchronized", () => { - const fixture = makeFixture(); - runSetup(fixture); - run(fixture.product, "sync"); - const result = run(fixture.product, "sync"); - expect(result.code).toBe(0); - expect(result.stdout).toContain("Plans synchronized: nothing to send."); - }); - - it("sends commits left over from a previously failed push", () => { - const fixture = makeFixture(); - runSetup(fixture); - writeFileSync(join(fixture.product, ".plans", "first.md"), "first\n"); - run(fixture.product, "sync"); - const plansClone = join(fixture.root, "team-plans"); - writeFileSync(join(fixture.product, ".plans", "note.md"), "note\n"); - execGit(plansClone, "add", "-A"); - execGit(plansClone, "commit", "--quiet", "-m", "sync"); - const result = run(fixture.product, "sync"); - expect(result.code).toBe(0); - expect(result.stdout).toContain("Plans synchronized: local changes sent."); - const remoteFiles = execGit(fixture.remoteUrl, "ls-tree", "-r", "HEAD", "--name-only"); - expect(remoteFiles).toContain("myproj/note.md"); - }); - - it("is a no-op in local plans mode", () => { - const fixture = makeFixture(); - mkdirSync(join(fixture.product, ".plans")); - const result = run(fixture.product, "sync"); - expect(result.stderr).toBe(""); - expect(result.code).toBe(0); - expect(result.stdout).toContain("(local plans mode, nothing to sync)"); - }); - - // A linked worktree has its own toplevel. Comparing against it would read this local `.plans` - // as shared, and sync would commit into the product repository. - it("is a no-op in local plans mode from a linked worktree", () => { - const fixture = makeFixture(); - mkdirSync(join(fixture.product, ".plans")); - const worktree = addWorktree(fixture); - symlinkSync(join("..", "product", ".plans"), join(worktree, ".plans")); - const head = execGit(fixture.product, "rev-parse", "HEAD"); - const result = run(worktree, "sync"); - expect(result.stderr).toBe(""); - expect(result.code).toBe(0); - expect(result.stdout).toContain("(local plans mode, nothing to sync)"); - expect(execGit(fixture.product, "rev-parse", "HEAD")).toBe(head); - }); - - // The worktree owns this .plans, so its toplevel is the worktree itself, never the main one. - it("is a no-op for a plain .plans belonging to a linked worktree", () => { - const fixture = makeFixture(); - const worktree = addWorktree(fixture); - mkdirSync(join(worktree, ".plans")); - const head = execGit(worktree, "rev-parse", "HEAD"); - const result = run(worktree, "sync"); - expect(result.stderr).toBe(""); - expect(result.code).toBe(0); - expect(result.stdout).toContain("(local plans mode, nothing to sync)"); - expect(execGit(worktree, "rev-parse", "HEAD")).toBe(head); - }); - - it("fails when .plans is a regular file", () => { - const fixture = makeFixture(); - writeFileSync(join(fixture.product, ".plans"), "oops\n"); - const result = run(fixture.product, "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/packages/plans-share/tsconfig.build.json b/packages/plans-share/tsconfig.build.json deleted file mode 100644 index f1e54d4d..00000000 --- a/packages/plans-share/tsconfig.build.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2023", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "strict": true, - "skipLibCheck": true, - "types": ["node"] - }, - "include": ["src"] -} diff --git a/packages/plans-share/tsconfig.json b/packages/plans-share/tsconfig.json deleted file mode 100644 index e7861fcf..00000000 --- a/packages/plans-share/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "./tsconfig.build.json", - "compilerOptions": { - "rootDir": ".", - "noEmit": true - }, - "include": ["src", "test"] -} diff --git a/packages/workspace/src/port-claim.ts b/packages/workspace/src/port-claim.ts new file mode 100644 index 00000000..d65bfe47 --- /dev/null +++ b/packages/workspace/src/port-claim.ts @@ -0,0 +1,72 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { ConfigError } from "./errors.js"; +import type { ResolvedPortsConfig } from "./ports.js"; + +interface PortRange { + first: number; + last: number; +} + +export function checkPortClaim( + currentWorktree: string, + ports: ResolvedPortsConfig | undefined, +): void { + const path = join(currentWorktree, ".alignfirst.json"); + if (!existsSync(path)) return; + const range = readPortRange(path); + if (ports === undefined) { + if (range !== undefined) + throw new ConfigError( + `Config error: .alignfirst.json declares \`portRange\` ${formatRange(range)} but ` + + "workspace.mjs declares no `ports`. Remove `portRange` or declare the port scheme.", + ); + return; + } + const expected = expectedPortRange(ports); + if (range === undefined) + throw new ConfigError( + "Config error: .alignfirst.json declares no `portRange`; the port scheme of workspace.mjs " + + `claims ${formatRange(expected)}. Write ${formatJsonRange(expected)}.`, + ); + if (range.first === expected.first && range.last === expected.last) return; + throw new ConfigError( + `Config error: \`portRange\` in .alignfirst.json is ${formatRange(range)}; the port scheme of ` + + `workspace.mjs claims ${formatRange(expected)}. Write ${formatJsonRange(expected)} or fix the scheme.`, + ); +} + +function readPortRange(path: string): PortRange | undefined { + let value: unknown; + try { + value = JSON.parse(readFileSync(path, "utf-8")); + } catch { + throw new ConfigError(`Config error: ${path} is not valid JSON.`); + } + if (!isRecord(value) || !isPortRange(value.portRange)) return; + return value.portRange; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isPortRange(value: unknown): value is PortRange { + return isRecord(value) && Number.isInteger(value.first) && Number.isInteger(value.last); +} + +function expectedPortRange(ports: ResolvedPortsConfig): PortRange { + return { + first: ports.base, + last: ports.base + ports.perWorkspace * ports.maxWorkspaces - 1, + }; +} + +function formatRange(range: PortRange): string { + return `${range.first}..${range.last}`; +} + +function formatJsonRange(range: PortRange): string { + return `\`"portRange": { "first": ${range.first}, "last": ${range.last} }\``; +} diff --git a/packages/workspace/src/workspace.ts b/packages/workspace/src/workspace.ts index 74297357..abd86cc6 100644 --- a/packages/workspace/src/workspace.ts +++ b/packages/workspace/src/workspace.ts @@ -42,6 +42,7 @@ import { followLogFile, LOG_TAIL_LINES, replayTail } from "./log-polling.js"; import { refuseOldRegistry, runMigrate } from "./migrate.js"; import { findOrphanNames } from "./orphans.js"; import { wsCmd } from "./package-manager.js"; +import { checkPortClaim } from "./port-claim.js"; import { firstPortOf, type PortsConfig, @@ -353,6 +354,7 @@ export async function runWorkspace(config: WorkspaceConfig): Promise { try { const ctx = detectWorktree(); + checkPortClaim(ctx.currentWorktree, kernel.ports); if (command.kind === "migrate") { runMigrate(ctx, { diff --git a/packages/workspace/test/port-claim.test.ts b/packages/workspace/test/port-claim.test.ts new file mode 100644 index 00000000..189e3206 --- /dev/null +++ b/packages/workspace/test/port-claim.test.ts @@ -0,0 +1,76 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { ConfigError } from "../src/errors.js"; +import { checkPortClaim } from "../src/port-claim.js"; +import type { ResolvedPortsConfig } from "../src/ports.js"; + +const dirs: string[] = []; +const ports: ResolvedPortsConfig = { + base: 8100, + perWorkspace: 2, + maxWorkspaces: 10, + names: ["web"], +}; + +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("port claim", () => { + it("skips the check when .alignfirst.json is absent", () => { + expect(() => checkPortClaim(temp(), ports)).not.toThrow(); + }); + + it("accepts a matching claim and ignores unrelated keys", () => { + const dir = temp(); + writeConfig(dir, { schemaVersion: 1, other: true, portRange: { first: 8100, last: 8119 } }); + expect(() => checkPortClaim(dir, ports)).not.toThrow(); + }); + + it("reports both ranges for a mismatch", () => { + const dir = temp(); + writeConfig(dir, { portRange: { first: 8200, last: 8219 } }); + expect(() => checkPortClaim(dir, ports)).toThrow(/8200\.\.8219.*8100\.\.8119/); + }); + + it("requires a claim when workspace declares ports", () => { + const dir = temp(); + writeConfig(dir, { schemaVersion: 1 }); + expect(() => checkPortClaim(dir, ports)).toThrow(/declares no `portRange`.*8100\.\.8119/); + }); + + it("rejects a claim when workspace is portless", () => { + const dir = temp(); + writeConfig(dir, { portRange: { first: 8100, last: 8119 } }); + expect(() => checkPortClaim(dir, undefined)).toThrow(/declares no `ports`/); + }); + + it("treats another portRange shape as absent", () => { + const dir = temp(); + writeConfig(dir, { portRange: { first: "8100", last: 8119 } }); + expect(() => checkPortClaim(dir, undefined)).not.toThrow(); + }); + + it("reports invalid JSON", () => { + const dir = temp(); + writeFileSync(join(dir, ".alignfirst.json"), "{"); + expect(() => checkPortClaim(dir, ports)).toThrow(ConfigError); + expect(() => checkPortClaim(dir, ports)).toThrow( + `${join(dir, ".alignfirst.json")} is not valid JSON`, + ); + }); +}); + +function temp(): string { + const dir = mkdtempSync(join(tmpdir(), "workspace-port-claim-")); + dirs.push(dir); + return dir; +} + +function writeConfig(dir: string, value: unknown): void { + writeFileSync(join(dir, ".alignfirst.json"), JSON.stringify(value)); +} diff --git a/skills/al/SKILL.md b/skills/al/SKILL.md index a4a3b0c7..020076a2 100644 --- a/skills/al/SKILL.md +++ b/skills/al/SKILL.md @@ -2,8 +2,11 @@ name: al description: "Execute the AlignFirst AAD protocol." disable-model-invocation: true +license: CC0 1.0 +metadata: + author: Paleo + version: "4.0.0" + repository: https://github.com/paleo/alignfirst --- -Read the *alignfirst* skill (`../alignfirst/SKILL.md`) and its `references/aad-protocol.md` if not already loaded. - -Execute the _AAD_ protocol from the *alignfirst* skill. Do not use your own plan mode. +Run `npx -y alignfirst guide aad` and follow the protocol. Do not use your own plan mode. diff --git a/skills/alcatchup/SKILL.md b/skills/alcatchup/SKILL.md index 5ef067b8..6c3ae570 100644 --- a/skills/alcatchup/SKILL.md +++ b/skills/alcatchup/SKILL.md @@ -2,8 +2,11 @@ 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 +license: CC0 1.0 +metadata: + author: Paleo + version: "4.0.0" + repository: https://github.com/paleo/alignfirst --- -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. +Run `npx -y alignfirst guide catchup` and follow the protocol. Do not use your own plan mode. diff --git a/skills/aldescription/SKILL.md b/skills/aldescription/SKILL.md index 99768e61..91e5c59a 100644 --- a/skills/aldescription/SKILL.md +++ b/skills/aldescription/SKILL.md @@ -2,8 +2,11 @@ name: aldescription description: "Execute the AlignFirst description protocol." disable-model-invocation: true +license: CC0 1.0 +metadata: + author: Paleo + version: "4.0.0" + repository: https://github.com/paleo/alignfirst --- -Read the *alignfirst* skill (`../alignfirst/SKILL.md`) and its `references/description-protocol.md` if not already loaded. - -Execute the _description_ protocol from the *alignfirst* skill. Do not use your own plan mode. +Run `npx -y alignfirst guide description` and follow the protocol. 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 6e2960fe..9fe544e5 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.32.0" + version: "0.33.0" repository: https://github.com/paleo/alignfirst --- @@ -31,14 +31,14 @@ One caveat everywhere: only the message that **ends your turn** is guaranteed to ## Projects -`alproject list --json` is the authoritative project inventory. Keep these values distinct: +`alcode projects list --json --root ~/projects` is the authoritative project inventory. Keep these values distinct: - **PROJECT** — the main-worktree directory name shown to the user. - **PROJECT_PATH** — the canonical absolute main-worktree path returned by the inventory. PROJECT_PATH anchors project-file reads, main-worktree Git commands, workspace tooling, and lifecycle delegation. After workspace setup, use the returned linked-worktree path for branch work and `alcode`. Linked worktrees may live under any configured project parent. -Channel/DM: obtain PROJECT and PROJECT_PATH from `alproject list --json`, following the channel procedure. Never rely on memorized names. +Channel/DM: obtain PROJECT and PROJECT_PATH from `alcode projects list --json --root ~/projects`, following the channel procedure. Never rely on memorized names. Thread: recover the values the starter recorded via `message action: "read"`. It always carries the task and may carry one or more projects, canonical paths, a ticket, and the full request. Resolve deferred values through the working-session procedure. Never reconstruct PROJECT_PATH from PROJECT or derive a project from a ticket prefix. diff --git a/skills/alignfirst-developer-openclaw-playbook/references/channel-handling.md b/skills/alignfirst-developer-openclaw-playbook/references/channel-handling.md index bc95ba4e..7f040558 100644 --- a/skills/alignfirst-developer-openclaw-playbook/references/channel-handling.md +++ b/skills/alignfirst-developer-openclaw-playbook/references/channel-handling.md @@ -4,22 +4,21 @@ You're running in a channel (Slack) or channel/DM (Discord). Your job is to tria ## Project lookup -`alproject list --json` (`exec`) is the only source of project names and paths. Any word you do not recognize may be a project name, so classifying a message that could refer to a project requires the inventory: reuse the transcript's inventory result or run the command first. Only a message with no possible project reference — a bare greeting, small talk — is answerable without it. +`alcode projects list --json --root ~/projects` (`exec`) is the only source of project names and paths. Any word you do not recognize may be a project name, so classifying a message that could refer to a project requires the inventory: reuse the transcript's inventory result or run the command first. Only a message with no possible project reference — a bare greeting, small talk — is answerable without it. -Retain the complete result; reuse it while it remains sufficient, and refresh it when the registry may have changed or it cannot resolve the request. +Retain the complete result; reuse it while it remains sufficient, and refresh it when the project tree may have changed or it cannot resolve the request. -If `alproject list --json` fails, report the error and end the turn. Do not route against a partial or remembered inventory. +If `alcode projects list --json --root ~/projects` fails, report the error and end the turn. Do not route against a partial or remembered inventory. Resolve PROJECT and PROJECT_PATH from that result: - **PROJECT** — the selected main-worktree directory name. - **PROJECT_PATH** — its canonical absolute main-worktree path. -- For ordinary work, only records whose status is `registered` or `unregistered` can supply PROJECT_PATH. A `missing` record is a discrepancy, not a usable project location. -- For project removal, a `missing` record can supply PROJECT_PATH so the lifecycle procedure can unregister it. -- A mentioned name with one eligible match supplies both values. A name counts as mentioned wherever it appears, including inside a resource URL's path (a repository URL naming the project, for instance). -- A mentioned name with several eligible matches supplies PROJECT but leaves PROJECT_PATH unresolved. Ask the user to select one of the matching canonical paths. -- A mentioned name with no eligible match supplies the proposed PROJECT but leaves PROJECT_PATH unresolved. Mention a same-name `missing` discrepancy when present. -- 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. +- Only a project in the `projects` list supplies PROJECT_PATH. A name that appears only under a directory's `others` is a directory without `.alignfirst.json`, not a prepared project: report it and ask for a usable project path. For project removal, the listed project's path is PROJECT_PATH. +- A mentioned name with one listed match supplies both values. A name counts as mentioned wherever it appears, including inside a resource URL's path (a repository URL naming the project, for instance). +- A mentioned name with several listed matches supplies PROJECT but leaves PROJECT_PATH unresolved. Ask the user to select one of the matching canonical paths. +- A mentioned name with no listed match supplies the proposed PROJECT but leaves PROJECT_PATH unresolved. +- With no mentioned project, infer both values only when the list contains exactly one project. Zero or several 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. @@ -91,9 +90,9 @@ Earlier channel context that the thread session would otherwise lose belongs in The `{ask}` is one sentence, and it reflects the first unresolved requirement: - Duplicate PROJECT matches → list the matching canonical paths and ask which PROJECT_PATH to use. -- No PROJECT_PATH for project removal → ask which registered canonical path to remove. +- No PROJECT_PATH for project removal → ask which listed canonical path to remove. - A clearly single-project task with no PROJECT → ask which project it belongs to, restating the ticket id when present. -- An unresolved PROJECT for ordinary single-project work → state that the name is not in the project inventory, then ask for the registered project path. +- An unresolved PROJECT for ordinary single-project work → state that the name is not in the project inventory, then ask for the path of a listed project. - No TICKET_ID for single-project work → ask for the ticket id, unless the message contains a resource URL that can provide it, carries a detailed request, explicitly says there is no ticket, or is operational work handled without an AlignFirst protocol. The working session handles ticket creation or collection for a detailed request. - No TASK → ask what needs to be done. - A resource URL that may provide the project or ticket → ask for neither; state that the user's diff --git a/skills/alignfirst-developer-openclaw-playbook/references/runbooks/project-lifecycle.md b/skills/alignfirst-developer-openclaw-playbook/references/runbooks/project-lifecycle.md index afdc76a2..9392d131 100644 --- a/skills/alignfirst-developer-openclaw-playbook/references/runbooks/project-lifecycle.md +++ b/skills/alignfirst-developer-openclaw-playbook/references/runbooks/project-lifecycle.md @@ -4,7 +4,7 @@ Use this procedure only to create a project, onboard a repository to clone, or p ## Start with the project guide -Run `alproject --guide` and read the complete output before any lifecycle action. Its appended project-specific section carries the host's allowed parents and operating constraints. Follow those constraints throughout this procedure. +Run `alcode projects --guide --root ~/projects` and read the complete output before any lifecycle action. This call is mandatory for creation, onboarding, and removal; the JSON project inventory does not replace it. The sections it renders for each projects directory carry the host's allowed directories, their descriptions, and port ranges. Follow those constraints throughout this procedure. ## Create a project @@ -14,10 +14,10 @@ Project creation is bootstrap work, not an AlignFirst protocol. Through the init Before creating a directory, load the `alignfirst-setup-guide` skill. If the skill is unavailable or cannot be read, project creation is disabled: report that requirement and stop. Use the skill throughout the bootstrap. -1. Settle the stack, allowed parent directory, project name, and port requirements with the user. Use the `alproject --guide` output to constrain the choices. +1. Settle the stack, allowed parent directory, project name, and port requirements with the user. Use the `alcode projects --guide --root ~/projects` output to constrain the choices. 2. Create the main-worktree directory under the selected allowed parent. Initialize its Git repository on `main`. -3. Once the directory contains its `.git` directory, register the main worktree with `alproject`. Request a port allocation when required. Retain the canonical path as PROJECT_PATH and retain the reported base port and range. -4. Read the `alignfirst` skill and create `.plans/`. Use the external ticket ID when the request has one. Otherwise reserve the next side ticket with `alcode reserve-side-ticket`, run from PROJECT_PATH; it creates `.plans/side-N/` and prints the id. Write `.plans/{TICKET_ID}/A1-request.md` with the complete creation request. The bot chooses the identifier and writes the request; alcode does neither. A later plans-share setup migrates this content when it replaces the directory with a symlink. +3. Once the directory contains its `.git` directory, retain the canonical path as PROJECT_PATH. When the project declares ports, run `alcode projects free-ports --root ~/projects --size ` and retain the block; the preparation step writes it into `.alignfirst.json` (`alignfirst setup --port-range -` through the setup guide). Report that `.alignfirst.json` was written and name the block. +4. Read the `alignfirst` skill and create `.plans/`. Use the external ticket ID when the request has one. Otherwise run `alignfirst ticket --side` from PROJECT_PATH; it creates `.plans/side-N/` and prints the directory. TICKET_ID is the `side-N` it reports. Write `.plans/{TICKET_ID}/A1-request.md` with the complete creation request. The bot chooses the identifier and writes the request; alcode does neither. A later plans setup migrates this content when it replaces the directory with a symlink. 5. Before delegating the bootstrap, run `alcode --openclaw-guide`. Then bootstrap directly from PROJECT_PATH through `alcode new --message`, with no protocol. Explicitly instruct it to use `alignfirst-setup-guide` and prepare the repository for an AlignFirst Developer. Include `.local/` as a gitignored shared directory in the workspace mechanism. Follow the selected stack and the host-specific guide. 6. Verify the project through the setup guide, synchronize the request artifact when the prepared project documents a plans command, and make its initial commit on `main` in PROJECT_PATH. Do not ask for confirmation before committing. 7. When a remote destination is known from the request, environment, or host instructions, configure it when needed and push `main`. Do not ask for confirmation before pushing. When no destination is known, or the user requested a local-only project, leave the committed project local and report that no remote was configured. @@ -33,9 +33,9 @@ The user hands you a repository URL to clone instead of asking for a new project Before any discussion: -1. Select a parent directory allowed by `alproject --guide`. Ask the user when several qualify. +1. Select a parent directory allowed by `alcode projects --guide --root ~/projects`. 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. +3. Retain the canonical path as PROJECT_PATH. When the project's workspace wrapper declares ports, run `alcode projects free-ports --root ~/projects --size ` and retain the block; the preparation step writes it into `.alignfirst.json` through `alignfirst setup --port-range -`. 4. Install dependencies and build, following the repository's own README. ### Step 2 — Check the AlignFirst Developer contract @@ -52,7 +52,7 @@ Ask the user to approve this procedure and whether `.plans` must be shared throu 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. +1. Create `.plans/` in the main worktree. Run `alignfirst ticket --side` 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. @@ -64,20 +64,19 @@ 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. +3. When the user chose the team plans repository, clone it under `~/projects` when no clone exists there (the projects guide names the repository), then run `alignfirst plans setup ~/projects/` from PROJECT_PATH. Otherwise, run `mkdir .plans`. 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. +Removal requires the listed PROJECT_PATH selected before the thread opened or supplied by the user. -1. Refresh `alproject list --json` and resolve the registered project at PROJECT_PATH. Read `{PROJECT_PATH}/DEVELOPERS.md`, then run and read the project workspace guide it names. +1. Run and read `alcode projects --guide --root ~/projects`, then refresh `alcode projects list --json --root ~/projects` and resolve the listed project at PROJECT_PATH. Read `{PROJECT_PATH}/DEVELOPERS.md`, then run and read the project workspace guide it names. 2. Use the project workspace tooling to enumerate every registered linked workspace and its exact absolute path. Include the exact PROJECT_PATH for the main worktree. 3. Show the user the complete linked-worktree path list and the main-worktree path. Wait for explicit confirmation of those exact paths. -4. Remove each confirmed linked workspace through the project workspace tooling. Stop immediately if any removal fails; keep the main worktree and registration intact. +4. Remove each confirmed linked workspace through the project workspace tooling. Stop immediately if any removal fails; keep the main worktree intact. 5. Remove only the confirmed main-worktree directory at PROJECT_PATH. Leave every additional directory reported by the inventory untouched. -6. After the main path is absent, run `alproject unregister ` to release the registration and port range. -7. Refresh `alproject list --json`. Report any remaining workspace, registration, or filesystem discrepancy. +6. Refresh `alcode projects list --json --root ~/projects`: the path must be absent from `projects`. Report any remaining workspace or filesystem discrepancy. Apply the host-specific and project-specific constraints read earlier throughout the sequence. diff --git a/skills/alignfirst-developer-openclaw-playbook/references/working-session.md b/skills/alignfirst-developer-openclaw-playbook/references/working-session.md index 5adbbf09..2f1f0cbe 100644 --- a/skills/alignfirst-developer-openclaw-playbook/references/working-session.md +++ b/skills/alignfirst-developer-openclaw-playbook/references/working-session.md @@ -32,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 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. +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 listed PROJECT_PATH selected in the starter or supplied by the user. Project-workspace cleanup is not physical project removal; follow "Cleanup requests" below. @@ -44,7 +44,7 @@ For new single-project work where the user explicitly says there is no ticket: 1. Read `{PROJECT_PATH}/DEVELOPERS.md` and the `alignfirst` skill. Retain the project's plans synchronization command when one is documented. 2. Run the documented plans synchronization command when the project has one, so identifier selection sees the current shared task set. -3. Run `alcode reserve-side-ticket` from PROJECT_PATH (`exec`). It creates the next free `.plans/side-N/` and prints `side-N`. Set TICKET_ID to that id. +3. Run `alignfirst ticket --side` from PROJECT_PATH (`exec`). It creates `.plans/side-N/` and prints the directory; TICKET_ID is the `side-N` it reports. 4. Immediately write `.plans/{TICKET_ID}/A1-request.md` with the complete recorded request. For a short request, use the starter's task line and the message that explicitly confirmed no ticket. 5. Run the documented plans synchronization command again when the project has one. @@ -99,7 +99,7 @@ Skip this capture workflow for a multi-project request with no main project and ### Multi-project and operational work -Delegate a multi-project request with no main project, workspace cleanup, base-branch refresh, and similar operational work to alcode without an AlignFirst protocol. Refresh `alproject list --json` when the affected project set is not already recorded. Run one project-bound alcode session from each affected PROJECT_PATH and coordinate their results in the thread. Supply the ticket ID when one identifies the workspaces and name every configured global tool the run can use. Set up project workspaces only when the operation needs them. +Delegate a multi-project request with no main project, workspace cleanup, base-branch refresh, and similar operational work to alcode without an AlignFirst protocol. Refresh `alcode projects list --json --root ~/projects` when the affected project set is not already recorded. Run one project-bound alcode session from each affected PROJECT_PATH and coordinate their results in the thread. Supply the ticket ID when one identifies the workspaces and name every configured global tool the run can use. Set up project workspaces only when the operation needs them. ### What you delegate vs do diff --git a/skills/alignfirst-setup-guide/SKILL.md b/skills/alignfirst-setup-guide/SKILL.md index 217d8f7f..2270d4bf 100644 --- a/skills/alignfirst-setup-guide/SKILL.md +++ b/skills/alignfirst-setup-guide/SKILL.md @@ -1,13 +1,13 @@ --- name: alignfirst-setup-guide description: >- - Install, upgrade, recommend, or combine AlignFirst skills, plans-share, docmap, and workspace in a + Install, upgrade, recommend, or combine the AlignFirst CLI, skills, docmap, and workspace in a consumer repository, or prepare a repository and Linux deployment for an AlignFirst Developer. compatibility: Requires git and a Node.js package manager (npm, pnpm, yarn, or bun). license: CC0 1.0 metadata: author: Paleo - version: "0.32.0" + version: "0.33.0" repository: https://github.com/paleo/alignfirst --- @@ -17,28 +17,29 @@ Route by the user's intent. Load only the references needed for that route. ## Terminology -AlignFirst is both the core `alignfirst` skill and the umbrella name for the related software -development tooling in this repository. In skill contexts, AlignFirst means the core skill, used -alone or with its command-alias companions. The core skill and any installed companions are the -**AlignFirst skills**. Use **AlignFirst tooling** for the broader product family when the -distinction matters. +The **AlignFirst CLI** is the `alignfirst` npm package and bin. It serves the protocols through +`alignfirst guide` and provides `ticket`, `sync`, `plans`, `docmap`, `config`, `setup`, and `doctor`. -The `alignfirst` skill contains the protocols. Its seven human-invoked command companions are -`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. +The **AlignFirst skills** are eight stubs that run the CLI: `alignfirst`, `alspec`, `alplan`, `al`, +`almerge`, `alreview`, `aldescription`, and `alcatchup`. The seven command skills keep +`disable-model-invocation: true`; humans invoke them as `/alspec` in Claude Code, GitHub Copilot, +Cursor, or `$alspec` in Codex. -`alignfirst-setup-guide` and `alignfirst-developer-openclaw-playbook` are separate skills. -plans-share is an optional companion to AlignFirst skills, not a fourth independent recommendation. +`alignfirst-setup-guide` and `alignfirst-developer-openclaw-playbook` are separate skills. A team +plans repository is an optional CLI mode configured through `alignfirst plans setup`. + +An AlignFirst Developer host also installs `@paleo/alcode`, the companion CLI for coding-agent +delegation and project discovery. ## Named Tool When the user names a tool, inspect the repository and proceed directly to that tool. Install or upgrade only what they requested. -- **AlignFirst skills**: [alignfirst-skills-setup.md](references/alignfirst-skills-setup.md). For an - existing v1 or v2 installation, start with [alignfirst-upgrade.md](references/alignfirst-upgrade.md). -- **plans-share**: [plans-share-setup.md](references/plans-share-setup.md). +- **AlignFirst CLI and skills**: [alignfirst-skills-setup.md](references/alignfirst-skills-setup.md). + For an existing v1, v2, or v3 installation, start with + [alignfirst-upgrade.md](references/alignfirst-upgrade.md). +- **Team plans repository**: [plans-setup.md](references/plans-setup.md). - **docmap**: [docmap-setup.md](references/docmap-setup.md). - **workspace**: [workspace-setup.md](references/workspace-setup.md). @@ -49,13 +50,14 @@ Do not present the tooling menu or add unrelated tools on this route. When the user asks what the project could adopt, inspect the repository and present these independent choices: -- **AlignFirst skills** add collaborative specification, planning, implementation, merge, review, - 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. +- **AlignFirst** installs the CLI and the eight skills for collaborative specification, planning, + implementation, merge, review, description, and catch-up workflows. `alignfirst setup` performs + the mechanical project changes. A team plans repository is an optional sub-choice. +- **docmap** makes the repository's `docs/` tree discoverable to agents and humans. It is available + through the AlignFirst CLI or as the standalone `@paleo/docmap` package. - **workspace** creates isolated git-worktree development environments. -Determine whether a team plans repository exists before recommending plans-share. Let the user choose +Determine whether a team plans repository exists before offering that option. Let the user choose any subset. ## AlignFirst Developer @@ -72,9 +74,10 @@ AlignFirst Developer builds and deploys the teammate itself. 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. When the repository has no `docs/` +1. The AlignFirst CLI as a prerequisite in `README.md`, `.alignfirst.json` as its project config, + the eight skills, and their project-specific `AGENTS.md` or `CLAUDE.md` section. +2. The team plans repository through `alignfirst plans setup` when the team has one. +3. docmap, including project scripts or CLI 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 @@ -86,6 +89,9 @@ Detect and verify the package manager, runtime, build, test, lint, dev-server, p directories, seeded configuration files, and team-plan details. Write only facts confirmed from the repository. Follow each selected tool reference above, then complete `DEVELOPERS.md`. +Every AlignFirst route uses `alignfirst setup` for its mechanical changes. The guide supplies the +project-specific judgment and prose. + ## Create an AlignFirst Developer For creating or operating the developer deployment itself, read @@ -103,10 +109,11 @@ Translate commands to that package manager. npm needs `--` before script flags; Detect existing footprints before proposing changes: -- docmap: a `docmap` script, `@paleo/docmap`, or `docs/`. +- docmap: a `docmap` script, `@paleo/docmap`, `alignfirst docmap` in an instruction file, or `docs/`. - workspace: a `workspace` script or `@paleo/workspace`. -- AlignFirst skills: a canonical skill installation, `.plans/`, or an AlignFirst instruction section. -- plans-share: a plans-share script, dependency, or `.plans` symlink. +- AlignFirst: `.alignfirst.json`, an AlignFirst CLI prerequisite in `README.md`, `.plans/`, an + AlignFirst instruction section, or a canonical skill installation. +- team plans: a `.plans` symlink or `plans.folder` in `.alignfirst.json`. - AlignFirst Developer preparation: the complete five-part contract above. Require a clean working tree immediately before project mutations. Read-only discovery and diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/.alignfirst.json b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/.alignfirst.json new file mode 100644 index 00000000..b82e50b5 --- /dev/null +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/.alignfirst.json @@ -0,0 +1,7 @@ +{ + "schemaVersion": 1, + // TEAM_PLANS_SECTION + "plans": { "folder": "{{ADMIN_REPOSITORY_NAME}}" }, + // TEAM_PLANS_SECTION + "ticketPattern": "^\\d+$" +} diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/AGENTS.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/AGENTS.md index 8baf2542..49010758 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/AGENTS.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/AGENTS.md @@ -20,13 +20,11 @@ Repository specifics: ## Docmap - Seek Documentation -*Before* any investigation or code exploration, run `npm run docmap`, then read the relevant documentation. Mandatory for every task. +*Before* any investigation or code exploration, run `alignfirst docmap`, then read the relevant documentation. Mandatory for every task. Always read `docs/overview.md`. -## AlignFirst - Ticket ID, Commit Message, Default Branch - -_Ticket ID:_ numeric, incremented from the highest existing directory in `.plans/`. This repository does not branch per ticket; ask the user when unsure. +## AlignFirst - Commit Message and Default Branch _Commit message convention:_ Conventional Commits with a very short subject, e.g. `docs: tighten 04 seed section`. No body unless the change needs one. Do not mention the ticket ID. @@ -37,7 +35,7 @@ _Default branch:_ `main`. In the main worktree, `.plans` is a symlink into a clone of the team plans repository (folder `{{ADMIN_REPOSITORY_NAME}}/`). Plans are shared with the team through that repository and are never committed in this one. -After every change in `.plans/`, synchronize the plans: `npm run plans:sync`. +After every change in `.plans/`, run `alignfirst sync`. ## Workspaces diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/DEVELOPERS.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/DEVELOPERS.md index 7f0f034a..fad209eb 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/DEVELOPERS.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/DEVELOPERS.md @@ -4,12 +4,12 @@ This repository holds the configuration of `{{SERVER_HOST}}`: runbooks under `do ## Layout -- `docs/` — runbooks and notes, listed by `npm run docmap`. -- `infra/openclaw/` — `seed.sh` and its modules, `environment.d/`, `bin/`, `alproject/`, `workspace/`, `coding-agent/`. `.env` is gitignored. +- `docs/` — runbooks and notes, listed by `alignfirst docmap`. +- `infra/openclaw/` — `seed.sh` and its modules, `environment.d/`, `bin/`, `projects/`, `workspace/`, `coding-agent/`. `.env` is gitignored. - `scripts/workspace/` — the portless workspace wrapper. - `.reports/` — one journal per operator task, committed. -- `.plans/` — task plans. Symlinked across worktrees, and into a clone of the team plans repository so plans are shared with the team. Run `npm run plans:sync` after changing anything under it. +- `.plans/` — task plans. Symlinked across worktrees, and into a clone of the team plans repository so plans are shared with the team. Run `alignfirst sync` after changing anything under it. - `.local/`, `.local-wt/` — shared notes and per-worktree state, gitignored. @@ -21,7 +21,6 @@ Run `npm run workspace -- --guide` for the procedures. ## Conventions -- _Ticket ID_: numeric, incremented from the highest existing directory in `.plans/`. Ask the user when unsure. - _Commit messages_: Conventional Commits, very short subject, no ticket ID. - _Default branch_: `main`. @@ -29,9 +28,9 @@ Run `npm run workspace -- --guide` for the procedures. | Command | Purpose | | --- | --- | -| `npm run docmap` | Browse the documentation; read `docs/overview.md` first | +| `alignfirst docmap` | Browse the documentation; read `docs/overview.md` first | | `npm run workspace -- ` | Manage worktree workspaces (`--guide` for the procedures) | | `npm run validate` | docmap check and a syntax check of the wrapper | -| `npm run plans:sync` | Publish and retrieve the task plans (`.plans`) | +| `alignfirst sync` | Publish and retrieve the task plans (`.plans`) | diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/README.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/README.md index 43342647..be150528 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/README.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/README.md @@ -1,6 +1,6 @@ # {{ADMIN_REPOSITORY_NAME}} -Private repository that reproduces and operates **{{DEVELOPER_NAME}}**, the AlignFirst Developer of {{TEAM_NAME}}, on `{{SERVER_HOST}}`. Runbooks under [`docs/`](docs/) (`npm run docmap` to browse); the OpenClaw seed, workspace files and scripts under [`infra/openclaw/`](infra/openclaw/). +Private repository that reproduces and operates **{{DEVELOPER_NAME}}**, the AlignFirst Developer of {{TEAM_NAME}}, on `{{SERVER_HOST}}`. Runbooks under [`docs/`](docs/) (`alignfirst docmap` to browse); the OpenClaw seed, workspace files and scripts under [`infra/openclaw/`](infra/openclaw/). ## Bootstrap order @@ -24,13 +24,14 @@ Then [`docs/operations/`](docs/operations/), starting with [add-project.md](docs In the admin account: ```sh +npm install -g alignfirst npm install # TEAM_PLANS_SECTION -npm run plans:setup -- +alignfirst plans setup # TEAM_PLANS_SECTION mkdir -p .plans .local npm run workspace -- setup -npm run docmap +alignfirst docmap ``` Optional upstream reference for investigations (host-only, gitignored): diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/configuration.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/configuration.md index c5cfb2fa..54c4aee7 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/configuration.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/configuration.md @@ -18,7 +18,7 @@ The seed is the record of the OpenClaw configuration. No copy of `openclaw.json` - `infra/openclaw/environment.d/` — non-secret variables for the gateway and login shells (`common.conf`, `coding-agent.conf`; `runtime.conf` is generated). - `infra/openclaw/workspace/` — the workspace files, applied by `apply-workspace.sh`. - `infra/openclaw/heartbeat-scratch.md` — the heartbeat job's checklist, pushed by `apply-heartbeat-scratch.sh` ([04 § 7](installations/04-openclaw.md#heartbeat-scratch)). -- `infra/openclaw/alproject/` — `.alproject.json` (project parent, port range) and the guide appended to `alproject --guide`. +- `infra/openclaw/projects/.alignfirst-projects.json` — the project parent, policy, and port range used by `alcode projects`. ## Module contract 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 fa2b3ae5..2b8bd248 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 @@ -37,7 +37,7 @@ coding agent: `skills remove` would delete the canonical copy for both. ## Moving a project breaks its workspace registry -`@paleo/workspace` stores each worktree as an absolute path in `.local-wt/workspace-registry/workspaces.json`. After a `mv`, every command fails with `The workspace name "" is already taken by `, and no command repairs it: `prune` skips main worktrees, `remove` is destructive. Rewrite the `worktree` string in place, keeping the name key, `createdAt`, `status` and `portIndex` (`portIndex` pins the linked worktrees' ports). `git worktree repair` is still needed for linked worktrees. `alproject` is unaffected: it reads git worktrees directly. +`@paleo/workspace` stores each worktree as an absolute path in `.local-wt/workspace-registry/workspaces.json`. After a `mv`, every command fails with `The workspace name "" is already taken by `, and no command repairs it: `prune` skips main worktrees, `remove` is destructive. Rewrite the `worktree` string in place, keeping the name key, `createdAt`, `status` and `portIndex` (`portIndex` pins the linked worktrees' ports). `git worktree repair` is still needed for linked worktrees. `alcode projects` reads the repaired git worktrees directly. ## Heartbeat cost is a main-session problem diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/02-admin-repository.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/02-admin-repository.md index 16169eb6..026f89a5 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/02-admin-repository.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/02-admin-repository.md @@ -39,22 +39,24 @@ git config --global user.name "" git config --global user.email "" git clone git@{{SERVER_HOST}}-admin: ~/{{ADMIN_REPOSITORY_NAME}} -cd ~/{{ADMIN_REPOSITORY_NAME}} && npm install +cd ~/{{ADMIN_REPOSITORY_NAME}} +npm install -g alignfirst +npm install ``` ## Team plans repository -`.plans` is a symlink into a clone of the team plans repository (folder `{{ADMIN_REPOSITORY_NAME}}/`), cloned beside this repository with the same deploy key. - -> **User action required.** Enable the deploy key on the plans repository too, with write access: `plans:sync` pushes. A key enabled read-only clones fine and fails on the first push with `This deploy key does not have write access`. +`.plans` is a symlink into `~/projects/{{PLANS_CLONE_NAME}}/{{ADMIN_REPOSITORY_NAME}}`. Clone the team plans repository once with the operator's credentials. -`` is the path part of the plans repository URL; `` is where the clone lands (a sibling of `~/{{ADMIN_REPOSITORY_NAME}}`): +> **User action required.** Enable the deploy key on the plans repository too, with write access: `alignfirst sync` pushes. A key enabled read-only clones fine and fails on the first push with `This deploy key does not have write access`. ```sh -git clone git@{{SERVER_HOST}}-admin: -cd ~/{{ADMIN_REPOSITORY_NAME}} && npm run plans:setup -- -npx --no plans-share check +mkdir -p ~/projects +git -C ~/projects clone {{PLANS_REPOSITORY_URL}} {{PLANS_CLONE_NAME}} +cd ~/{{ADMIN_REPOSITORY_NAME}} +alignfirst plans setup ~/projects/{{PLANS_CLONE_NAME}} +alignfirst plans check ``` @@ -66,7 +68,7 @@ npx --no plans-share check cd ~/{{ADMIN_REPOSITORY_NAME}} mkdir -p .plans .local npm run workspace -- setup -npm run docmap +alignfirst docmap ``` Continue with [03-toolchain.md](03-toolchain.md). diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/03-toolchain.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/03-toolchain.md index 1c7df7e5..f97cbbaf 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/03-toolchain.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/03-toolchain.md @@ -50,7 +50,7 @@ The account has no sudo, so npm globals go to `~/.npm-system-global/`. Versions ```sh sudo -H -u {{SERVICE_USER}} bash -c 'printf "prefix=%s\n" "$HOME/.npm-system-global" > ~/.npmrc' -sudo -i -u {{SERVICE_USER}} -- /usr/bin/npm install -g openclaw @paleo/alcode @paleo/alproject ctx7 +sudo -i -u {{SERVICE_USER}} -- /usr/bin/npm install -g openclaw alignfirst @paleo/alcode ctx7 ``` Install the selected coding agent under the same prefix: [08-coding-agent.md § Install](08-coding-agent.md#install). The seed in `04` requires it. @@ -58,7 +58,7 @@ Install the selected coding agent under the same prefix: [08-coding-agent.md § Verify: ```sh -sudo -i -u {{SERVICE_USER}} -- bash -lc 'which node npm openclaw alcode alproject ctx7' +sudo -i -u {{SERVICE_USER}} -- bash -lc 'which node npm openclaw alignfirst alcode ctx7' # Expected: /usr/bin/node, /usr/bin/npm, then /home/{{SERVICE_USER}}/.npm-system-global/bin/… for the rest ``` @@ -95,4 +95,4 @@ sudo -i -u {{SERVICE_USER}} -- podman info --format '{{.Host.Security.Rootless}} # Expected: true ``` -`alproject` is configured in `04`; running it before that fails on the missing `~/.alproject.json`. Continue with [05-openclaw-dependencies.md](05-openclaw-dependencies.md). +`alcode projects` needs the projects marker installed in `04`. Continue with [05-openclaw-dependencies.md](05-openclaw-dependencies.md). diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/04-openclaw.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/04-openclaw.md index 04620b2b..b4a97f5a 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/04-openclaw.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/04-openclaw.md @@ -18,7 +18,7 @@ infra/openclaw/ ├── seed/ # common.sh, surface.sh, coding-agent.sh — the configuration modules ├── environment.d/ # non-secret variables for systemd --user and login shells ├── bin/ # workspace, backup, kill-switch and maintenance scripts -├── alproject/ # .alproject.json and alproject-guide.md +├── projects/ # .alignfirst-projects.json ├── workspace/ # curated workspace files (AGENTS.md, IDENTITY.md, …) ├── heartbeat-scratch.md # the heartbeat job's comment-only checklist (step 7) └── coding-agent/ # global instruction file of the delegated coding agent @@ -114,19 +114,19 @@ sudo -i -u {{SERVICE_USER}} -- /home/{{SERVICE_USER}}/seed/bin/apply-workspace.s Later changes follow [update-workspace.md](../operations/update-workspace.md): once `06` has run, the files are immutable. -## 6. alproject +## 6. Projects marker -Create the project parent, then install the configuration and the guide as root-owned files. The registry, `{{PROJECTS_ROOT}}/alproject-registry.json`, is created by the first registration and stays owned by the service account. +Create the fixed project parent, install its marker as a root-owned file, then verify that an empty listing exits 0. ```sh -sudo -H -u {{SERVICE_USER}} bash -lc 'mkdir -p {{PROJECTS_ROOT}}' -projects_root=$(sudo -H -u {{SERVICE_USER}} bash -lc 'echo {{PROJECTS_ROOT}}') -sudo install -m 644 -o root -g root /home/{{SERVICE_USER}}/seed/alproject/.alproject.json /home/{{SERVICE_USER}}/.alproject.json -sudo install -m 644 -o root -g root /home/{{SERVICE_USER}}/seed/alproject/alproject-guide.md "$projects_root/alproject-guide.md" -sudo -i -u {{SERVICE_USER}} -- alproject list +sudo -H -u {{SERVICE_USER}} bash -lc 'mkdir -p ~/projects' +sudo install -m 644 -o root -g root \ + /home/{{SERVICE_USER}}/seed/projects/.alignfirst-projects.json \ + /home/{{SERVICE_USER}}/projects/.alignfirst-projects.json +sudo -H -u {{SERVICE_USER}} bash -lc 'alcode projects list --root ~/projects' ``` -An empty registry lists nothing and exits 0. Projects come later, through [add-project.md](../operations/add-project.md). +Projects come later through [add-project.md](../operations/add-project.md). ## 7. Gateway unit @@ -138,7 +138,7 @@ loginctl show-user {{SERVICE_USER}} | grep Linger # Expected: Linger=yes ``` -`openclaw gateway install` writes the user unit: `ExecStart` points at the installed `dist/index.js`, and the current `PATH` is baked in as `Environment=PATH=`. With the `.bash_profile` of `03`, that is `/usr/bin:…:~/.npm-system-global/bin`, which is what lets exec children find `alcode` and the coding agent. +`openclaw gateway install` writes the user unit: `ExecStart` points at the installed `dist/index.js`, and the current `PATH` is baked in as `Environment=PATH=`. With the `.bash_profile` of `03`, that is `/usr/bin:…:~/.npm-system-global/bin`, which is what lets exec children find `alignfirst`, `alcode`, and the coding agent. The installer refuses group-writable unit paths, and the account's default umask creates them that way ([gotchas.md](../gotchas.md#gateway-install-refuses-group-writable-systemd-paths)). Strip the bit first: diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/05-openclaw-dependencies.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/05-openclaw-dependencies.md index 2be54878..f0df2a92 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/05-openclaw-dependencies.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/05-openclaw-dependencies.md @@ -29,7 +29,7 @@ Then download Chromium as the service account. Resolve `playwright-core` from th sudo -i -u {{SERVICE_USER}} -- bash -lc 'openclaw_entry=$(readlink -f "$(command -v openclaw)"); playwright_cli=$(node -e '\''const fs = require("node:fs"); const path = require("node:path"); const { createRequire } = require("node:module"); const fromOpenClaw = createRequire(process.argv[1]); let cli; try { const packageFile = fromOpenClaw.resolve("playwright-core/package.json"); cli = path.join(path.dirname(packageFile), "cli.js"); } catch {} if (!cli || !fs.existsSync(cli)) { const root = path.dirname(process.argv[1]); cli = [path.join(root, "node_modules/playwright-core/cli.js"), path.join(root, "dist/extensions/browser/node_modules/playwright-core/cli.js")].find(fs.existsSync); } if (!cli) throw new Error("OpenClaw playwright-core CLI not found"); process.stdout.write(cli);'\'' "$openclaw_entry"); node "$playwright_cli" install chromium' ``` -When Chromium fails to launch, list the unresolved direct dependencies. GTK and Vulkan load through `dlopen` and do not show here; Playwright's own manifest (`deb.deps` next to the binary) is the cross-check. +When Chromium fails to launch, list the unresolved direct dependencies. GTK and Vulkan load through `dlopen` and do not show here; Playwright's own dependency list (`deb.deps` next to the binary) is the cross-check. ```sh sudo -u {{SERVICE_USER}} ldd /home/{{SERVICE_USER}}/.cache/ms-playwright/chromium-*/chrome-linux64/chrome | grep "not found" @@ -64,7 +64,7 @@ sudo ln -sf /usr/bin/batcat /usr/local/bin/bat - `build-essential` — native npm bindings (node-gyp). - `fd-find`, `bat` — Debian renames the binaries to `fdfind` and `batcat`; the symlinks restore the upstream names. - `httpie` — `http` and `https`, JSON-aware client. -- `yq` — the Ubuntu package is the Python jq wrapper; sufficient for YAML manifests and compose files. +- `yq` — the Ubuntu package is the Python jq wrapper; sufficient for YAML and compose files. - `postgresql-client` — `psql` for remote databases; a containerized one is reached with `docker exec`. - `shellcheck`, `shfmt` — for the shell scripts the developer writes. diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/06-security-hardening.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/06-security-hardening.md index c70871ed..e302d4e4 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/06-security-hardening.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/installations/06-security-hardening.md @@ -49,13 +49,12 @@ sudo chattr +i /home/{{SERVICE_USER}}/.openclaw/openclaw.json \ Accepted gap: the heartbeat checklist is the `heartbeat:main` cron job's scratch, a SQLite row ([04 § 7](04-openclaw.md#heartbeat-scratch)). No flag protects it; the agent can rewrite it through `heartbeat_respond` or `openclaw cron scratch --set`. It joins the agent-written state the policy tolerates (memory, sessions), its reach is the daily tick, and [update-developer.md](../operations/update-developer.md#smoke-test) restores it. -The alproject configuration and guide are repository-managed; the registry stays service-owned and writable: +The projects marker is repository-managed and immutable: ```sh -projects_root=$(sudo -H -u {{SERVICE_USER}} bash -lc 'echo {{PROJECTS_ROOT}}') -sudo chown root:root /home/{{SERVICE_USER}}/.alproject.json "$projects_root/alproject-guide.md" -sudo chmod 644 /home/{{SERVICE_USER}}/.alproject.json "$projects_root/alproject-guide.md" -sudo chattr +i /home/{{SERVICE_USER}}/.alproject.json "$projects_root/alproject-guide.md" +sudo chown root:root /home/{{SERVICE_USER}}/projects/.alignfirst-projects.json +sudo chmod 644 /home/{{SERVICE_USER}}/projects/.alignfirst-projects.json +sudo chattr +i /home/{{SERVICE_USER}}/projects/.alignfirst-projects.json ``` ## Skills and instructions @@ -73,7 +72,7 @@ The coding agent's own skill directory and global instruction file: [08-coding-a ## Global packages -`~/.npm-system-global/` holds `openclaw`, the coding agent, `@paleo/alcode`, `@paleo/alproject` and `ctx7`. Contract: as the service account, `npm install -g` fails with `EACCES`; project-level installs still work. +`~/.npm-system-global/` holds `openclaw`, the coding agent, `alignfirst`, `@paleo/alcode` and `ctx7`. Contract: as the service account, `npm install -g` fails with `EACCES`; project-level installs still work. ```sh sudo chown -R root:root /home/{{SERVICE_USER}}/.npm-system-global @@ -83,7 +82,7 @@ sudo chattr +i /home/{{SERVICE_USER}}/.npm-system-global ## Unlocking for maintenance -Use `/usr/local/sbin/alignfirst-developer-maintenance`. It accepts only named scopes: `config`, `workspace`, `packages`, `skills`, `alproject`, `instructions` and `agent-skills`. Before an unlock, it contains the account and refreshes `~/seed/` from this repository. Its `EXIT` trap contains the account again and restores ownership, modes and immutable flags on success, failure or interruption. The gateway stays stopped. +Use `/usr/local/sbin/alignfirst-developer-maintenance`. It accepts only named scopes: `config`, `workspace`, `packages`, `skills`, `projects`, `instructions` and `agent-skills`. Before an unlock, it contains the account and refreshes `~/seed/` from this repository. Its `EXIT` trap contains the account again and restores ownership, modes and immutable flags on success, failure or interruption. The gateway stays stopped. The operation runbooks supply the scopes and service-account command. Start the gateway only after the wrapper reports that hardening was restored and exits 0. @@ -104,14 +103,13 @@ As the service account, every write must fail with `Operation not permitted` or ```sh sudo -H -u {{SERVICE_USER}} bash -lc 'echo x >> ~/.openclaw/workspace/AGENTS.md' sudo -H -u {{SERVICE_USER}} bash -lc 'echo x >> ~/.openclaw/openclaw.json' -sudo -H -u {{SERVICE_USER}} bash -lc 'echo x >> ~/.alproject.json' -sudo -H -u {{SERVICE_USER}} bash -lc 'echo x >> {{PROJECTS_ROOT}}/alproject-guide.md' +sudo -H -u {{SERVICE_USER}} bash -lc 'echo x >> ~/projects/.alignfirst-projects.json' sudo -H -u {{SERVICE_USER}} bash -lc 'touch ~/.agents/skills/alignfirst/SKILL.md' sudo -H -u {{SERVICE_USER}} bash -lc 'mv ~/.agents ~/.agents-x' sudo -i -u {{SERVICE_USER}} -- /usr/bin/npm install -g cowsay ``` -Still working: reads of the instructions and skills, `alproject register`/`unregister`, writes under `~/.openclaw/workspace/scratch/`, `npm install` inside a project, the coding agent's authentication and session state. +Still working: reads of the instructions, skills and project listing; writes under `~/.openclaw/workspace/scratch/`; `npm install` inside a project; the coding agent's authentication and session state. Rootless podman closes the bind-mount bypass: container root maps to the service account, which cannot override the flag. diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/operations/add-project.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/operations/add-project.md index 68688be7..3a8a79ee 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/operations/add-project.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/operations/add-project.md @@ -7,72 +7,109 @@ read_when: # Add a Project -**Operator.** Run `alproject --guide` first: every project is a direct child of `{{PROJECTS_ROOT}}`, and the rendered `alproject-guide.md` describes the parent. The clone uses the service account's git access from `03`, so the repository must grant that account write access. +**Operator.** Read the projects guide first. Every project is a direct child of `~/projects`, and the +clone uses the service account's git access from `03`. + +```sh +sudo -H -u {{SERVICE_USER}} bash -lc 'alcode projects --guide --root ~/projects' +``` ## Clone and prepare ```sh -sudo -H -u {{SERVICE_USER}} bash -lc 'git -C {{PROJECTS_ROOT}} clone ' +sudo -H -u {{SERVICE_USER}} bash -lc 'git -C ~/projects clone ' ``` -A repository that lacks the AlignFirst Developer contract (AlignFirst skills, docmap, the workspace system, a `DEVELOPERS.md`) is prepared through the `alignfirst-setup-guide` skill, from a coding-agent session in the clone. +Prepare the clone through the setup skill's **Prepare a Project for an AlignFirst Developer** route. +It installs the CLI prerequisite and skills, writes `.alignfirst.json`, and configures docmap, +workspace, and `DEVELOPERS.md`. ## Team plans -The service account has its own clone of the team plans repository, under `{{PROJECTS_ROOT}}` beside the projects. It is a repository, not a project: it stays unregistered, as the rendered `alproject-guide.md` says. Clone it once: +The service account keeps its plans clone beside the projects. Clone it with the service account's +credentials when it is missing: ```sh -sudo -H -u {{SERVICE_USER}} bash -lc 'git -C {{PROJECTS_ROOT}} clone ' +sudo -H -u {{SERVICE_USER}} bash -lc ' +if [ ! -d ~/projects/{{PLANS_CLONE_NAME}}/.git ]; then + git -C ~/projects clone {{PLANS_REPOSITORY_URL}} {{PLANS_CLONE_NAME}} +fi +' ``` -Link each new project to it. Without this, `workspace setup` aborts on `plans-share check`: +From the new project root, link the plans folder configured in `.alignfirst.json`: ```sh -sudo -H -u {{SERVICE_USER}} bash -lc 'cd {{PROJECTS_ROOT}}/ && npm install && npm run plans:setup -- {{PROJECTS_ROOT}}/' +sudo -H -u {{SERVICE_USER}} bash -lc ' +cd ~/projects/ +alignfirst plans setup ~/projects/{{PLANS_CLONE_NAME}} +' ``` + +Without a usable link, `workspace setup` aborts on `alignfirst plans check`. -## Register +## Claim ports -A portless project registers with the bare command. When the project's wrapper declares ports, pass its `perWorkspace` and `maxWorkspaces`; an existing project claims its configured base, a new one omits `--base-port` and writes the returned base into its workspace configuration: +A portless project needs no claim. For a wrapper with ports, calculate +`size = perWorkspace × maxWorkspaces`, then reserve the complete project block: ```sh -sudo -i -u {{SERVICE_USER}} -- alproject register -sudo -i -u {{SERVICE_USER}} -- alproject register --ports-per-workspace --max-workspaces --base-port +sudo -H -u {{SERVICE_USER}} bash -lc ' +alcode projects free-ports --root ~/projects --size +' ``` -Registration fails without changing the registry when the range is unavailable. Moving a registered project costs more than a `mv` — see [gotchas.md](../gotchas.md#moving-a-project-breaks-its-workspace-registry). +Use the returned first and last ports while preparing the clone: + +```sh +sudo -H -u {{SERVICE_USER}} bash -lc ' +cd ~/projects/ +alignfirst setup --port-range - +' +``` + +When setup already created `.alignfirst.json`, edit its `portRange` to the returned block. The +workspace kernel checks the claim against its port scheme on every command. Confirm discovery: + +```sh +sudo -H -u {{SERVICE_USER}} bash -lc 'alcode projects list --root ~/projects' +``` ## Set up the workspace ```sh -sudo -H -u {{SERVICE_USER}} bash -lc 'cd {{PROJECTS_ROOT}}/ && npm install && npm run workspace -- setup' +sudo -H -u {{SERVICE_USER}} bash -lc 'cd ~/projects/ && npm install && npm run workspace -- setup' ``` -A project reachable through the gateway uses the `remote` profile instead. It reads `REMOTE_DEV_DOMAIN` from `environment.d/common.conf`: +A project reachable through the gateway uses the `remote` profile instead. It reads +`REMOTE_DEV_DOMAIN` from `environment.d/common.conf`: ```sh -sudo -H -u {{SERVICE_USER}} bash -lc 'cd {{PROJECTS_ROOT}}/ && npm run workspace -- setup --profile remote' +sudo -H -u {{SERVICE_USER}} bash -lc 'cd ~/projects/ && npm run workspace -- setup --profile remote' ``` ## Smoke test -Bring the dev server up, probe the URL it prints, bring it down: +Bring the dev server up, probe the URL it prints, bring it down, then inspect the discovered project: ```sh -sudo -H -u {{SERVICE_USER}} bash -lc 'cd {{PROJECTS_ROOT}}/ && npm run dev -- up' +sudo -H -u {{SERVICE_USER}} bash -lc 'cd ~/projects/ && npm run dev -- up' curl -s -o /dev/null -w "%{http_code}\n" http://localhost:/ -sudo -H -u {{SERVICE_USER}} bash -lc 'cd {{PROJECTS_ROOT}}/ && npm run dev -- down' -sudo -i -u {{SERVICE_USER}} -- alproject status +sudo -H -u {{SERVICE_USER}} bash -lc 'cd ~/projects/ && npm run dev -- down' +sudo -H -u {{SERVICE_USER}} bash -lc 'alcode projects status --root ~/projects' ``` ## Remove +Remove every linked workspace through the project's workspace command, then delete the clone. The +next listing no longer shows it: + ```sh -sudo -i -u {{SERVICE_USER}} -- alproject unregister +sudo -H -u {{SERVICE_USER}} bash -lc 'cd ~/projects/ && npm run workspace -- remove ' +sudo -H -u {{SERVICE_USER}} bash -lc 'rm -rf ~/projects/' +sudo -H -u {{SERVICE_USER}} bash -lc 'alcode projects list --root ~/projects' ``` - -The clone stays on disk until deleted by hand; remove its workspaces first (`npm run workspace -- remove`), so no container or dev server is stranded. diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/operations/configure-developer.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/operations/configure-developer.md index 62499bda..4f88d4f5 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/operations/configure-developer.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/operations/configure-developer.md @@ -47,5 +47,5 @@ Run these commands only after the wrapper reports that hardening was restored an The seed writes `~/.openclaw/openclaw.json` through `openclaw config set`, creates `~/.openclaw/workspace/scratch/`, rewrites `~/.openclaw/secrets/secrets.json` and `~/.openclaw/.env` from `~/seed/.env`, installs `~/.config/environment.d/*.conf`, and merges the coding agent's global instruction file. It does not touch: - `~/.openclaw/workspace/*.md` — [update-workspace.md](update-workspace.md). -- `~/.alproject.json` and `alproject-guide.md` — [update-developer.md](update-developer.md). +- `~/projects/.alignfirst-projects.json` — [update-developer.md](update-developer.md). - The provider and coding-agent logins — [04 § 10](../installations/04-openclaw.md#10-provider-login), [08 § Authenticate](../installations/08-coding-agent.md#authenticate). diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/operations/recover-developer.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/operations/recover-developer.md index c3a6d478..69d2a324 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/operations/recover-developer.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/operations/recover-developer.md @@ -46,7 +46,6 @@ A backup at `~/backups/deployment//` is flat. Each file goes back to one | `openclaw.env` | `~/.openclaw/.env` | — | | `workspace/*.md` | `~/.openclaw/workspace/` | `workspace` | | `environment.d/*.conf` | `~/.config/environment.d/` | — | -| `alproject-registry.json` | `{{PROJECTS_ROOT}}/alproject-registry.json` | — | ```sh sudo /usr/local/sbin/alignfirst-developer-maintenance config -- install -m 600 \ @@ -56,7 +55,7 @@ sudo /usr/local/sbin/alignfirst-developer-maintenance config -- install -m 600 \ The archive `*-openclaw-backup.tar.gz` holds the SQLite state (sessions, cron jobs and their scratch, plugin consent, device pairing) and the auth profiles. Unpack it with `openclaw backup restore --target `, then copy the needed files under `~/.openclaw/` through the `config` maintenance scope, gateway stopped. -Restoring the configuration rarely beats re-seeding: the seed rebuilds `openclaw.json`, `secrets.json`, `~/.openclaw/.env` and `environment.d/` from the repository and `.env`. Prefer the backup for the workspace files and the registry, which the seed does not write. +Restoring the configuration rarely beats re-seeding: the seed rebuilds `openclaw.json`, `secrets.json`, `~/.openclaw/.env` and `environment.d/` from the repository and `.env`. Prefer the backup for workspace files, which the seed does not write. ## Re-seed and validate @@ -67,7 +66,7 @@ Follow [configure-developer.md](configure-developer.md) to re-seed through a con ```sh sudo -i -u {{SERVICE_USER}} -- systemctl --user start openclaw-gateway sudo -i -u {{SERVICE_USER}} -- systemctl --user status openclaw-gateway -sudo -i -u {{SERVICE_USER}} -- alproject list +sudo -H -u {{SERVICE_USER}} bash -lc 'alcode projects list --root ~/projects' ``` Finish with [08-coding-agent.md § Verification](../installations/08-coding-agent.md#verification) and the smoke test of [07-channel.md](../installations/07-channel.md) before reopening the channel to users. diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/operations/update-developer.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/operations/update-developer.md index 818141fb..302a7016 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/operations/update-developer.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/operations/update-developer.md @@ -1,7 +1,7 @@ --- title: Update the Developer read_when: - - upgrading OpenClaw, the coding agent, alcode, alproject, ctx7 or the skills + - upgrading OpenClaw, the coding agent, alignfirst, alcode, ctx7 or the skills --- # Update the Developer @@ -37,7 +37,7 @@ The prefix is root-owned and immutable ([06](../installations/06-security-harden ```sh sudo /usr/local/sbin/alignfirst-developer-maintenance packages -- bash -lc ' openclaw update --yes --no-restart --accept-capabilities -/usr/bin/npm install -g @paleo/alproject@latest @paleo/alcode@latest ctx7@latest +/usr/bin/npm install -g alignfirst@latest @paleo/alcode@latest ctx7@latest ' ``` @@ -47,10 +47,10 @@ Update the coding agent through its package-scoped command: [08-coding-agent.md `openclaw update` exits 1 when its post-install doctor attempts a config write, which the immutable `openclaw.json` blocks (`ENOTDIR: not a directory, scandir '…/openclaw.json'`). Exit 0 means no write was attempted. Either way the package update succeeded; the verify step is what counts, and the migration step below finishes what the lock interrupted. -Verify — the listing must show exactly five packages (`openclaw`, the coding agent, `@paleo/alproject`, `@paleo/alcode`, `ctx7`); anything else is a stray from a mistyped install, to remove through another `packages` maintenance window: +Verify — the listing must show exactly five packages (`openclaw`, the coding agent, `alignfirst`, `@paleo/alcode`, `ctx7`); anything else is a stray from a mistyped install, to remove through another `packages` maintenance window: ```sh -sudo -i -u {{SERVICE_USER}} -- bash -lc 'openclaw --version && alproject --version && alcode --help >/dev/null && echo alcode-ok && ctx7 --version && npm ls -g --depth=0' +sudo -i -u {{SERVICE_USER}} -- bash -lc 'openclaw --version && alignfirst --version && alcode --help >/dev/null && echo alcode-ok && ctx7 --version && npm ls -g --depth=0' ``` ## Skills @@ -76,18 +76,16 @@ sudo -i -u {{SERVICE_USER}} -- find /home/{{SERVICE_USER}}/.openclaw/skills -max `~/.agents/skills/` is shared between OpenClaw and the coding agent; the `al*` command skills there are not orphans — see [gotchas.md](../gotchas.md#agentsskills-is-shared-between-openclaw-and-the-coding-agent). -## Seed snapshot and alproject files +## Seed snapshot and projects marker The wrapper refreshes the contained seed snapshot before each unlock. -Reinstall the repository-managed alproject configuration and guide; the registry is mutable state and is left alone: +Reinstall the repository-managed projects marker: ```sh -sudo /usr/local/sbin/alignfirst-developer-maintenance alproject -- bash -lc ' -projects_root=$(echo {{PROJECTS_ROOT}}) -install -m 644 ~/seed/alproject/.alproject.json ~/.alproject.json -install -m 644 ~/seed/alproject/alproject-guide.md "$projects_root/alproject-guide.md" -alproject list +sudo /usr/local/sbin/alignfirst-developer-maintenance projects -- bash -lc ' +install -m 644 ~/seed/projects/.alignfirst-projects.json ~/projects/.alignfirst-projects.json +alcode projects list --root ~/projects ' ``` diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/overview.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/overview.md index ec505d86..bbb98845 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/overview.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/overview.md @@ -11,7 +11,7 @@ read_when: - **Host:** `{{SERVER_HOST}}`, Ubuntu 24.04, time zone `{{TIME_ZONE}}`. - **Admin account:** `{{SERVER_ADMIN_USER}}` (sudo, key-only SSH). Holds this repository at `~/{{ADMIN_REPOSITORY_NAME}}`. -- **Service account:** `{{SERVICE_USER}}` (no sudo, no inbound SSH, lingering, rootless podman). Runs OpenClaw as `{{DEVELOPER_NAME}}`, the delegated coding agent, `alcode`, `alproject`, and the managed projects under `{{PROJECTS_ROOT}}`. +- **Service account:** `{{SERVICE_USER}}` (no sudo, no inbound SSH, lingering, rootless podman). Runs OpenClaw as `{{DEVELOPER_NAME}}`, the delegated coding agent, `alignfirst`, `alcode`, and the managed projects under `~/projects`. - **Public IP:** deployment-specific, written `` throughout the docs. Never substitute it from a guess. ## Request flow @@ -20,9 +20,9 @@ read_when: channel message ({{DEVELOPER_NAME}} on the selected surface) → OpenClaw gateway (systemd --user unit, loopback :18789) → workspace AGENTS.md → alignfirst-developer-openclaw-playbook (thread routing, working session) - → alproject (project inventory, canonical paths, ports) + → alcode projects (project inventory, canonical paths, ports) → alcode (delegation) → coding agent - → project workspace under {{PROJECTS_ROOT}} + → project workspace under ~/projects ``` The runtime model and the coding agent are independent choices: OpenClaw authenticates its provider, `alcode` starts the agent selected by `ALIGNFIRST_CODE_AGENT`. @@ -50,11 +50,11 @@ The dev-server range `{{PORT_RANGE_FIRST}}–{{PORT_RANGE_LAST}}` is closed. ## Projects -`alproject` is the inventory; this repository keeps no project list. +`alcode projects` discovers projects from `.alignfirst.json`; this repository keeps no project list. ```sh -sudo -i -u {{SERVICE_USER}} -- alproject list -sudo -i -u {{SERVICE_USER}} -- alproject status +sudo -H -u {{SERVICE_USER}} bash -lc 'alcode projects list --root ~/projects' +sudo -H -u {{SERVICE_USER}} bash -lc 'alcode projects status --root ~/projects' ``` Adding one: [add-project.md](operations/add-project.md). diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/troubleshooting.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/troubleshooting.md index cd5ca086..de034ecb 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/troubleshooting.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/docs/troubleshooting.md @@ -22,11 +22,11 @@ Run `secrets audit` from a login shell, as above: the file provider resolves fro ## Project routing ```sh -sudo -i -u {{SERVICE_USER}} -- alproject list --json -sudo -i -u {{SERVICE_USER}} -- alproject status --json +sudo -H -u {{SERVICE_USER}} bash -lc 'alcode projects list --json --root ~/projects' +sudo -H -u {{SERVICE_USER}} bash -lc 'alcode projects status --json --root ~/projects' ``` -`unregistered on filesystem` needs [add-project.md](operations/add-project.md); `registered but missing from filesystem` needs the clone restored or a deliberate `unregister`. Never edit the registry by hand, except for the moved-project case in [gotchas.md](gotchas.md#moving-a-project-breaks-its-workspace-registry). +A project absent from the listing has no `.alignfirst.json` or sits outside a marked projects directory. Follow [add-project.md](operations/add-project.md). For moved worktrees, see [gotchas.md](gotchas.md#moving-a-project-breaks-its-workspace-registry). ## Delegation diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/alproject/.alproject.json b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/alproject/.alproject.json deleted file mode 100644 index d6dc9ac0..00000000 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/alproject/.alproject.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "root": { - "path": "{{PROJECTS_ROOT}}", - "portRange": { "first": {{PORT_RANGE_FIRST}}, "last": {{PORT_RANGE_LAST}} } - }, - "projectParents": [{ "path": "{{PROJECTS_ROOT}}" }] -} diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/alproject/alproject-guide.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/alproject/alproject-guide.md deleted file mode 100644 index cb6d1972..00000000 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/alproject/alproject-guide.md +++ /dev/null @@ -1,9 +0,0 @@ -## Projects and directory structure - -Every project is a direct child of `{{PROJECTS_ROOT}}`. The managed port range is `{{PORT_RANGE_FIRST}}..{{PORT_RANGE_LAST}}`. Adding a project is an operator's decision: ask before creating or registering one. - - -A clone of the team plans repository may sit under the parent. It is a repository, not a project: it stays unregistered, with no ports and no workspace. - - - diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/bin/backup.sh b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/bin/backup.sh index 95838f14..a6abae90 100755 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/bin/backup.sh +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/bin/backup.sh @@ -2,7 +2,7 @@ # # Copies the deployment state of the service account into ~/backups/deployment//: # openclaw.json, the secret store, the gateway env file, the workspace files, environment.d, -# the alproject registry when present, and OpenClaw's own archive of its SQLite state. +# and OpenClaw's own archive of its SQLite state. # # Run as the service account: # sudo -i -u {{SERVICE_USER}} -- /home/{{SERVICE_USER}}/seed/bin/backup.sh @@ -13,8 +13,6 @@ umask 077 BACKUP_BASE="$HOME/backups/deployment" BACKUP_DIR= WORKSPACE="$HOME/.openclaw/workspace" -# Unquoted so a ~-prefixed PROJECTS_ROOT expands. -REGISTRY_FILE={{PROJECTS_ROOT}}/alproject-registry.json main() { check_user @@ -24,7 +22,6 @@ main() { copy_file "$HOME/.openclaw/.env" openclaw.env copy_workspace copy_environment - copy_file "$REGISTRY_FILE" alproject-registry.json create_openclaw_archive chmod -R go-rwx "$BACKUP_DIR" echo "$BACKUP_DIR" diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/bin/developer-maintenance.sh b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/bin/developer-maintenance.sh index c66f7e4a..ef5191f0 100755 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/bin/developer-maintenance.sh +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/bin/developer-maintenance.sh @@ -7,7 +7,7 @@ # Usage: # alignfirst-developer-maintenance [ ...] -- [ ...] # -# Scopes: config, workspace, packages, skills, alproject, instructions, agent-skills. +# Scopes: config, workspace, packages, skills, projects, instructions, agent-skills. set -Eeuo pipefail @@ -15,8 +15,7 @@ SERVICE_USER={{SERVICE_USER}} SERVICE_HOME=/home/{{SERVICE_USER}} ADMIN_USER={{SERVER_ADMIN_USER}} ADMIN_REPOSITORY=/home/{{SERVER_ADMIN_USER}}/{{ADMIN_REPOSITORY_NAME}} -PROJECTS_ROOT_SPEC='{{PROJECTS_ROOT}}' -PROJECTS_ROOT= +PROJECTS_MARKER="$SERVICE_HOME/projects/.alignfirst-projects.json" KILL_SWITCH=/usr/local/sbin/alignfirst-developer-kill declare -a SCOPES=() declare -a COMMAND=() @@ -56,7 +55,7 @@ parse_arguments() { while [ "$#" -gt 0 ] && [ "$1" != -- ]; do scope=$1 case "$scope" in - config|workspace|packages|skills|alproject|instructions|agent-skills) ;; + config|workspace|packages|skills|projects|instructions|agent-skills) ;; *) echo "Unknown maintenance scope: $scope" >&2; exit 2 ;; esac if [[ "$seen" = *" $scope "* ]]; then @@ -81,12 +80,6 @@ parse_arguments() { resolve_paths() { local config="$ADMIN_REPOSITORY/infra/openclaw/environment.d/coding-agent.conf" - case "$PROJECTS_ROOT_SPEC" in - '~/'*) PROJECTS_ROOT="$SERVICE_HOME/${PROJECTS_ROOT_SPEC:2}" ;; - /*) PROJECTS_ROOT=$PROJECTS_ROOT_SPEC ;; - *) echo "PROJECTS_ROOT must be absolute or start with ~/: $PROJECTS_ROOT_SPEC" >&2; exit 1 ;; - esac - if [ -r "$config" ]; then CODING_AGENT=$(sed -n 's/^ALIGNFIRST_CODE_AGENT=//p' "$config" | tail -1) fi @@ -145,10 +138,9 @@ unlock_skills() { fi } -unlock_alproject() { - chattr -i "$SERVICE_HOME/.alproject.json" "$PROJECTS_ROOT/alproject-guide.md" - chown "$SERVICE_USER:$SERVICE_USER" \ - "$SERVICE_HOME/.alproject.json" "$PROJECTS_ROOT/alproject-guide.md" +unlock_projects() { + chattr -i "$PROJECTS_MARKER" + chown "$SERVICE_USER:$SERVICE_USER" "$PROJECTS_MARKER" } unlock_instructions() { @@ -223,10 +215,10 @@ restore_skills() { return "$status" } -restore_alproject() { - chown root:root "$SERVICE_HOME/.alproject.json" "$PROJECTS_ROOT/alproject-guide.md" && - chmod 644 "$SERVICE_HOME/.alproject.json" "$PROJECTS_ROOT/alproject-guide.md" && - chattr +i "$SERVICE_HOME/.alproject.json" "$PROJECTS_ROOT/alproject-guide.md" +restore_projects() { + chown root:root "$PROJECTS_MARKER" && + chmod 644 "$PROJECTS_MARKER" && + chattr +i "$PROJECTS_MARKER" } restore_instructions() { diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/environment.d/common.conf b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/environment.d/common.conf index 2cdea258..a26a79b1 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/environment.d/common.conf +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/environment.d/common.conf @@ -5,6 +5,10 @@ NODE_COMPILE_CACHE=/home/{{SERVICE_USER}}/.cache/openclaw-compile-cache OPENCLAW_NO_RESPAWN=1 # Ryuk is unreliable on rootless podman; testcontainers cleans up on its own. TESTCONTAINERS_RYUK_DISABLED=true +# TEAM_PLANS_SECTION +# Project overlays stored in the team plans clone. The AlignFirst CLI expands the leading ~/. +ALIGNFIRST_OVERLAYS=~/projects/{{PLANS_CLONE_NAME}} +# TEAM_PLANS_SECTION # DEV_SERVER_GATEWAY_SECTION # Base domain of the remote dev URLs (workspace setup --profile remote). REMOTE_DEV_DOMAIN={{DEV_DOMAIN}} diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/projects/.alignfirst-projects.json b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/projects/.alignfirst-projects.json new file mode 100644 index 00000000..bfcda2b8 --- /dev/null +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/infra/openclaw/projects/.alignfirst-projects.json @@ -0,0 +1,7 @@ +{ + "description": "Every project is a direct child of ~/projects. Adding a project is an operator's decision: ask before creating one.", + // TEAM_PLANS_SECTION + "description": "Every project is a direct child of ~/projects. Adding a project is an operator's decision: ask before creating one. The plans clone at ~/projects/{{PLANS_CLONE_NAME}} is a repository, not a project.", + // TEAM_PLANS_SECTION + "portRange": { "first": {{PORT_RANGE_FIRST}}, "last": {{PORT_RANGE_LAST}} } +} diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/package.json b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/package.json index fc4ad296..e4639a25 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/package.json +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/package.json @@ -4,12 +4,11 @@ "type": "module", "engines": { "node": ">=22.11.0" }, "scripts": { - "docmap": "docmap", + "docmap": "alignfirst docmap", "workspace": "node scripts/workspace/workspace.mjs", - "validate": "npm run docmap -- --check && node --check scripts/workspace/workspace.mjs" + "validate": "alignfirst docmap --check && node --check scripts/workspace/workspace.mjs" }, "devDependencies": { - "@paleo/docmap": "~0.9.1", "@paleo/workspace": "~0.32.0" } } diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/scripts/workspace/workspace.mjs b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/scripts/workspace/workspace.mjs index 8e0ee115..a7087526 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/scripts/workspace/workspace.mjs +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/base/scripts/workspace/workspace.mjs @@ -13,7 +13,7 @@ await runWorkspace({ // TEAM_PLANS_SECTION preSetup: ({ isMainWorktree, currentWorktree }) => { if (!isMainWorktree) return; - execFileSync("npx", ["--no", "plans-share", "check"], { + execFileSync("alignfirst", ["plans", "check"], { cwd: currentWorktree, stdio: "inherit", }); 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 8601e333..7fc8ed9f 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 @@ -60,12 +60,12 @@ sudo -H -u {{SERVICE_USER}} bash -c "echo \"alias claudy='claude --dangerously-s ```sh sudo -i -u {{SERVICE_USER}} claude # /login, then follow the browser flow and paste the code; /exit -cd {{PROJECTS_ROOT}} && claude # accept "Trust this folder?", then /exit +cd ~/projects && claude # accept "Trust this folder?", then /exit claude auth status exit ``` -Trusting `{{PROJECTS_ROOT}}` once covers every project cloned under it; `alcode` starts `claude` inside the project directory, and an unanswered trust prompt would block the run. +Trusting `~/projects` once covers every project cloned under it; `alcode` starts `claude` inside the project directory, and an unanswered trust prompt would block the run. ### Skills @@ -139,7 +139,7 @@ After the seed and the gateway start (`04-openclaw.md`): ```sh sudo -i -u {{SERVICE_USER}} -- bash -lc 'alcode --guide | head' # names claude as the agent -sudo -i -u {{SERVICE_USER}} -- bash -lc 'alproject --guide >/dev/null && echo alproject-ok' +sudo -i -u {{SERVICE_USER}} -- bash -lc 'alcode projects --guide --root ~/projects >/dev/null && echo projects-ok' sudo -i -u {{SERVICE_USER}} -- bash -lc 'npx -y skills list -g --json' # 11 skills: 4 shared, 7 commands ``` diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/coding-agents/claude-code/infra/openclaw/environment.d/coding-agent.conf b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/coding-agents/claude-code/infra/openclaw/environment.d/coding-agent.conf index bd642af9..52eb1393 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/coding-agents/claude-code/infra/openclaw/environment.d/coding-agent.conf +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/coding-agents/claude-code/infra/openclaw/environment.d/coding-agent.conf @@ -1,4 +1,4 @@ -# alcode selects the delegated coding agent CLI. +# `alcode` selects the delegated coding agent CLI; `alignfirst` serves project-side protocols. ALIGNFIRST_CODE_AGENT=claude # Backgrounded runs have no approval loop: pass the CLI's permission-bypass flag. ALIGNFIRST_CODE_SKIP_PERMISSIONS=1 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 7b2e4a51..0e09cd73 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 @@ -162,7 +162,7 @@ After the seed and the gateway start (`04-openclaw.md`): ```sh sudo -i -u {{SERVICE_USER}} -- bash -lc 'alcode --guide | head' # names codex as the agent -sudo -i -u {{SERVICE_USER}} -- bash -lc 'alproject --guide >/dev/null && echo alproject-ok' +sudo -i -u {{SERVICE_USER}} -- bash -lc 'alcode projects --guide --root ~/projects >/dev/null && echo projects-ok' sudo -i -u {{SERVICE_USER}} -- bash -lc 'npx -y skills list -g --json' # 11 skills: 4 shared, 7 commands ``` diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/coding-agents/codex/infra/openclaw/environment.d/coding-agent.conf b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/coding-agents/codex/infra/openclaw/environment.d/coding-agent.conf index 0e5b5a08..be0f137a 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/coding-agents/codex/infra/openclaw/environment.d/coding-agent.conf +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/coding-agents/codex/infra/openclaw/environment.d/coding-agent.conf @@ -1,4 +1,4 @@ -# alcode selects the delegated coding agent CLI. +# `alcode` selects the delegated coding agent CLI; `alignfirst` serves project-side protocols. ALIGNFIRST_CODE_AGENT=codex # Backgrounded runs have no approval loop: pass the CLI's approval-and-sandbox bypass flag. ALIGNFIRST_CODE_SKIP_PERMISSIONS=1 diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/discord/docs/installations/07-channel.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/discord/docs/installations/07-channel.md index 87c3d978..00946a2a 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/discord/docs/installations/07-channel.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/discord/docs/installations/07-channel.md @@ -58,7 +58,7 @@ The seed allowlists that one channel (`channels.discord.guilds`, `groupPolicy al Run it after `08`, as the operator, from the Discord client. -1. In the allowlisted channel, request a small read-only task against a registered project (a question about the codebase, no change). +1. In the allowlisted channel, request a small read-only task against a listed project (a question about the codebase, no change). 2. The channel session creates one named thread on your message. Its starter carries the task plus the known project path and ticket. The channel root receives no duplicate starter and no setup message. 3. Answer in the thread. The fresh thread session reads its own history, delegates the read-only task, and reports in the same thread. 4. Post the same request in a channel or guild the bot is not allowlisted in. No thread opens, no work starts. diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/discord/infra/openclaw/workspace/AGENTS.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/discord/infra/openclaw/workspace/AGENTS.md index 318398f8..415d0c7a 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/discord/infra/openclaw/workspace/AGENTS.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/discord/infra/openclaw/workspace/AGENTS.md @@ -43,7 +43,7 @@ This deployment provides no ticket-system integration. Use a ticket ID the user You run **natively** on `{{SERVER_HOST}}` as the unprivileged Linux user `{{SERVICE_USER}}`. No container around you. You have **no sudo**. -`alproject` allocates dev ports in **{{PORT_RANGE_FIRST}}–{{PORT_RANGE_LAST}}**. +The projects directory's marker allocates dev ports in **{{PORT_RANGE_FIRST}}–{{PORT_RANGE_LAST}}**. That range is reachable only through the authenticated HTTPS gateway; ports outside it stay local to the server. @@ -54,7 +54,7 @@ That range is reachable only through the authenticated HTTPS gateway; ports outs - **Git and git hosts.** `git` and the CLIs of {{GIT_HOSTS}} are authenticated for your own account. Use the git-host CLI for PRs, issues, and comments. - **Browser (Playwright).** OpenClaw's Playwright plugin drives a headless Chromium from `~/.cache/ms-playwright/`; no Xvfb, no `--no-sandbox` flag. Use `page.pdf()` for HTML → PDF. - **Coding agent.** `alcode` launches the delegated coding agent CLI with its own authentication. Delegate through the playbook; never invoke the agent CLI directly. -- **Projects.** `alproject` lists registered project paths, worktrees, and port allocations. Read `alproject --guide` before project lifecycle work. +- **Projects.** `alcode projects` lists project paths, workspaces, and port ranges. Read `alcode projects --guide --root ~/projects` before project lifecycle work. - **CLI tools.** Beyond the basics (`bash`, `git`, `curl`, `wget`, `ssh`, `python3`, `vim`, `nano`, `jq`, `rg`, `dig`): - search/nav: `fd`, `tree`, `ncdu`, `bat` - data: `yq`, `sqlite3`, `psql` (local DBs live in containers — reach them via `docker exec`) diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/slack/docs/installations/07-channel.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/slack/docs/installations/07-channel.md index de0e87fc..3bc262e4 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/slack/docs/installations/07-channel.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/slack/docs/installations/07-channel.md @@ -14,11 +14,11 @@ read_when: OpenClaw talks to Slack in Socket Mode: the gateway opens an outbound WebSocket, so the server needs no public URL, no webhook, and no inbound firewall rule. Two tokens are involved: a **bot token** (`xoxb-…`) that reads and posts messages, and an **app-level token** (`xapp-…`) that only opens the WebSocket. -## Create the App from the Manifest +## Create the App from JSON -The manifest below keeps what a member of one **private** channel needs: read history and files, post and edit its own messages, read and add reactions, manage pins, see users, and run the `/openclaw` slash command. It omits the DM, group-DM, and Home-tab surfaces, so Slack cannot deliver a DM to the bot even if the gateway's `dmPolicy` drifts. It omits `channels:manage`, so the bot cannot invite, rename, or archive the channel. +The JSON configuration below keeps what a member of one **private** channel needs: read history and files, post and edit its own messages, read and add reactions, manage pins, see users, and run the `/openclaw` slash command. It omits the DM, group-DM, and Home-tab surfaces, so Slack cannot deliver a DM to the bot even if the gateway's `dmPolicy` drifts. It omits `channels:manage`, so the bot cannot invite, rename, or archive the channel. -> **User action required.** Go to → **From a manifest** → pick the workspace → paste the JSON → **Create**. Leave **Distribution** off. +> **User action required.** Go to , choose the JSON configuration path, pick the workspace, paste the JSON, then select **Create**. Leave **Distribution** off. ```json { @@ -95,13 +95,13 @@ After any scope or event change, Slack marks the app as needing re-installation: The seed allowlists that one channel (`channels.slack.channels`, `groupPolicy allowlist`) and disables inbound DMs (`dmPolicy disabled`). An invite elsewhere leaves the bot silent there. -## What the Manifest Can't Confine +## What the App Configuration Cannot Confine `users:read` is workspace-wide: it reads the whole member directory, and Slack offers no channel-scoped equivalent. `groups:read` covers the private channels the bot is a member of. The single-channel rule therefore rests on two other layers: the bot is invited to one channel (history and posting fail elsewhere with `not_in_channel`), and the gateway processes only the allowlisted channel ID. ## If the Channel Becomes Public -The manifest is scoped to a private channel (`groups:*`, `message.groups`). Converting the channel to public cuts the bot off: Slack starts sending `message.channels`, which the app does not subscribe to, and history reads fail. To recover: +The app configuration is scoped to a private channel (`groups:*`, `message.groups`). Converting the channel to public cuts the bot off: Slack starts sending `message.channels`, which the app does not subscribe to, and history reads fail. To recover: 1. **OAuth & Permissions**: add the bot scopes `channels:history` and `channels:read`; drop the `groups:*` pair. 2. **Event Subscriptions**: add `message.channels`, drop `message.groups`. @@ -128,7 +128,7 @@ The manifest is scoped to a private channel (`groups:*`, `message.groups`). Conv Run it after `08`, as the operator, from the Slack client. -1. In the allowlisted channel, request a small read-only task against a registered project (a question about the codebase, no change). +1. In the allowlisted channel, request a small read-only task against a listed project (a question about the codebase, no change). 2. The first reply opens a thread on your message. Its starter carries the task plus the known project path and ticket. 3. Answer in the thread. The fresh thread session reads the thread history, delegates the read-only task, and reports in the same thread, never in the channel root. 4. Post the same request in a channel the bot is not allowlisted in, then DM the bot. Neither gets a reply or starts work. diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/slack/infra/openclaw/seed/surface.sh b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/slack/infra/openclaw/seed/surface.sh index c2862bec..6716251d 100755 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/slack/infra/openclaw/seed/surface.sh +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/slack/infra/openclaw/seed/surface.sh @@ -47,7 +47,7 @@ configure_surface() { set_scalar channels.slack.replyToMode all set_json channels.slack.thread \ '{"historyScope":"thread","inheritParent":false,"initialHistoryLimit":100}' - # The name must match the slash command declared in the Slack app manifest (07-channel.md). + # The name must match the slash command declared in the Slack app configuration (07-channel.md). set_json channels.slack.slashCommand '{"enabled":true,"name":"openclaw"}' echo "[seed] owner allowlist — admin chat commands and exec approvals" diff --git a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/slack/infra/openclaw/workspace/AGENTS.md b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/slack/infra/openclaw/workspace/AGENTS.md index d1982ab5..e89dc35e 100644 --- a/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/slack/infra/openclaw/workspace/AGENTS.md +++ b/skills/alignfirst-setup-guide/assets/alignfirst-developer-template/variants/surfaces/slack/infra/openclaw/workspace/AGENTS.md @@ -41,7 +41,7 @@ This deployment provides no ticket-system integration. Use a ticket ID the user You run **natively** on `{{SERVER_HOST}}` as the unprivileged Linux user `{{SERVICE_USER}}`. No container around you. You have **no sudo**. -`alproject` allocates dev ports in **{{PORT_RANGE_FIRST}}–{{PORT_RANGE_LAST}}**. +The projects directory's marker allocates dev ports in **{{PORT_RANGE_FIRST}}–{{PORT_RANGE_LAST}}**. That range is reachable only through the authenticated HTTPS gateway; ports outside it stay local to the server. @@ -52,7 +52,7 @@ That range is reachable only through the authenticated HTTPS gateway; ports outs - **Git and git hosts.** `git` and the CLIs of {{GIT_HOSTS}} are authenticated for your own account. Use the git-host CLI for PRs, issues, and comments. - **Browser (Playwright).** OpenClaw's Playwright plugin drives a headless Chromium from `~/.cache/ms-playwright/`; no Xvfb, no `--no-sandbox` flag. Use `page.pdf()` for HTML → PDF. - **Coding agent.** `alcode` launches the delegated coding agent CLI with its own authentication. Delegate through the playbook; never invoke the agent CLI directly. -- **Projects.** `alproject` lists registered project paths, worktrees, and port allocations. Read `alproject --guide` before project lifecycle work. +- **Projects.** `alcode projects` lists project paths, workspaces, and port ranges. Read `alcode projects --guide --root ~/projects` before project lifecycle work. - **CLI tools.** Beyond the basics (`bash`, `git`, `curl`, `wget`, `ssh`, `python3`, `vim`, `nano`, `jq`, `rg`, `dig`): - search/nav: `fd`, `tree`, `ncdu`, `bat` - data: `yq`, `sqlite3`, `psql` (local DBs live in containers — reach them via `docker exec`) diff --git a/skills/alignfirst-setup-guide/references/alignfirst-developer.md b/skills/alignfirst-setup-guide/references/alignfirst-developer.md index 7751c611..faa06b8b 100644 --- a/skills/alignfirst-setup-guide/references/alignfirst-developer.md +++ b/skills/alignfirst-setup-guide/references/alignfirst-developer.md @@ -7,15 +7,15 @@ An AlignFirst Developer is a dedicated Linux service account that receives work Three roles, named as the runbooks name them: - **Support** — a coding-agent session on a laptop. Edits the admin repository, never executes on the server. -- **Operator** — a coding-agent session in the admin account `{{SERVER_ADMIN_USER}}` (sudo) on `{{SERVER_HOST}}`. Edits and executes. Holds the admin repository at `~{{SERVER_ADMIN_USER}}/{{ADMIN_REPOSITORY_NAME}}` and, with team plans, the plans clone beside it. Root steps are the operator's, through `sudo`. -- **Service account** — `{{SERVICE_USER}}`, no sudo, no inbound SSH, reached with `sudo -i -u {{SERVICE_USER}} -- ` (or `sudo -H -u {{SERVICE_USER}} bash -lc '…'` when the command defines a variable). Runs OpenClaw, the coding agent, `alcode`, `alproject`, rootless podman and the managed projects. +- **Operator** — a coding-agent session in the admin account `{{SERVER_ADMIN_USER}}` (sudo) on `{{SERVER_HOST}}`. Edits and executes. Holds the admin repository at `~{{SERVER_ADMIN_USER}}/{{ADMIN_REPOSITORY_NAME}}` and, with team plans, the plans clone under `~/projects`. Root steps are the operator's, through `sudo`. +- **Service account** — `{{SERVICE_USER}}`, no sudo, no inbound SSH, reached with `sudo -i -u {{SERVICE_USER}} -- ` (or `sudo -H -u {{SERVICE_USER}} bash -lc '…'` when the command defines a variable). Runs OpenClaw, the coding agent, `alignfirst`, `alcode`, rootless podman and the managed projects. The service account never reads the admin repository. It works from a snapshot at `~{{SERVICE_USER}}/seed/`, an `rsync` of `infra/openclaw/` with `.env` included, refreshed by the root-owned maintenance wrapper before every protected change. The wrapper contains the service account, unlocks only named scopes, runs one command as that account, and restores hardening through an exit trap. From there: - `~/.openclaw/` — `openclaw.json` (written by the seed through `openclaw config set`), `workspace/` (applied from `~/seed/workspace/`), `secrets/secrets.json` (every credential, referenced from `openclaw.json` as file SecretRefs), `.env` (the gateway env file, `CONTEXT7_API_KEY` only). - `~/.config/environment.d/` — the non-secret variables `systemd --user` injects into the gateway and `~/.bash_profile` sources for login shells. - The gateway unit, written by `openclaw gateway install`, enabled under lingering. -- `{{PROJECTS_ROOT}}` — the managed projects, the `alproject` registry and, with team plans, the service account's own clone of the plans repository (a repository, never a project). +- `~/projects` — the managed projects, their `.alignfirst-projects.json` marker and, with team plans, the service account's own clone of the plans repository (a repository, never a project). Both accounts install the same selected coding agent. The admin account uses it as the operator with the project-local `sysadmin` skill; the service account uses it through `alcode`. @@ -27,7 +27,7 @@ The human performs every interactive authentication and secret entry. Credential - **Surface**: `slack` or `discord`. - **Coding agent**: `claude-code` or `codex`. -- **Team plans repository**: yes or no. Yes when the team has one (see [plans-share-setup.md](plans-share-setup.md)). +- **Team plans repository**: yes or no. Yes when the team has one (see [plans-setup.md](plans-setup.md)). - **Dev-server gateway**: yes or no, default yes. Skipping is not recommended: without the gateway there are no remote dev URLs, and `workspace setup --profile remote` is unusable in the managed projects. Choose the model provider and model separately; the template favors no provider. @@ -46,21 +46,20 @@ The agent **runtime** is fixed: every AlignFirst Developer uses OpenClaw's embed | `{{SERVER_ADMIN_USER}}` | Server administrator | admin account (`01`), operator commands, hardening ownership | | `{{SERVICE_USER}}` | Server administrator | service account (`03`), every `sudo -i -u` command, the scripts | | `{{DEVELOPER_NAME}}` | Operator | agent identity, bot name (`07`), secret provider id `{{DEVELOPER_NAME}}file` (lowercased by the seed; letters, digits, `-` and `_`, starting with a letter) | -| `{{PROJECTS_ROOT}}` | Operator | `.alproject.json`, `alproject-guide.md`, `backup.sh`, the project runbooks | | `{{TIME_ZONE}}` | Operator | `timedatectl` (`01`), `USER.md`, overview | | `{{GIT_HOSTS}}` | Operator | `03`, `05` (git-host CLIs), workspace `AGENTS.md`, coding-agent instructions | | `{{RUNTIME_PROVIDER}}`, `{{RUNTIME_MODEL}}` | Operator | `.env.example`, `IDENTITY.md`, `04` (provider login) | | `{{TEAM_NAME}}` | Operator | README, `IDENTITY.md`, `SOUL.md`, `USER.md` | | `{{TEAM_MEMBERS}}` | Operator | `USER.md` | -| `{{PORT_RANGE_FIRST}}`, `{{PORT_RANGE_LAST}}` | Operator (suggested 28000–28599) | `.alproject.json`, `alproject-guide.md`, overview, workspace `AGENTS.md`, `09` | +| `{{PORT_RANGE_FIRST}}`, `{{PORT_RANGE_LAST}}` | Operator (suggested 28000–28599) | `.alignfirst-projects.json`, overview, workspace `AGENTS.md`, `09` | +| `{{PLANS_REPOSITORY_URL}}` | Operator, team plans only | `02`, `add-project.md` | +| `{{PLANS_CLONE_NAME}}` | Operator, team plans only | `common.conf`, `02`, `add-project.md`, projects marker | | `{{SLACK_OWNER_ID}}`, `{{SLACK_CHANNEL_ID}}` | Slack administrator | `.env.example` (Slack overlay) | | `{{DISCORD_OWNER_ID}}`, `{{DISCORD_GUILD_ID}}`, `{{DISCORD_CHANNEL_ID}}` | Discord administrator | `.env.example` (Discord overlay) | | `{{DEV_DOMAIN}}` | Operator | `09`, Caddyfile, `authelia.yml`, `REMOTE_DEV_DOMAIN` in `common.conf`, overview, gotchas | | `{{CADDY_DNS_MODULE}}`, `{{CADDY_DNS_PROVIDER}}` | Operator | `caddy add-package` (`09`), `acme_dns` (Caddyfile) | | `{{PORT_RANGE_REGEX}}`, `{{DEV_DOMAIN_REGEX}}` | Derived | Caddyfile host regex | -`{{PROJECTS_ROOT}}` is written as the service account sees it: `~/projects` (the default) or an absolute path. `alproject` expands `~/` only, and the runbooks resolve the value through the service account's shell. - `{{TEAM_MEMBERS}}` is a Markdown list. Every member carries their role and the handle OpenClaw reports: the Slack member ID (`U…`) or the Discord `username`. ```markdown @@ -71,7 +70,7 @@ The channel IDs are known before the bot exists (the channel, the server and the The last two rows exist only when the gateway option is on. `{{PORT_RANGE_REGEX}}` is a regex matching exactly the integers `PORT_RANGE_FIRST..PORT_RANGE_LAST`: one digit class per position when the range allows it (`28000..28599` → `28[0-5][0-9]{2}`), otherwise an alternation of such classes (`6500..7700` → `6[5-9][0-9]{2}|7[0-6][0-9]{2}|7700`). `{{DEV_DOMAIN_REGEX}}` is `DEV_DOMAIN` with every `.` escaped as `\.`. -With team plans, also collect the plans repository URL and the two clone locations (beside the admin repository for the operator, under `{{PROJECTS_ROOT}}` for the service account). They are execution-time values of `02` and `add-project.md`, not tokens. +With team plans, collect `{{PLANS_REPOSITORY_URL}}` and `{{PLANS_CLONE_NAME}}` at render time. Both the operator and the service account clone it under `~/projects`; each account supplies its own credentials. ## Assemble the Admin Repository @@ -91,8 +90,8 @@ 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 --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. +6. Team plans on: delete the `TEAM_PLANS_SECTION` marker lines. The guarded block in `.alignfirst.json` writes `plans.folder` as `{{ADMIN_REPOSITORY_NAME}}`. Off: delete each block, including that field. +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`, `.alignfirst.json`, `.alignfirst-projects.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 **User action required.**`. Execution order: 1. `01-server-setup.md` — **human administrator**, on the fresh server: admin account, SSH key-only, firewall, Node, podman. Ends with the coding agent installed and logged in for the admin account, then a session of that agent in the clone takes over as the **operator**. -2. `02-admin-repository.md` — operator: deploy key (human registers it), clone, plans clone and `plans:setup` when enabled, `workspace setup`. +2. `02-admin-repository.md` — operator: deploy key (human registers it), clone, plans clone and `alignfirst plans setup` when enabled, `workspace setup`. 3. `03-toolchain.md` — service account created, npm prefix, the CLIs, the coding agent, git access (human: key registration or device code). 4. `05-openclaw-dependencies.md` — OS packages for the tools, git-host CLIs and their authentication (human), Chromium. 5. `07-channel.md`, platform part — **channel administrator** creates the app and collects the tokens and IDs for `.env`. -6. `04-openclaw.md` — human fills `.env`; snapshot, seed, workspace files, alproject files, lingering, `gateway install`, `podman.socket`; human: provider login when no API key, dashboard pairing through the SSH tunnel, reboot check from the laptop. +6. `04-openclaw.md` — human fills `.env`; snapshot, seed, workspace files, the projects marker, lingering, `gateway install`, `podman.socket`; human: provider login when no API key, dashboard pairing through the SSH tunnel, reboot check from the laptop. 7. `08-coding-agent.md` — human authenticates the coding agent in the service account; skills, global instructions, verification. 8. `09-dev-server-gateway.md` when selected — human: DNS wildcard record and API token, Authelia secrets, gateway users. 9. `06-security-hardening.md` — last, because it locks what the others write. 10. `07-channel.md`, smoke test — operator, from the chat client. -11. `docs/operations/add-project.md` for each managed project. Prepare the project first through this skill's "Prepare a Project for an AlignFirst Developer" route, then `alproject register`. +11. `docs/operations/add-project.md` for each managed project. Prepare the project first through this skill's "Prepare a Project for an AlignFirst Developer" route, which writes `.alignfirst.json`. Project discovery then needs no registration step. The operator records each task in `.reports/`, committed. The operations runbooks own the rest: `configure-developer.md` (re-seed, secret rotation), `update-developer.md`, `update-workspace.md`, `recover-developer.md` (kill switch, backup, restore), `pair-dm-sender.md` (Discord). diff --git a/skills/alignfirst-setup-guide/references/alignfirst-skills-setup.md b/skills/alignfirst-setup-guide/references/alignfirst-skills-setup.md index 63640182..a9e5b0e8 100644 --- a/skills/alignfirst-setup-guide/references/alignfirst-skills-setup.md +++ b/skills/alignfirst-setup-guide/references/alignfirst-skills-setup.md @@ -1,49 +1,23 @@ -# AlignFirst Skills Setup +# AlignFirst Setup -Install the AlignFirst content skill and seven human command skills, then configure the consumer -repository. AlignFirst does not require docmap or workspace. +Install the AlignFirst CLI and its eight stub skills, then configure the consumer repository. +AlignFirst does not require the standalone docmap package or workspace. -## Skill Model +## Install the CLI -`alignfirst` contains the protocols. `alspec`, `alplan`, `al`, `almerge`, `alreview`, -`aldescription`, and `alcatchup` are human-invoked commands with `disable-model-invocation: true`. -Repository discovery still lists all eight directories. +Install the CLI globally on the developer's machine: -Humans invoke commands with `/`, such as `/alspec`, in Claude Code, GitHub Copilot, and Cursor. Codex -uses `$`, such as `$alspec`. - -## Configure the Project - -1. Create `.plans/` when absent and ensure `.gitignore` contains `.plans`. - - When upgrading from `.plans/**`, `!.plans/**/`, and `!.plans/**/*.shared.md`, replace that block - with `.plans`. Untrack committed `*.shared.md` files with `git rm --cached` and report them. -2. Use the existing `AGENTS.md` or `CLAUDE.md`; create `AGENTS.md` when neither exists. -3. Detect the ticket format from `git branch -a`, the commit convention from - `git log --oneline -20`, and the default branch from the remote HEAD. Ask only for a convention - that repository evidence cannot establish; allow the user to omit it. -4. Extend the existing code-search ignore instruction with `.plans`. -5. Add or update this section with the detected values. Omit convention lines without a value. - - ```markdown - ## AlignFirst - Ticket ID, Commit Message, Default Branch - - _Ticket ID:_ Format is `{DETECTED_FORMAT}`. Use the ticket ID if explicitly provided. Otherwise, - deduce it from the current branch name without confirmation. If unavailable, run - `git branch --show-current`. Ask the user only as a last resort. - - _Commit message convention:_ `{DETECTED_CONVENTION}` - - _Default branch:_ `{DETECTED_DEFAULT_BRANCH}` - ``` +```sh +npm install -g alignfirst +``` -Preserve repository-specific instructions and adapt the heading when the project already uses an -equivalent conventions section. +Add `npm install -g alignfirst` to the README prerequisites so teammates install the same command. ## Install the Skills -When installation is requested, run the matching command. Global symlink-based installation is the -default because the commands are reusable across repositories. Omit `--global` for an explicitly -project-local installation. Add `--copy` only where symlinks are unsuitable. +The `alignfirst` skill runs the core guide. `alspec`, `alplan`, `al`, `almerge`, `alreview`, +`aldescription`, and `alcatchup` run individual protocols. Humans invoke them with `/` in Claude +Code, GitHub Copilot, and Cursor, or `$` in Codex. Discover the package without installing it: @@ -80,9 +54,49 @@ npx -y skills add https://github.com/paleo/alignfirst --global --yes \ Restart the target agent after installation. Use `npx -y skills update --global --yes` to update global skills, or `npx -y skills update --project --yes` for project skills. Remove an installation -with `npx -y skills remove [--global] --yes`. Let the CLI manage `skills-lock.json`. +with `npx -y skills remove [--global] --yes`. Let the skills CLI manage +`skills-lock.json`. + +## Configure the Project + +1. Detect the ticket pattern from the branch and ticket conventions already visible in the + repository: + + - Issue numbers use `^\d+$`. + - Jira-like keys use `^[A-Z]+-\d+$`. + - Omit the pattern when the repository has no ticket convention. -## Team Plans Repository +2. Run setup from the project root. Include only the applicable options: -`.plans/` stays local by default. When the team has a dedicated plans repository, continue with -[plans-share-setup.md](plans-share-setup.md). The AlignFirst skills behave identically in both modes. + ```sh + alignfirst setup --ticket-pattern '' [--plans-folder ] [--port-range -] + ``` + + The command writes `.alignfirst.json` with the compatible `cli` range, creates `.plans/`, updates + `.gitignore`, installs the skills, and adds the README prerequisite. A second run validates the + existing setup instead of rewriting it. + +3. Detect the commit-message convention from `git log --oneline -20` and the default branch from + the remote HEAD. Preserve repository-specific instructions and add or update this section in + `AGENTS.md` or `CLAUDE.md`: + + ```markdown + ## AlignFirst - Commit Message and Default Branch + + _Commit message convention:_ `{DETECTED_CONVENTION}` + + _Default branch:_ `{DETECTED_DEFAULT_BRANCH}` + ``` + + Omit a convention line when repository evidence cannot establish it. When the project uses a + team plans repository, add this sentence under the same section: + + > After every change in `.plans/`, run `alignfirst sync`. + +4. Continue with [plans-setup.md](plans-setup.md) when the team has a plans repository. + +5. Run the final check: + + ```sh + alignfirst doctor + ``` diff --git a/skills/alignfirst-setup-guide/references/alignfirst-upgrade-from-v1.md b/skills/alignfirst-setup-guide/references/alignfirst-upgrade-from-v1.md index 0e76307d..5955778d 100644 --- a/skills/alignfirst-setup-guide/references/alignfirst-upgrade-from-v1.md +++ b/skills/alignfirst-setup-guide/references/alignfirst-upgrade-from-v1.md @@ -1,7 +1,7 @@ # Upgrade from AlignFirst v1 This migration removes the v1 `_docs/` system while preserving project knowledge. Continue with the -current setup reference after these steps. +v3 upgrade reference after these steps. > **Note**: Commands shown are Unix-style. Adapt to your OS if needed (e.g., PowerShell on Windows). @@ -91,9 +91,8 @@ Check if any `.md` files remain in `_docs/`. ## Finish the Upgrade -Follow [alignfirst-skills-setup.md](alignfirst-skills-setup.md). Install the current `alignfirst` -content skill and all seven command skills for the requested agent and scope, then reconcile `.plans`, -`.gitignore`, and project instructions. +Continue with [alignfirst-upgrade-from-v3.md](alignfirst-upgrade-from-v3.md) for the CLI installation, +project config, stub skills, and current commands. Summarize: @@ -103,4 +102,4 @@ Summarize: - `.gitignore` updated - `AGENTS.md` cleaned - Whether documentation was migrated to `docs/` via docmap, or no remaining docs were found -- The scope and agents that received the current AlignFirst skills +- The scope and agents that received the v4 AlignFirst skills diff --git a/skills/alignfirst-setup-guide/references/alignfirst-upgrade-from-v2.md b/skills/alignfirst-setup-guide/references/alignfirst-upgrade-from-v2.md index c638fb70..78e062a0 100644 --- a/skills/alignfirst-setup-guide/references/alignfirst-upgrade-from-v2.md +++ b/skills/alignfirst-setup-guide/references/alignfirst-upgrade-from-v2.md @@ -1,7 +1,7 @@ # Upgrade from AlignFirst v2 Remove the v2 agent-skills installation while preserving project-specific instructions and custom -skills. Continue with the current setup reference after these steps. +skills. Continue with the v3 upgrade reference after these steps. Commands are Unix-style. Adapt them for another shell. @@ -46,9 +46,8 @@ otherwise leave them untouched. ## Finish the Upgrade -Follow [alignfirst-skills-setup.md](alignfirst-skills-setup.md). Install the current `alignfirst` -content skill and all seven command skills for the requested agent and scope, then reconcile `.plans`, -`.gitignore`, and project instructions. +Continue with [alignfirst-upgrade-from-v3.md](alignfirst-upgrade-from-v3.md) for the CLI installation, +project config, stub skills, and current commands. Summarize the removed legacy files, plan migration, preserved custom skills, and the scope and agents -that received the current AlignFirst skills. +that received the v4 AlignFirst skills. diff --git a/skills/alignfirst-setup-guide/references/alignfirst-upgrade-from-v3.md b/skills/alignfirst-setup-guide/references/alignfirst-upgrade-from-v3.md new file mode 100644 index 00000000..5b49458f --- /dev/null +++ b/skills/alignfirst-setup-guide/references/alignfirst-upgrade-from-v3.md @@ -0,0 +1,77 @@ +# Upgrade from AlignFirst v3 + +Replace the full-content v3 skills and plans package with the AlignFirst CLI and v4 stub skills. + +## Install the CLI + +Install the CLI globally on each developer machine. An AlignFirst Developer host also replaces the +retired project-discovery package: + +```sh +npm install -g alignfirst +# AlignFirst Developer host only +npm install -g alignfirst @paleo/alcode +npm uninstall -g @paleo/alproject +``` + +## Remove the Plans Package + +Remove `@paleo/plans-share` from the project's dev dependencies and delete the `plans:setup` and +`plans:sync` scripts. Keep `.plans` unchanged; an existing symlink remains valid. + +Use the detected package manager. For npm: + +```sh +npm uninstall -D @paleo/plans-share +npm pkg delete scripts.plans:setup scripts.plans:sync +``` + +## Create the Project Config + +Detect the ticket pattern from the repository's branch and ticket conventions. Issue-number tickets +use `^\d+$`; Jira-like keys use `^[A-Z]+-\d+$`; omit the option when there is no convention. + +If the old `plans:setup` script named a folder, preserve it with `--plans-folder`: + +```sh +alignfirst setup --ticket-pattern '' [--plans-folder ] +``` + +Include `--port-range -` when the workspace wrapper declares a port scheme. The command +writes `.alignfirst.json`, keeps `.plans`, updates the README prerequisite, and installs the stubs. + +## Replace Project Commands + +In `AGENTS.md` or `CLAUDE.md`: + +- Replace `npm run plans:sync` with `alignfirst sync`. +- Replace `npm run docmap` with `alignfirst docmap` when the project adopts docmap through the CLI. + +In `workspace.mjs`, replace the main-worktree plans check with: + +```js +preSetup: ({ isMainWorktree, currentWorktree }) => { + if (!isMainWorktree) return; + execFileSync("alignfirst", ["plans", "check"], { + cwd: currentWorktree, + stdio: "inherit", + }); +}, +``` + +Keep a distinct `preSetup` guard for remote-development requirements when the project has one. + +## Update the Skills + +Replace the v3 protocol content with the v4 stubs: + +```sh +npx -y skills update --global --yes +``` + +Use `--project` instead of `--global` for a project-local installation. Finish by checking the +effective project: + +```sh +alignfirst doctor +``` diff --git a/skills/alignfirst-setup-guide/references/alignfirst-upgrade.md b/skills/alignfirst-setup-guide/references/alignfirst-upgrade.md index 59440f58..f3f80747 100644 --- a/skills/alignfirst-setup-guide/references/alignfirst-upgrade.md +++ b/skills/alignfirst-setup-guide/references/alignfirst-upgrade.md @@ -1,7 +1,7 @@ -# Upgrade AlignFirst to v3 +# Upgrade AlignFirst to v4 -Migrate an existing AlignFirst v1 or v2 project, then install the current AlignFirst skills. This -workflow does not install docmap or workspace unless the user separately requests them. +Migrate an existing AlignFirst v1, v2, or v3 project to the CLI-backed v4 skills. This workflow does +not install standalone docmap or workspace unless the user separately requests them. Commands are Unix-style. Adapt them for another shell. @@ -9,22 +9,27 @@ Commands are Unix-style. Adapt them for another shell. 1. Verify the git working tree is clean immediately before mutations. 2. Use the existing `AGENTS.md` or `CLAUDE.md`; create `AGENTS.md` when neither exists. -3. Preserve the project's ticket, commit, default-branch, and other local conventions. The current - setup reference will reconcile them after legacy cleanup. +3. Preserve the project's commit, default-branch, and other local conventions. ## Detect the Installed Version -Detect v1 from `_docs/alignfirst/`, `_docs/vibe-flow/`, or `_docs/ai-workflow/`. +- **v1:** `_docs/alignfirst/`, `_docs/vibe-flow/`, or `_docs/ai-workflow/` exists. +- **v2:** `alignfirst/SKILL.md` exists under a canonical project skill root, but the skill has no + `references/` directory and its `metadata.version` is not `3.x`. +- **v3:** the eight skills contain their full protocol content. Detect a `references/` directory + under the `alignfirst` skill, `metadata.version` beginning with `3.`, + [the retired plans package](alignfirst-upgrade-from-v3.md#remove-the-plans-package), or its setup + and sync scripts. -Detect v2 from `alignfirst/SKILL.md` under a canonical project skill root such as `.agents/skills/`, -`.claude/skills/`, `.codex/skills/`, `.github/skills/`, `.cursor/skills/`, `.gemini/skills/`, or -`.agent/skills/`. Also inspect `skills-lock.json` through the current skills CLI when present. Ignore -dependencies and generated output. +Inspect `skills-lock.json` through the current skills CLI when present. Ignore dependencies and +generated output. -- For v1, follow [alignfirst-upgrade-from-v1.md](alignfirst-upgrade-from-v1.md). -- For v2, follow [alignfirst-upgrade-from-v2.md](alignfirst-upgrade-from-v2.md). -- If neither exists, use [alignfirst-skills-setup.md](alignfirst-skills-setup.md) as a fresh setup. +## Route the Upgrade -After the version-specific migration, always follow -[alignfirst-skills-setup.md](alignfirst-skills-setup.md) to install the current content skill and all -seven commands, establish `.plans`, and reconcile project instructions. +- v1: follow [alignfirst-upgrade-from-v1.md](alignfirst-upgrade-from-v1.md). +- v2: follow [alignfirst-upgrade-from-v2.md](alignfirst-upgrade-from-v2.md). +- v3: follow [alignfirst-upgrade-from-v3.md](alignfirst-upgrade-from-v3.md). +- No detected installation: use [alignfirst-skills-setup.md](alignfirst-skills-setup.md). + +The v1 and v2 migrations preserve project knowledge and remove their legacy layouts. After either +one, continue with the v3 migration for the CLI installation and project config. diff --git a/skills/alignfirst-setup-guide/references/docmap-setup.md b/skills/alignfirst-setup-guide/references/docmap-setup.md index 508fc572..a9d0dcc4 100644 --- a/skills/alignfirst-setup-guide/references/docmap-setup.md +++ b/skills/alignfirst-setup-guide/references/docmap-setup.md @@ -1,40 +1,56 @@ # Docmap Setup -Install the docmap CLI in a consumer repo so humans and agents share one set of docs under `docs/`. +Choose one adoption form. Both expose the same documentation tree under `docs/`. -## Install the CLI +## Through the AlignFirst CLI -1. **Use the package manager** identified in the skill's Shared Investigation Rules (fall back to npm). -2. **Add a `docmap` script** to the root `package.json`: +Use this form when the project already requires the AlignFirst CLI. It adds no project dependency. - ```json - "docmap": "docmap" - ``` - -3. **Install `@paleo/docmap`** as a dev dependency with the detected package manager: `npm install -D @paleo/docmap` (`pnpm add -D` / `yarn add -D` / `bun add -D`). -4. **Ensure a `docs/` directory** exists (`mkdir docs` if missing). When preparing a project for an - AlignFirst Developer, populate a directory created here through - [docmap-bootstrapping.md](docmap-bootstrapping.md); do not leave it empty. -5. **Add the docmap section** to `AGENTS.md` (or `CLAUDE.md`): +1. Add `npm install -g alignfirst` to the README prerequisites. +2. Ensure `docs/` exists. When preparing a project for an AlignFirst Developer, populate a newly + created directory through [docmap-bootstrapping.md](docmap-bootstrapping.md). +3. Add this section to `AGENTS.md` or `CLAUDE.md`: ```markdown ## Docmap - Seek Documentation - *Before* any investigation or code exploration, run `npm run docmap`, then read the relevant documentation. Mandatory for every task. + *Before* any investigation or code exploration, run `alignfirst docmap`, then read the relevant documentation. Mandatory for every task. + ``` + +4. Read the authoring guide with `alignfirst docmap --guide`. + +CI can validate the documentation without relying on the machine's global version: - ### Essential Documentation +```sh +npx -y alignfirst@ docmap --check +``` - Always read before any investigation or work: +## Through `@paleo/docmap` - - `docs/.md` — +Use the standalone package when the project wants docmap pinned in its lockfile or does not adopt +AlignFirst. + +1. Add the root script: + + ```json + "docmap": "docmap" ``` - The **Essential Documentation** sub-list names the 1–3 docs an agent must always read first (e.g. architecture, code style) — the always-read subset, not the full index. Populate it from the docs that already exist; if there are none yet, omit the sub-list for now — the bootstrap/migrate step below adds it once docs exist. +2. Install `@paleo/docmap` as a dev dependency with the detected package manager: + `npm install -D @paleo/docmap` (`pnpm add -D`, `yarn add -D`, or `bun add -D`). +3. Ensure `docs/` exists and add the same instruction section, using `npm run docmap` for npm. +4. Read the authoring guide with `npm run docmap -- --guide`. + +Translate the script commands for the detected package manager according to the skill's Shared +Investigation Rules. + +## Documentation Work -## Optional Documentation Work +Continue only when the user also requested one of these tasks: -1. Read authoring and browsing conventions by running `npm run docmap -- --guide`. -2. Continue only when the user also requested one of these documentation tasks: - - [docmap-bootstrapping.md](docmap-bootstrapping.md) — create or extend documentation by exploring the codebase. - - [docmap-migrate-existing-docs.md](docmap-migrate-existing-docs.md) — bring an existing docs folder into docmap conventions. - - [docmap-migrate-skills.md](docmap-migrate-skills.md) — move internal knowledge from agent skills into `docs/`. +- [docmap-bootstrapping.md](docmap-bootstrapping.md) — create or extend documentation by exploring + the codebase. +- [docmap-migrate-existing-docs.md](docmap-migrate-existing-docs.md) — bring an existing docs folder + into docmap conventions. +- [docmap-migrate-skills.md](docmap-migrate-skills.md) — move internal knowledge from agent skills + into `docs/`. diff --git a/skills/alignfirst-setup-guide/references/plans-setup.md b/skills/alignfirst-setup-guide/references/plans-setup.md new file mode 100644 index 00000000..475b94c8 --- /dev/null +++ b/skills/alignfirst-setup-guide/references/plans-setup.md @@ -0,0 +1,106 @@ +# Team Plans Repository Setup + +Share `.plans/` through a dedicated team repository. Solo users keep `.plans/` as a local directory. + +## How It Works + +The team hosts one private, multi-project plans repository: + +```text +myteam-plans/ + project-a/ + 250/ + A1-spec.md + _archives/ + project-b/ + 103/ +``` + +Each developer clones this repository once per machine with their own credentials, anywhere they +choose, typically beside the code repositories. In a configured project, `.plans` is a symlink to +the folder named by `plans.folder` in `.alignfirst.json`. Linked worktrees continue through the main +worktree's symlink. + +A contributor without access to the plans repository uses a plain `.plans` directory. The CLI +accepts both modes. Run `alignfirst plans check` to report the current mode. + +`alignfirst sync --auto-archive` publishes changes and archives stale ticket directories and +no-ticket session files under `_archives/`. `ALIGNFIRST_ARCHIVE_DAYS` sets the threshold in days and +defaults to `7`. + +## Configure the Project + +Set the project folder during the initial setup: + +```sh +alignfirst setup --plans-folder project-a +``` + +When `.alignfirst.json` already exists without `plans.folder`, supply the folder once while linking: + +```sh +alignfirst plans setup --folder project-a +``` + +Ensure the instruction file says: + +> After every change in `.plans/`, run `alignfirst sync`. + +For a project prepared for an AlignFirst Developer, the `.plans/` entry in `DEVELOPERS.md` also +names the shared repository and the sync command. + +## Configure Each Machine + +Clone the plans repository with the developer's own credentials. From the project root, link it: + +```sh +git clone ../myteam-plans +alignfirst plans setup ../myteam-plans +alignfirst sync --auto-archive +``` + +`alignfirst plans setup` creates the configured project folder in the clone, migrates an existing +local `.plans`, and replaces it with a relative symlink. Re-run it after moving the clone. + +For local mode, create the directory instead: + +```sh +mkdir .plans +``` + +## With the Workspace System + +When the project also uses workspace, check the link before setting up the main worktree: + +```js +preSetup: ({ isMainWorktree, currentWorktree }) => { + if (!isMainWorktree) return; + execFileSync("alignfirst", ["plans", "check"], { + cwd: currentWorktree, + stdio: "inherit", + }); +}, +``` + +Keep the `isMainWorktree` gate. `preSetup` runs before the kernel creates shared-directory symlinks, +so a fresh linked worktree has no `.plans` yet. The check accepts a usable plans symlink and a local +directory. + +Document these new-machine steps in `README.md` before workspace setup: + +```sh +npm install -g alignfirst +npm install +git clone +alignfirst plans setup +npm run workspace -- setup +``` + +For a public repository, make local mode the default and avoid naming a private repository: + +```sh +npm install -g alignfirst +npm install +mkdir .plans # or run alignfirst plans setup with the team plans clone +npm run workspace -- setup +``` diff --git a/skills/alignfirst-setup-guide/references/plans-share-setup.md b/skills/alignfirst-setup-guide/references/plans-share-setup.md deleted file mode 100644 index 321651ae..00000000 --- a/skills/alignfirst-setup-guide/references/plans-share-setup.md +++ /dev/null @@ -1,102 +0,0 @@ -# Team Plans Repository Setup - -Share `.plans/` among the developers of a team through a dedicated **plans repository**. This is an optional overlay over the AlignFirst skills setup: solo users skip it, and the skills read and write `.plans` identically in both modes. - -## How It Works - -The team hosts a plans repository, multi-project — one folder per code repo, ticket directories inside: - -```text -myteam-plans/ - project-a/ - 250/ - A1-spec.md - _archives/ - project-b/ - 103/ -``` - -On each machine, the repository is cloned once, wherever the user wants (typically next to the other repos). In each project, `.plans` in the main worktree is a symlink to the project's folder inside the clone. Plans never enter the product repository. Linked worktrees created by the workspace system keep pointing at the main worktree's `.plans`; the symlink chain resolves on its own. - -A contributor without access to the plans repository leaves `.plans` as a plain directory. `sync` then reports local plans mode and exits successfully, and the skills work unchanged. Run `plans-share check` to report which mode a project is in. - -Plan history has no value, so the plans repository only receives synchronization commits (`sync`). The instruction file asks the agent to sync after each change in `.plans/`; a forgotten sync means a teammate sees a stale version, never pollution. The skills themselves never trigger a sync and never detect which mode they run in. - -To keep `.plans/` small, `sync --auto-archive` moves stale ticket directories and no-ticket session files into `_archives/`. `PLANS_SHARE_ARCHIVE_DAYS` sets the threshold in days and defaults to `7`. Manual moves remain valid (e.g. `mv .plans/250 .plans/_archives/`). - -## Setup, Once Per Team - -Create the plans repository on the team's git host (recommended name: `{team-name}-plans`). Keep it private; its access rights define who sees the plans. Plans of a project must be visible to all its contributors, so a repo contributed to by several teams must pick a single plans repository. - -## Setup, Once Per Project - -1. Install the tool: `npm install -D @paleo/plans-share`. -2. Add the npm scripts, with the project's folder name baked in: - - ```json - "plans:setup": "plans-share setup --folder project-a", - "plans:sync": "plans-share sync --auto-archive" - ``` - -3. Ensure `.gitignore` contains `.plans` (the skills setup already does this). -4. Add this subsection to the instruction file (`AGENTS.md` or `CLAUDE.md`), under the AlignFirst section added by the skills setup, adapting the folder and the commands to the project and the detected package manager: - - > ### Team Plans Repository - > - > In the main worktree, `.plans` is a symlink into a clone of the team plans repository (folder `project-a/`). Plans are shared with the team through that repository and are never committed in this one. - > - > After every change in `.plans/`, synchronize the plans: `npm run plans:sync`. - -5. On a project prepared for an AlignFirst Developer, add the sync instruction to `DEVELOPERS.md` as well, on the `.plans/` entry of its layout section: - - > - `.plans/` — task plans. Symlinked across worktrees, and into a clone of the team plans repository so plans are shared with the team. Run `npm run plans:sync` after changing anything under it. - -## Setup, Once Per Machine - -Clone the plans repository anywhere (typically next to the worktrees) — cloning is the user's move, with their own SSH configuration. Then, from the main worktree root, pass the clone location: - -```sh -npm run plans:setup -- ../myteam-plans -``` - -The command creates the project folder inside the clone, migrates any existing `.plans` content into it, and replaces `.plans` with the symlink. A moved clone leaves a broken symlink — re-run the command with the new location. - -Then publish any migrated content: `npm run plans:sync`. - -A contributor without access to the clone creates the directory instead: `mkdir .plans`. Either way `.plans` must exist before the workspace bootstrap, which fails on a missing one. - -## With the Workspace System (Recommended) - -When the project also uses the workspace system, make the link a prerequisite of the local environment: - -1. In the `preSetup` callback of `workspace.mjs`, add the check. The clone location stays the user's choice, nothing is hardcoded: - - ```js - if (isMainWorktree) { - // `.plans` must be usable - execFileSync("npx", ["--no", "plans-share", "check"], { - cwd: currentWorktree, - stdio: "inherit", - }); - } - ``` - - Copy the code, not the explanations below: they belong here, and the check prints its own guidance when it fails. - - An unusable `.plans` then fails `workspace setup`, with the check's guidance on stderr. `check` accepts both a symlink into the clone and a plain local directory, so a contributor without access to the plans repository still sets up. - - Keep the `isMainWorktree` gate: `preSetup` runs before the kernel symlinks the shared directories, so a fresh linked worktree has no `.plans` yet. The main worktree is subject to the same ordering — nothing creates `.plans` before the check — so a fresh clone must get it beforehand, which is what the README step below documents. - - Pass `--no` to keep npx off the registry. The bin is `plans-share`, while the package is `@paleo/plans-share`. - -2. Document the new-machine steps in `README.md` (the entry point that owns fresh-clone setup), before the workspace bootstrap command. Then drop the "On a new machine" line from the instruction-file section: machine setup is covered where machines get installed. - - In a **public repository**, write the local mode as the default and name no private repository: - - ```sh - npm install - mkdir .plans # or use plans:setup if you have a team plans repository - npm run workspace -- setup - ``` - - An outside contributor then reads a step that works for them, and the inline comment points teammates at `plans:setup -- ` without exposing where the clone lives. A private repository can spell the clone step out instead. diff --git a/skills/alignfirst-setup-guide/references/workspace-setup.md b/skills/alignfirst-setup-guide/references/workspace-setup.md index 9761df7a..415acc22 100644 --- a/skills/alignfirst-setup-guide/references/workspace-setup.md +++ b/skills/alignfirst-setup-guide/references/workspace-setup.md @@ -53,7 +53,7 @@ Suggest `.local/` by default, even when the repo has no such directory yet: a gi `runtimeDir` (`.local-wt/` above) stays per-worktree, but the kernel symlinks its `workspace-registry/` sub-directory to the main worktree's, so every worktree reads one registry. -The main worktree's `.plans` may itself be a symlink — into a clone of a team plans repository (see [plans-share-setup.md](plans-share-setup.md)); the symlink chain resolves on its own. +The main worktree's `.plans` may itself be a symlink — into a clone of a team plans repository (see [plans-setup.md](plans-setup.md)); the symlink chain resolves on its own. ### Contiguous port scheme @@ -125,7 +125,7 @@ Builds a `WorkspaceConfig` and calls `runWorkspace`. Key fields: - `ports` — optional group: `base` (first port of the main worktree's block), `maxWorkspaces` (main included, required), `perWorkspace` (defaults to `names.length`; required with `compute`), and exactly one of `names` (consecutive ports from `firstPort`) or `compute({ index, firstPort })` (full control; computed ports must stay within the block). Omit the whole group for [portless mode](#portless-mode). See [The workspace registry](#the-workspace-registry). - `sharedDirs` (symlinked from main), `runtimeDir` (per-worktree; holds logs and the registry). - `gitignoredFiles: Array<{ path, source, patch?, optional? }>` — one entry per gitignored file (see above). `source` (required) is `{ kind: "mainWorktree", fallback? }`, `{ kind: "committed", path }`, or `{ kind: "content", content }`. Functional `content(ctx)` and `patch(content, ctx)` receive `{ name, ports, mainWorktree, currentWorktree, isMainWorktree }`; omit `patch` to copy verbatim. -- `preSetup({ name, isMainWorktree, currentWorktree, mainWorktree, force, profile?, log })` — optional; runs **before** `gitignoredFiles` are copied. Use it for work outside file-source resolution, such as checking a plans clone, creating directories, or configuring git hooks. **MUST be idempotent**; on a linked-worktree setup it MUST NOT mutate the main worktree. Omit the hook only when it has no remaining work. `profile` is set only during `setup --profile `: check the profile's external requirements here (an environment variable, a reachable host) to fail before any file is written. +- `preSetup({ name, isMainWorktree, currentWorktree, mainWorktree, force, profile?, log })` — optional; runs **before** `gitignoredFiles` are copied. Use it for work outside file-source resolution, such as checking the plans clone with `alignfirst plans check`, creating directories, or configuring git hooks. **MUST be idempotent**; on a linked-worktree setup it MUST NOT mutate the main worktree. Omit the hook only when it has no remaining work. `profile` is set only during `setup --profile `: check the profile's external requirements here (an environment variable, a reachable host) to fail before any file is written. - `setupProfiles: { : { description, apply } }` — optional; enables `setup --profile `. The kernel checks the name and lists each `description` (one line) in `--help` and `--guide`. `apply({ name, ports, currentWorktree, mainWorktree, isMainWorktree, log })` runs on the **main worktree only**, after `gitignoredFiles` are seeded, and rewrites the ignored files for that environment. The profile rewrites the ignored main files once; linked worktrees inherit them through `mainWorktree` sources, so patchers stay profile-agnostic. Check every computed change before the first write, leave unrelated files untouched, and **MUST be idempotent** — reapplying the same profile produces the same files. - `finalizeWorkspace(ctx)` — the detached background step: infrastructure startup, DB readiness wait, install / build, migrations, seed. `ctx` carries `name`, `ports`, `branch`, `currentWorktree`, `mainWorktree`, `isMainWorktree`, `force`, and `progress(label)`. **MUST be idempotent** — `workspace setup` is the documented retry path and re-runs it; idempotency also covers a name reused after an orphan (force-remove the stale container named after the workspace before `up`). **Run `npm install` first**, so any later failure still leaves usable `node_modules/` for the retry to import `@paleo/workspace`. May `return { purgeData }` — an opaque blob persisted on the registry entry and handed to `purgeInfrastructure`; use it **only** for teardown identifiers you can't re-derive at purge time (deterministic container / volume names come from `name` + paths, so they don't go here). - `purgeInfrastructure(ctx)` — optional destructive teardown (typically `docker compose down -v`). Runs on `workspace remove`, `prune`, and orphan removal. **MUST be idempotent and cwd-independent**: `ctx.worktree` may be gone (orphan), so branch on its presence and tear down *by name* in that case — derive names from `ctx.name` / `ctx.worktree` / `ctx.mainWorktree`, and read `ctx.purgeData` for non-derivable ids. Swallow errors. @@ -252,7 +252,7 @@ Public-IP variant: the same section without the `export` line, introduced by "Wh Items marked *(ports)* drop out without a port scheme, items marked *(dev server)* without a dev server — see [portless mode](#portless-mode). - [ ] **Make all dev ports configurable and contiguous.** *(ports)* Prerequisite. -- [ ] **Design the port scheme.** *(ports)* Ports per environment? `perWorkspace` defaults to `names.length`; set it explicitly to reserve headroom. Base port 8100 unless you have a reason. Document the resulting layout in `docs/`. +- [ ] **Design and claim the port scheme.** *(ports)* `perWorkspace` defaults to `names.length`; set it explicitly to reserve headroom. Base port 8100 unless you have a reason. Set `.alignfirst.json`'s `portRange` to the whole block: `first = base`, `last = base + perWorkspace × maxWorkspaces − 1`. The workspace kernel checks both ranges on every command and refuses a mismatch. Document the resulting layout in `docs/`. - [ ] **Identify your gitignored files.** Every gitignored file a worktree needs — port-bearing *and* verbatim (editor settings, secondary `.env`, private-registry tokens). Do they have `.example` versions? - [ ] **Classify gitignored directories.** Shared (symlinked) vs per-worktree. Suggest a shared `.local/` by default. - [ ] **Decide database provisioning.** File copy (SQLite) or Docker + migrate + seed. diff --git a/skills/alignfirst/SKILL.md b/skills/alignfirst/SKILL.md index 938404da..8a0bb38a 100644 --- a/skills/alignfirst/SKILL.md +++ b/skills/alignfirst/SKILL.md @@ -4,65 +4,8 @@ description: "Collaborative problem-solving protocols. Read when the user names license: CC0 1.0 metadata: author: Paleo - version: "3.12.0" + version: "4.0.0" repository: https://github.com/paleo/alignfirst --- -# AlignFirst Guide - -If you don't already know which protocol to use, read [overview.md](references/overview.md) first. - -## Protocols - -- **Technical Specification** (_spec_, or _alspec_): [spec-protocol.md](references/spec-protocol.md) -- **Implementation Plans** (_plan_, or _alplan_): [plan-protocol.md](references/plan-protocol.md) -- **Align-and-Do Protocol** (_AAD_): [aad-protocol.md](references/aad-protocol.md) -- **Catch Up** (_catchup_, or _alcatchup_): [catchup-protocol.md](references/catchup-protocol.md) -- **Merge** (_merge_, or _almerge_): [merge-protocol.md](references/merge-protocol.md) -- **Code Review** (_alreview_): [review-protocol.md](references/review-protocol.md) -- **Description** (_aldescription_): [description-protocol.md](references/description-protocol.md) - -## TASK_DIR Location - -**TASK_DIR** is the directory where work files related to a task are stored. Usually, we use **TASK_DIR** = `.plans/{TICKET_ID}/` (a sub-directory of the `.plans` folder). If no ticket ID is known, ask the user for it. - -When `.plans/{TICKET_ID}/` is missing, test whether the single path `.plans/_archives/{TICKET_ID}/` exists. If it does, move it back to `.plans/{TICKET_ID}/` before continuing. Never list `.plans/_archives/`: its content would flood the context with old ticket IDs. - -- Create TASK_DIR if it doesn't exist -- Or, list all existing files (do not truncate) - -**Work without a ticket:** when the user says there is no ticket, issue a *side ticket*, a ticket kept aside from the ticket system. Its ID is `side-{N}`: find the highest `side-{N}` directory in `.plans/` and take N + 1 (`side-1` if there is none). Reuse an existing `side-{N}` directory when the user refers to that earlier work. Omit the ticket ID from commit messages. - -## File Naming Convention - -Format: `{CYCLE_LETTER}{FILE_NUMBER}-{FILE_TYPE}.md` - -**Common file types:** - -- `spec` - technical specification -- `plan` - implementation plan -- `AAD.summary` - AAD summary document -- `description` - PR/MR description -- `review` - code review report -- `merge.summary` - merge conflicts resolution summary - -**Example structure:** - -```text -.plans/ -├── 123/ -│ ├── A1-spec.md -│ ├── A2-plan.md -│ └── A3-AAD.summary.md -│ └── B1-spec.md -``` - -## Notes - -- **TICKET_ID** is a unique identifier for the task, often an issue or ticket number. -- Cycles are identified by a **CYCLE_LETTER** (A, B, C...). -- The protocol or the user decides whether the next file continues the current cycle or starts a new one. -- To determine the next filename in the current cycle: find the highest CYCLE_LETTER, then the highest FILE_NUMBER within it. Bump the number. -- For a new cycle: bump CYCLE_LETTER and reset FILE_NUMBER to 1. -- Do not bother the user with CYCLE_LETTER or FILE_NUMBER. They are for internal organization. Start CYCLE_LETTER with `A` if there is no existing cycle. So you just need to ask for a **ticket ID** if you don't have one. -- There is no strict sequence of file types in the workflow. Available file types are also flexible; if you need a new one, just create it. +Run `npx -y alignfirst guide` and follow it. Add the protocol name to read a protocol: `npx -y alignfirst guide spec`. diff --git a/skills/almerge/SKILL.md b/skills/almerge/SKILL.md index c193fd39..89304e61 100644 --- a/skills/almerge/SKILL.md +++ b/skills/almerge/SKILL.md @@ -2,8 +2,11 @@ name: almerge description: "Execute the AlignFirst merge protocol." disable-model-invocation: true +license: CC0 1.0 +metadata: + author: Paleo + version: "4.0.0" + repository: https://github.com/paleo/alignfirst --- -Read the *alignfirst* skill (`../alignfirst/SKILL.md`) and its `references/merge-protocol.md` if not already loaded. - -Execute the _merge_ protocol from the *alignfirst* skill. Do not use your own plan mode. +Run `npx -y alignfirst guide merge` and follow the protocol. Do not use your own plan mode. diff --git a/skills/alplan/SKILL.md b/skills/alplan/SKILL.md index bc6ea632..d3ae46eb 100644 --- a/skills/alplan/SKILL.md +++ b/skills/alplan/SKILL.md @@ -2,8 +2,11 @@ name: alplan description: "Execute the AlignFirst planning protocol." disable-model-invocation: true +license: CC0 1.0 +metadata: + author: Paleo + version: "4.0.0" + repository: https://github.com/paleo/alignfirst --- -Read the *alignfirst* skill (`../alignfirst/SKILL.md`) and its `references/plan-protocol.md` if not already loaded. - -Execute the _plan_ protocol from the *alignfirst* skill. Do not use your own plan mode. +Run `npx -y alignfirst guide plan` and follow the protocol. Do not use your own plan mode. diff --git a/skills/alreview/SKILL.md b/skills/alreview/SKILL.md index 223c2c69..4349fa75 100644 --- a/skills/alreview/SKILL.md +++ b/skills/alreview/SKILL.md @@ -2,8 +2,11 @@ name: alreview description: "Execute the AlignFirst review protocol." disable-model-invocation: true +license: CC0 1.0 +metadata: + author: Paleo + version: "4.0.0" + repository: https://github.com/paleo/alignfirst --- -Read the *alignfirst* skill (`../alignfirst/SKILL.md`) and its `references/review-protocol.md` if not already loaded. - -Execute the _review_ protocol from the *alignfirst* skill. Do not use your own plan mode. +Run `npx -y alignfirst guide review` and follow the protocol. Do not use your own plan mode. diff --git a/skills/alspec/SKILL.md b/skills/alspec/SKILL.md index 5229e657..ebf1c1e2 100644 --- a/skills/alspec/SKILL.md +++ b/skills/alspec/SKILL.md @@ -2,8 +2,11 @@ name: alspec description: "Execute the AlignFirst specification protocol." disable-model-invocation: true +license: CC0 1.0 +metadata: + author: Paleo + version: "4.0.0" + repository: https://github.com/paleo/alignfirst --- -Read the *alignfirst* skill (`../alignfirst/SKILL.md`) and its `references/spec-protocol.md` if not already loaded. - -Execute the _spec_ protocol from the *alignfirst* skill. Do not use your own plan mode. +Run `npx -y alignfirst guide spec` and follow the protocol. Do not use your own plan mode.