create-agent-rig 0.4.0 → 0.5.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 +207 -8
- package/README.md +50 -17
- package/package.json +2 -1
- package/packages/cli/dist/commands/create.js +8 -3
- package/packages/cli/dist/commands/init.js +82 -34
- package/packages/cli/dist/commands/upgrade.js +112 -30
- package/packages/cli/dist/index.js +38 -14
- package/packages/cli/dist/lib/copy-tree.js +35 -6
- package/packages/cli/dist/lib/init-settings.js +12 -0
- package/packages/cli/dist/lib/install-set.js +6 -8
- package/packages/cli/dist/lib/manifest.js +21 -9
- package/packages/cli/dist/lib/safe-path.js +30 -0
- package/templates/agent-os/init/AGENTS.md +191 -0
- package/templates/agent-os/init/CLAUDE.md +61 -9
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +98 -0
- package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +34 -3
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +34 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +233 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +676 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +338 -0
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +33 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +64 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +33 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +274 -24
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +480 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +61 -1
- package/templates/agent-os/universal/.claude/rules/invariants.md +71 -16
- package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1242 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +433 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +6 -16
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +89 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +334 -23
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +7 -2
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +413 -8
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +75 -13
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +222 -21
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
- package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +441 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -0
- package/templates/agent-os/universal/.claude/settings.json +7 -2
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +115 -7
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +378 -31
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +300 -25
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
- package/templates/agent-os/universal/.codex/hooks.json +65 -0
- package/templates/agent-os/universal/AGENTS.md +164 -0
- package/templates/agent-os/universal/CLAUDE.md +47 -14
- package/templates/agent-os/universal/PLAN.md +7 -40
- package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
- package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
- package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
- package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
- package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
- package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +62 -0
- package/templates/agent-os/universal/journal/README.md +101 -0
- package/templates/agent-os/universal/layers.json +36 -2
- package/templates/hash-history.json +2 -1
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
- package/templates/skeleton/aws-serverless/README.md +91 -9
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
- package/templates/skeleton/aws-serverless/gitignore +37 -0
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
- package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
- package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
- package/templates/skeleton/node-service/README.md +11 -1
- package/templates/skeleton/node-service/gitignore +34 -0
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
- package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
- package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
- package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
- package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
- package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Why one run directory per run, and why nothing can enforce it
|
|
2
|
+
|
|
3
|
+
The rule lives in the `loop` skill, section 1 ("Declare the run directory here").
|
|
4
|
+
This file explains what the enforcement does and does not reach, so nobody
|
|
5
|
+
mistakes the warning for a guarantee. It is not loaded into any session.
|
|
6
|
+
|
|
7
|
+
## An undeclared run is not a run with a missing journal
|
|
8
|
+
|
|
9
|
+
`RIG_RUN_DIR` holds two unrelated things: the machine trace, and the run's
|
|
10
|
+
**stop conditions** — the escalation streak, the deploy verdict, the budget flag.
|
|
11
|
+
|
|
12
|
+
With `RIG_RUN_DIR` unset, the two verdicts a session writes by hand
|
|
13
|
+
(`run-state.mjs deploy …`, `run-state.mjs budget …`) refuse loudly, so it finds
|
|
14
|
+
out. **The escalation count does not.** It is recorded nowhere, silently, and
|
|
15
|
+
selection then hands out work after two walls in a row with nothing on stderr to
|
|
16
|
+
say why.
|
|
17
|
+
|
|
18
|
+
So an undeclared run is a run whose main brake is off, and which looks exactly
|
|
19
|
+
like a healthy one from the outside.
|
|
20
|
+
|
|
21
|
+
The trace's first call site is **selection**, which runs before every task.
|
|
22
|
+
Declared later, it misses everything that already happened — which is why the
|
|
23
|
+
declaration belongs in preflight or nowhere.
|
|
24
|
+
|
|
25
|
+
## What the reuse check catches
|
|
26
|
+
|
|
27
|
+
- Two writers landing on the same sequence number.
|
|
28
|
+
- A directory that already carries its run-end marker.
|
|
29
|
+
|
|
30
|
+
Both are refused. Selection still works and says so on stderr, and that run's
|
|
31
|
+
trace ends there — **loudly**, which is the good case.
|
|
32
|
+
|
|
33
|
+
## What it cannot catch
|
|
34
|
+
|
|
35
|
+
Two runs whose records happen not to collide.
|
|
36
|
+
|
|
37
|
+
A run that died before writing its end marker — and dying unexpectedly is
|
|
38
|
+
exactly the case the checkpoint discipline exists for — leaves an intact
|
|
39
|
+
sequence behind it. The next run pointed at that directory **continues it in
|
|
40
|
+
silence**: one seamless trace of two runs, with nothing in the file able to say
|
|
41
|
+
so.
|
|
42
|
+
|
|
43
|
+
Nothing detects this after the fact. A fresh directory per run is the only thing
|
|
44
|
+
that prevents it, and it is the session's to do.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Why the elevated ration counts mechanisms and not documents
|
|
2
|
+
|
|
3
|
+
The rule lives in the `loop` skill, section 2; the code is
|
|
4
|
+
`.claude/scripts/queue/state.mjs` (which tier a close records) and
|
|
5
|
+
`.claude/scripts/queue/core.mjs` (which tiers clear the ration). This file
|
|
6
|
+
explains why the elevated tier splits in two, why only one half spaces the next
|
|
7
|
+
item, and why an unrecognised tier holds. It is not loaded into any session.
|
|
8
|
+
|
|
9
|
+
## What the ration was bought for
|
|
10
|
+
|
|
11
|
+
The rule's own sentence names it: _one **unreviewed** schema or permissions
|
|
12
|
+
change is recoverable; a chain of them compounding overnight is not._ Two words
|
|
13
|
+
in it do the work. **Unreviewed** — the risk is an unattended run stacking
|
|
14
|
+
changes nobody read. **Compounding** — the second change lands on top of what the
|
|
15
|
+
first one did, so the damage is not additive but multiplied.
|
|
16
|
+
|
|
17
|
+
A rulebook document satisfies neither. It is reviewed on the model lane by two
|
|
18
|
+
cold readers, and it compounds into nothing overnight because no runtime
|
|
19
|
+
executes it. Yet in any repository whose rulebook lives under a declared
|
|
20
|
+
elevated path — every rig, by construction — a `.md` close recorded the same
|
|
21
|
+
`elevated` as a migration and held the next item exactly as hard.
|
|
22
|
+
|
|
23
|
+
Two measurements on this repository forced the change, and they are different
|
|
24
|
+
facts — the second is the mechanism's own report, the first is not:
|
|
25
|
+
|
|
26
|
+
- The `loop` run of 2026-08-17 hit `nothing-selectable` with **56 takeable items
|
|
27
|
+
held, 53 of them by spacing** — the count `queue/index.mjs next` printed, so
|
|
28
|
+
this one is the ration speaking. It holds on the item's **marker**, which is
|
|
29
|
+
what those 53 carried. ⚠ Not reproducible from this repository: the queue is
|
|
30
|
+
tracker-backed and the run's own record is under the gitignored
|
|
31
|
+
`.claude/runs/`, so this figure is a report, not a check anyone can re-run.
|
|
32
|
+
- Earlier, the AR-63 run recorded (journal, 2026-08) that of 58 selectable items
|
|
33
|
+
**56 were elevated in fact — by declared path** — 31 of them marked so and 25
|
|
34
|
+
marked `normal` while their own bodies named an elevated path. That is a
|
|
35
|
+
measurement of the queue, not of the ration: it says the pressure is not going
|
|
36
|
+
to ease, because a rig's rulebook lives under a declared path by construction.
|
|
37
|
+
|
|
38
|
+
Together: the ration was not pacing the project, it was halting it, and the halt
|
|
39
|
+
was indistinguishable from the rule working.
|
|
40
|
+
|
|
41
|
+
## The split, and where it does NOT apply
|
|
42
|
+
|
|
43
|
+
`recordCompletedTier` classifies the elevated paths a close actually crossed:
|
|
44
|
+
|
|
45
|
+
| what the close crossed | tier recorded | effect on the next elevated item |
|
|
46
|
+
| --- | --- | --- |
|
|
47
|
+
| every elevated path is a document | `elevated-prose` | clears the ration |
|
|
48
|
+
| any elevated path is not | `elevated-mechanism` | spaces it |
|
|
49
|
+
| no elevated path at all | `normal` | clears the ration |
|
|
50
|
+
|
|
51
|
+
⚠ **"Elevated path" is the sweep's answer, not the diff's.** `elevatedPathsIn`
|
|
52
|
+
drops inert paths *before* this classification runs, and a non-rulebook `.md` is
|
|
53
|
+
inert — so `scripts/notes.md` under a declared directory records `normal`, not
|
|
54
|
+
`elevated-prose`. In practice the only markdown that ever reaches the split is
|
|
55
|
+
**rulebook** markdown: `CLAUDE.md` anywhere, everything under `.claude/`, and the
|
|
56
|
+
decision records. That is pre-existing sweep behaviour, restated here because the
|
|
57
|
+
table above reads more broadly than the code behaves.
|
|
58
|
+
|
|
59
|
+
The predicate is `executesNothing` — **`.md` only** — and it lives in
|
|
60
|
+
`detect-missed-gate.mjs` beside the sweep's own markdown test so the two cannot
|
|
61
|
+
drift apart.
|
|
62
|
+
|
|
63
|
+
**They are different tests on purpose, and `.mdx` is where they part.** The
|
|
64
|
+
sweep asks *does this need a reviewer* and calls both flavours inert. The ration
|
|
65
|
+
asks *can a merge of this compound overnight*, and MDX carries components and
|
|
66
|
+
imports — it is a program that renders, not a document that is read, which is why
|
|
67
|
+
`decision-router.mjs` already sends it down the code lane
|
|
68
|
+
(`review-lanes.md`). Calling it prose here would clear the spacing hold on a file
|
|
69
|
+
this same rig treats as a program, on the permissive side.
|
|
70
|
+
|
|
71
|
+
Two questions, two predicates, **one file** — that pairing is why they cannot
|
|
72
|
+
drift. It is not a rule that every markdown test in the rig belongs there:
|
|
73
|
+
`decision-router.mjs` keeps its own sets on purpose, and `review-lanes.md` — now
|
|
74
|
+
with a row for this ration — exists to stop anyone consolidating them.
|
|
75
|
+
|
|
76
|
+
**The known soft spot: a skill's `SKILL.md` is prose by this test**, and some
|
|
77
|
+
skills carry shell snippets an agent copies and runs. The ruling is that skills
|
|
78
|
+
stay prose for rationing — they are reviewed like the rules they are, and
|
|
79
|
+
rewriting a procedure is not the chain of unreviewed compounding changes the
|
|
80
|
+
ration was bought to stop. It is still the weakest ground the "no runtime
|
|
81
|
+
executes it" justification stands on, and the first place to look if the ration
|
|
82
|
+
turns out too loose.
|
|
83
|
+
|
|
84
|
+
**A mixed diff is a mechanism close.** The half that runs decides. Reading the
|
|
85
|
+
tier off the first path, or off "most of them are documents", would ship a ration
|
|
86
|
+
any diff can opt out of by also touching a `.md`.
|
|
87
|
+
|
|
88
|
+
The split is read by **exactly one** consumer: the ration. `elevated-prose` is
|
|
89
|
+
still an elevated change wherever it is *reviewed* — the model lane, the cold
|
|
90
|
+
readers, the `human-review` label, the gate sweep — and `recordCompletedTier`
|
|
91
|
+
still reports every elevated path it found, unchanged. Narrowing the ration must
|
|
92
|
+
not narrow the report: a prose merge that stopped listing its rulebook files
|
|
93
|
+
would look clean to the sweep that exists to catch exactly those merges.
|
|
94
|
+
|
|
95
|
+
## Unknown holds; absent does not
|
|
96
|
+
|
|
97
|
+
`core.mjs` releases on `normal`, on `elevated-prose`, and on an **absent** tier —
|
|
98
|
+
`null`/`undefined`, which is the honest statement that nothing has closed yet and
|
|
99
|
+
what a fresh checkout says. Everything else holds: the legacy `'elevated'` an
|
|
100
|
+
older state file carries, a word in the wrong case, a value that is not a string.
|
|
101
|
+
|
|
102
|
+
The tempting implementation is `tier === 'elevated-mechanism'`. It is wrong in
|
|
103
|
+
the one direction that matters: every unrecognised value would read as "nothing
|
|
104
|
+
elevated closed" and hand out the next elevated item — un-rationing the queue
|
|
105
|
+
silently, on state files this project itself wrote last week. The whole seam
|
|
106
|
+
exists because a filter whose input nobody supplies is indistinguishable from a
|
|
107
|
+
filter that agrees with you.
|
|
108
|
+
|
|
109
|
+
The legacy word stays *readable* for the same reason it must not be *permissive*:
|
|
110
|
+
a checkout that upgrades mid-run still has one on disk, and refusing it outright
|
|
111
|
+
would exit 1 on the next selection. `loadState` accepts four words and refuses
|
|
112
|
+
the rest — **for the state file only**. Selection falls back to a tier left in
|
|
113
|
+
`queue.json`, and `loadConfig` validates nothing but JSON syntax, so a word like
|
|
114
|
+
`banana` in the config reaches `selectNext` unrefused — measured: the CLI exits 0
|
|
115
|
+
with no refusal, and then **holds** the elevated item with `causes: ["spacing"]`.
|
|
116
|
+
It holds because `core.mjs` is the layer that reads every unrecognised value
|
|
117
|
+
restrictively — the same layer that holds when `selectNext` is called directly.
|
|
118
|
+
|
|
119
|
+
## What this does not fix
|
|
120
|
+
|
|
121
|
+
The livelock where *every* remaining item is elevated-by-mechanism still exists —
|
|
122
|
+
narrowed, not closed. Its operator-only remedy is filed and **not built**: no
|
|
123
|
+
such command exists in this repository today, so a run that hits the livelock
|
|
124
|
+
stops and says so rather than clearing its own tier. Spacing is also still per
|
|
125
|
+
checkout, so a second worktree does not get its own allowance.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Why the run's stop conditions are a file, not a memory
|
|
2
|
+
|
|
3
|
+
The rule lives in the `loop` skill, section 3 ("What keeps the loop running,
|
|
4
|
+
and what stops it"). This file records why the state was moved out of the
|
|
5
|
+
session, and why only one of the two written verdicts can be taken back. It is
|
|
6
|
+
not loaded into any session.
|
|
7
|
+
|
|
8
|
+
## The defect the file fixed
|
|
9
|
+
|
|
10
|
+
`escalations` and `lastDeployVerdict` now live in `<RIG_RUN_DIR>/state.json`,
|
|
11
|
+
written by `run-state.mjs`.
|
|
12
|
+
|
|
13
|
+
Before that existed, the CLI called `stopConditionOf` with the queue counts
|
|
14
|
+
alone. Every state-dependent branch therefore held its **default** on every real
|
|
15
|
+
selection — the runtime-regression stop, the two-escalations stop, the budget
|
|
16
|
+
stop. None of them could fire. The rules were enforced only by whichever session
|
|
17
|
+
happened to remember them.
|
|
18
|
+
|
|
19
|
+
Compaction is exactly the moment a long unattended run stops remembering, and a
|
|
20
|
+
long unattended run is the only situation these stops exist for. So the stops
|
|
21
|
+
were reliably absent precisely where they were needed.
|
|
22
|
+
|
|
23
|
+
## Why the escalation count has two writers, and both must be the documented one
|
|
24
|
+
|
|
25
|
+
- It **rises** through the adapter's `escalate()`. Hand-labelling an item
|
|
26
|
+
escalated marks the item and counts nothing, so the run grinds past the wall.
|
|
27
|
+
- It **resets** through the close step's `recordCompletedTier`, and only when
|
|
28
|
+
that call is passed `runDir` — see `docs/decisions/closing-a-task.md`.
|
|
29
|
+
|
|
30
|
+
Both halves fail quietly when done the undocumented way, and they fail in
|
|
31
|
+
opposite directions: one never stops a broken run, the other stops a healthy one.
|
|
32
|
+
|
|
33
|
+
## Why only the deploy verdict can be taken back
|
|
34
|
+
|
|
35
|
+
The asymmetry is deliberate.
|
|
36
|
+
|
|
37
|
+
`HEALTHY` names a **real later event** — the revert landed and the runtime was
|
|
38
|
+
re-verified. Without a way to record it, one bad deploy would end every later
|
|
39
|
+
selection in the run, however thoroughly it was fixed.
|
|
40
|
+
|
|
41
|
+
Spend only accumulates. Un-exhausting a budget would name no event at all — only
|
|
42
|
+
a decision to keep going, taken by the very run that declared the stop. That is
|
|
43
|
+
the shape of a stop condition a run can talk itself out of, which is not a stop
|
|
44
|
+
condition.
|
|
45
|
+
|
|
46
|
+
A new run starts from a clean state. That is the way back from both.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Why "queue empty" and "nothing selectable" are different endings
|
|
2
|
+
|
|
3
|
+
The rule lives in the `loop` skill, section 3. This file explains why the two
|
|
4
|
+
stops are reported separately, how the parked pile grows under each adapter, and
|
|
5
|
+
why a parked cause outranks a holding one. It is not loaded into any session.
|
|
6
|
+
|
|
7
|
+
## The two endings ask the owner for opposite things
|
|
8
|
+
|
|
9
|
+
- **Queue empty after the filters** — there is no work. Refilling it is the
|
|
10
|
+
owner's job. Expect this to be the most common ending: the queue is finite and
|
|
11
|
+
the loop drains it.
|
|
12
|
+
- **Nothing selectable** — the work is there and every piece of it is held back
|
|
13
|
+
by a condition that clears when _something else happens_: the elevated spacing
|
|
14
|
+
(a normal item lands — or an elevated one whose paths were all documents, see
|
|
15
|
+
`spacing-rations-mechanisms.md`), a blocker (its item closes), `in-progress`
|
|
16
|
+
(the other session finishes), a trigger (a human declares it).
|
|
17
|
+
|
|
18
|
+
An empty queue wants refilling. A held one wants interleaving, or simply time.
|
|
19
|
+
Reporting the second as the first sends the owner to write tickets that already
|
|
20
|
+
exist; reporting the first as the second tells them to wait for nothing.
|
|
21
|
+
|
|
22
|
+
## The parked pile is reported next to the verdict, never inside it
|
|
23
|
+
|
|
24
|
+
Parked items are out of play and waiting on a human. They are not work this run
|
|
25
|
+
can take, and they are not why the queue is empty — so they are named separately,
|
|
26
|
+
by cause and count.
|
|
27
|
+
|
|
28
|
+
How that pile grows is the adapter's business, and the stop line does not guess:
|
|
29
|
+
|
|
30
|
+
| adapter | what lands in the parked pile |
|
|
31
|
+
| ---------------------------------------- | ----------------------------------------------------- |
|
|
32
|
+
| tracker-backed (`github-issues`, `jira`) | an escalated item and a filed proposal both stay open |
|
|
33
|
+
| `plan-md` | only a `[triage]` line sitting in the Agent queue |
|
|
34
|
+
|
|
35
|
+
Under `plan-md` a filed proposal goes to the Operator queue, where selection
|
|
36
|
+
never looks, and an escalation leaves **no mark on the queue at all** — a flat
|
|
37
|
+
list has no per-item state.
|
|
38
|
+
|
|
39
|
+
## That absence is not a safety
|
|
40
|
+
|
|
41
|
+
`plan-md`'s `escalate` writes nothing to the queue and returns `ok: false`,
|
|
42
|
+
handing back the instruction with it: move the item to the Operator queue in the
|
|
43
|
+
same edit. That move is the session's, and skipping it means the next run takes
|
|
44
|
+
the stuck item straight back.
|
|
45
|
+
|
|
46
|
+
It does still record the escalation into the run state, like every other
|
|
47
|
+
adapter. The two are different facts, which is why escalating must go through
|
|
48
|
+
the adapter rather than by hand-labelling: the count is what ends a run that has
|
|
49
|
+
hit the same wall twice.
|
|
50
|
+
|
|
51
|
+
## Why a parked cause outranks a holding one
|
|
52
|
+
|
|
53
|
+
An escalated item is left **claimed** on purpose, so it arrives carrying
|
|
54
|
+
`in-progress` as well as `escalated`.
|
|
55
|
+
|
|
56
|
+
Reading that as _held_ would report "another session will finish it" about an
|
|
57
|
+
item no session is on — and, worse, would make the empty verdict unreachable
|
|
58
|
+
from the first escalation onward. Reporting a working queue as empty is the
|
|
59
|
+
defect this distinction exists to prevent; reporting a parked one as working is
|
|
60
|
+
the same defect reversed.
|
|
61
|
+
|
|
62
|
+
Neither ending is an invitation to refill the queue or invent work.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# The journal
|
|
2
|
+
|
|
3
|
+
The human record of what sessions did here. **One file per month**, named
|
|
4
|
+
`journal/YYYY-MM.md` — the month is the filename's job, so entries themselves
|
|
5
|
+
stay date-free and their order carries the sequence.
|
|
6
|
+
|
|
7
|
+
**Newest-on-top inside each file.** A new entry goes directly under the month's
|
|
8
|
+
heading, so the file reads as a stack. Prune freely: this is operational memory,
|
|
9
|
+
not an archive.
|
|
10
|
+
|
|
11
|
+
**The shape of a month file**, stated because the first session of a new month
|
|
12
|
+
creates it and a shape nobody wrote down is one that has to be guessed:
|
|
13
|
+
|
|
14
|
+
- it **opens with a single `#` heading** naming the month;
|
|
15
|
+
- **every entry is a `###`** under that heading, and there is nothing at `##`
|
|
16
|
+
or any other level in between;
|
|
17
|
+
- a new entry is inserted directly beneath the `#` heading, above the previous
|
|
18
|
+
newest.
|
|
19
|
+
|
|
20
|
+
**What is mechanically checked, precisely — because this file ships into a
|
|
21
|
+
project and its checks do not.** The generator that produced this rig has tests
|
|
22
|
+
for the first two bullets and for the `YYYY-MM.md` filename; **this project has
|
|
23
|
+
none of them** unless you write them. That is the same arrangement
|
|
24
|
+
`.claude/rules/invariants.md` describes for the hooks, and the same caveat
|
|
25
|
+
applies: what is not tested here is convention, however firmly it is written.
|
|
26
|
+
|
|
27
|
+
The third bullet — *where* a new entry is inserted — is convention even in the
|
|
28
|
+
generator. Nothing checks entry order anywhere yet; making "newest" decidable
|
|
29
|
+
for date-free entries is its own piece of work. Do not read the presence of a
|
|
30
|
+
shape rule as a guarantee that a misordered file would be caught.
|
|
31
|
+
|
|
32
|
+
`README.md` is this document, not a month file. Other names: a file that does
|
|
33
|
+
not match `YYYY-MM.md` is never **read** as a month file — but do not read that
|
|
34
|
+
as permission, because in the generator any other `*.md` in this directory
|
|
35
|
+
**fails** the filename check. Keep archives and side notes out of this
|
|
36
|
+
directory, or somewhere with a non-`.md` extension.
|
|
37
|
+
|
|
38
|
+
An unattended run writes an entry at every stop **and** at checkpoints along the
|
|
39
|
+
way, because a run that dies unexpectedly must not take its history with it.
|
|
40
|
+
|
|
41
|
+
🔴 **This is not the run trace.** `.claude/runs/<run>/events.jsonl` and
|
|
42
|
+
`decisions.jsonl` are machine-readable, append-only and **oldest-first**, written
|
|
43
|
+
by `run-journal.mjs`. This file is the human record and is newest-on-top. They
|
|
44
|
+
answer different questions — the trace says what the run decided and on what
|
|
45
|
+
basis, this says what a reader needs to know afterwards — and reading one as the
|
|
46
|
+
other is how a reader concludes a run did nothing.
|
|
47
|
+
|
|
48
|
+
## The entry
|
|
49
|
+
|
|
50
|
+
The fields exist so an entry can be visibly **incomplete**. A journal with no
|
|
51
|
+
stated shape decays into a diary that reads fine and proves nothing.
|
|
52
|
+
|
|
53
|
+
Copy the block and drop the fields that do not apply — `unblocked` is the
|
|
54
|
+
exception, and it is stated even when the answer is "nothing".
|
|
55
|
+
|
|
56
|
+
```markdown
|
|
57
|
+
### <one-line summary of the session>
|
|
58
|
+
|
|
59
|
+
- **done** — what landed, one line each, with the PR reference
|
|
60
|
+
- **escalated** — what stopped, and the diagnosis: what failed, what was tried,
|
|
61
|
+
the current hypothesis, and the one question whose answer unblocks it
|
|
62
|
+
- **reviewed** — changes that went through a reviewer gate, and what it returned
|
|
63
|
+
- **stopped at** — which stop condition ended the session (or "checkpoint,
|
|
64
|
+
still running")
|
|
65
|
+
- **unblocked** — what the session's closes released
|
|
66
|
+
- **queue hygiene** — queue state the session found unreliable and reported
|
|
67
|
+
- **cost** — the counts the session actually observed
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## The two fields that are most often got wrong
|
|
71
|
+
|
|
72
|
+
**`unblocked` is never dropped**, and it has **three** answers that do not
|
|
73
|
+
substitute for each other:
|
|
74
|
+
|
|
75
|
+
1. the items that were waiting, by name;
|
|
76
|
+
2. "nothing was waiting" — the queue carries dependency links and none pointed
|
|
77
|
+
here;
|
|
78
|
+
3. "this queue has no dependency links" — it cannot answer at all.
|
|
79
|
+
|
|
80
|
+
A flat-list queue is **absent, not satisfied**, and writing "nothing was waiting"
|
|
81
|
+
there claims a look that no query could perform. A missing line and an unpaid
|
|
82
|
+
debt read identically from outside, which is why the empty case has to be
|
|
83
|
+
written to mean anything.
|
|
84
|
+
|
|
85
|
+
**`cost` carries only what the session observed** — reviewer subagents run, CI
|
|
86
|
+
runs consumed (re-runs included, the cheapest signal that a task fought its
|
|
87
|
+
tests), deploys triggered.
|
|
88
|
+
|
|
89
|
+
## Never estimated — and this governs every field, not just `cost`
|
|
90
|
+
|
|
91
|
+
**Any** field the session cannot observe stays **visibly empty, never
|
|
92
|
+
estimated**. A plausible number will be believed, by the next reader and by the
|
|
93
|
+
next run reasoning about its own budget — and the same is true of a plausible
|
|
94
|
+
sentence: a guessed `reviewed` or an inferred `unblocked` is the identical
|
|
95
|
+
failure without the digits. Leave the gap; it is information.
|
|
96
|
+
|
|
97
|
+
## Queue hygiene is reported, never corrected in passing
|
|
98
|
+
|
|
99
|
+
A stale marker, a dependency already satisfied, an item describing work already
|
|
100
|
+
done — record it here. Quietly fixing the metadata destroys the evidence that the
|
|
101
|
+
metadata is unreliable.
|
|
@@ -14,21 +14,53 @@
|
|
|
14
14
|
".claude/hooks/guard-bash.mjs",
|
|
15
15
|
".claude/hooks/gate-stop-dod.mjs",
|
|
16
16
|
".claude/hooks/inject-rules.mjs",
|
|
17
|
+
".claude/hooks/guard-secret-file.mjs",
|
|
18
|
+
".claude/hooks/lib/edit-input.mjs",
|
|
17
19
|
".claude/skills/pr-ship/SKILL.md",
|
|
18
20
|
".claude/skills/loop/SKILL.md",
|
|
19
21
|
".claude/skills/check-premises/SKILL.md",
|
|
20
22
|
".claude/skills/worktree-task/SKILL.md",
|
|
23
|
+
".agents/skills/new-invariant/SKILL.md",
|
|
24
|
+
".agents/skills/new-invariant/guard-invariant.example.mjs",
|
|
25
|
+
".agents/skills/new-invariant/guard-invariant.example.test.mjs",
|
|
26
|
+
".agents/skills/pr-ship/SKILL.md",
|
|
27
|
+
".agents/skills/loop/SKILL.md",
|
|
28
|
+
".agents/skills/check-premises/SKILL.md",
|
|
29
|
+
".agents/skills/worktree-task/SKILL.md",
|
|
30
|
+
".codex/agents/test-writer.toml",
|
|
31
|
+
".codex/agents/code-reviewer.toml",
|
|
32
|
+
".codex/agents/security-scanner.toml",
|
|
33
|
+
".codex/agents/prose-reviewer.toml",
|
|
21
34
|
".claude/scripts/detect-missed-gate.mjs",
|
|
35
|
+
".claude/scripts/decision-router.mjs",
|
|
22
36
|
".claude/scripts/reconcile-external-prs.mjs",
|
|
23
37
|
".claude/scripts/stop-flag.mjs",
|
|
38
|
+
".claude/scripts/git-env.mjs",
|
|
24
39
|
".claude/scripts/preflight.mjs",
|
|
40
|
+
".claude/scripts/run-journal.mjs",
|
|
41
|
+
".claude/scripts/run-state.mjs",
|
|
42
|
+
".claude/scripts/verdict.mjs",
|
|
43
|
+
".claude/scripts/lib/verdict.mjs",
|
|
44
|
+
".claude/scripts/lib/secrets.mjs",
|
|
25
45
|
".claude/scripts/queue/core.mjs",
|
|
26
46
|
".claude/scripts/queue/plan-md.mjs",
|
|
27
47
|
".claude/scripts/queue/github-issues.mjs",
|
|
28
48
|
".claude/scripts/queue/jira.mjs",
|
|
29
49
|
".claude/scripts/queue/index.mjs",
|
|
50
|
+
".claude/scripts/queue/checkout.mjs",
|
|
51
|
+
".claude/scripts/queue/state.mjs",
|
|
52
|
+
".claude/scripts/queue/gate-rounds.mjs",
|
|
30
53
|
".claude/queue.json",
|
|
31
|
-
"PLAN.md"
|
|
54
|
+
"PLAN.md",
|
|
55
|
+
"journal/README.md",
|
|
56
|
+
"docs/decisions/fail-open-guards.md",
|
|
57
|
+
"docs/decisions/codex-adapter.md",
|
|
58
|
+
"docs/decisions/closing-a-task.md",
|
|
59
|
+
"docs/decisions/review-lanes.md",
|
|
60
|
+
"docs/decisions/run-directory.md",
|
|
61
|
+
"docs/decisions/spacing-rations-mechanisms.md",
|
|
62
|
+
"docs/decisions/stop-conditions-in-a-file.md",
|
|
63
|
+
"docs/decisions/two-empty-endings.md"
|
|
32
64
|
],
|
|
33
65
|
"architecture": [
|
|
34
66
|
".claude/rules/architecture.md",
|
|
@@ -37,6 +69,8 @@
|
|
|
37
69
|
],
|
|
38
70
|
"meta": [
|
|
39
71
|
".claude/settings.json",
|
|
40
|
-
"
|
|
72
|
+
".codex/hooks.json",
|
|
73
|
+
"CLAUDE.md",
|
|
74
|
+
"AGENTS.md"
|
|
41
75
|
]
|
|
42
76
|
}
|
|
@@ -53,5 +53,38 @@ jobs:
|
|
|
53
53
|
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
|
54
54
|
aws-region: ${{ vars.AWS_REGION || '__REGION__' }}
|
|
55
55
|
|
|
56
|
+
# `--require-approval never` is deliberate and dev-only: this workflow has
|
|
57
|
+
# no production path at all (see the header), so the approval prompt would
|
|
58
|
+
# only ever block an unattended dev deploy on a change nobody is watching.
|
|
56
59
|
- if: steps.guard.outputs.skip != 'true'
|
|
57
|
-
run:
|
|
60
|
+
run: >
|
|
61
|
+
pnpm --filter @app/infra exec cdk deploy AppStack WebStack
|
|
62
|
+
--require-approval never --outputs-file cdk-outputs.json
|
|
63
|
+
|
|
64
|
+
# The bundle is built above and the bucket is created by the deploy — but
|
|
65
|
+
# nothing puts one into the other, so without this step CloudFront keeps
|
|
66
|
+
# serving whatever was uploaded by hand, last time somebody remembered.
|
|
67
|
+
# `web-stack.ts` deliberately does not use BucketDeployment (synth must
|
|
68
|
+
# never depend on `next build` having run), which is why the upload lives
|
|
69
|
+
# here rather than in the stack.
|
|
70
|
+
- if: steps.guard.outputs.skip != 'true'
|
|
71
|
+
name: Upload the web bundle and invalidate the edge cache
|
|
72
|
+
run: |
|
|
73
|
+
# `jq -r` prints the string "null" and exits 0 for a missing key, and
|
|
74
|
+
# GitHub's shell is `bash -e` with no `-u` and no pipefail — so a
|
|
75
|
+
# renamed stack or a half-finished deploy would sync the bundle into
|
|
76
|
+
# `s3://null`, a real bucket in a global namespace someone else owns,
|
|
77
|
+
# and issue --delete against it. `-e` makes jq exit non-zero instead.
|
|
78
|
+
set -euo pipefail
|
|
79
|
+
BUCKET=$(jq -er '.WebStack.WebBucketName' infra/cdk-outputs.json)
|
|
80
|
+
DISTRIBUTION=$(jq -er '.WebStack.WebDistributionId' infra/cdk-outputs.json)
|
|
81
|
+
# An existing-but-empty bundle syncs zero objects, and --delete then
|
|
82
|
+
# empties the live site with a green checkmark.
|
|
83
|
+
[ -n "$(ls -A apps/web/out 2>/dev/null)" ] || {
|
|
84
|
+
echo "apps/web/out is empty — refusing to sync"; exit 1;
|
|
85
|
+
}
|
|
86
|
+
# --delete makes the bucket the bundle's territory alone: anything not
|
|
87
|
+
# in this build is removed from it.
|
|
88
|
+
aws s3 sync apps/web/out "s3://$BUCKET" --delete
|
|
89
|
+
aws cloudfront create-invalidation \
|
|
90
|
+
--distribution-id "$DISTRIBUTION" --paths '/*'
|
|
@@ -45,13 +45,27 @@ do not edit the workflow:
|
|
|
45
45
|
|
|
46
46
|
1. In AWS, create an IAM role your repo can assume via GitHub's OIDC provider
|
|
47
47
|
(`token.actions.githubusercontent.com`) — a short-lived federated role, no
|
|
48
|
-
long-lived access keys anywhere.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
48
|
+
long-lived access keys anywhere. Beyond what `cdk deploy` needs, the
|
|
49
|
+
workflow uploads the bundle itself, so the role also needs
|
|
50
|
+
**`s3:ListBucket`, `s3:PutObject`, `s3:DeleteObject`** on the web bucket and
|
|
51
|
+
**`cloudfront:CreateInvalidation`** on the distribution. A role scoped only
|
|
52
|
+
to CDK's bootstrap roles gets through the deploy and fails on the upload —
|
|
53
|
+
after both stacks are already up.
|
|
54
|
+
2. Add its ARN as the repository secret **`AWS_DEPLOY_ROLE_ARN`**, and set the
|
|
55
|
+
repo variable **`API_URL`** to the deployed API's URL (the `ApiUrl` output
|
|
56
|
+
of a first deploy). It becomes `NEXT_PUBLIC_API_URL`, which Next **inlines
|
|
57
|
+
into the bundle at build time** — unset, the site calls `/notes` on its own
|
|
58
|
+
CloudFront domain, where nothing answers. `AWS_REGION` is genuinely
|
|
59
|
+
optional. The first deploy is therefore two passes: deploy, read `ApiUrl`
|
|
60
|
+
from the job's `cdk deploy` Outputs, set the variable, then re-run the
|
|
61
|
+
workflow (push, or **Run workflow** — it is `workflow_dispatch`-enabled).
|
|
62
|
+
|
|
63
|
+
The workflow then assumes the role, builds the web bundle, runs
|
|
64
|
+
`cdk deploy AppStack WebStack --outputs-file`, then **uploads `apps/web/out` to
|
|
65
|
+
the web bucket (`aws s3 sync --delete`) and invalidates the CloudFront cache**,
|
|
66
|
+
reading both destinations from the stack outputs. You do not sync anything by
|
|
67
|
+
hand on this path. The site is served from S3 + CloudFront (the `WebUrl`
|
|
68
|
+
output).
|
|
55
69
|
|
|
56
70
|
### Local / manual
|
|
57
71
|
|
|
@@ -59,8 +73,26 @@ The workflow then assumes the role, builds the web bundle, and runs
|
|
|
59
73
|
# needs AWS credentials; region comes from your profile (generator default: __REGION__)
|
|
60
74
|
cd infra
|
|
61
75
|
npx cdk bootstrap # first time per account/region
|
|
62
|
-
npx cdk deploy AppStack WebStack
|
|
63
|
-
|
|
76
|
+
npx cdk deploy AppStack WebStack --outputs-file cdk-outputs.json
|
|
77
|
+
# --delete makes the bucket the bundle's territory alone, so a stale or missing
|
|
78
|
+
# `out/` would empty the live site — or restore last month's. The workflow
|
|
79
|
+
# builds two steps before its sync; by hand, build here.
|
|
80
|
+
# Read the outputs into variables FIRST. `jq -er` exits non-zero on a missing
|
|
81
|
+
# key, but a command substitution inside an assignment or an argument throws
|
|
82
|
+
# that status away — which is how a stale outputs file becomes a bundle built
|
|
83
|
+
# against `null` and an `aws s3 sync … s3://null --delete`.
|
|
84
|
+
API=$(jq -er '.AppStack.ApiUrl' cdk-outputs.json) || { echo "no ApiUrl"; exit 1; }
|
|
85
|
+
BUCKET=$(jq -er '.WebStack.WebBucketName' cdk-outputs.json) || { echo "no WebBucketName"; exit 1; }
|
|
86
|
+
DIST=$(jq -er '.WebStack.WebDistributionId' cdk-outputs.json) || { echo "no WebDistributionId"; exit 1; }
|
|
87
|
+
|
|
88
|
+
# NEXT_PUBLIC_API_URL is inlined at build time: without it the bundle calls its
|
|
89
|
+
# own CloudFront domain instead of the API, and the sync below makes that live.
|
|
90
|
+
(cd .. && NEXT_PUBLIC_API_URL="$API" pnpm build:web)
|
|
91
|
+
[ -f ../apps/web/out/index.html ] || { echo "no web bundle — build failed"; exit 1; }
|
|
92
|
+
aws s3 sync ../apps/web/out "s3://$BUCKET" --delete
|
|
93
|
+
# a synced bucket whose distribution still serves the old objects has not
|
|
94
|
+
# deployed — invalidate, or you are looking at the previous build
|
|
95
|
+
aws cloudfront create-invalidation --paths '/*' --distribution-id "$DIST"
|
|
64
96
|
```
|
|
65
97
|
|
|
66
98
|
### Production — a human step, on purpose
|
|
@@ -76,12 +108,41 @@ your own approval — reusing the dev workflow's OIDC pattern.
|
|
|
76
108
|
After every deploy:
|
|
77
109
|
|
|
78
110
|
```sh
|
|
111
|
+
# From the deploy's outputs file — run this from `infra/`, where the manual
|
|
112
|
+
# section leaves you. Deployed through CI instead? That file is written on the
|
|
113
|
+
# runner and never lands here: take both values from the job's `cdk deploy`
|
|
114
|
+
# Outputs, or re-run `cdk deploy … --outputs-file cdk-outputs.json` locally.
|
|
115
|
+
API_URL=$(jq -er '.AppStack.ApiUrl' cdk-outputs.json)
|
|
116
|
+
WEB_URL=$(jq -er '.WebStack.WebUrl' cdk-outputs.json)
|
|
117
|
+
|
|
79
118
|
curl -s -X POST "$API_URL/notes" \
|
|
80
119
|
-H 'content-type: application/json' \
|
|
81
120
|
-d '{"title":"smoke test","tags":["deploy"]}'
|
|
82
121
|
# expect: HTTP 201 with { "note": { … } }
|
|
83
122
|
```
|
|
84
123
|
|
|
124
|
+
**That checks the API, not the site.** The two fail independently: a bundle
|
|
125
|
+
built without `NEXT_PUBLIC_API_URL` calls its own CloudFront domain, and a
|
|
126
|
+
misnamed origin is refused by the browser — neither is visible to a `curl` that
|
|
127
|
+
sends no `Origin` header. So also open the `WebUrl` output, create a note in
|
|
128
|
+
the form, and reload:
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
curl -s -I "$WEB_URL" | head -1 # the bundle is served
|
|
132
|
+
|
|
133
|
+
# API Gateway answers a preflight 204 whether or not the origin matched, so the
|
|
134
|
+
# status code proves nothing here. The browser gates on the echoed header —
|
|
135
|
+
# that is what to look for. `content-type` is sent because the real call uses
|
|
136
|
+
# it, and a non-safelisted header is what makes `allowHeaders` matter.
|
|
137
|
+
curl -s -X OPTIONS "$API_URL/notes" -D - -o /dev/null \
|
|
138
|
+
-H "Origin: $WEB_URL" \
|
|
139
|
+
-H 'access-control-request-method: POST' \
|
|
140
|
+
-H 'access-control-request-headers: content-type' \
|
|
141
|
+
| grep -i '^access-control-allow-origin:'
|
|
142
|
+
# expect: a line echoing $WEB_URL. No line = the browser will refuse the call,
|
|
143
|
+
# whatever the status code said.
|
|
144
|
+
```
|
|
145
|
+
|
|
85
146
|
Then confirm the pipeline: the worker logs `note.created processed`, and the
|
|
86
147
|
**DLQ alarm stays quiet**. If the smoke test regresses: `npx cdk deploy` the
|
|
87
148
|
previous revision (or `git revert` and redeploy) **first**, diagnose second.
|
|
@@ -95,5 +156,26 @@ previous revision (or `git revert` and redeploy) **first**, diagnose second.
|
|
|
95
156
|
- `packages/db` is the only module that touches the storage SDK.
|
|
96
157
|
- A failing queue message is poison: it throws, SQS retries ×3, the DLQ alarm
|
|
97
158
|
fires. Never wrap the worker in a broad catch.
|
|
159
|
+
- **Creating a note is a dual write, and it is not atomic.** `create-note`
|
|
160
|
+
puts to DynamoDB and then publishes to SQS. If the publish fails, the note
|
|
161
|
+
is stored and its event never happened — the worker never runs, and nothing
|
|
162
|
+
compensates; the caller gets a 500 for a note that exists. That is a
|
|
163
|
+
deliberate simplification for a starter. When it starts to matter, the two
|
|
164
|
+
ways out are an **outbox** (write the event alongside the note, relay it
|
|
165
|
+
afterwards) or DynamoDB Streams feeding the worker, which deletes the second
|
|
166
|
+
write instead of coordinating it.
|
|
167
|
+
- **CORS names who may call the API, and that half is wired for you.**
|
|
168
|
+
`bin/app.ts` builds `WebStack` first and passes its CloudFront origin to
|
|
169
|
+
`AppStack`, so the API allows the deployed site without you configuring it.
|
|
170
|
+
The other half is not automatic: the bundle has to know where the API *is*,
|
|
171
|
+
and that is `NEXT_PUBLIC_API_URL` above. For a custom domain or a second
|
|
172
|
+
origin, deploy with `-c allowedOrigins=https://app.example.com` (comma-
|
|
173
|
+
separated for several) — the entrypoint prefers the flag over the wired
|
|
174
|
+
default, and the cross-stack export disappears with it. An `allowedOrigins`
|
|
175
|
+
that parses to nothing, or an entry no browser could send, is refused at
|
|
176
|
+
synth rather than deployed as an API nobody can call. `*` is not the default
|
|
177
|
+
and never will be, but the flag does take it: you get a warning naming the
|
|
178
|
+
consequence, not a refusal, because the alternative is people editing the
|
|
179
|
+
stack.
|
|
98
180
|
|
|
99
181
|
See `.claude/rules/architecture.md` for the full rules.
|
|
@@ -2,8 +2,15 @@
|
|
|
2
2
|
// (the guard-web-boundary hook refuses such imports at the tool layer).
|
|
3
3
|
import type { Note } from '@app/core';
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
5
|
+
// In THIS target the API is never same-origin: the bundle is served from
|
|
6
|
+
// CloudFront and the API is API Gateway, so `NEXT_PUBLIC_API_URL` has to be set
|
|
7
|
+
// at build time — Next inlines it, and an unset one leaves the empty string
|
|
8
|
+
// here, which sends every call to whatever is serving this page.
|
|
9
|
+
//
|
|
10
|
+
// The `?? ''` is a fallback to nothing, not to a working default: there is no
|
|
11
|
+
// dev proxy in this project, so `pnpm --filter @app/web dev` needs the variable
|
|
12
|
+
// too. It exists only so a missing value is a relative URL rather than the
|
|
13
|
+
// string "undefined" in a request path.
|
|
7
14
|
const base = process.env.NEXT_PUBLIC_API_URL ?? '';
|
|
8
15
|
|
|
9
16
|
export async function createNote(input: { title: string; tags: string[] }): Promise<Note> {
|