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.
Files changed (105) hide show
  1. package/CHANGELOG.md +207 -8
  2. package/README.md +50 -17
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +8 -3
  5. package/packages/cli/dist/commands/init.js +82 -34
  6. package/packages/cli/dist/commands/upgrade.js +112 -30
  7. package/packages/cli/dist/index.js +38 -14
  8. package/packages/cli/dist/lib/copy-tree.js +35 -6
  9. package/packages/cli/dist/lib/init-settings.js +12 -0
  10. package/packages/cli/dist/lib/install-set.js +6 -8
  11. package/packages/cli/dist/lib/manifest.js +21 -9
  12. package/packages/cli/dist/lib/safe-path.js +30 -0
  13. package/templates/agent-os/init/AGENTS.md +191 -0
  14. package/templates/agent-os/init/CLAUDE.md +61 -9
  15. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +98 -0
  16. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  17. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +34 -3
  18. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  19. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +34 -0
  20. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  21. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +233 -0
  22. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +676 -0
  23. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  24. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  25. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  26. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +338 -0
  27. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  28. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +33 -1
  29. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +64 -1
  30. package/templates/agent-os/universal/.claude/agents/security-scanner.md +33 -0
  31. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +274 -24
  32. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  33. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  34. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  35. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  36. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  37. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +480 -0
  38. package/templates/agent-os/universal/.claude/rules/autonomy.md +61 -1
  39. package/templates/agent-os/universal/.claude/rules/invariants.md +71 -16
  40. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  41. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1242 -0
  42. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  43. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  44. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  45. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +433 -0
  46. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +6 -16
  47. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +89 -0
  48. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +334 -23
  49. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  50. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +7 -2
  51. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +413 -8
  52. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +75 -13
  53. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +222 -21
  54. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  55. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  56. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +441 -0
  57. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -0
  58. package/templates/agent-os/universal/.claude/settings.json +7 -2
  59. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +115 -7
  60. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +378 -31
  61. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +300 -25
  62. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  63. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  64. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  65. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  66. package/templates/agent-os/universal/.codex/hooks.json +65 -0
  67. package/templates/agent-os/universal/AGENTS.md +164 -0
  68. package/templates/agent-os/universal/CLAUDE.md +47 -14
  69. package/templates/agent-os/universal/PLAN.md +7 -40
  70. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  71. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  72. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  73. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  74. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  75. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  76. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  77. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +62 -0
  78. package/templates/agent-os/universal/journal/README.md +101 -0
  79. package/templates/agent-os/universal/layers.json +36 -2
  80. package/templates/hash-history.json +2 -1
  81. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  82. package/templates/skeleton/aws-serverless/README.md +91 -9
  83. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  84. package/templates/skeleton/aws-serverless/gitignore +37 -0
  85. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  86. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  87. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  88. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  89. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  90. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  91. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  92. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  93. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  94. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  95. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  96. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  97. package/templates/skeleton/node-service/README.md +11 -1
  98. package/templates/skeleton/node-service/gitignore +34 -0
  99. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  100. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  101. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  102. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  103. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  104. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  105. 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
- "CLAUDE.md"
72
+ ".codex/hooks.json",
73
+ "CLAUDE.md",
74
+ "AGENTS.md"
41
75
  ]
42
76
  }
@@ -2,7 +2,8 @@
2
2
  "versions": [
3
3
  "0.3.0",
4
4
  "0.3.1",
5
- "0.3.2"
5
+ "0.3.2",
6
+ "0.4.0"
6
7
  ],
7
8
  "files": {
8
9
  ".claude/agents/cdk-diff-reviewer.md": {
@@ -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: pnpm --filter @app/infra exec cdk deploy AppStack WebStack --require-approval never
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
- 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).
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
- aws s3 sync ../apps/web/out "s3://<WebBucketName output>"
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
- // Same-origin by default (the API server serves this bundle); set
6
- // NEXT_PUBLIC_API_URL at build time when the API lives elsewhere.
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> {