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.
- package/README.md +57 -23
- package/package.json +1 -1
- package/packages/cli/dist/commands/create.js +8 -2
- package/packages/cli/dist/commands/init.js +72 -0
- package/packages/cli/dist/index.js +44 -2
- package/packages/cli/dist/lib/copy-tree.js +2 -0
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +24 -11
- package/templates/agent-os/stack/node-ts/.claude/hooks/dod-checks.json +1 -0
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +18 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +66 -0
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +36 -0
- package/templates/agent-os/universal/.claude/rules/architecture.md +7 -0
- package/templates/agent-os/universal/.claude/rules/workflow.md +33 -7
- package/templates/agent-os/universal/.claude/settings.json +20 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +45 -0
- package/templates/agent-os/universal/CLAUDE.md +14 -4
- package/templates/agent-os/universal/PLAN.md +27 -0
- package/templates/agent-os/universal/layers.json +21 -0
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +57 -0
- package/templates/skeleton/aws-serverless/README.md +28 -7
- package/templates/skeleton/node-service/.github/workflows/deploy.yml +29 -0
- package/templates/skeleton/node-service/README.md +21 -1
- package/templates/skeleton/node-service/gitignore +1 -0
- package/templates/skeleton/node-service/package.json +4 -2
- package/templates/skeleton/node-service/pnpm-lock.yaml +3 -0
- package/templates/skeleton/node-service/scripts/build-artifact.mjs +34 -0
- 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
|
-
|
|
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
|
|
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**
|
|
26
|
-
gate; `post-deploy-verify` with its
|
|
27
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
60
|
-
violation is refused live by the hook** → the
|
|
61
|
-
travels every layer, the worker drains the queue,
|
|
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
|
|
79
|
-
generates a project cold and runs the
|
|
80
|
-
(install → lint → typecheck → test
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
@@ -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. **
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
-
|
|
27
|
-
branch
|
|
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`
|
|
49
|
-
the core; `
|
|
50
|
-
|
|
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
|
|
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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app/root",
|
|
3
|
-
"version": "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
|
|
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",
|
|
@@ -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
|
+
});
|