create-agent-rig 0.2.0 → 0.3.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/CHANGELOG.md +134 -0
- package/README.md +57 -9
- package/package.json +9 -2
- package/packages/cli/dist/lib/summary.js +19 -5
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +46 -0
- package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +12 -2
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +808 -0
- package/templates/agent-os/universal/.claude/queue.json +3 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +43 -0
- package/templates/agent-os/universal/.claude/rules/invariants.md +169 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +489 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +161 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +305 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +231 -0
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +175 -0
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +345 -0
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +239 -0
- package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +280 -0
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +62 -0
- package/templates/agent-os/universal/.claude/settings.json +4 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +297 -40
- package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/CLAUDE.md +57 -7
- package/templates/agent-os/universal/PLAN.md +28 -2
- package/templates/agent-os/universal/layers.json +20 -1
- package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +6 -1
- package/templates/skeleton/aws-serverless/gitignore +8 -0
- package/templates/skeleton/node-service/.github/workflows/ci.yml +6 -1
- package/templates/skeleton/node-service/gitignore +8 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable changes per release. The generated projects are the product, so an entry
|
|
4
|
+
says what a **newly scaffolded project** gains or loses — not what moved inside
|
|
5
|
+
the generator.
|
|
6
|
+
|
|
7
|
+
Versions are published to npm as [`create-agent-rig`](https://www.npmjs.com/package/create-agent-rig);
|
|
8
|
+
`npx github:serhii-baksheiev/create-agent-rig` keeps working for either path.
|
|
9
|
+
|
|
10
|
+
## 0.3.0
|
|
11
|
+
|
|
12
|
+
The factory extraction: a scaffolded project now arrives with a working
|
|
13
|
+
autonomous loop and the mechanisms that watch it, rather than an empty `.claude/`.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`guard-bash` hook** — the "Never" tier made mechanical: force-pushing or
|
|
18
|
+
deleting a shared branch, a direct push to the default branch, a production
|
|
19
|
+
deploy trigger, a catastrophic delete. It **parses** the command (quotes
|
|
20
|
+
honoured) instead of pattern-matching, so a commit message that mentions a
|
|
21
|
+
forbidden flag is prose, not a bypass.
|
|
22
|
+
- **A kill switch that is a real file.** `touch ~/.claude/<project>-loop-STOP`
|
|
23
|
+
and no merge lands until it is removed. Everything short of the merge stays
|
|
24
|
+
allowed on purpose — stopping cleanly must not mean losing work.
|
|
25
|
+
- **The queue seam.** `loop` no longer reads one tracker: selection goes through
|
|
26
|
+
`.claude/scripts/queue/`, with a pure core (filters, blocker resolution, tier
|
|
27
|
+
ration, sort, stop conditions) and three adapters — `plan-md` (the default; the
|
|
28
|
+
only one that works before a project has a remote), `github-issues`, `jira`.
|
|
29
|
+
- **Two sweeps that run outside any session** — `detect-missed-gate` finds merges
|
|
30
|
+
that crossed an elevated path with no recorded reviewer verdict;
|
|
31
|
+
`reconcile-external-prs` accounts for work that reached the default branch
|
|
32
|
+
outside the queue. Both exist because a run cannot report its own missed gate.
|
|
33
|
+
- **`preflight`** — the pre-run checks, which also print the items it did _not_
|
|
34
|
+
check, every time.
|
|
35
|
+
- **Skills** — `worktree-task` (isolation when a second session may run) and
|
|
36
|
+
`new-invariant` (a generator for the invariant→hook→test pattern, with a
|
|
37
|
+
working example and its test).
|
|
38
|
+
- **`rules/invariants.md`** — the pattern behind every hook here, stated once, so
|
|
39
|
+
the hooks read as examples rather than as laws.
|
|
40
|
+
- **`aws-cdk` target extras** — the `ro-debug` skill (read-only runtime
|
|
41
|
+
investigation, with the traps that produce confident wrong diagnoses) and the
|
|
42
|
+
transferable AWS rules.
|
|
43
|
+
- **`elevated-paths`** — a declaration in `CLAUDE.md`, composed with any block in
|
|
44
|
+
`.claude/rules/`, naming the paths where Tier-2 changes live.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- The governance summary counts `.mjs` hooks only — a config file listed as an
|
|
49
|
+
enforced hook overstated the one number this tool exists to make credible.
|
|
50
|
+
- `autonomy.md`: the tier is decided by what a change **touches**, not by what
|
|
51
|
+
the task predicted it would touch.
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
Four review rounds, ten reviewers, on this release's own code. What they caught,
|
|
56
|
+
each reproduced before the fix and re-verified after:
|
|
57
|
+
|
|
58
|
+
- a PR body could **forge its own reviewer verdict** and suppress the gate sweep
|
|
59
|
+
— the body is written by the actor being audited, so only the `human-review`
|
|
60
|
+
label (which needs repository permission) suppresses now;
|
|
61
|
+
- the `plan-md` adapter's close **deleted the wrong line**, destroying a human's
|
|
62
|
+
Operator-queue entry and leaving the shipped item selectable;
|
|
63
|
+
- every `github-issues` write **threw on success** (those `gh` subcommands print
|
|
64
|
+
text, not JSON) — `escalate` posted its diagnosis and then died before applying
|
|
65
|
+
the label that stops the item being re-picked;
|
|
66
|
+
- the kill switch could be **disarmed by an env variable**, and was fixed in the
|
|
67
|
+
hook while the identical hole sat in `preflight` — the brake now has one
|
|
68
|
+
implementation;
|
|
69
|
+
- three **total bypasses** in the guard, each an exception inside its own work
|
|
70
|
+
that the fail-open catch turned into "allow": an unbounded spread, a recursive
|
|
71
|
+
brace expansion, and a quadratic path collapse;
|
|
72
|
+
- a heredoc pre-pass that could **hide any command** from every rule;
|
|
73
|
+
- a ReDoS in the blocker parser reachable by anyone able to open an issue.
|
|
74
|
+
|
|
75
|
+
Rounds 4 and 5, on the fixes themselves:
|
|
76
|
+
|
|
77
|
+
- a here-string (`cat <<<X`) and an arithmetic left shift (`$((1<<n))`) were each
|
|
78
|
+
read as heredoc markers, **hiding every command up to the next matching line**;
|
|
79
|
+
- `git commit -nm "msg"` bypassed the pre-commit gate outright — the one thing
|
|
80
|
+
that hook exists to stop, in the spelling people actually type;
|
|
81
|
+
- with the kill switch armed, `git merge feat/x && git push` still landed a merge
|
|
82
|
+
on the default branch; a push must now name its ref while stopped;
|
|
83
|
+
- pointing `HOME` at an empty directory disarmed the brake; it is now found
|
|
84
|
+
through the password database as well as the environment;
|
|
85
|
+
- `gh --json files` truncates at 100 with **no marker**, and the gate sweep read
|
|
86
|
+
the short list as "touched nothing elevated" — a PR padded past 100 files hid
|
|
87
|
+
its elevated change. The sweep now compares against `changedFiles`;
|
|
88
|
+
- declaring `.claude/` elevated was a no-op, because every `.md` under it counted
|
|
89
|
+
as inert — so a merge rewriting the autonomy tiers passed the gate meant to
|
|
90
|
+
catch exactly that;
|
|
91
|
+
- a quadratic reviewer-name regex cost ~4 s per crafted PR body, minutes across a
|
|
92
|
+
sweep that reports nothing when killed.
|
|
93
|
+
|
|
94
|
+
The README's enforcement claims were overstated and are now scoped to what the
|
|
95
|
+
guard actually inspects, with the omissions listed in the hook itself: only `rm`
|
|
96
|
+
for deletes, only a workflow dispatch for deploys, only a push that names its
|
|
97
|
+
branch, and nothing carried as a flag value.
|
|
98
|
+
|
|
99
|
+
The lesson that generalises is now a rule (`invariants.md`): **a guard that fails
|
|
100
|
+
open must do provably bounded work**, because fail-open makes every line of work
|
|
101
|
+
a potential total bypass — and prefer deleting a rule to adding one.
|
|
102
|
+
|
|
103
|
+
## 0.2.0
|
|
104
|
+
|
|
105
|
+
Distribution hardening (file modes, the `gitignore`→`.gitignore` trick, a
|
|
106
|
+
pack-path e2e per target), agent-os v2 (`pr-ship`, `post-deploy-verify`,
|
|
107
|
+
`cdk-diff-reviewer`, review-context isolation, session staleness), the `apps/web`
|
|
108
|
+
frontend proving core purity across the wire, the `gate-stop-dod` and
|
|
109
|
+
`inject-rules` hooks, `agent-rig init`, the `loop` skill, and the dev deploy
|
|
110
|
+
workflows.
|
|
111
|
+
|
|
112
|
+
## 0.1.0
|
|
113
|
+
|
|
114
|
+
First release: the CLI, `agent-os/universal` + the `node-ts`/`aws-cdk` stack
|
|
115
|
+
layers, and the `aws-serverless` and `node-service` targets.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Releasing
|
|
120
|
+
|
|
121
|
+
`npm publish` needs 2FA and cannot be undone, so an agent prepares a release and
|
|
122
|
+
**stops at that command**. Everything before it is mechanical:
|
|
123
|
+
|
|
124
|
+
1. `pnpm test` — the full suite, including the e2e that generates both targets
|
|
125
|
+
cold and runs their own checks through the git path **and** the pack path.
|
|
126
|
+
2. `npm pack --dry-run` — confirm the templates, including the dotted `.claude/`
|
|
127
|
+
tree, are in the tarball. This is where scaffolders break, and the git path
|
|
128
|
+
cannot catch it.
|
|
129
|
+
3. Version in `package.json` (and the private inner package, kept in step).
|
|
130
|
+
4. This file, and `PLAN.md` if the plan's claims changed.
|
|
131
|
+
5. `git tag v<version> && git push --tags`.
|
|
132
|
+
6. **Owner:** `npm publish`.
|
|
133
|
+
7. **Owner:** smoke the published artifact — `npx create-agent-rig@<version>` in
|
|
134
|
+
an empty directory, then `pnpm install && pnpm check` inside it.
|
package/README.md
CHANGED
|
@@ -39,19 +39,55 @@ never inflated). The hooks live in `.claude/hooks/` and are wired in
|
|
|
39
39
|
- **`block-no-verify`** — refuses bypassing pre-commit checks (and knows the
|
|
40
40
|
difference between using the `--no-verify`/`-n` flag and merely mentioning it
|
|
41
41
|
in a message);
|
|
42
|
+
- **`guard-bash`** — refuses the part of the "Never" tier a text scan can decide:
|
|
43
|
+
a force-push or `--delete` naming a shared branch, a push that names the default
|
|
44
|
+
branch, `gh workflow run`/`gh api …/dispatches` against a production workflow,
|
|
45
|
+
and `rm` on a catastrophic target. It **parses** the command rather than
|
|
46
|
+
pattern-matching it, so a commit message mentioning a forbidden flag is prose,
|
|
47
|
+
not a bypass — and the file states exactly what it does **not** inspect
|
|
48
|
+
(`cdk deploy`, `find -delete`, a bare `git push`, and more);
|
|
42
49
|
- **`gate-stop-dod`** — refuses to end the session while a Definition-of-Done
|
|
43
50
|
check is red; it fails open (a missing or corrupt config never makes the
|
|
44
51
|
session unquittable) and never blocks twice in a row;
|
|
45
52
|
- **`inject-rules`** — re-injects the autonomy rules at session start, so they
|
|
46
53
|
survive compaction and resumes.
|
|
47
54
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
**A brake that is a real file.** `touch ~/.claude/<project>-loop-STOP` and no
|
|
56
|
+
merge lands until it is removed — enforced at the tool layer, so it holds even if
|
|
57
|
+
nothing reads the rule. Everything short of the merge stays allowed on purpose:
|
|
58
|
+
finish the task, push the branch, open the PR, write the journal. Stopping
|
|
59
|
+
cleanly must not mean losing work.
|
|
60
|
+
|
|
61
|
+
**Two sweeps meant to run outside any session** — nothing schedules them for you;
|
|
62
|
+
that is deliberate, because a check a run performs on itself is one a hurried run
|
|
63
|
+
skips. `detect-missed-gate` finds merges
|
|
64
|
+
that crossed an elevated path with no recorded reviewer verdict;
|
|
65
|
+
`reconcile-external-prs` accounts for work that reached the default branch outside
|
|
66
|
+
the queue. They exist because the one failure a run cannot report is its own
|
|
67
|
+
missed gate — the run that skipped it is exactly the run that will not mention it.
|
|
68
|
+
|
|
69
|
+
**A queue behind an adapter.** The `loop` driver selects through
|
|
70
|
+
`.claude/scripts/queue/`: a pure core (filters in order, blocker resolution, the
|
|
71
|
+
elevated-tier ration, stop conditions) with adapters for `PLAN.md` (the default,
|
|
72
|
+
working before a project has a remote), GitHub Issues, and Jira. Two rules are
|
|
73
|
+
load-bearing and tested from both directions — **blockers resolve from links,
|
|
74
|
+
never labels**, and **the agent never files its own work items**.
|
|
75
|
+
|
|
76
|
+
Around all of it: **autonomy tiers** (what an agent does alone / after review /
|
|
77
|
+
never), **stop rules** (three strikes, flaky ≠ retry, session staleness),
|
|
78
|
+
**subagent gates** (`test-writer`, `code-reviewer`, `security-scanner`, and
|
|
79
|
+
`cdk-diff-reviewer` on the AWS target), **skills** (`pr-ship` pre-merge gate;
|
|
80
|
+
`loop` queue driver; `worktree-task` for concurrent sessions; `new-invariant`, a
|
|
81
|
+
generator for the invariant→hook→test pattern; `post-deploy-verify` and
|
|
82
|
+
`ro-debug` on the AWS target), and a one-page `CLAUDE.md` map a fresh session
|
|
83
|
+
orients by.
|
|
84
|
+
|
|
85
|
+
**The hooks are examples, not laws.** `.claude/rules/invariants.md` states the
|
|
86
|
+
pattern behind each one — a stated invariant, a mechanical check, a test for the
|
|
87
|
+
check — so you can delete the ones whose invariant your project does not have and
|
|
88
|
+
spend the slot on one it does. An inherited rule nobody chose is worse than an
|
|
89
|
+
empty rule file: the empty one is visibly incomplete, the inherited one is
|
|
90
|
+
invisibly wrong.
|
|
55
91
|
|
|
56
92
|
The skeleton around it is real and runnable — pure core shared by server _and_
|
|
57
93
|
browser (one schema validates on both sides of the wire), a mandatory usecase
|
|
@@ -117,5 +153,17 @@ dependencies fresh to catch upstream breakage early. This repo dogfoods its own
|
|
|
117
153
|
rulebook — `CLAUDE.md` and `.claude/` are composed from the templates, and
|
|
118
154
|
drift fails the suite.
|
|
119
155
|
|
|
120
|
-
|
|
121
|
-
|
|
156
|
+
**And the enforcement layer is adversarially reviewed, not just tested.** The
|
|
157
|
+
Bash guard went through four review rounds with ten reviewers, who executed it
|
|
158
|
+
rather than read it. They found a PR body that could forge its own reviewer
|
|
159
|
+
verdict, a queue write that deleted the wrong line, and three ways to make the
|
|
160
|
+
guard crash into permitting everything. Each round's findings — including the
|
|
161
|
+
ones introduced by the previous round's _fix_ — are in the git history and in
|
|
162
|
+
`CHANGELOG.md`. The rule that came out of it is now part of what ships: a guard
|
|
163
|
+
that fails open must do provably bounded work, because fail-open turns every line
|
|
164
|
+
of its own work into a potential bypass.
|
|
165
|
+
|
|
166
|
+
Development (from a clone — `PLAN.md` and `demo.sh` live in the repository, not
|
|
167
|
+
in the published tarball): `pnpm test` (full), `pnpm test:unit` (fast loop),
|
|
168
|
+
`pnpm template:check` (templates in place). The plan of record is `PLAN.md`;
|
|
169
|
+
release notes and the release checklist ship in `CHANGELOG.md`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-agent-rig",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
"node-service"
|
|
16
16
|
],
|
|
17
17
|
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/serhii-baksheiev/create-agent-rig.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/serhii-baksheiev/create-agent-rig#readme",
|
|
23
|
+
"bugs": "https://github.com/serhii-baksheiev/create-agent-rig/issues",
|
|
18
24
|
"type": "module",
|
|
19
25
|
"bin": {
|
|
20
26
|
"create-agent-rig": "packages/cli/dist/index.js"
|
|
@@ -22,7 +28,8 @@
|
|
|
22
28
|
"files": [
|
|
23
29
|
"packages/cli/dist",
|
|
24
30
|
"templates",
|
|
25
|
-
"scripts/prepare.mjs"
|
|
31
|
+
"scripts/prepare.mjs",
|
|
32
|
+
"CHANGELOG.md"
|
|
26
33
|
],
|
|
27
34
|
"engines": {
|
|
28
35
|
"node": ">=20"
|
|
@@ -1,21 +1,35 @@
|
|
|
1
1
|
import { readdir } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
const names = async (dir, strip) => {
|
|
3
|
+
const names = async (dir, strip, keep) => {
|
|
4
4
|
try {
|
|
5
|
-
return (await readdir(dir))
|
|
5
|
+
return (await readdir(dir))
|
|
6
|
+
.filter((entry) => !keep || keep.test(entry))
|
|
7
|
+
.map((entry) => entry.replace(strip, ''))
|
|
8
|
+
.sort();
|
|
6
9
|
}
|
|
7
10
|
catch {
|
|
8
11
|
return [];
|
|
9
12
|
}
|
|
10
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* Hooks whose filename names the *tool* they intercept rather than the
|
|
16
|
+
* invariant they enforce. Stripping the prefix would print "bash", which tells
|
|
17
|
+
* the reader nothing — and a screen that sells enforcement may not be vague.
|
|
18
|
+
*/
|
|
19
|
+
const HOOK_LABELS = {
|
|
20
|
+
'guard-bash': 'never tier',
|
|
21
|
+
};
|
|
11
22
|
export async function collectGovernance(projectDir) {
|
|
12
23
|
const claude = path.join(projectDir, '.claude');
|
|
13
24
|
return {
|
|
14
25
|
rules: await names(path.join(claude, 'rules'), /\.md$/),
|
|
15
26
|
agents: await names(path.join(claude, 'agents'), /\.md$/),
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
27
|
+
// `.mjs` only: the stack layer also drops config (dod-checks.json) in this
|
|
28
|
+
// directory, and counting a config file as an enforced hook would make the
|
|
29
|
+
// screen overstate the one thing this tool actually sells.
|
|
30
|
+
hooks: (await names(path.join(claude, 'hooks'), /\.mjs$/, /\.mjs$/)).map(
|
|
31
|
+
// guard-core-purity → "core purity": the mechanism, not the filename
|
|
32
|
+
(hook) => HOOK_LABELS[hook] ?? hook.replace(/^(guard|block)-/, '').replaceAll('-', ' ')),
|
|
19
33
|
skills: await names(path.join(claude, 'skills'), /$^/),
|
|
20
34
|
};
|
|
21
35
|
}
|
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
How the universal boundaries land on AWS. If a rule here seems to fight a
|
|
4
4
|
universal rule, that is an invariant conflict — stop and surface it.
|
|
5
5
|
|
|
6
|
+
## The elevated paths this layer adds
|
|
7
|
+
|
|
8
|
+
`CLAUDE.md` declares the project's own elevated paths; this block adds the ones
|
|
9
|
+
that exist only because this layer does. The gate sweep
|
|
10
|
+
(`.claude/scripts/detect-missed-gate.mjs`) unions every declaration it finds, so a
|
|
11
|
+
target without infrastructure never declares a directory it does not have.
|
|
12
|
+
|
|
13
|
+
```elevated-paths
|
|
14
|
+
infra/
|
|
15
|
+
```
|
|
16
|
+
|
|
6
17
|
## Infrastructure is code, and only code
|
|
7
18
|
|
|
8
19
|
- Everything lives in the CDK app under `infra/`. A console change ("click-ops")
|
|
@@ -17,6 +28,24 @@ universal rule, that is an invariant conflict — stop and surface it.
|
|
|
17
28
|
teardown; flip to RETAIN before real data arrives — that flip is a Tier-2
|
|
18
29
|
decision.
|
|
19
30
|
|
|
31
|
+
## Stacks: what may move, and what may never
|
|
32
|
+
|
|
33
|
+
- 🔴 **Never move a stateful construct between stacks.** A Table, Bucket, Secret
|
|
34
|
+
or user pool that changes stack is **deleted and recreated** by
|
|
35
|
+
CloudFormation — that is data loss, not a refactor, and it passes review as
|
|
36
|
+
"tidying" if nobody knows this rule. Stateful constructs stay put and are
|
|
37
|
+
referenced cross-stack.
|
|
38
|
+
- **A stack has a hard resource ceiling (500).** One HTTP route costs several
|
|
39
|
+
resources, so a growing API stack approaches it long before it looks big. When
|
|
40
|
+
it does, split out the least-coupled domain as a **stateless-only** stack —
|
|
41
|
+
functions, roles, routes — attached to the same API cross-stack, with a
|
|
42
|
+
**one-way** dependency. Splitting stateless costs nothing; splitting stateful
|
|
43
|
+
costs the data (above).
|
|
44
|
+
- **Fleet-wide function defaults live in one module, not in a stack.**
|
|
45
|
+
Architecture, runtime, `NODE_OPTIONS`, tracing: one edit there reaches every
|
|
46
|
+
function. A per-stack override of a fleet default is the thing to reject in
|
|
47
|
+
review — it is invisible from anywhere except that stack.
|
|
48
|
+
|
|
20
49
|
## IAM: least privilege, by construction
|
|
21
50
|
|
|
22
51
|
- Use the narrow grant for the operation actually performed
|
|
@@ -48,6 +77,23 @@ universal rule, that is an invariant conflict — stop and surface it.
|
|
|
48
77
|
clients constructed once, handler exported. Nothing else imports an entry.
|
|
49
78
|
- Functions stay single-purpose — one route or one event source each. Fan-out
|
|
50
79
|
belongs to infrastructure (queues, topics), not to in-process branching.
|
|
80
|
+
- **SDK clients are constructed at module top level, never inside the handler.**
|
|
81
|
+
Containers are reused across warm invocations, so a client built in the handler
|
|
82
|
+
body is paid on every invocation and defeats connection reuse — the most common
|
|
83
|
+
Lambda performance bug, and invisible in tests because tests are always cold.
|
|
84
|
+
The same applies to anything expensive and stateless: build once per container.
|
|
85
|
+
- Environment is parsed **once, at module scope**, through `loadEnv(zod)`. A
|
|
86
|
+
misconfigured function then fails its cold start loudly instead of misbehaving
|
|
87
|
+
quietly per request, and no code below the entry file reads the environment
|
|
88
|
+
directly.
|
|
89
|
+
|
|
90
|
+
## When the deployed runtime misbehaves
|
|
91
|
+
|
|
92
|
+
Investigate before writing anything: the **`ro-debug` skill** has the read-only
|
|
93
|
+
recipes and, more usefully, the traps that have produced confident wrong
|
|
94
|
+
diagnoses — a stale local branch read as current, `UPDATE_COMPLETE` left over
|
|
95
|
+
from the previous deploy, an empty metric read as healthy when it means the
|
|
96
|
+
function was never invoked.
|
|
51
97
|
|
|
52
98
|
## Post-deploy verification (target-specific means)
|
|
53
99
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ro-debug
|
|
3
|
+
description: Investigate the deployed runtime read-only — Lambda logs and error rates, DLQ depth and message age, table reads — with the traps that have produced confidently-wrong diagnoses before. Use when something on the deployed stage misbehaves, and before writing any fix.
|
|
4
|
+
allowed-tools: Bash, Read, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Read-only runtime investigation
|
|
8
|
+
|
|
9
|
+
A fix is always a code change through a PR. This skill only **looks** — and it is
|
|
10
|
+
scoped read-only so that a session diagnosing an incident cannot become a session
|
|
11
|
+
mutating production state under pressure, which is when that decision is worst.
|
|
12
|
+
|
|
13
|
+
## The role this assumes, and the honest caveat
|
|
14
|
+
|
|
15
|
+
Every command below wants a **read-only profile**: permission to read logs,
|
|
16
|
+
metrics, queue attributes and table items, and nothing else. No decrypt, no
|
|
17
|
+
secret reads, no mutations — so a credential cannot leak through this path even
|
|
18
|
+
by accident.
|
|
19
|
+
|
|
20
|
+
⚠ **The skeleton does not provision that role.** It ships the application, not
|
|
21
|
+
your account's access model, and minting a role is an **owner action** (a Tier-2
|
|
22
|
+
decision — it is IAM). Until it exists, either create it once with those four
|
|
23
|
+
read scopes, or accept that you are investigating with wider credentials than the
|
|
24
|
+
task needs and say so in the write-up. Do not silently upgrade to an admin
|
|
25
|
+
profile and carry on.
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
export AWS_PROFILE=<your read-only profile>
|
|
29
|
+
export AWS_REGION=__REGION__
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Before reading ANY code to explain a runtime behaviour
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
git fetch origin && git rev-parse HEAD "origin/$(git symbolic-ref --short HEAD)"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**If the local branch differs from its remote, diagnose from the remote**
|
|
39
|
+
(`git show origin/<branch>:<file>`, or a worktree). **A fetch does not move your
|
|
40
|
+
checkout** — reading stale local code while describing deployed behaviour is
|
|
41
|
+
the single most reliable way to produce a confident, wrong diagnosis, and it has
|
|
42
|
+
happened more than once. See `.claude/rules/autonomy.md`, "Session staleness".
|
|
43
|
+
|
|
44
|
+
## Recipes
|
|
45
|
+
|
|
46
|
+
**Find the function first** — CDK generates the physical names, so never guess one:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
aws lambda list-functions \
|
|
50
|
+
--query "Functions[?contains(FunctionName,'Notes')].FunctionName" --output text
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Errors in a window:**
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
aws logs filter-log-events --log-group-name "/aws/lambda/<fn>" \
|
|
57
|
+
--start-time <epoch-ms> --filter-pattern "ERROR" \
|
|
58
|
+
--max-items 20 --query 'events[].message' --output text
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**DLQ depth, then message age.** Depth comes from the queue; **age does not** —
|
|
62
|
+
`ApproximateAgeOfOldestMessage` is a **CloudWatch metric, not an SQS attribute**,
|
|
63
|
+
and asking for it as an attribute fails with `InvalidAttributeName`:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
aws sqs get-queue-attributes --queue-url <notes-dlq-url> \
|
|
67
|
+
--attribute-names ApproximateNumberOfMessages
|
|
68
|
+
|
|
69
|
+
aws cloudwatch get-metric-statistics --namespace AWS/SQS \
|
|
70
|
+
--metric-name ApproximateAgeOfOldestMessage \
|
|
71
|
+
--dimensions Name=QueueName,Value=<notes-dlq-name> \
|
|
72
|
+
--start-time <iso> --end-time <iso> --period 300 --statistics Maximum
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Age is what tells you whether a non-empty DLQ **predates** the thing you are
|
|
76
|
+
investigating. A days-old backlog is not your regression, and treating it as one
|
|
77
|
+
sends the whole diagnosis in the wrong direction.
|
|
78
|
+
|
|
79
|
+
**Table reads** — key by whatever `packages/db` composes; nothing else knows the
|
|
80
|
+
key shape:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
aws dynamodb query --table-name <NotesTable output> \
|
|
84
|
+
--key-condition-expression "pk = :pk" \
|
|
85
|
+
--expression-attribute-values '{":pk":{"S":"NOTE#<id>"}}' --max-items 3
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Error rate:** `AWS/Lambda` `Errors`, `Sum`, by `FunctionName`, over the suspect
|
|
89
|
+
window.
|
|
90
|
+
|
|
91
|
+
## Interpretation rules — where wrong diagnoses actually come from
|
|
92
|
+
|
|
93
|
+
- 🔴 **An empty metric result is "no signal", never "healthy".** Zero datapoints
|
|
94
|
+
means **no invocations in the window** — the function was not exercised, so the
|
|
95
|
+
metric says nothing about whether it works. Report it as no signal and go find
|
|
96
|
+
a window with traffic. This is the same rule the `post-deploy-verify` skill
|
|
97
|
+
states, and for the same reason: a vacuous pass is worse than a missing one,
|
|
98
|
+
because it gets believed.
|
|
99
|
+
- 🔴 **`StackStatus: UPDATE_COMPLETE` is stale evidence.** It persists from the
|
|
100
|
+
previous deploy, so it is true of a stack whose latest deploy failed. The
|
|
101
|
+
authoritative signals are the **deploy job's conclusion**
|
|
102
|
+
(`gh run list --workflow deploy`) and the stack's `LastUpdatedTime` freshness.
|
|
103
|
+
- **DNS and asset-upload failures are infrastructure flakes, not code
|
|
104
|
+
regressions.** A deploy that died at asset publish or checkout tells you nothing
|
|
105
|
+
about the change. Your own read-only calls can hit the same flake — a failed
|
|
106
|
+
probe is `unknown`, not a finding.
|
|
107
|
+
- **Never work around the profile's denials — that is the point of the profile.**
|
|
108
|
+
Needing a decrypt, a secret or a mutation means the investigation has reached
|
|
109
|
+
its boundary: escalate to the human with what you found
|
|
110
|
+
(`.claude/rules/autonomy.md`, "Escalation format").
|
|
111
|
+
|
|
112
|
+
## What to hand back
|
|
113
|
+
|
|
114
|
+
What was observed (verbatim, not summarised), which window, which signals were
|
|
115
|
+
**unavailable** and why, the current hypothesis, and the narrowest reproduction.
|
|
116
|
+
A read-only investigation that ends in a named uncertainty is a good outcome; one
|
|
117
|
+
that ends in a confident story built on a stale read is not.
|
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Contract (Claude Code): JSON on stdin; exit 0 = allow, exit 2 = block, and
|
|
6
6
|
// stderr is shown to the agent as the reason.
|
|
7
|
+
//
|
|
8
|
+
// Stated limit: it strips QUOTED text so prose about the flag is not a bypass,
|
|
9
|
+
// but it does not parse heredocs. Writing `git commit -nm …` inside a heredoc
|
|
10
|
+
// body — a doc, a test fixture, a PR description — is therefore blocked. That
|
|
11
|
+
// happened while writing this hook's own tests. The fix is not to teach this file
|
|
12
|
+
// to tokenise: it owns exactly one invariant and stays readable because of it
|
|
13
|
+
// (see .claude/rules/invariants.md, "One invariant per hook"). Use a file rather
|
|
14
|
+
// than a heredoc, or quote the example.
|
|
7
15
|
import { readFileSync } from 'node:fs';
|
|
8
16
|
|
|
9
17
|
function main() {
|
|
@@ -26,8 +34,10 @@ function main() {
|
|
|
26
34
|
const [segment, verb] = match;
|
|
27
35
|
const bypasses =
|
|
28
36
|
/(^|\s)--no-verify\b/.test(segment) ||
|
|
29
|
-
//
|
|
30
|
-
|
|
37
|
+
// `-n` is --no-verify for commit only (for push it means --dry-run), and it
|
|
38
|
+
// counts inside a COMBINED cluster: `git commit -nm "msg"` bypassed the
|
|
39
|
+
// pre-commit gate outright, which is the one thing this hook exists to stop.
|
|
40
|
+
(verb === 'commit' && /(^|\s)-[a-zA-Z]*n[a-zA-Z]*(\s|$)/.test(segment));
|
|
31
41
|
if (bypasses) {
|
|
32
42
|
process.stderr.write(
|
|
33
43
|
'BLOCKED — bypassing pre-commit checks is never allowed. ' +
|