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,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run journal — the machine-readable trace BEHIND the human journal in
|
|
3
|
+
* `journal/YYYY-MM.md`.
|
|
4
|
+
*
|
|
5
|
+
* Gate verdicts go to `decisions.jsonl`, everything else to `events.jsonl`, both
|
|
6
|
+
* append-only, both inside one per-run directory the caller hands over. It
|
|
7
|
+
* answers *what did the run decide, and on what basis*; it never answers *was
|
|
8
|
+
* that the right call*, and it replaces neither the month file nor `PLAN.md`.
|
|
9
|
+
*
|
|
10
|
+
* The two are opposites and stay that way: this trace is append-only and
|
|
11
|
+
* OLDEST-first, the month file is newest-on-top. Reading one as the other is
|
|
12
|
+
* how a reader concludes a run did nothing.
|
|
13
|
+
*
|
|
14
|
+
* 🔴 **A journal with no call site records nothing.** The writer is the easy
|
|
15
|
+
* half and the worthless one on its own — the shape this module is ported from
|
|
16
|
+
* shipped the writer plus the rule and no gate ever called it, so a run that
|
|
17
|
+
* looked instrumented produced empty directories. The caller ships with it:
|
|
18
|
+
* `queue/index.mjs` records its `item-selection` verdict.
|
|
19
|
+
*
|
|
20
|
+
* 🔴 **The ordering is asserted here, not documented somewhere.** A journal
|
|
21
|
+
* whose order the reader cannot trust is worse than none: a stale record reads
|
|
22
|
+
* as the current one, which is the exact failure a journal exists to prevent.
|
|
23
|
+
* So every record carries `seq`, the run-wide counter, and `readRun` REFUSES a
|
|
24
|
+
* run whose sequence has a gap or runs backwards rather than handing back
|
|
25
|
+
* records it cannot vouch for.
|
|
26
|
+
*
|
|
27
|
+
* ⚠ **`seq` is the authority on order, and the timestamp is data.** The clock is
|
|
28
|
+
* injected, so it is whatever the caller passed — a re-synced host clock, a
|
|
29
|
+
* caller stamping one value across a batch, or two callers on different
|
|
30
|
+
* machines all produce timestamps that do not order. A counter incremented by a
|
|
31
|
+
* single writer does. Which is the whole of the claim: with two writers the
|
|
32
|
+
* counter does not order either, it only makes the collision *visible* — see the
|
|
33
|
+
* one-writer assumption below.
|
|
34
|
+
*
|
|
35
|
+
* **What this module deliberately does NOT own:** the run-id convention, creating
|
|
36
|
+
* the directory, and rotation. Those belong to whatever drives the run — in this
|
|
37
|
+
* rig, the `loop` skill, which declares `RIG_RUN_DIR` in its preflight. This
|
|
38
|
+
* module is handed a `runDir` and uses it verbatim. Two owners of one convention
|
|
39
|
+
* disagree the first time either changes, and a rotation scheme for files nobody
|
|
40
|
+
* has accumulated is invention.
|
|
41
|
+
*
|
|
42
|
+
* ⚠ **It assumes one writer.** `seq` is read from the files and written back with
|
|
43
|
+
* no lock, so two processes sharing one run directory can compute the same
|
|
44
|
+
* number. That is *detected* on the next read or write and never silently
|
|
45
|
+
* accepted — but detection is where it ends: the journal then refuses records
|
|
46
|
+
* rather than repairing itself, and the run's trace stops there. One run
|
|
47
|
+
* directory per run is the caller's part of the contract.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { appendFileSync, readFileSync, statSync } from 'node:fs';
|
|
51
|
+
import { join } from 'node:path';
|
|
52
|
+
|
|
53
|
+
const DECISIONS = 'decisions.jsonl';
|
|
54
|
+
const EVENTS = 'events.jsonl';
|
|
55
|
+
|
|
56
|
+
/** The end marker is an ordinary record; this is the field that makes it one. */
|
|
57
|
+
const RUN_END = 'run-end';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Why a journal call failed, as a value rather than a sentence.
|
|
61
|
+
*
|
|
62
|
+
* The caller has to act differently on two of these than on the rest, and the
|
|
63
|
+
* only other way to tell them apart is to match on the message text — which puts
|
|
64
|
+
* the decision in two files at once and lets them drift the day someone improves
|
|
65
|
+
* the wording.
|
|
66
|
+
*/
|
|
67
|
+
export const JOURNAL_FAILURES = Object.freeze([
|
|
68
|
+
'undeclared', // no runDir was passed at all
|
|
69
|
+
'run-dir-missing', // a runDir was passed and there is no such directory
|
|
70
|
+
'field-missing', // a record that would name neither its gate nor its verdict
|
|
71
|
+
'field-invalid', // a field was supplied in a shape the record cannot carry
|
|
72
|
+
'unusable', // the journal on disk cannot be trusted (sequence, or unreadable)
|
|
73
|
+
'ended', // this run already carries its run-end marker
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
export class RunJournalError extends Error {
|
|
77
|
+
constructor(failure, message, options) {
|
|
78
|
+
super(message, options);
|
|
79
|
+
this.name = 'RunJournalError';
|
|
80
|
+
this.failure = failure;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Is this a failure of the TRACE alone, leaving the caller's own work valid?
|
|
86
|
+
*
|
|
87
|
+
* 🔴 The distinction is the difference between a lost trace and a stalled loop.
|
|
88
|
+
* A journal that cannot accept records — a directory two sessions shared, one
|
|
89
|
+
* reused after its run ended — is unrepairable by design: it is append-only, and
|
|
90
|
+
* every later write re-reads it. If that stopped the caller, one collision would
|
|
91
|
+
* make the queue unselectable **forever**, and the trace would have taken the
|
|
92
|
+
* work down with it. The other failures are the caller's own mis-declaration,
|
|
93
|
+
* caught before anything happened and fixed in a second.
|
|
94
|
+
*/
|
|
95
|
+
export const isTraceExhausted = (error) =>
|
|
96
|
+
error instanceof RunJournalError && (error.failure === 'unusable' || error.failure === 'ended');
|
|
97
|
+
|
|
98
|
+
const requireRunDir = (runDir) => {
|
|
99
|
+
if (typeof runDir !== 'string' || runDir.trim() === '') {
|
|
100
|
+
throw new RunJournalError(
|
|
101
|
+
'undeclared',
|
|
102
|
+
'the run journal needs a runDir: the run directory is the caller\'s to choose ' +
|
|
103
|
+
'(the convention is `.claude/runs/<run-id>/`), and this module deliberately ' +
|
|
104
|
+
'invents none — a default would be a second owner of that convention.',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return runDir;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The directory has to exist, and its absence is an absence — never an empty run.
|
|
112
|
+
*
|
|
113
|
+
* A missing file inside the run directory is zero records, honestly: the run has
|
|
114
|
+
* not written that kind yet. A missing DIRECTORY is a different fact entirely —
|
|
115
|
+
* a typo in the declaration, or a directory someone deleted — and folding it into
|
|
116
|
+
* the same answer would hand back "this run decided nothing and is still going"
|
|
117
|
+
* about a run whose trace is simply not there. Checked in one place, for both the
|
|
118
|
+
* read and the write path, so the two cannot disagree about it.
|
|
119
|
+
*/
|
|
120
|
+
const requireRunDirExists = (runDir) => {
|
|
121
|
+
let stats;
|
|
122
|
+
try {
|
|
123
|
+
stats = statSync(runDir);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
throw new RunJournalError(
|
|
126
|
+
'run-dir-missing',
|
|
127
|
+
`the run directory ${runDir} does not exist (${error?.code ?? 'unknown error'}), so ` +
|
|
128
|
+
'there is no trace to read or append to. An absent directory is not an empty ' +
|
|
129
|
+
'run: it is a declaration nobody honoured.',
|
|
130
|
+
{ cause: error },
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (!stats.isDirectory()) {
|
|
134
|
+
throw new RunJournalError(
|
|
135
|
+
'run-dir-missing',
|
|
136
|
+
`the run directory ${runDir} exists but is not a directory, so this run's two ` +
|
|
137
|
+
'journal files have nowhere to live.',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return runDir;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const requireClock = (now) => {
|
|
144
|
+
// 🔴 Reading the clock here instead would make every record untestable and
|
|
145
|
+
// every replay a different file. The caller stamps; this module records.
|
|
146
|
+
if (typeof now !== 'string' || now.trim() === '') {
|
|
147
|
+
throw new RunJournalError(
|
|
148
|
+
'field-missing',
|
|
149
|
+
'the run journal needs `now` (an ISO timestamp): the clock is injected, never ' +
|
|
150
|
+
'read here, which is what makes these records reproducible in a test.',
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return now;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A record has to say what it is about.
|
|
158
|
+
*
|
|
159
|
+
* A verdict naming no gate, or a marker naming no reason for stopping, is a line
|
|
160
|
+
* in a file that answers none of the questions the file exists for — and the
|
|
161
|
+
* argument this module makes for a record over a sentinel file is precisely that
|
|
162
|
+
* a record carries when and why. Writing one that carries neither would refute
|
|
163
|
+
* its own header.
|
|
164
|
+
*/
|
|
165
|
+
const requireField = (name, value) => {
|
|
166
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
167
|
+
throw new RunJournalError(
|
|
168
|
+
'field-missing',
|
|
169
|
+
`the run journal needs \`${name}\`: a record that does not name its ${name} ` +
|
|
170
|
+
'answers none of the questions the journal is read for.',
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return value;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* One journal file, parsed.
|
|
178
|
+
*
|
|
179
|
+
* An absent file is zero records — a run that has not written that kind yet is
|
|
180
|
+
* the normal state. A file that exists and does not parse is NOT: folding it
|
|
181
|
+
* into an empty list would silently shorten the trace, and a shorter trace of a
|
|
182
|
+
* run is indistinguishable from a trace of a shorter run.
|
|
183
|
+
*/
|
|
184
|
+
const linesOf = (runDir, file) => {
|
|
185
|
+
const path = join(runDir, file);
|
|
186
|
+
let raw;
|
|
187
|
+
try {
|
|
188
|
+
raw = readFileSync(path, 'utf8');
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return [];
|
|
191
|
+
throw new RunJournalError(
|
|
192
|
+
'unusable',
|
|
193
|
+
`${path} exists but could not be read (${error?.code ?? 'unknown error'}), so this ` +
|
|
194
|
+
'run\'s trace cannot be trusted to be complete. Refusing rather than reading ' +
|
|
195
|
+
'the readable half as the whole.',
|
|
196
|
+
{ cause: error },
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return raw
|
|
201
|
+
.split('\n')
|
|
202
|
+
.filter((line) => line.trim() !== '')
|
|
203
|
+
.map((line, index) => {
|
|
204
|
+
try {
|
|
205
|
+
return JSON.parse(line);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
throw new RunJournalError(
|
|
208
|
+
'unusable',
|
|
209
|
+
`${path} line ${index + 1} is not a journal record, so the sequence cannot be ` +
|
|
210
|
+
'checked and the order of this run cannot be trusted. A journal degrades ' +
|
|
211
|
+
'loudly or not at all.',
|
|
212
|
+
{ cause: error },
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Both files, with the ordering invariant enforced across them.
|
|
220
|
+
*
|
|
221
|
+
* Two checks, and they catch different things:
|
|
222
|
+
* - **within a file**, `seq` must increase down the lines. Append-only makes
|
|
223
|
+
* line order write order, so a decreasing pair means the file was edited or
|
|
224
|
+
* assembled — the case a reader cannot see by eye;
|
|
225
|
+
* - **across both files**, the union of `seq` must be exactly `1..N`. A gap is
|
|
226
|
+
* a lost record; a duplicate is two records claiming one position, which is
|
|
227
|
+
* what concurrent writers into one run directory produce.
|
|
228
|
+
*/
|
|
229
|
+
const readBoth = (runDir) => {
|
|
230
|
+
const decisions = linesOf(runDir, DECISIONS);
|
|
231
|
+
const events = linesOf(runDir, EVENTS);
|
|
232
|
+
|
|
233
|
+
const refuse = (why) => {
|
|
234
|
+
throw new RunJournalError(
|
|
235
|
+
'unusable',
|
|
236
|
+
`the run journal in ${runDir} cannot be trusted: ${why}. The sequence is what ` +
|
|
237
|
+
'orders these records — with it broken, a stale record reads as the current ' +
|
|
238
|
+
'one, which is the failure the journal exists to prevent.',
|
|
239
|
+
);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
for (const [file, records] of [
|
|
243
|
+
[DECISIONS, decisions],
|
|
244
|
+
[EVENTS, events],
|
|
245
|
+
]) {
|
|
246
|
+
for (const [index, record] of records.entries()) {
|
|
247
|
+
if (!Number.isInteger(record?.seq)) {
|
|
248
|
+
refuse(`${file} line ${index + 1} carries no integer seq`);
|
|
249
|
+
}
|
|
250
|
+
if (index > 0 && record.seq <= records[index - 1].seq) {
|
|
251
|
+
refuse(
|
|
252
|
+
`${file} runs backwards at line ${index + 1} (seq ${records[index - 1].seq} ` +
|
|
253
|
+
`then ${record.seq}), so its order is not its write order`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const all = [...decisions, ...events].sort((a, b) => a.seq - b.seq);
|
|
260
|
+
for (const [index, record] of all.entries()) {
|
|
261
|
+
if (record.seq !== index + 1) {
|
|
262
|
+
refuse(
|
|
263
|
+
`the records across both files are not a whole sequence — expected seq ` +
|
|
264
|
+
`${index + 1}, found ${record.seq}`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { decisions, events, all };
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Append one record, after the two questions every write has to answer first:
|
|
274
|
+
* is this run already over, and what number is this record.
|
|
275
|
+
*
|
|
276
|
+
* Both answers come from the files themselves rather than from memory, because
|
|
277
|
+
* every caller is its own short-lived process — the CLI that selects an item and
|
|
278
|
+
* the gate that returns a verdict never share a variable.
|
|
279
|
+
*/
|
|
280
|
+
const append = (runDir, file, fields, now) => {
|
|
281
|
+
requireRunDir(runDir);
|
|
282
|
+
requireRunDirExists(runDir);
|
|
283
|
+
requireClock(now);
|
|
284
|
+
|
|
285
|
+
const { all } = readBoth(runDir);
|
|
286
|
+
|
|
287
|
+
// 🔴 A run whose end can be followed by more records has no end. The marker
|
|
288
|
+
// exists to remove exactly one ambiguity — "did this run stop, or is it still
|
|
289
|
+
// going" — and a late record puts it straight back.
|
|
290
|
+
if (all.some((record) => record.kind === RUN_END)) {
|
|
291
|
+
throw new RunJournalError(
|
|
292
|
+
'ended',
|
|
293
|
+
`the run in ${runDir} already carries its run-end marker, so nothing more may be ` +
|
|
294
|
+
'recorded against it. A second record after the end would make "when did this ' +
|
|
295
|
+
'run stop" have two answers and give the reader no way to pick one.',
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const record = { seq: all.length + 1, at: now, ...fields };
|
|
300
|
+
try {
|
|
301
|
+
appendFileSync(join(runDir, file), `${JSON.stringify(record)}\n`);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
// 🔴 The write door needs the same classification as the read door, and it
|
|
304
|
+
// was the one fs call left unwrapped. A full disk or a file the run cannot
|
|
305
|
+
// write reached the caller as a plain error, missed `isTraceExhausted`, and
|
|
306
|
+
// withheld the work — reproducing from this side the exact failure the
|
|
307
|
+
// classification exists to prevent. A journal that cannot take this record
|
|
308
|
+
// will not take the next one either: the trace is over, the run is not.
|
|
309
|
+
throw new RunJournalError(
|
|
310
|
+
'unusable',
|
|
311
|
+
`the run journal in ${runDir} could not be appended to ` +
|
|
312
|
+
`(${error?.code ?? 'unknown error'}), so this run's trace stops here. The ` +
|
|
313
|
+
'record was not written and nothing was modified.',
|
|
314
|
+
{ cause: error },
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
return record;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* A gate verdict: what was decided, and on what basis.
|
|
322
|
+
*
|
|
323
|
+
* `blockers` is what a structured verdict carries beyond the word
|
|
324
|
+
* (`lib/verdict.mjs`), and it is written verbatim — the blocker's own `rule` is
|
|
325
|
+
* what the author acts on, so a record keeping only a count would be the prose
|
|
326
|
+
* verdict again wearing a field name.
|
|
327
|
+
*
|
|
328
|
+
* 🔴 **Omitted is not empty.** With no `blockers` argument the key is absent
|
|
329
|
+
* from the record; `[]` is a CLAIM — "this gate named none" — and a caller that
|
|
330
|
+
* never passed the field never made it. Writing one in would let a later reader
|
|
331
|
+
* conclude a gate ruled clean from a record whose writer said nothing at all.
|
|
332
|
+
*/
|
|
333
|
+
export const recordDecision = ({
|
|
334
|
+
runDir,
|
|
335
|
+
gate,
|
|
336
|
+
verdict,
|
|
337
|
+
why = null,
|
|
338
|
+
blockers,
|
|
339
|
+
headSha,
|
|
340
|
+
reviewers,
|
|
341
|
+
now,
|
|
342
|
+
} = {}) => {
|
|
343
|
+
requireField('gate', gate);
|
|
344
|
+
requireField('verdict', verdict);
|
|
345
|
+
|
|
346
|
+
const fields = { gate, verdict, why };
|
|
347
|
+
if (headSha !== undefined) {
|
|
348
|
+
if (typeof headSha !== 'string' || headSha.trim() === '') {
|
|
349
|
+
throw new RunJournalError(
|
|
350
|
+
'field-invalid',
|
|
351
|
+
'the run journal takes `headSha` as the commit the gate answered for. Absent is the ' +
|
|
352
|
+
'honest answer when the gate did not say which one; a blank stands in for a commit ' +
|
|
353
|
+
'and names none.',
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
fields.headSha = headSha;
|
|
357
|
+
}
|
|
358
|
+
if (reviewers !== undefined) {
|
|
359
|
+
if (!Array.isArray(reviewers) || reviewers.some((name) => typeof name !== 'string')) {
|
|
360
|
+
throw new RunJournalError(
|
|
361
|
+
'field-invalid',
|
|
362
|
+
'the run journal takes `reviewers` as the list of reviewers a route asked for, or a ' +
|
|
363
|
+
'gate launched. A summary sentence in its place cannot be compared against the ' +
|
|
364
|
+
'verdicts that came back, which is the only thing this field is for.',
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
// 🔴 No length check: `[]` is the answer for a lane that launches nobody,
|
|
368
|
+
// and it has to be distinguishable from the key being absent. Absent means
|
|
369
|
+
// the writer said nothing about reviewers at all — which is what every
|
|
370
|
+
// record written before this field existed means.
|
|
371
|
+
fields.reviewers = reviewers;
|
|
372
|
+
}
|
|
373
|
+
if (blockers !== undefined) {
|
|
374
|
+
if (!Array.isArray(blockers)) {
|
|
375
|
+
throw new RunJournalError(
|
|
376
|
+
'field-invalid',
|
|
377
|
+
'the run journal takes `blockers` as a list of the blockers a verdict named. A ' +
|
|
378
|
+
'summary sentence in its place is the prose verdict this field exists to replace.',
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
fields.blockers = blockers;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return append(runDir, DECISIONS, fields, now);
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Everything that is not a gate verdict.
|
|
389
|
+
*
|
|
390
|
+
* 🔴 It may not forge the end of the run. `kind` is a free string from the
|
|
391
|
+
* caller and exactly one value is load-bearing, so without this an ordinary
|
|
392
|
+
* event closes the run — and every later record is refused, by a marker nobody
|
|
393
|
+
* meant to write and no reader can distinguish from a real one.
|
|
394
|
+
*/
|
|
395
|
+
export const recordEvent = ({ runDir, kind, data = null, now } = {}) => {
|
|
396
|
+
requireField('kind', kind);
|
|
397
|
+
if (kind === RUN_END) {
|
|
398
|
+
throw new RunJournalError(
|
|
399
|
+
'field-missing',
|
|
400
|
+
`\`${RUN_END}\` is the marker's own kind and only \`endRun\` may write it: an ` +
|
|
401
|
+
'event carrying it would end the run without naming why it stopped, and ' +
|
|
402
|
+
'nothing downstream could tell the two apart.',
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
return append(runDir, EVENTS, { kind, data }, now);
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The run-end marker — shipped from day one, not added once someone is confused.
|
|
410
|
+
*
|
|
411
|
+
* It is a record and not a sentinel file on purpose: a touch-file would satisfy
|
|
412
|
+
* "did it end" while carrying no time, no position in the sequence and no
|
|
413
|
+
* reason, and the trace could not say when or why the run stopped. The failure it
|
|
414
|
+
* exists to prevent is an ordinary one — a journal entry reading *"stopped at —
|
|
415
|
+
* checkpoint, still running"* outliving by weeks the run that wrote it, with
|
|
416
|
+
* nothing in the file able to contradict it.
|
|
417
|
+
*/
|
|
418
|
+
export const endRun = ({ runDir, stop, now } = {}) => {
|
|
419
|
+
requireField('stop', stop);
|
|
420
|
+
return append(runDir, EVENTS, { kind: RUN_END, stop }, now);
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The run as a reader sees it: both files, and whether it ended.
|
|
425
|
+
*
|
|
426
|
+
* It refuses rather than returning a sequence it cannot vouch for — see
|
|
427
|
+
* `readBoth`. That refusal IS the ordering invariant; documenting the ordering
|
|
428
|
+
* and checking nothing is what this module was ported to stop doing.
|
|
429
|
+
*/
|
|
430
|
+
export const readRun = ({ runDir } = {}) => {
|
|
431
|
+
requireRunDir(runDir);
|
|
432
|
+
requireRunDirExists(runDir);
|
|
433
|
+
const { decisions, events, all } = readBoth(runDir);
|
|
434
|
+
return { decisions, events, ended: all.some((record) => record.kind === RUN_END) };
|
|
435
|
+
};
|