create-agent-rig 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/README.md +57 -23
  2. package/package.json +1 -1
  3. package/packages/cli/dist/commands/create.js +8 -2
  4. package/packages/cli/dist/commands/init.js +72 -0
  5. package/packages/cli/dist/index.js +44 -2
  6. package/packages/cli/dist/lib/copy-tree.js +2 -0
  7. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +24 -11
  8. package/templates/agent-os/stack/node-ts/.claude/hooks/dod-checks.json +1 -0
  9. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +18 -0
  10. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +66 -0
  11. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +36 -0
  12. package/templates/agent-os/universal/.claude/rules/architecture.md +7 -0
  13. package/templates/agent-os/universal/.claude/rules/workflow.md +33 -7
  14. package/templates/agent-os/universal/.claude/settings.json +20 -0
  15. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +45 -0
  16. package/templates/agent-os/universal/CLAUDE.md +14 -4
  17. package/templates/agent-os/universal/PLAN.md +27 -0
  18. package/templates/agent-os/universal/layers.json +21 -0
  19. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +57 -0
  20. package/templates/skeleton/aws-serverless/README.md +28 -7
  21. package/templates/skeleton/node-service/.github/workflows/deploy.yml +29 -0
  22. package/templates/skeleton/node-service/README.md +21 -1
  23. package/templates/skeleton/node-service/gitignore +1 -0
  24. package/templates/skeleton/node-service/package.json +4 -2
  25. package/templates/skeleton/node-service/pnpm-lock.yaml +3 -0
  26. package/templates/skeleton/node-service/scripts/build-artifact.mjs +34 -0
  27. package/templates/skeleton/node-service/services/api/test/artifact.test.ts +93 -0
package/README.md CHANGED
@@ -4,38 +4,66 @@ Scaffold a project that ships with an **agent operating system** — rules,
4
4
  gates, and hooks that hold the architecture mechanically, not by prose.
5
5
 
6
6
  ```sh
7
- npx create-agent-rig my-app
7
+ npx create-agent-rig my-app # choose a target interactively
8
+ npx create-agent-rig my-app --target node-service # or name it up front
8
9
  ```
9
10
 
11
+ Two coherent targets — `aws-serverless` (the default) and `node-service`. On a
12
+ terminal the CLI prompts; in CI it wants `--target` explicitly. `--no-git`
13
+ skips the initial baseline commit; `--no-color` (and `NO_COLOR`) plainens the
14
+ output.
15
+
16
+ Already have a repo? Install just the **process layer** into it — no
17
+ architecture assumptions, no skeleton:
18
+
19
+ ```sh
20
+ npx create-agent-rig init # rules, gates, stop rules into the current repo
21
+ npx create-agent-rig init --dry-run # print the plan, write nothing
22
+ ```
23
+
24
+ `init` drops in the autonomy tiers, stop rules, workflow, and the enforcement
25
+ hooks, and refuses to clobber an existing `CLAUDE.md`.
26
+
10
27
  ## What you get
11
28
 
12
29
  **A system of boundaries, each held by tooling.** An agent (or a human using
13
- one) cannot talk its way past them:
30
+ one) cannot talk its way past them — each guard is a pre-write scan that stops
31
+ the normal path cold (review and tests back it; the claim is stated exactly,
32
+ never inflated). The hooks live in `.claude/hooks/` and are wired in
33
+ `.claude/settings.json`:
14
34
 
15
35
  - **`guard-core-purity`** — refuses any edit that puts I/O, clock, randomness,
16
- or environment access into the pure domain core;
36
+ environment access, or a non-allowlisted import into the pure domain core;
17
37
  - **`guard-web-boundary`** — refuses `db`/service imports from the frontend;
18
38
  the web talks to the backend over HTTP only;
19
39
  - **`block-no-verify`** — refuses bypassing pre-commit checks (and knows the
20
- difference between using the flag and merely mentioning it in a message).
40
+ difference between using the `--no-verify`/`-n` flag and merely mentioning it
41
+ in a message);
42
+ - **`gate-stop-dod`** — refuses to end the session while a Definition-of-Done
43
+ check is red; it fails open (a missing or corrupt config never makes the
44
+ session unquittable) and never blocks twice in a row;
45
+ - **`inject-rules`** — re-injects the autonomy rules at session start, so they
46
+ survive compaction and resumes.
21
47
 
22
48
  Around the hooks, the operating system: **autonomy tiers** (what an agent does
23
49
  alone / after review / never), **stop rules** (three strikes, flaky ≠ retry,
24
50
  session staleness), **subagent gates** (`test-writer`, `code-reviewer`,
25
- `security-scanner`, `cdk-diff-reviewer`), **skills** (`pr-ship` pre-merge
26
- gate; `post-deploy-verify` with its binary HEALTHY/REGRESSION verdict), and a
27
- one-page `CLAUDE.md` map a fresh session orients by.
51
+ `security-scanner`, and `cdk-diff-reviewer` on the AWS target), **skills**
52
+ (`pr-ship` pre-merge gate; `loop` queue driver; `post-deploy-verify` with its
53
+ binary HEALTHY/REGRESSION verdict on the AWS target), and a one-page
54
+ `CLAUDE.md` map a fresh session orients by.
28
55
 
29
56
  The skeleton around it is real and runnable — pure core shared by server _and_
30
57
  browser (one schema validates on both sides of the wire), a mandatory usecase
31
- layer, a queue with DLQ discipline, tests at every layer.
58
+ layer (`payload → handler → usecase → model`), a queue with DLQ discipline,
59
+ tests at every layer.
32
60
 
33
61
  ## Targets
34
62
 
35
- | Target | One line |
36
- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
37
- | `aws-serverless` | DynamoDB single-table, SQS + DLQ + alarm, three Lambdas behind an HTTP API, static web via S3 + CloudFront, CDK with least-privilege IAM |
38
- | `node-service` | `node:http` server that also serves the web bundle, JSON-file store behind the same model boundary, spool-directory queue, worker process |
63
+ | Target | One line |
64
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | `aws-serverless` | DynamoDB single-table, SQS + DLQ + CloudWatch alarm, three Lambdas (POST/GET `/notes` behind an HTTP API, plus an SQS worker), static web on S3 + CloudFront, CDK with least-privilege IAM grants |
66
+ | `node-service` | `node:http` server that also serves the built web bundle, atomic JSON-file store behind the same model boundary, spool-directory queue + DLQ, worker process |
39
67
 
