create-agent-rig 0.4.0 → 0.6.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 +316 -11
- package/README.md +58 -19
- package/package.json +2 -1
- package/packages/cli/dist/commands/create.js +8 -3
- package/packages/cli/dist/commands/init.js +85 -34
- package/packages/cli/dist/commands/upgrade.js +112 -30
- package/packages/cli/dist/index.js +81 -19
- 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 +31 -9
- package/packages/cli/dist/lib/safe-path.js +30 -0
- package/scripts/prepare.mjs +1 -1
- package/templates/agent-os/init/AGENTS.md +199 -0
- package/templates/agent-os/init/CLAUDE.md +69 -9
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +105 -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 +41 -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 +41 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +236 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +958 -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 +396 -0
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +40 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +71 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +301 -26
- 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-rulebook.mjs +127 -0
- 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 +503 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +69 -1
- package/templates/agent-os/universal/.claude/rules/invariants.md +104 -19
- package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1260 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
- package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +462 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +33 -17
- package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +149 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +809 -28
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +95 -16
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +543 -15
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +457 -47
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +289 -25
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
- package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +539 -0
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +198 -0
- package/templates/agent-os/universal/.claude/settings.json +11 -2
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +118 -7
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +667 -38
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +359 -26
- 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 +70 -0
- package/templates/agent-os/universal/AGENTS.md +166 -0
- package/templates/agent-os/universal/CLAUDE.md +49 -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/gate-coverage.md +83 -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 +74 -0
- package/templates/agent-os/universal/journal/README.md +101 -0
- package/templates/agent-os/universal/layers.json +45 -2
- package/templates/hash-history.json +310 -49
- package/templates/release-ledger.json +9 -0
- 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,539 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run's own state — three of the four values `stopConditionOf` asks for and
|
|
3
|
+
* nothing used to answer, plus one value it does not ask for: the take-up
|
|
4
|
+
* snapshot `takeUps` ({@link recordTakeUp}), which is the run's fact as much as
|
|
5
|
+
* the other three. The fourth stop input, `killSwitch`, is deliberately not here:
|
|
6
|
+
* it is already mechanical in `guard-bash` and scripted in preflight, and a
|
|
7
|
+
* second answer to "is the brake on" is the disagreement `invariants.md`
|
|
8
|
+
* forbids.
|
|
9
|
+
*
|
|
10
|
+
* 🔴 **Why this file exists.** `core.mjs` takes `consecutiveEscalations`,
|
|
11
|
+
* `lastDeployVerdict`, `budgetExhausted` and `killSwitch`, and every branch that
|
|
12
|
+
* reads them was live — but the CLI called it with `candidates` and `skipped`
|
|
13
|
+
* only, so all four held their defaults on every real selection. The values
|
|
14
|
+
* were "remembered" by the session instead, which is precisely the guarantee a
|
|
15
|
+
* mechanical check exists to replace, and they are lost at compaction — the
|
|
16
|
+
* moment a long run needs them most. A stop rule nobody can supply the input
|
|
17
|
+
* for is a stop rule that never fires.
|
|
18
|
+
*
|
|
19
|
+
* 🔴 **Per run, not per checkout, and the difference is the whole design.**
|
|
20
|
+
* `escalations` means *two in a row in this run*; a declared budget belongs to
|
|
21
|
+
* this run; a `REGRESSION` verdict is about the deploy this run made. Written
|
|
22
|
+
* into a checkout-wide file, every one of them would outlive the run that
|
|
23
|
+
* learned it: two runs sharing a checkout would stop each other on a counter
|
|
24
|
+
* neither of them raised, and a regression would carry into tomorrow's run,
|
|
25
|
+
* which knows nothing about the deploy that caused it and is the run best
|
|
26
|
+
* placed to fix it. (`HEALTHY` below can clear a verdict — so the argument is
|
|
27
|
+
* *whose fact is it*, not *can it be undone*. A stop that has to be
|
|
28
|
+
* hand-cleared by a run that did not set it is a stop that will be
|
|
29
|
+
* hand-cleared without being read.)
|
|
30
|
+
*
|
|
31
|
+
* The one value that is genuinely per-checkout stays where it is:
|
|
32
|
+
* `lastCompletedTier` in `queue/state.mjs` rations elevated work *across* runs,
|
|
33
|
+
* so moving it here would hand each new run a clean slate and restore the exact
|
|
34
|
+
* defect that file was written to close. The close writes both, deliberately.
|
|
35
|
+
*
|
|
36
|
+
* **What this module does NOT own:** the run-id convention and creating the
|
|
37
|
+
* directory. Those belong to whatever drives the run — here the `loop` skill,
|
|
38
|
+
* which declares `RIG_RUN_DIR` in its preflight. Handed a `runDir`, this module
|
|
39
|
+
* uses it verbatim, exactly as `run-journal.mjs` does. Two owners of one
|
|
40
|
+
* convention disagree the first time either changes.
|
|
41
|
+
*
|
|
42
|
+
* ⚠ **It assumes one writer**, like the journal beside it — but degrades
|
|
43
|
+
* differently, and the difference is worth knowing before relying on either.
|
|
44
|
+
* The journal *detects* a collision through its sequence and refuses; this
|
|
45
|
+
* merge is read-then-write with no lock, so two processes sharing a run
|
|
46
|
+
* directory silently lose one of their patches. Measured: four processes ×
|
|
47
|
+
* 200 increments recorded 215. The loss is always downward, so the stop this
|
|
48
|
+
* file exists to fire fires **late or never** — never early. One run directory
|
|
49
|
+
* per run is the caller's part of the contract, and the `loop` skill states it.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { opendirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
53
|
+
import { basename, dirname, join } from 'node:path';
|
|
54
|
+
import { fileURLToPath } from 'node:url';
|
|
55
|
+
|
|
56
|
+
const STATE = 'state.json';
|
|
57
|
+
|
|
58
|
+
/** The state file's path inside a run directory — one definition, not two. */
|
|
59
|
+
export const statePathIn = (runDir) => join(runDir, STATE);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What the run has recorded so far; `{}` when it has recorded nothing.
|
|
63
|
+
*
|
|
64
|
+
* 🔴 **Unreadable is empty, and that is a decision rather than an oversight.**
|
|
65
|
+
* A corrupt or half-written state file must not stop the run from selecting
|
|
66
|
+
* work: the failure mode of reading it as "no state" is today's behaviour —
|
|
67
|
+
* which is exactly what the caller had before this module — while the failure
|
|
68
|
+
* mode of throwing is a run that cannot take an item because of a file that
|
|
69
|
+
* only ever *adds* stop conditions. Fail towards the behaviour that was already
|
|
70
|
+
* trusted.
|
|
71
|
+
*
|
|
72
|
+
* Note the asymmetry with `run-journal.mjs`, which refuses a broken sequence
|
|
73
|
+
* loudly: the journal's whole job is to be trustworthy evidence, so a journal
|
|
74
|
+
* that cannot vouch for itself must say so. This file's job is to supply stop
|
|
75
|
+
* inputs, and an absent input is a defined state there.
|
|
76
|
+
*/
|
|
77
|
+
export const readState = (runDir) => {
|
|
78
|
+
if (!runDir) return {};
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(readFileSync(statePathIn(runDir), 'utf8'));
|
|
81
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
82
|
+
} catch {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Merge `patch` over what is already recorded, and return the result.
|
|
89
|
+
*
|
|
90
|
+
* Shallow by design: the fields are flat values plus one object
|
|
91
|
+
* (`triggersFired`), and a deep merge would make "clear this field"
|
|
92
|
+
* unexpressible — a caller passing an object means *this value now*, not
|
|
93
|
+
* *these keys added to whatever was there*. The `trigger` command therefore
|
|
94
|
+
* merges its own map before calling, where the intent is explicit.
|
|
95
|
+
*
|
|
96
|
+
* 🔴 **Write-then-rename, because the reader is another process.** A plain
|
|
97
|
+
* write leaves a window where the file on disk is half a JSON document, and the
|
|
98
|
+
* reader above turns that into `{}` — a silently forgotten escalation streak.
|
|
99
|
+
* A rename within one directory is atomic on **POSIX**, so a reader sees either
|
|
100
|
+
* the old state or the new one and never a torn one. On Windows the same call
|
|
101
|
+
* goes through `MoveFileEx`, which does not guarantee atomicity when it
|
|
102
|
+
* replaces an existing file and fails outright if the destination is open — so
|
|
103
|
+
* there the failure is loud rather than torn, which is the direction to prefer
|
|
104
|
+
* if it ever has to be handled.
|
|
105
|
+
*/
|
|
106
|
+
export const updateState = (runDir, patch) => {
|
|
107
|
+
const next = { ...readState(runDir), ...patch };
|
|
108
|
+
const file = statePathIn(runDir);
|
|
109
|
+
// The temp name carries the pid so a second writer cannot clobber the first
|
|
110
|
+
// one's half-written file — the merge above is still unsafe under two
|
|
111
|
+
// writers, but a torn read is not how it fails.
|
|
112
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
113
|
+
// `wx` refuses an existing path rather than writing through it. Without it a
|
|
114
|
+
// symlink planted at the temp name is followed, and the rename then makes
|
|
115
|
+
// `state.json` itself that symlink — so the run's stop conditions would be
|
|
116
|
+
// read from, and written to, a file somebody else chose. The pid narrows who
|
|
117
|
+
// can guess the name; it does not stop anyone who watches the directory.
|
|
118
|
+
//
|
|
119
|
+
// `0o600` narrows this file specifically. It is not a claim about the run
|
|
120
|
+
// directory: `run-journal.mjs` appends beside it at the default mode, and its
|
|
121
|
+
// records carry the same run's item ids.
|
|
122
|
+
writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
123
|
+
try {
|
|
124
|
+
renameSync(tmp, file);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
// A failed rename used to leave the temp file behind, where `wx` then
|
|
127
|
+
// refuses any later write from a process that redraws the same pid — the
|
|
128
|
+
// name carries it, so this is a narrow collision rather than a bricked run,
|
|
129
|
+
// and it is loud either way. The one case this cannot clean is a process
|
|
130
|
+
// killed between the write and the rename: no handler runs, and the temp
|
|
131
|
+
// leaks until the run directory is discarded. The unlink's own failure is
|
|
132
|
+
// swallowed on purpose: the caller needs the rename's error, not this one.
|
|
133
|
+
try {
|
|
134
|
+
unlinkSync(tmp);
|
|
135
|
+
} catch {
|
|
136
|
+
/* the temp file is already gone, or unreachable for the same reason the rename was */
|
|
137
|
+
}
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
return next;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Count one task that hit a wall, and hand back the new total.
|
|
145
|
+
*
|
|
146
|
+
* 🔴 **One recorder, called by three adapters — not three counters.** Every
|
|
147
|
+
* adapter's `escalate()` needs this, and three copies of read-then-increment is
|
|
148
|
+
* how the number means something different depending on which tracker the rig
|
|
149
|
+
* happens to use (`invariants.md`, "one mechanism, one implementation"). The
|
|
150
|
+
* adapters own *how a tracker records an escalation*; the count of them in this
|
|
151
|
+
* run is not tracker business at all.
|
|
152
|
+
*
|
|
153
|
+
* 🔴 **Where to call it from, stated because the next adapter's author cannot
|
|
154
|
+
* infer it:** the count rises **after** the tracker has been mutated, and
|
|
155
|
+
* unconditionally only where the adapter mutates no tracker at all. So
|
|
156
|
+
* `github-issues` and `jira` call this last, once the comment and the label
|
|
157
|
+
* have landed; `plan-md` calls it immediately, because a flat list has nothing
|
|
158
|
+
* to write. Called first, the count would claim an escalation the tracker never
|
|
159
|
+
* received — and it is the count, not the tracker, that ends the run.
|
|
160
|
+
*
|
|
161
|
+
* A run that declared no directory records nothing and **does not throw**: an
|
|
162
|
+
* attended session escalating an item by hand is an ordinary thing to do, and a
|
|
163
|
+
* recorder that refused there would push callers into not calling it.
|
|
164
|
+
*
|
|
165
|
+
* A stored value that is not a number reads as none. It is the same permissive
|
|
166
|
+
* read as {@link readState} and for the same reason — a hand-edited state file
|
|
167
|
+
* must not be able to make the count `NaN`, which compares false against every
|
|
168
|
+
* threshold and silently disables the stop it feeds.
|
|
169
|
+
*/
|
|
170
|
+
export const recordEscalation = (runDir) => {
|
|
171
|
+
if (!runDir) return 0;
|
|
172
|
+
const onDisk = readState(runDir).escalations;
|
|
173
|
+
const current = Number.isInteger(onDisk) && onDisk >= 0 ? onDisk : 0;
|
|
174
|
+
try {
|
|
175
|
+
updateState(runDir, { escalations: current + 1 });
|
|
176
|
+
return current + 1;
|
|
177
|
+
} catch (error) {
|
|
178
|
+
// 🔴 **This one call tolerates a failed write, and the reason is the caller,
|
|
179
|
+
// not the value.** Two of the three adapters run this AFTER the tracker is
|
|
180
|
+
// already mutated — the comment posted, the `escalated` label applied — so a
|
|
181
|
+
// stale `RIG_RUN_DIR` (an export surviving the last run, a cleaned run
|
|
182
|
+
// directory, a worktree the loop's `mkdir -p` never reached) would turn a
|
|
183
|
+
// fully successful escalation into a thrown error. The caller then retries,
|
|
184
|
+
// and the retry double-posts the diagnosis onto the issue.
|
|
185
|
+
//
|
|
186
|
+
// Loud, not silent: swallowing this quietly would trade a crash for a lie,
|
|
187
|
+
// and the count feeding the two-in-a-row stop would drift below the truth
|
|
188
|
+
// with nothing to show for it. Stderr, never stdout — a stray line on stdout
|
|
189
|
+
// lands inside `queue next --json`'s document.
|
|
190
|
+
//
|
|
191
|
+
// `updateState` itself keeps throwing. The CLI writers below refuse loudly
|
|
192
|
+
// on purpose, and only this call site has a mutated tracker behind it.
|
|
193
|
+
process.stderr.write(
|
|
194
|
+
`run state: the escalation was NOT counted in ${runDir} — ${error.message}\n` +
|
|
195
|
+
' the tracker was updated; only the local count was lost. Fix the run ' +
|
|
196
|
+
'directory before the next task, or the two-in-a-row stop reads low.\n',
|
|
197
|
+
);
|
|
198
|
+
// The count actually on disk, not the one intended: a caller journalling
|
|
199
|
+
// this number must not record a streak the state file does not carry.
|
|
200
|
+
return current;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The take-up snapshot: the selected item's `updatedAt` marker, keyed by id, as
|
|
206
|
+
* seen at SELECT. `queue/core.mjs` › revalidationOf compares the next selection
|
|
207
|
+
* against it, so a stale take-up is reported rather than silently continued.
|
|
208
|
+
*
|
|
209
|
+
* Per run, like everything else here — a snapshot from yesterday's run is not
|
|
210
|
+
* a take-up this run made. Merged by id, so a second item does not erase the
|
|
211
|
+
* first; re-recording an id moves its baseline forward. A marker that is not a
|
|
212
|
+
* string is not recorded at all: `plan-md` has none, and writing `null` would
|
|
213
|
+
* later compare equal to `null` and read as "unchanged".
|
|
214
|
+
*
|
|
215
|
+
* No run directory → nothing written, `null` back, no throw: an attended
|
|
216
|
+
* selection has no run to snapshot into. (Like {@link recordEscalation} it
|
|
217
|
+
* refuses to throw there; unlike it, there is no count to hand back, so `null`.)
|
|
218
|
+
*/
|
|
219
|
+
export const recordTakeUp = (runDir, { id, updatedAt } = {}) => {
|
|
220
|
+
if (!runDir || typeof updatedAt !== 'string' || id === undefined || id === null) return null;
|
|
221
|
+
const state = readState(runDir);
|
|
222
|
+
const takeUps = typeof state.takeUps === 'object' && state.takeUps !== null ? state.takeUps : {};
|
|
223
|
+
return updateState(runDir, { takeUps: { ...takeUps, [String(id)]: updatedAt } });
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The take-up an EARLIER run recorded for this item, or null (AR-138).
|
|
228
|
+
*
|
|
229
|
+
* {@link recordTakeUp} is per run on purpose, and that left a hole RX1
|
|
230
|
+
* measured: an item taken up yesterday and re-offered today compared against
|
|
231
|
+
* nothing and reported a first sight, however far its marker had moved. So
|
|
232
|
+
* when this run has no take-up for the item, SELECT asks the sibling run
|
|
233
|
+
* directories — newest first by name, which is the `YYYYMMDD-HHMMSS` the
|
|
234
|
+
* `loop` skill declares — and takes the first that recorded one. The answer
|
|
235
|
+
* names the run it came from, so the revalidation event can say whose
|
|
236
|
+
* baseline it compared against.
|
|
237
|
+
*
|
|
238
|
+
* 🔴 A sibling is a run only by NAME — `YYYYMMDD-HHMMSS`, the shape the `loop`
|
|
239
|
+
* skill declares — and so is the run asking. The first version took every
|
|
240
|
+
* sibling directory, and two measurements showed why that is wrong. On CI,
|
|
241
|
+
* with `RIG_RUN_DIR` under a shared temp root, a neighbouring test's scratch
|
|
242
|
+
* directory carrying a `state.json` was read as yesterday's run, and a first
|
|
243
|
+
* sight came back as a hold against a marker nobody had taken up. Locally,
|
|
244
|
+
* the same temp root held 454 000 entries, and every SELECT paid a 540 ms
|
|
245
|
+
* directory read for a question it could not answer. So a run declared under
|
|
246
|
+
* another naming looks at no siblings at all, and the limit is the mirror
|
|
247
|
+
* image: an earlier run declared under another naming is not seen here.
|
|
248
|
+
*
|
|
249
|
+
* Bounded and fail-soft: the runs root is walked through one directory handle
|
|
250
|
+
* and at most 10 000 entries are looked at, whatever is in there; at most 200
|
|
251
|
+
* candidate runs are read, an unreadable state is skipped rather than trusted,
|
|
252
|
+
* and the answer is `null` for no run directory, an unnamed one, or no runs
|
|
253
|
+
* root. Never this run's own state — that is {@link readState}'s answer, and
|
|
254
|
+
* the caller asks it first.
|
|
255
|
+
*/
|
|
256
|
+
const RUN_DIR_NAME = /^\d{8}-\d{6}$/;
|
|
257
|
+
const RUNS_ROOT_ENTRY_BUDGET = 10_000;
|
|
258
|
+
const RUNS_READ_CAP = 200;
|
|
259
|
+
|
|
260
|
+
export const previousTakeUp = (runDir, id) => {
|
|
261
|
+
if (!runDir || id === undefined || id === null) return null;
|
|
262
|
+
const root = dirname(runDir);
|
|
263
|
+
const self = basename(runDir);
|
|
264
|
+
if (!RUN_DIR_NAME.test(self)) return null;
|
|
265
|
+
const names = [];
|
|
266
|
+
let dir;
|
|
267
|
+
try {
|
|
268
|
+
dir = opendirSync(root);
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
for (let seen = 0; seen < RUNS_ROOT_ENTRY_BUDGET; seen += 1) {
|
|
274
|
+
const entry = dir.readSync();
|
|
275
|
+
if (entry === null) break;
|
|
276
|
+
if (entry.isDirectory() && entry.name !== self && RUN_DIR_NAME.test(entry.name)) {
|
|
277
|
+
names.push(entry.name);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
} finally {
|
|
283
|
+
dir.closeSync();
|
|
284
|
+
}
|
|
285
|
+
names.sort().reverse();
|
|
286
|
+
for (const name of names.slice(0, RUNS_READ_CAP)) {
|
|
287
|
+
const candidate = join(root, name);
|
|
288
|
+
let state;
|
|
289
|
+
try {
|
|
290
|
+
state = JSON.parse(readFileSync(statePathIn(candidate), 'utf8'));
|
|
291
|
+
} catch {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const updatedAt = state?.takeUps?.[String(id)];
|
|
295
|
+
if (typeof updatedAt === 'string') return { updatedAt, runDir: candidate };
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The stop inputs, read out of a state object that anything may have written.
|
|
302
|
+
*
|
|
303
|
+
* 🔴 **An uninterpretable value is never "no stop".** The writers here are
|
|
304
|
+
* careful — `recordEscalation` refuses a non-integer, the CLI checks its
|
|
305
|
+
* vocabulary before writing — but the file is documented as hand-editable, and
|
|
306
|
+
* the reader used to pass whatever JSON held straight into JS comparisons.
|
|
307
|
+
* Measured, before this existed: `{"escalations":{}}` selected work, because
|
|
308
|
+
* `{} >= 2` is `NaN >= 2`; so did a `lastDeployVerdict` that was an object,
|
|
309
|
+
* because `=== 'REGRESSION'` is false for one. Both are the dangerous
|
|
310
|
+
* direction — a value that *looks* like a recorded stop and silently disables
|
|
311
|
+
* it.
|
|
312
|
+
*
|
|
313
|
+
* So this throws, naming the field, and the caller refuses to select. The
|
|
314
|
+
* permissive readings that remain are the ones where absence is a defined
|
|
315
|
+
* state: no file, no key, or an explicit `null`.
|
|
316
|
+
*/
|
|
317
|
+
export const stopInputsOf = (state = {}) => {
|
|
318
|
+
const refuse = (field, value) => {
|
|
319
|
+
// 🔴 The rendering has a CONSTANT failure path, and that is not fussiness.
|
|
320
|
+
// `JSON.stringify` recurses, so a deeply nested value overflows the stack
|
|
321
|
+
// *inside the refusal* — the operator then gets `Maximum call stack size
|
|
322
|
+
// exceeded` from a function whose entire job is to name the field that is
|
|
323
|
+
// wrong. `String(value)` is no fallback: on an array it recurses too, via
|
|
324
|
+
// `join`. Measured: `JSON.stringify` gives out around 6 800 levels deep.
|
|
325
|
+
let rendered;
|
|
326
|
+
try {
|
|
327
|
+
rendered = JSON.stringify(value);
|
|
328
|
+
} catch {
|
|
329
|
+
rendered = '(a value too deeply nested to print)';
|
|
330
|
+
}
|
|
331
|
+
throw new Error(
|
|
332
|
+
`run state: ${field} is ${rendered ?? typeof value}, which is not a value this ` +
|
|
333
|
+
`can act on. An input a stop condition cannot read must not be read as ` +
|
|
334
|
+
`"no stop" — fix or delete the field rather than leaving it.`,
|
|
335
|
+
);
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// 🔴 **A type gate ahead of the coercion — because `Number()` decides this
|
|
339
|
+
// question on the wrong axis.** The rule the three fields share is: coerce
|
|
340
|
+
// where any coercion fails safe, refuse where it fails dangerous. `Number()`
|
|
341
|
+
// gets that right for `"5"`, and wrong for everything that quietly becomes 0
|
|
342
|
+
// or 1: measured, `true` read as one escalation, and `false`, `''`, `' '` and
|
|
343
|
+
// `[]` all read as none — a present value silently disabling the stop it was
|
|
344
|
+
// written to enforce, which is the exact case this function exists for. What
|
|
345
|
+
// survives the gate is still coerced, and every one of those coercions rounds
|
|
346
|
+
// toward stopping earlier (`"1e3"` → 1000, `"0x10"` → 16).
|
|
347
|
+
//
|
|
348
|
+
// A hand-edit reaching for `"2"` means two; one reaching for `false` does not
|
|
349
|
+
// mean zero escalations, it means the file is not saying anything this can
|
|
350
|
+
// act on.
|
|
351
|
+
//
|
|
352
|
+
// **An array is refused rather than unwrapped**, though a one-element array
|
|
353
|
+
// used to read as its contents. Nothing writes one — `recordEscalation`
|
|
354
|
+
// writes an integer, `recordCompletedTier` writes `0`, and the CLI writes
|
|
355
|
+
// only the other three fields — and the branch that
|
|
356
|
+
// accepted it carried three defects of the very kind above: `[null]` read as
|
|
357
|
+
// zero, a multi-element array was refused only by an untested length check,
|
|
358
|
+
// and the unwrapping recursed as deep as its input. A shape with no writer is
|
|
359
|
+
// not a shape worth interpreting.
|
|
360
|
+
const countOf = (value) => {
|
|
361
|
+
if (typeof value === 'number') return Number.isInteger(value) && value >= 0 ? value : null;
|
|
362
|
+
if (typeof value !== 'string' || value.trim() === '') return null;
|
|
363
|
+
const parsed = Number(value);
|
|
364
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const escalations = state.escalations ?? 0;
|
|
368
|
+
const count = countOf(escalations);
|
|
369
|
+
if (count === null) refuse('escalations', escalations);
|
|
370
|
+
|
|
371
|
+
const verdict = state.lastDeployVerdict ?? null;
|
|
372
|
+
if (verdict !== null && typeof verdict !== 'string') refuse('lastDeployVerdict', verdict);
|
|
373
|
+
// A verdict outside the vocabulary is the same defect wearing a plausible
|
|
374
|
+
// spelling: `REGRESSED` would sit in the file looking recorded and matching
|
|
375
|
+
// nothing. Case is normalised, exactly as the CLI normalises it on the way in.
|
|
376
|
+
const normalised = verdict === null ? null : verdict.toUpperCase();
|
|
377
|
+
if (normalised !== null && !DEPLOY_VERDICTS.includes(normalised)) {
|
|
378
|
+
refuse('lastDeployVerdict', verdict);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// `triggersFired` is deliberately absent from this, and its absence is a
|
|
382
|
+
// decision rather than an oversight: `core.mjs` compares `fired !== true`
|
|
383
|
+
// strictly, so a string, a number or a nonsense shape can only ever leave an
|
|
384
|
+
// item held back. Every misreading fails in the safe direction, which is the
|
|
385
|
+
// one case this function has nothing to add to.
|
|
386
|
+
return {
|
|
387
|
+
consecutiveEscalations: count,
|
|
388
|
+
lastDeployVerdict: normalised,
|
|
389
|
+
// Any truthy value means exhausted: the only writer stores `true`, and a
|
|
390
|
+
// hand-edit reaching for `"yes"` means yes. A flag has an honest `false`,
|
|
391
|
+
// unlike a count — so unlike `escalations` above, nothing here needs
|
|
392
|
+
// refusing: no present value silently disables the stop.
|
|
393
|
+
budgetExhausted: Boolean(state.budgetExhausted),
|
|
394
|
+
};
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The post-deploy verdict, as `autonomy.md` defines it: binary, and the only
|
|
399
|
+
* two words `stopConditionOf` can act on.
|
|
400
|
+
*
|
|
401
|
+
* `HEALTHY` is not decoration. The field is `lastDeployVerdict` and "last" is
|
|
402
|
+
* the whole of it — without a word that clears a regression, one bad deploy
|
|
403
|
+
* ends every later selection in the run and the operator's only way out is to
|
|
404
|
+
* hand-edit a state file.
|
|
405
|
+
*/
|
|
406
|
+
export const DEPLOY_VERDICTS = Object.freeze(['REGRESSION', 'HEALTHY']);
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The budget vocabulary — one word, and **no word that lifts it**.
|
|
410
|
+
*
|
|
411
|
+
* The asymmetry with {@link DEPLOY_VERDICTS} is deliberate. `lastDeployVerdict`
|
|
412
|
+
* is about the *last* deploy, so a revert-and-redeploy inside the same run
|
|
413
|
+
* genuinely changes the answer and `HEALTHY` names that event. Spend only
|
|
414
|
+
* accumulates: nothing a run does later refunds it, so "un-exhaust" would name
|
|
415
|
+
* no event at all — it would name a decision to keep going, taken by the run
|
|
416
|
+
* that declared the stop. This is the one stop a run could lift on its own
|
|
417
|
+
* authority, which is exactly why it cannot.
|
|
418
|
+
*
|
|
419
|
+
* The escape hatch already exists and is cheaper than a word: a new run gets a
|
|
420
|
+
* new run directory, and therefore a clean state.
|
|
421
|
+
*/
|
|
422
|
+
export const BUDGET_WORDS = Object.freeze(['EXHAUSTED']);
|
|
423
|
+
|
|
424
|
+
const invokedDirectly = () => {
|
|
425
|
+
if (!process.argv[1]) return false;
|
|
426
|
+
const real = (p) => {
|
|
427
|
+
try {
|
|
428
|
+
return realpathSync(p);
|
|
429
|
+
} catch {
|
|
430
|
+
return p;
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* What each command writes, and what it refuses first.
|
|
438
|
+
*
|
|
439
|
+
* Two shapes share this table: `deploy` and `budget` take a word from a closed
|
|
440
|
+
* vocabulary, `trigger` takes an item id, which is whatever the tracker calls
|
|
441
|
+
* it — so it validates presence instead of membership (`words: null`). That
|
|
442
|
+
* sentinel leaks into three places below, which is the price of one table over
|
|
443
|
+
* two handlers; a fourth command is where that stops being worth it.
|
|
444
|
+
*/
|
|
445
|
+
const COMMANDS = Object.freeze({
|
|
446
|
+
deploy: {
|
|
447
|
+
words: DEPLOY_VERDICTS,
|
|
448
|
+
field: 'lastDeployVerdict',
|
|
449
|
+
// The verdict is stored as the word itself: `stopConditionOf` compares it
|
|
450
|
+
// to `'REGRESSION'`, so the value on disk has to be the canonical spelling.
|
|
451
|
+
valueOf: (word) => word,
|
|
452
|
+
missingDir:
|
|
453
|
+
'there is no run to record this verdict against. The run declares it in ' +
|
|
454
|
+
'preflight; a verdict written nowhere would read as a healthy deploy to the ' +
|
|
455
|
+
'next selection.',
|
|
456
|
+
},
|
|
457
|
+
budget: {
|
|
458
|
+
words: BUDGET_WORDS,
|
|
459
|
+
field: 'budgetExhausted',
|
|
460
|
+
valueOf: () => true,
|
|
461
|
+
missingDir:
|
|
462
|
+
'there is no run whose budget this could exhaust. A budget belongs to one ' +
|
|
463
|
+
'run — that is why a new run starts with a clean one.',
|
|
464
|
+
},
|
|
465
|
+
// The one command whose argument is not a vocabulary: an item id is whatever
|
|
466
|
+
// the tracker calls it. So it validates presence rather than membership —
|
|
467
|
+
// `trigger` with nothing after it must refuse rather than fire everything.
|
|
468
|
+
trigger: {
|
|
469
|
+
words: null,
|
|
470
|
+
field: 'triggersFired',
|
|
471
|
+
// Merged, not replaced: the shallow merge in `updateState` would drop the
|
|
472
|
+
// triggers already fired this run, and a second declaration must not
|
|
473
|
+
// silently retract the first.
|
|
474
|
+
valueOf: (id, state) => ({ ...(state.triggersFired ?? {}), [id]: true }),
|
|
475
|
+
missingDir:
|
|
476
|
+
'there is no run for this trigger to be fired in. A declaration belongs to ' +
|
|
477
|
+
'one run, which is what stops it carrying into tomorrow.',
|
|
478
|
+
missingWord:
|
|
479
|
+
'trigger needs the id of the item whose trigger fired. Firing nothing in ' +
|
|
480
|
+
'particular would either do nothing or arm every gated item at once, and ' +
|
|
481
|
+
'both are worse than refusing.',
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// The CLI the post-deploy step and the budget check call. It exists because both
|
|
486
|
+
// values are produced by a judgement taken outside any module — and a stop
|
|
487
|
+
// condition nothing can write is the state this whole file was added to end.
|
|
488
|
+
//
|
|
489
|
+
// 🔴 **Reading is permissive; writing refuses.** `readState` turns an absent or
|
|
490
|
+
// corrupt file into `{}` because an absent stop input is a defined state. A
|
|
491
|
+
// write has nowhere to go instead, and exiting 0 would tell the operator the
|
|
492
|
+
// regression was filed while the next selection hands out work on top of it —
|
|
493
|
+
// the one move `autonomy.md` names as never fix-forward.
|
|
494
|
+
if (invokedDirectly()) {
|
|
495
|
+
const [command, word] = process.argv.slice(2);
|
|
496
|
+
const runDir = process.env.RIG_RUN_DIR;
|
|
497
|
+
const spec = Object.hasOwn(COMMANDS, String(command)) ? COMMANDS[command] : null;
|
|
498
|
+
|
|
499
|
+
if (!spec) {
|
|
500
|
+
const names = Object.entries(COMMANDS).map(
|
|
501
|
+
([name, { words }]) => `\`${name} <${words ? words.join('|').toLowerCase() : 'item-id'}>\``,
|
|
502
|
+
);
|
|
503
|
+
process.stderr.write(
|
|
504
|
+
`unknown command: ${command ?? '(none)'}. This CLI has ${names.length}: ` +
|
|
505
|
+
`${names.slice(0, -1).join(', ')} and ${names.at(-1)}. Everything else in this ` +
|
|
506
|
+
'module is an import, not a command.\n',
|
|
507
|
+
);
|
|
508
|
+
process.exit(1);
|
|
509
|
+
}
|
|
510
|
+
if (!runDir) {
|
|
511
|
+
process.stderr.write(`RIG_RUN_DIR is not set, so ${spec.missingDir}\n`);
|
|
512
|
+
process.exit(1);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Case is normalised rather than refused: a lowercase word is not a typo of
|
|
516
|
+
// meaning, while `REGRESSED` is — it matches nothing in `stopConditionOf` and
|
|
517
|
+
// would sit in the file looking recorded and stopping nothing. An item id is
|
|
518
|
+
// not a vocabulary, so it is taken as given and only checked for presence.
|
|
519
|
+
const given = String(word ?? '');
|
|
520
|
+
if (spec.words) {
|
|
521
|
+
const normalised = given.toUpperCase();
|
|
522
|
+
if (!spec.words.includes(normalised)) {
|
|
523
|
+
process.stderr.write(
|
|
524
|
+
`unknown ${command} word: ${word ?? '(none)'}. It must be one of ` +
|
|
525
|
+
`${spec.words.join(', ')} — the vocabulary the stop conditions compare ` +
|
|
526
|
+
'against. A word outside it would be stored, read back, and match nothing.\n',
|
|
527
|
+
);
|
|
528
|
+
process.exit(1);
|
|
529
|
+
}
|
|
530
|
+
} else if (given === '') {
|
|
531
|
+
process.stderr.write(`${spec.missingWord}\n`);
|
|
532
|
+
process.exit(1);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const argument = spec.words ? given.toUpperCase() : given;
|
|
536
|
+
const value = spec.valueOf(argument, readState(runDir));
|
|
537
|
+
updateState(runDir, { [spec.field]: value });
|
|
538
|
+
process.stdout.write(`run state: ${spec.field} = ${JSON.stringify(value)}\n`);
|
|
539
|
+
}
|
|
@@ -21,19 +21,26 @@ import { existsSync } from 'node:fs';
|
|
|
21
21
|
import { homedir, userInfo } from 'node:os';
|
|
22
22
|
import { delimiter, join } from 'node:path';
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
/**
|
|
25
|
+
* BOTH homes, env-derived first: `$HOME` (what `.claude/settings.json` can set —
|
|
26
|
+
* pointing it at an empty directory once disarmed the brake) and the password
|
|
27
|
+
* database's, which ignores the environment, so the operator's real flag is
|
|
28
|
+
* always among the paths checked. Shared with the unattended flag (AR-51): one
|
|
29
|
+
* lookup, one place to be wrong.
|
|
30
|
+
*/
|
|
31
|
+
export const homesOf = (env = process.env) => {
|
|
32
|
+
const homes = new Set([env.HOME || homedir()]);
|
|
31
33
|
try {
|
|
32
34
|
homes.add(userInfo().homedir);
|
|
33
35
|
} catch {
|
|
34
36
|
// no password entry — the env-derived home is all there is
|
|
35
37
|
}
|
|
36
|
-
|
|
38
|
+
return [...homes];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Every path that arms the brake. The machine-level default is always first. */
|
|
42
|
+
export const stopFlags = (env = process.env) => {
|
|
43
|
+
const paths = homesOf(env).map((home) => join(home, '.claude', '__PROJECT_NAME__-loop-STOP'));
|
|
37
44
|
const extra = env.AGENT_LOOP_STOP;
|
|
38
45
|
if (extra) {
|
|
39
46
|
// Filtered and CAPPED before the spread, never after. Spreading an
|