40
68
  Coherent alternatives, not a parameterized abstraction. Flexibility is
41
69
  **subtraction**: the generated project is yours — delete what you don't need.
@@ -56,9 +84,10 @@ gets fought. If you need one of these, add it — the project is yours.
56
84
  ./demo.sh # from a clone of this repo
57
85
  ```
58
86
 
59
- generate → the generated project's own gates pass → **an attempted core-purity
60
- violation is refused live by the hook** → the service runs, a smoke request
61
- travels every layer, the worker drains the queue, the DLQ stays empty:
87
+ generates the `node-service` target → the generated project's own gates pass →
88
+ **an attempted core-purity violation is refused live by the hook** → the
89
+ service runs, a smoke request travels every layer, the worker drains the queue,
90
+ the DLQ stays empty:
62
91
 
63
92
  ```
64
93
  == 3/4 an agent tries to put I/O and clock access into the pure core… ==
@@ -71,17 +100,22 @@ Move the impure part behind the usecase layer or into an adapter.
71
100
 
72
101
  ## Requirements
73
102
 
74
- - Node ≥ 20 (pnpm recommended for the generated workspace)
103
+ - Node ≥ 20 (pnpm recommended for the generated workspace). The CLI itself
104
+ carries zero runtime dependencies — the `npx github:…`, tarball, and
105
+ published-package paths all work.
75
106
 
76
107
  ## How it stays honest
77
108
 
78
- Every template is a real project tested in place on every push; every e2e run
79
- generates a project cold and runs the generated project's own full checks
80
- (install → lint → typecheck → test → build → synth); a grep-test keeps the
81
- universal rules free of any provider mention; the hook-blocking behavior
82
- itself is under test; and a weekly lockfile-free run catches upstream breakage
83
- early. This repo dogfoods its own rulebook — `CLAUDE.md` and `.claude/` are
84
- composed from the templates, and drift fails the suite.
109
+ Every template is a real project, installed with a frozen lockfile and run in
110
+ place on every push. Every e2e run generates a project cold and runs the
111
+ generated project's own checks (install → lint → typecheck → test, plus
112
+ `cdk synth` on the AWS target); the pack-path and git-path installs are both
113
+ under test, because that is exactly where scaffolders break. A grep-test keeps
114
+ the universal rules free of any provider mention; the hook-blocking behavior
115
+ itself is under test; and a weekly lockfile-free run resolves each template's
116
+ dependencies fresh to catch upstream breakage early. This repo dogfoods its own
117
+ rulebook — `CLAUDE.md` and `.claude/` are composed from the templates, and
118
+ drift fails the suite.
85
119
 
86
120
  Development: `pnpm test` (full), `pnpm test:unit` (fast loop),
87
121
  `pnpm template:check` (templates in place). The plan of record is `PLAN.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-agent-rig",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Scaffold a new project with an agent operating system (rules, gates, hooks) and a runnable code skeleton",
5
5
  "keywords": [
6
6
  "create",
@@ -67,14 +67,20 @@ export async function createProject(dirArg, options) {
67
67
  }
68
68
  const run = promisify(execFile);
69
69
  async function initGitBaseline(projectDir) {
70
+ // Disable git's background maintenance for these one-shot commands: a commit
71
+ // can otherwise fork an auto-gc / maintenance process that keeps writing to
72
+ // .git/objects/pack after we return — a non-deterministic tail that races any
73
+ // caller cleaning up the directory, and pointless work on a one-commit repo.
74
+ const quiet = ['-c', 'gc.auto=0', '-c', 'maintenance.auto=false'];
70
75
  try {
71
- await run('git', ['init', '--quiet'], { cwd: projectDir });
72
- await run('git', ['add', '-A'], { cwd: projectDir });
76
+ await run('git', [...quiet, 'init', '--quiet'], { cwd: projectDir });
77
+ await run('git', [...quiet, 'add', '-A'], { cwd: projectDir });
73
78
  // Explicit identity: the baseline must commit even where git has no
74
79
  // global user configured (fresh machines, CI). --no-verify here shields
75
80
  // the baseline from the USER'S global hooks only — the generated
76
81
  // project's own gates do not exist yet, so nothing is being bypassed.
77
82
  await run('git', [
83
+ ...quiet,
78
84
  '-c',
79
85
  'user.name=create-agent-rig',
80
86
  '-c',
@@ -0,0 +1,72 @@
1
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { agentOsUniversalDir } from '../templates.js';
4
+ /** A user-facing failure: message is printed as-is, no stack trace. */
5
+ export class InitError extends Error {
6
+ }
7
+ async function loadManifest() {
8
+ const raw = await readFile(path.join(agentOsUniversalDir(), 'layers.json'), 'utf8');
9
+ return JSON.parse(raw);
10
+ }
11
+ /**
12
+ * `init` installs only the PROCESS layer (hooks-and-reach brief §3/§4): rules
13
+ * that assume nothing about the codebase shape. Architecture rules reference
14
+ * `packages/core` and friends — installing them into an arbitrary repo would
15
+ * describe a structure that does not exist, which is worse than no rule.
16
+ *
17
+ * CLAUDE.md is the meta file we bring, but never over an existing one.
18
+ */
19
+ async function processFiles(manifest) {
20
+ // the process layer, plus CLAUDE.md as the map (guarded separately)
21
+ return [...manifest.process, 'CLAUDE.md'];
22
+ }
23
+ async function exists(p) {
24
+ try {
25
+ await access(p);
26
+ return true;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ export async function planInit(repoDir) {
33
+ const manifest = await loadManifest();
34
+ const files = await processFiles(manifest);
35
+ const conflicts = [];
36
+ for (const rel of files) {
37
+ if (await exists(path.join(repoDir, rel)))
38
+ conflicts.push(rel);
39
+ }
40
+ return { files: files.map((p) => ({ path: p })), conflicts };
41
+ }
42
+ export async function initProject(repoDir, options) {
43
+ const manifest = await loadManifest();
44
+ const files = await processFiles(manifest);
45
+ const universal = agentOsUniversalDir();
46
+ // Refuse to clobber an existing CLAUDE.md unless forced — init edits
47
+ // someone's working repository (brief §4, non-negotiable).
48
+ if (!options.force && files.includes('CLAUDE.md')) {
49
+ if (await exists(path.join(repoDir, 'CLAUDE.md'))) {
50
+ throw new InitError('This repo already has a CLAUDE.md. Refusing to overwrite it. ' +
51
+ 'Merge the agent-os map in by hand, or re-run with --force to replace it.');
52
+ }
53
+ }
54
+ const written = [];
55
+ const skipped = [];
56
+ const plannedCount = files.length;
57
+ for (const rel of files) {
58
+ const dest = path.join(repoDir, rel);
59
+ const isForceableMeta = rel === 'CLAUDE.md';
60
+ if ((await exists(dest)) && !(isForceableMeta && options.force)) {
61
+ // never overwrite a file init did not write (a user's own copy)
62
+ skipped.push(rel);
63
+ continue;
64
+ }
65
+ if (options.dryRun)
66
+ continue;
67
+ await mkdir(path.dirname(dest), { recursive: true });
68
+ await writeFile(dest, await readFile(path.join(universal, rel)));
69
+ written.push(rel);
70
+ }
71
+ return { written, skipped, plannedCount };
72
+ }
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { parseArgs } from 'node:util';
6
6
  import { CreateError, createProject } from './commands/create.js';
7
+ import { InitError, initProject, planInit } from './commands/init.js';
7
8
  import { makePalette } from './lib/colors.js';
8
9
  import { promptTarget } from './lib/prompts.js';
9
10
  import { collectGovernance, renderSummary } from './lib/summary.js';
@@ -20,7 +21,11 @@ Options
20
21
  --no-git skip git init + the pristine-template baseline commit
21
22
  --no-color plain output (NO_COLOR is respected too)
22
23
  --version print the version
23
- -h, --help this text`;
24
+ -h, --help this text
25
+
26
+ Also: create-agent-rig init [--dry-run] [--force]
27
+ Install the process layer (rules, gates, stop rules — no architecture
28
+ assumptions) into the CURRENT existing repo. Refuses to clobber CLAUDE.md.`;
24
29
  async function packageVersion() {
25
30
  // dist/index.js lives three levels under the package root — same walk as
26
31
  // the templates resolver, valid in the repo and in the published package.
@@ -28,7 +33,44 @@ async function packageVersion() {
28
33
  const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
29
34
  return pkg.version;
30
35
  }
36
+ async function runInit(rawArgs) {
37
+ let values;
38
+ try {
39
+ ({ values } = parseArgs({
40
+ args: rawArgs,
41
+ options: { 'dry-run': { type: 'boolean' }, force: { type: 'boolean' } },
42
+ allowPositionals: false,
43
+ }));
44
+ }
45
+ catch (error) {
46
+ process.stderr.write(`${error.message}\n\n${USAGE}\n`);
47
+ return 1;
48
+ }
49
+ const cwd = process.cwd();
50
+ const dryRun = values['dry-run'] === true;
51
+ const plan = await planInit(cwd);
52
+ process.stdout.write(`agent-rig init — process layer into ${cwd}\n\n` +
53
+ plan.files.map((f) => ` + ${f.path}`).join('\n') +
54
+ '\n');
55
+ if (plan.conflicts.length > 0) {
56
+ process.stdout.write(`\nAlready present (kept, not overwritten):\n` +
57
+ plan.conflicts.map((c) => ` · ${c}`).join('\n') +
58
+ '\n');
59
+ }
60
+ const result = await initProject(cwd, { dryRun, force: values.force === true });
61
+ if (dryRun) {
62
+ process.stdout.write(`\nDry run — nothing written (${result.plannedCount} files planned).\n`);
63
+ return 0;
64
+ }
65
+ process.stdout.write(`\nInstalled ${result.written.length} files` +
66
+ (result.skipped.length ? `, kept ${result.skipped.length} existing` : '') +
67
+ '.\n');
68
+ return 0;
69
+ }
31
70
  async function main() {
71
+ if (process.argv[2] === 'init') {
72
+ return runInit(process.argv.slice(3));
73
+ }
32
74
  let positionals;
33
75
  let values;
34
76
  try {
@@ -92,7 +134,7 @@ main()
92
134
  process.exitCode = code;
93
135
  })
94
136
  .catch((error) => {
95
- if (error instanceof CreateError) {
137
+ if (error instanceof CreateError || error instanceof InitError) {
96
138
  process.stderr.write(`${error.message}\n`);
97
139
  }
98
140
  else {
@@ -16,6 +16,8 @@ export const DEFAULT_IGNORE = [
16
16
  '.next',
17
17
  'out',
18
18
  'next-env.d.ts',
19
+ // the init-manifest of the universal layer — tooling metadata, not payload
20
+ 'layers.json',
19
21
  ];
20
22
  /** A file is treated as binary if its first bytes contain a NUL byte. */
21
23
  function isBinary(buffer) {
@@ -10,24 +10,37 @@ You verify runtime health after a deploy and return a **verdict**, not a vibe.
10
10
  You are read-only: you observe, you never fix. The autonomy rules
11
11
  (`.claude/rules/autonomy.md`, "Post-deploy verification") consume your verdict.
12
12
 
13
+ Scope yourself to what this skeleton actually provisions — one API, one worker
14
+ with one DLQ, two CloudFormation stacks. Do not invent signals it does not have.
15
+
13
16
  ## Steps — evidence for each, in order
14
17
 
15
- 1. **Stack freshness.** Confirm the deploy you are judging actually landed.
16
- `UPDATE_COMPLETE` **alone is stale evidence** — it persists from the
17
- previous deploy. Authoritative is the deploy run's own conclusion plus a
18
- freshness check: `LastUpdatedTime` from
19
- `aws cloudformation describe-stacks` must postdate the deploy you are
20
- verifying. Judging a stale stack is the classic false-HEALTHY.
21
- 2. **Smoke the route.** POST a request through the API (the README's smoke
18
+ 1. **The deploy job's conclusion — the primary, always-available signal.**
19
+ Start here: did the deploy job itself succeed? This exists on every project
20
+ from day one, before any metric has data. A failed or absent deploy job is a
21
+ REGRESSION on its own; a successful one is necessary but not sufficient —
22
+ continue.
23
+ 2. **Stack status + freshness cross-check.** `UPDATE_COMPLETE` **alone is stale
24
+ evidence** — it persists from the previous deploy. Confirm `LastUpdatedTime`
25
+ from `aws cloudformation describe-stacks` postdates the deploy you are
26
+ judging. A fresh-looking status on a stale stack is the classic false-HEALTHY.
27
+ 3. **Smoke the route.** POST a request through the API (the README's smoke
22
28
  command). Expect the documented success response (201 with a body).
23
- 3. **The async path.** Confirm the worker consumed the event this smoke
29
+ 4. **The async path.** Confirm the worker consumed the event this smoke
24
30
  produced: `aws logs filter-log-events` on the worker's log group for the
25
31
  processed-marker within the last few minutes.
26
- 4. **Queue discipline.** The DLQ is empty and its alarm is quiet:
32
+ 5. **Queue discipline.** The skeleton's DLQ is empty and its alarm is quiet:
27
33
  `aws sqs get-queue-attributes` (ApproximateNumberOfMessages = 0) and
28
34
  `aws cloudwatch describe-alarms` (state OK, not ALARM).
29
- 5. **Error noise.** Scan both functions' recent logs for new ERROR-level
30
- entries that did not exist before the deploy.
35
+ 6. **Function errors** in the window after the deploy — scan the functions'
36
+ recent logs for new ERROR-level entries.
37
+
38
+ 🔴 **A vacuous result is "no signal", not a pass.** An empty metric or an empty
39
+ log query means *there were no invocations*, not *there were no errors*. Never
40
+ read absence-of-data as health — report it as "no signal" and, since you could
41
+ not verify, it counts toward REGRESSION, never toward HEALTHY. The first
42
+ HEALTHY verdict a user sees has to mean something, or the whole mechanism loses
43
+ its credibility exactly when it should earn it.
31
44
 
32
45
  ## Verdict — the only two answers
33
46
 
@@ -0,0 +1 @@
1
+ ["pnpm lint", "pnpm typecheck", "pnpm test"]
@@ -37,3 +37,21 @@ are; this file says how they are expressed in TypeScript.
37
37
  string-match messages.
38
38
  - Logs are structured JSON lines through the shared logger — no bare
39
39
  `console.log` in service code.
40
+
41
+ ## Confirming the merge criterion (GitHub Actions)
42
+
43
+ `workflow.md` states the criterion provider-neutrally: confirm the required
44
+ check completed **for this commit**. Here that is concrete — and it matters
45
+ because `gh pr checks --watch` can exit successfully while checks are still
46
+ *unregistered*, reporting a green wall that has not been built yet.
47
+
48
+ Poll the check runs for the PR's head SHA and require the named check to have
49
+ `conclusion: success`, not merely "not failing":
50
+
51
+ ```sh
52
+ SHA=$(gh pr view --json headRefOid -q .headRefOid)
53
+ gh api "repos/{owner}/{repo}/commits/$SHA/check-runs" \
54
+ -q '.check_runs[] | select(.name=="ci") | .conclusion'
55
+ # must print: success (a result set containing only a scanner is NOT done)
56
+ ```
57
+
@@ -0,0 +1,66 @@
1
+ // Stop hook: the Definition of Done as a mechanical gate. The session may not
2
+ // end while a named DoD check fails — the checklist stops being a wish.
3
+ //
4
+ // The stack layer supplies the checks (.claude/hooks/dod-checks.json — an
5
+ // array of shell commands, cheap and deterministic). Universal supplies only
6
+ // the mechanism: no config → nothing to gate.
7
+ //
8
+ // Anti-loop discipline (the classic Stop-hook trap):
9
+ // - stop_hook_active in the payload means we already blocked this stop once
10
+ // — never block again, or an agent that cannot go green spins forever;
11
+ // - a clean git tree stops instantly: nothing changed, nothing to gate;
12
+ // - fail open on any hook error — a crashed gate must not make the session
13
+ // unquittable.
14
+ import { execSync } from 'node:child_process';
15
+ import { readFileSync } from 'node:fs';
16
+
17
+ function main() {
18
+ let input;
19
+ try {
20
+ input = JSON.parse(readFileSync(0, 'utf8'));
21
+ } catch {
22
+ return 0;
23
+ }
24
+ if (input.hook_event_name !== 'Stop' && input.hook_event_name !== 'SubagentStop') return 0;
25
+ if (input.stop_hook_active) return 0;
26
+
27
+ try {
28
+ const status = execSync('git status --porcelain', {
29
+ encoding: 'utf8',
30
+ stdio: ['ignore', 'pipe', 'ignore'],
31
+ });
32
+ if (status.trim() === '') return 0;
33
+ } catch {
34
+ // not a git repo — run the checks anyway
35
+ }
36
+
37
+ let checks;
38
+ try {
39
+ checks = JSON.parse(readFileSync(new URL('./dod-checks.json', import.meta.url), 'utf8'));
40
+ } catch {
41
+ return 0;
42
+ }
43
+ if (!Array.isArray(checks) || checks.length === 0) return 0;
44
+
45
+ for (const command of checks) {
46
+ try {
47
+ execSync(command, { stdio: ['ignore', 'pipe', 'pipe'] });
48
+ } catch (error) {
49
+ const tail = String(error.stdout ?? '')
50
+ .split('\n')
51
+ .slice(-15)
52
+ .join('\n');
53
+ process.stderr.write(
54
+ `STOP GATED — a Definition of Done check fails: ${command}\n` +
55
+ (tail.trim() ? `${tail}\n` : '') +
56
+ `Fix the failure before ending the session. If this failure has resisted ` +
57
+ `repeated attempts, follow the stop rules instead: end with a written ` +
58
+ `diagnosis (.claude/rules/autonomy.md). This gate never fires twice in a row.\n`,
59
+ );
60
+ return 2;
61
+ }
62
+ }
63
+ return 0;
64
+ }
65
+
66
+ process.exit(main());
@@ -0,0 +1,36 @@
1
+ // SessionStart hook: the autonomy rules survive compaction and resumes.
2
+ //
3
+ // Long sessions compact their context, and project rules are exactly what
4
+ // gets dropped — an unattended run would finish the night without the tiers
5
+ // and stop rules that were supposed to govern it. SessionStart is one of the
6
+ // few events whose stdout is added to the context Claude sees, and it re-runs
7
+ // on resume and after compaction (source: "resume" / "compact"), so this
8
+ // refreshes instead of going stale.
9
+ //
10
+ // The injected content is deliberately STATELESS — rules, never facts about
11
+ // the moment (mid-session injections are replayed on resume, so timestamps
12
+ // or SHAs here would lie). And it is only the load-bearing part, not the
13
+ // whole rulebook: CLAUDE.md is already loaded by the tool itself.
14
+ import { readFileSync } from 'node:fs';
15
+
16
+ function main() {
17
+ let input;
18
+ try {
19
+ input = JSON.parse(readFileSync(0, 'utf8'));
20
+ } catch {
21
+ return 0;
22
+ }
23
+ if (input.hook_event_name !== 'SessionStart') return 0;
24
+
25
+ try {
26
+ const rules = readFileSync(new URL('../rules/autonomy.md', import.meta.url), 'utf8');
27
+ process.stdout.write(
28
+ `[agent-os] Autonomy rules refresh — in force regardless of compaction:\n\n${rules}\n`,
29
+ );
30
+ } catch {
31
+ // no rules file — nothing to inject, never an error
32
+ }
33
+ return 0;
34
+ }
35
+
36
+ process.exit(main());
@@ -67,6 +67,13 @@ This is not a convention you are trusted to follow; the
67
67
  you, the answer is to move the impure part out — never to look for a way around
68
68
  the hook.
69
69
 
70
+ Precision about the claim, so it is never overstated: the guard is a
71
+ **best-effort text scan of each edit fragment before it lands** — an `Edit`
72
+ shows the hook its new text, not the whole resulting file. It stops the normal
73
+ path cold; a determined evasion is caught by the layers behind it (review and
74
+ tests), not by the hook. A rulebook that sells enforcement must describe its
75
+ enforcement exactly.
76
+
70
77
  ## Storage has exactly one owner
71
78
 
72
79
  `packages/db/` is the only place that touches the storage SDK/driver. Every
@@ -23,8 +23,10 @@ that reproduces the bug.
23
23
 
24
24
  ## Branches and commits
25
25
 
26
- - Work happens on short-lived branches off the default branch; the default
27
- branch stays releasable.
26
+ - **One task, one branch.** Every unit of work gets its own short-lived branch
27
+ off the default branch. **Never commit work to the default branch** — it
28
+ stays releasable at all times. This isolation of a unit of work is the rule;
29
+ it holds even before there is a remote (local branches are enough).
28
30
  - Commits are small and single-purpose; the message says *why*, not just *what*.
29
31
 
30
32
  ## Review-context isolation
@@ -36,16 +38,40 @@ subagent with a fresh context, and why the `pr-ship` gate fans reviewers out
36
38
  instead of self-checking. This isolation is load-bearing, not ceremony — do
37
39
  not "optimise" it away by reviewing in the authoring session.
38
40
 
41
+ ## PR flow
42
+
43
+ This applies **once the project has a remote and CI checks** — a freshly
44
+ generated project has neither, and until it does the branch discipline above is
45
+ the whole of it. When they exist, a human-review change (see `autonomy.md`)
46
+ travels one path to merge, in this order:
47
+
48
+ 1. **Local checks** — the full suite, lint, typecheck, all green locally first.
49
+ A red check is information, never something to retry until green (`autonomy.md`).
50
+ 2. **Reviewer fan-out**, by what the change touches:
51
+ - the `code-reviewer` agent **always**;
52
+ - `security-scanner` when it touches auth, secrets/configuration, input
53
+ parsing, file handling, or outbound calls;
54
+ - an infrastructure review when it touches infrastructure (the stack layer
55
+ names the reviewing agent for the target).
56
+
57
+ The `pr-ship` skill drives this fan-out and returns a SHIP / HOLD verdict
58
+ with named blockers; blocking findings are resolved, not argued with.
59
+ 3. **Merge — on an explicit, non-lazy criterion.** Do not trust a watcher
60
+ command that can exit before the checks have even registered. Confirm that
61
+ the **required** check completed successfully **for this commit** — a list
62
+ that is merely "not failing yet" is not a pass. The concrete command is
63
+ stack-specific and lives in `stack/*`; the criterion here does not name one.
64
+
65
+ **Post-merge tail:** verify the deployed surface is healthy (the target's
66
+ post-deploy verdict — `autonomy.md`), then update `PLAN.md` (close the task,
67
+ record any follow-up in a queue). Merge is not the finish line; a healthy
68
+ runtime and an honest plan are.
69
+
39
70
  ## PR policy
40
71
 
41
72
  - One concern per PR. If the description needs the word "also", split it.
42
- - The `pr-ship` skill is the pre-merge gate: full checks, reviewer fan-out,
43
- DoD walk, and a SHIP / HOLD verdict with named blockers.
44
73
  - The PR description states: intent, what changed, how it was verified, and any
45
74
  autonomy-tier judgment calls made (see `autonomy.md`).
46
- - The `code-reviewer` agent runs before a PR is opened; its blocking findings
47
- are resolved, not argued with. The `security-scanner` agent runs whenever the
48
- change touches auth, secrets, input parsing, or outbound calls.
49
75
  - CI must be green before merge. A red check is fixed or the PR is closed —
50
76
  never merged around.
51
77
 
@@ -23,6 +23,26 @@
23
23
  }
24
24
  ]
25
25
  }
26
+ ],
27
+ "Stop": [
28
+ {
29
+ "hooks": [
30
+ {
31
+ "type": "command",
32
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/gate-stop-dod.mjs\""
33
+ }
34
+ ]
35
+ }
36
+ ],
37
+ "SessionStart": [
38
+ {
39
+ "hooks": [
40
+ {
41
+ "type": "command",
42
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/inject-rules.mjs\""
43
+ }
44
+ ]
45
+ }
26
46
  ]
27
47
  }
28
48
  }
@@ -0,0 +1,45 @@
1
+ ---
2
+ name: loop
3
+ description: The unattended work driver. Runs the Agent queue from PLAN.md task by task under the autonomy rules, journals progress, and ends the session when the queue is empty — never inventing work.
4
+ argument-hint: [max-tasks]
5
+ ---
6
+
7
+ You drive an unattended session. Work comes from one place only: the **Agent
8
+ queue** in `PLAN.md`. The autonomy tiers and stop rules
9
+ (`.claude/rules/autonomy.md`) govern every step; this skill adds the
10
+ loop-level protocol around them.
11
+
12
+ ## The loop
13
+
14
+ 1. **Read `PLAN.md`.** If the Agent queue is empty → write a journal line
15
+ ("queue empty, session ended") and **end the session. Do not invent work:**
16
+ no unbidden refactors, no speculative polish, no "improvements". An empty
17
+ queue is a completed state, not a vacuum to fill.
18
+ 2. **Take the top task.** Judge its tier first: Tier 2 territory → move it to
19
+ the **Operator queue** with a short plan (what, why, risk, rollback) and
20
+ take the next task instead.
21
+ 3. **Do the work** under the standing rules: failing test first, gates before
22
+ any PR (`pr-ship`), boundaries enforced by the hooks.
23
+ 4. **Close the task:** remove it from the queue, add one journal line (what
24
+ moved, what it touched, any follow-up created — follow-ups go into a
25
+ queue, never into ad-hoc scope creep).
26
+ 5. Repeat from 1, within `max-tasks` if given.
27
+
28
+ ## Loop-level stop conditions (distinct from per-task ones)
29
+
30
+ Stop the session — with the journal updated and a short diagnosis — when:
31
+
32
+ - the Agent queue is empty (the normal, successful end);
33
+ - **two consecutive tasks** ended in a per-task stop (N-strike, invariant
34
+ conflict, surprise scope) — the queue itself is probably mis-scoped; that
35
+ is operator information, not something to push through;
36
+ - your context has degraded or files changed under you (the session-staleness
37
+ rule): write the summary, end, let a fresh session continue;
38
+ - `max-tasks` is reached.
39
+
40
+ ## Journal discipline
41
+
42
+ One line per event in the `Journal` section of `PLAN.md`, newest first,
43
+ **stateless in form** (no timestamps needed — order carries the sequence).
44
+ Prune freely: the journal is operational memory, not an archive that only
45
+ grows.
@@ -42,12 +42,22 @@ them all; they are one rulebook.
42
42
 
43
43
  - **TDD, without exception.** The failing test comes first — use the
44
44
  `test-writer` agent for it. See `.claude/rules/workflow.md`.
45
+ - **One task, one branch — and merge via PR.** Every unit of work gets its own
46
+ short-lived branch; the default branch is never committed to directly. Once
47
+ the project has a remote and CI, changes reach it through the PR flow (local
48
+ checks → reviewer fan-out → merge on an explicit criterion). See
49
+ `.claude/rules/workflow.md` ("Branches and commits", "PR flow").
45
50
  - **Gates.** `code-reviewer` runs before every PR; `security-scanner` runs when
46
51
  a change touches auth, secrets, parsing, or outbound calls. Blocking findings
47
- are resolved, not argued with.
48
- - **Enforcement is mechanical.** `guard-core-purity` refuses impure edits to
49
- the core; `block-no-verify` refuses pre-commit bypasses. If a hook blocks
50
- you, fix the cause; never route around a hook.
52
+ are resolved, not argued with. The `pr-ship` skill drives the gate.
53
+ - **Enforcement is mechanical.** `guard-core-purity` catches an impure edit to
54
+ the core the moment it lands; `guard-web-boundary` keeps the frontend off the
55
+ backend; `block-no-verify` refuses pre-commit bypasses; `gate-stop-dod`
56
+ refuses to end the session while a Definition-of-Done check fails. If a hook
57
+ blocks you, fix the cause; never route around a hook.
58
+ - **Work comes from the queue.** The Agent queue in `PLAN.md` is where
59
+ autonomous work is picked up (the `loop` skill drives it); an empty queue
60
+ ends the session — it is never a cue to invent work.
51
61
 
52
62
  ## Foot-guns
53
63
 
@@ -0,0 +1,27 @@
1
+ # __PROJECT_NAME__ — plan and work queues
2
+
3
+ Work in this project has a stated origin: one of the two queues below. An
4
+ agent session picks from the **Agent queue** (see the `loop` skill); anything
5
+ that needs a human decision waits in the **Operator queue**. An empty Agent
6
+ queue means the session ends — it is never an invitation to improvise.
7
+
8
+ Keep entries one line each, most valuable first. Delete done items — the
9
+ journal records history; the queues state only what is next.
10
+
11
+ ## Agent queue
12
+
13
+ <!-- Tasks an agent may pick up autonomously (Tier 0/1 — see
14
+ .claude/rules/autonomy.md). One line each, e.g.:
15
+ - add a GET /notes/:id route through every layer (TDD)
16
+ -->
17
+
18
+ ## Operator queue
19
+
20
+ <!-- Decisions and Tier-2 work waiting on a human. State what is needed, e.g.:
21
+ - decide: retention policy before real data (RemovalPolicy flip)
22
+ -->
23
+
24
+ ## Journal
25
+
26
+ <!-- One line per session, newest first: date-free, what moved, what blocked.
27
+ Prune freely — this is an operational log, not an archive. -->
@@ -0,0 +1,21 @@
1
+ {
2
+ "process": [
3
+ ".claude/rules/workflow.md",
4
+ ".claude/rules/autonomy.md",
5
+ ".claude/agents/test-writer.md",
6
+ ".claude/agents/code-reviewer.md",
7
+ ".claude/agents/security-scanner.md",
8
+ ".claude/hooks/block-no-verify.mjs",
9
+ ".claude/hooks/gate-stop-dod.mjs",
10
+ ".claude/hooks/inject-rules.mjs",
11
+ ".claude/skills/pr-ship/SKILL.md",
12
+ ".claude/skills/loop/SKILL.md",
13
+ "PLAN.md"
14
+ ],
15
+ "architecture": [
16
+ ".claude/rules/architecture.md",
17
+ ".claude/hooks/guard-core-purity.mjs",
18
+ ".claude/hooks/guard-web-boundary.mjs"
19
+ ],
20
+ "meta": [".claude/settings.json", "CLAUDE.md"]
21
+ }
@@ -0,0 +1,57 @@
1
+ name: Deploy (dev)
2
+
3
+ # DEV deployment only. Production is a HUMAN step (see README) — the Never tier
4
+ # (.claude/rules/autonomy.md) forbids an agent from triggering a production
5
+ # deploy, so no production path exists in this workflow at all.
6
+ on:
7
+ push:
8
+ branches: [main, master]
9
+ workflow_dispatch:
10
+
11
+ permissions:
12
+ # OIDC: assume a short-lived federated role. No long-lived keys in secrets —
13
+ # a starter multiplies whatever it ships, so it ships zero static credentials.
14
+ id-token: write
15
+ contents: read
16
+
17
+ jobs:
18
+ deploy:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ # Degrade cleanly: with no configured role this SKIPS with a message and
24
+ # the job stays green — never a red X on a fresh project with no cloud
25
+ # account. Enabling deployment is "add the AWS_DEPLOY_ROLE_ARN secret",
26
+ # not "edit this file".
27
+ - id: guard
28
+ env:
29
+ ROLE: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
30
+ run: |
31
+ if [ -z "$ROLE" ]; then
32
+ echo "deploy skipped: no credentials configured — see README (Deploy)."
33
+ echo "skip=true" >> "$GITHUB_OUTPUT"
34
+ fi
35
+
36
+ - if: steps.guard.outputs.skip != 'true'
37
+ uses: pnpm/action-setup@v4
38
+ - if: steps.guard.outputs.skip != 'true'
39
+ uses: actions/setup-node@v4
40
+ with:
41
+ node-version: 22
42
+ cache: pnpm
43
+ - if: steps.guard.outputs.skip != 'true'
44
+ run: pnpm install --frozen-lockfile
45
+ - if: steps.guard.outputs.skip != 'true'
46
+ run: pnpm build:web
47
+ env:
48
+ NEXT_PUBLIC_API_URL: ${{ vars.API_URL }}
49
+
50
+ - if: steps.guard.outputs.skip != 'true'
51
+ uses: aws-actions/configure-aws-credentials@v4
52
+ with:
53
+ role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
54
+ aws-region: ${{ vars.AWS_REGION || '__REGION__' }}
55
+
56
+ - if: steps.guard.outputs.skip != 'true'
57
+ run: pnpm --filter @app/infra exec cdk deploy AppStack WebStack --require-approval never
@@ -36,20 +36,41 @@ pnpm check # all of the above
36
36
 
37
37
  ## Deploy it
38
38
 
39
+ ### Dev — automated, on push (OIDC, no static keys)
40
+
41
+ `.github/workflows/deploy.yml` runs a **dev** deploy on every push to the
42
+ default branch. It ships with **no credentials**, so on a fresh repo it skips
43
+ cleanly with a message — never a red X. To enable it, you *add a secret*, you
44
+ do not edit the workflow:
45
+
46
+ 1. In AWS, create an IAM role your repo can assume via GitHub's OIDC provider
47
+ (`token.actions.githubusercontent.com`) — a short-lived federated role, no
48
+ long-lived access keys anywhere.
49
+ 2. Add its ARN as the repository secret **`AWS_DEPLOY_ROLE_ARN`** (and,
50
+ optionally, repo variables `AWS_REGION` and `API_URL`).
51
+
52
+ The workflow then assumes the role, builds the web bundle, and runs
53
+ `cdk deploy AppStack WebStack`. The web bundle is served from S3 + CloudFront
54
+ (the `WebUrl` output).
55
+
56
+ ### Local / manual
57
+
39
58
  ```sh
40
59
  # needs AWS credentials; region comes from your profile (generator default: __REGION__)
41
60
  cd infra
42
61
  npx cdk bootstrap # first time per account/region
43
- npx cdk deploy AppStack
44
-
45
- # the web bundle: build against the deployed API, then sync to the web stack
46
- cd ..
47
- NEXT_PUBLIC_API_URL=<ApiUrl output> pnpm build:web
48
- cd infra && npx cdk deploy WebStack
62
+ npx cdk deploy AppStack WebStack
49
63
  aws s3 sync ../apps/web/out "s3://<WebBucketName output>"
50
- # the site is at the WebUrl output
51
64
  ```
52
65
 
66
+ ### Production — a human step, on purpose
67
+
68
+ There is **no production deploy in this repo**, by design: the agent operating
69
+ system's Never tier forbids an agent from triggering a production deploy, so
70
+ shipping an automated prod path would contradict the rules on day one.
71
+ Promote to production yourself — a separate account/role, a reviewed change,
72
+ your own approval — reusing the dev workflow's OIDC pattern.
73
+
53
74
  ## Verify runtime health (CI-green ≠ runtime-healthy)
54
75
 
55
76
  After every deploy:
@@ -0,0 +1,29 @@
1
+ name: Build artifact
2
+
3
+ # node-service ships NO deploy destination — a container registry, a VPS, a
4
+ # PaaS is the project owner's choice, not the starter's. This builds a runnable
5
+ # artifact (dist/) and stops short of delivery: it produces something real,
6
+ # then leaves shipping to you. Production is a HUMAN step (see README).
7
+ on:
8
+ push:
9
+ branches: [main, master]
10
+ workflow_dispatch:
11
+
12
+ jobs:
13
+ build-artifact:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: pnpm/action-setup@v4
18
+ - uses: actions/setup-node@v4
19
+ with:
20
+ node-version: 22
21
+ cache: pnpm
22
+ - run: pnpm install --frozen-lockfile
23
+ - run: pnpm build:web
24
+ - run: pnpm build:artifact
25
+ - uses: actions/upload-artifact@v4
26
+ with:
27
+ name: server-bundle
28
+ path: dist/
29
+ - run: echo "Artifact built (dist/). Deploy destination is your choice — see README (Deploy)."
@@ -45,7 +45,27 @@ Open http://localhost:3000/ — the form validates with the same core schema
45
45
  the server applies again on POST.
46
46
 
47
47
  Configuration is environment-first (`PORT`, `DATA_DIR`, `QUEUE_DIR`, `DLQ_DIR`,
48
- `POLL_INTERVAL_MS`) with working defaults — see each service's `main.ts`.
48
+ `POLL_INTERVAL_MS`, `STATIC_DIR`) with working defaults — see each service's
49
+ `main.ts`.
50
+
51
+ ## Deploy it
52
+
53
+ This target ships **no deploy destination** — a container registry, a VPS, a
54
+ PaaS is your choice, not the starter's. What it ships is a **build that
55
+ produces a runnable artifact** and stops there:
56
+
57
+ ```sh
58
+ pnpm build:artifact # → dist/server.mjs + dist/public/
59
+ STATIC_DIR=dist/public node dist/server.mjs # run the artifact anywhere Node runs
60
+ ```
61
+
62
+ `.github/workflows/deploy.yml` runs this on every push and uploads `dist/` as a
63
+ build artifact — it produces something real and delivers it nowhere. Point it
64
+ at your host of choice (add a `docker build`, an `scp`, a PaaS deploy step).
65
+
66
+ **Production is a human step, on purpose.** The agent operating system's Never
67
+ tier forbids an agent from triggering a production deploy, so no automated prod
68
+ path ships here — promoting the artifact to production is your reviewed action.
49
69
 
50
70
  ## Verify runtime health (CI-green ≠ runtime-healthy)
51
71
 
@@ -7,3 +7,4 @@ var/
7
7
  .next/
8
8
  out/
9
9
  next-env.d.ts
10
+ dist/
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app/root",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -11,13 +11,15 @@
11
11
  "lint": "eslint .",
12
12
  "typecheck": "tsc -p tsconfig.json && tsc -p apps/web/tsconfig.json",
13
13
  "build:web": "pnpm --filter @app/web build",
14
+ "build:artifact": "pnpm build:web && node scripts/build-artifact.mjs",
14
15
  "start:api": "pnpm --filter @app/api start",
15
16
  "start:worker": "pnpm --filter @app/worker start",
16
- "check": "pnpm lint && pnpm typecheck && pnpm test && pnpm build:web"
17
+ "check": "pnpm lint && pnpm typecheck && pnpm build:artifact && pnpm test"
17
18
  },
18
19
  "devDependencies": {
19
20
  "@eslint/js": "^10.0.1",
20
21
  "@types/node": "^26.1.1",
22
+ "esbuild": "^0.28.1",
21
23
  "eslint": "^10.7.0",
22
24
  "globals": "^17.7.0",
23
25
  "tsx": "^4.23.1",
@@ -14,6 +14,9 @@ importers:
14
14
  '@types/node':
15
15
  specifier: ^26.1.1
16
16
  version: 26.1.1
17
+ esbuild:
18
+ specifier: ^0.28.1
19
+ version: 0.28.1
17
20
  eslint:
18
21
  specifier: ^10.7.0
19
22
  version: 10.7.0
@@ -0,0 +1,34 @@
1
+ // Builds the deployable artifact: a bundled server plus the web public dir,
2
+ // into dist/. It produces a real, runnable thing — it does not ship it
3
+ // anywhere. Run it: STATIC_DIR=dist/public node dist/server.mjs
4
+ import { build } from 'esbuild';
5
+ import { cp, mkdir, rm } from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+ const dist = path.join(root, 'dist');
11
+
12
+ await rm(dist, { recursive: true, force: true });
13
+ await mkdir(dist, { recursive: true });
14
+
15
+ await build({
16
+ entryPoints: [path.join(root, 'services', 'api', 'src', 'main.ts')],
17
+ outfile: path.join(dist, 'server.mjs'),
18
+ bundle: true,
19
+ platform: 'node',
20
+ format: 'esm',
21
+ target: 'node22',
22
+ // ESM-on-node output may reference require() from a dependency — shim it.
23
+ banner: {
24
+ js: "import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);",
25
+ },
26
+ });
27
+
28
+ // The built web bundle, if present (pnpm build:web). Served via STATIC_DIR.
29
+ await cp(path.join(root, 'apps', 'web', 'out'), path.join(dist, 'public'), {
30
+ recursive: true,
31
+ }).catch(() => {});
32
+
33
+ console.log('artifact built: dist/server.mjs (+ dist/public if the web was built).');
34
+ console.log('run it: STATIC_DIR=dist/public node dist/server.mjs');
@@ -0,0 +1,93 @@
1
+ // CD brief §2: node-service's deployable artifact must actually build and run.
2
+ // This bundles the server, boots the bundle over a real socket, and closes the
3
+ // path — proving the artifact is genuine, not a stub.
4
+ import { execFile, spawn } from 'node:child_process';
5
+ import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import type { AddressInfo } from 'node:net';
8
+ import { createServer } from 'node:net';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { promisify } from 'node:util';
12
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
13
+
14
+ const exec = promisify(execFile);
15
+ const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
16
+ const dist = path.join(projectRoot, 'dist');
17
+
18
+ function freePort(): Promise<number> {
19
+ return new Promise((resolve, reject) => {
20
+ const srv = createServer();
21
+ srv.listen(0, () => {
22
+ const { port } = srv.address() as AddressInfo;
23
+ srv.close(() => resolve(port));
24
+ });
25
+ srv.on('error', reject);
26
+ });
27
+ }
28
+
29
+ describe('deployable artifact (dist/)', () => {
30
+ let child: ReturnType<typeof spawn> | undefined;
31
+ let work: string;
32
+
33
+ beforeAll(async () => {
34
+ work = await mkdtemp(path.join(tmpdir(), 'artifact-'));
35
+ // `pnpm check` builds the artifact before tests; build it here only if a
36
+ // bare `pnpm test` run left no dist to boot.
37
+ const built = await stat(path.join(dist, 'server.mjs')).then(
38
+ () => true,
39
+ () => false,
40
+ );
41
+ if (!built) await exec('pnpm', ['build:artifact'], { cwd: projectRoot });
42
+ }, 180_000);
43
+
44
+ afterAll(async () => {
45
+ child?.kill();
46
+ await rm(work, { recursive: true, force: true });
47
+ });
48
+
49
+ it('produces a bundled server and the web public dir', async () => {
50
+ expect((await stat(path.join(dist, 'server.mjs'))).size).toBeGreaterThan(1000);
51
+ await expect(readFile(path.join(dist, 'public', 'index.html'), 'utf8')).resolves.toContain(
52
+ '<',
53
+ );
54
+ });
55
+
56
+ it('the bundle boots and serves the full request path', async () => {
57
+ const port = await freePort();
58
+ child = spawn(process.execPath, [path.join(dist, 'server.mjs')], {
59
+ env: {
60
+ ...process.env,
61
+ PORT: String(port),
62
+ DATA_DIR: path.join(work, 'data'),
63
+ QUEUE_DIR: path.join(work, 'queue'),
64
+ STATIC_DIR: path.join(dist, 'public'),
65
+ },
66
+ stdio: 'ignore',
67
+ });
68
+
69
+ const base = `http://127.0.0.1:${port}`;
70
+ // wait for readiness
71
+ for (let i = 0; i < 50; i++) {
72
+ try {
73
+ await fetch(base + '/notes');
74
+ break;
75
+ } catch {
76
+ await new Promise((r) => setTimeout(r, 100));
77
+ }
78
+ }
79
+
80
+ const created = await fetch(base + '/notes', {
81
+ method: 'POST',
82
+ headers: { 'content-type': 'application/json' },
83
+ body: JSON.stringify({ title: 'artifact proof' }),
84
+ });
85
+ expect(created.status).toBe(201);
86
+ const listed = (await (await fetch(base + '/notes')).json()) as { notes: unknown[] };
87
+ expect(listed.notes).toHaveLength(1);
88
+ // the bundle also serves the web shell
89
+ const root = await fetch(base + '/');
90
+ expect(root.status).toBe(200);
91
+ expect(root.headers.get('content-type')).toContain('text/html');
92
+ }, 30_000);
93
+ });