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
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// node .claude/scripts/queue/index.mjs next # the item to take, and why
|
|
5
5
|
// node .claude/scripts/queue/index.mjs next --json
|
|
6
6
|
// node .claude/scripts/queue/index.mjs list # every item, with skip reasons
|
|
7
|
-
// node .claude/scripts/queue/index.mjs hygiene # stale labels
|
|
7
|
+
// node .claude/scripts/queue/index.mjs hygiene # stale labels, link anomalies, overtaken proposals
|
|
8
|
+
// node .claude/scripts/queue/index.mjs gate-round --branch <b> # count a gate round
|
|
8
9
|
//
|
|
9
10
|
// The adapter comes from `.claude/queue.json` (`{"adapter": "plan-md"}`) and
|
|
10
11
|
// defaults to `plan-md`, which is the only adapter that works in a freshly
|
|
@@ -12,8 +13,22 @@
|
|
|
12
13
|
// that silently reads the wrong queue is worse than one that refuses to start.
|
|
13
14
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
15
|
-
import { dirname, join } from 'node:path';
|
|
16
|
-
import {
|
|
16
|
+
import { basename, dirname, join } from 'node:path';
|
|
17
|
+
import {
|
|
18
|
+
asOfOf,
|
|
19
|
+
citedPathsOf,
|
|
20
|
+
hygieneOf,
|
|
21
|
+
overtakenOf,
|
|
22
|
+
revalidationOf,
|
|
23
|
+
selectNext,
|
|
24
|
+
stopConditionOf,
|
|
25
|
+
} from './core.mjs';
|
|
26
|
+
import { changedSinceOf, headShaOf } from './as-of.mjs';
|
|
27
|
+
// One resolver, imported rather than re-derived: writer and reader disagreeing
|
|
28
|
+
// about which checkout they are in is the whole of the worktree defect. It lives
|
|
29
|
+
// apart from `state.mjs` so the read path does not drag the tier computation —
|
|
30
|
+
// and `detect-missed-gate.mjs` behind it — into a CLI that never calls either.
|
|
31
|
+
import { checkoutIsShippable, mainCheckoutRoot } from './checkout.mjs';
|
|
17
32
|
|
|
18
33
|
const ADAPTERS = {
|
|
19
34
|
'plan-md': './plan-md.mjs',
|
|
@@ -33,7 +48,7 @@ export const resolveAdapter = async (adapterName) => {
|
|
|
33
48
|
return import(new URL(modulePath, import.meta.url).href);
|
|
34
49
|
};
|
|
35
50
|
|
|
36
|
-
export const COMMANDS = ['next', 'list', 'hygiene'];
|
|
51
|
+
export const COMMANDS = ['next', 'list', 'hygiene', 'gate-round'];
|
|
37
52
|
|
|
38
53
|
/**
|
|
39
54
|
* A missing config is the normal state of a fresh project. A config that exists
|
|
@@ -60,21 +75,176 @@ export const loadConfig = (configPath) => {
|
|
|
60
75
|
}
|
|
61
76
|
};
|
|
62
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The state file that travels with a config: `<name>.json` → `<name>.state.json`.
|
|
80
|
+
*
|
|
81
|
+
* Derived from the config path rather than from the project root on purpose. The
|
|
82
|
+
* two must stay a pair: a run pointed at a temp config by `--config` would
|
|
83
|
+
* otherwise read the developer's own leftover state and let a tier from an
|
|
84
|
+
* unrelated checkout decide its selection.
|
|
85
|
+
*/
|
|
86
|
+
export const statePathFor = (configPath) => configPath.replace(/(\.json)?$/, '.state.json');
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The project root a config path implies, or `null` when it implies none.
|
|
90
|
+
*
|
|
91
|
+
* A config inside a directory named `.claude` names a project: the rig puts it
|
|
92
|
+
* there, so its parent is the root. Anything else — `--config /tmp/x.json`, a
|
|
93
|
+
* config nested for a test fixture — is a bare file with no project around it,
|
|
94
|
+
* and this returns `null` so the caller keeps whatever default it had.
|
|
95
|
+
*
|
|
96
|
+
* 🔴 Deliberately NOT `join(dirname(configPath), '..')`, which assumes every
|
|
97
|
+
* config sits in a `.claude` directory: pointed at `<dir>/.claude-queue.json` it
|
|
98
|
+
* climbs a level out of the project and looks for the plan in the parent.
|
|
99
|
+
*/
|
|
100
|
+
export const projectRootOfConfig = (configPath) => {
|
|
101
|
+
const dir = dirname(configPath);
|
|
102
|
+
return basename(dir) === '.claude' ? dirname(dir) : null;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The adapter options the CLI hands down, with a plan path that does not depend
|
|
107
|
+
* on the directory the command was typed in.
|
|
108
|
+
*
|
|
109
|
+
* 🔴 The asymmetry this closes: this file resolves its config from
|
|
110
|
+
* `import.meta.url`, so the config is found from any directory — while
|
|
111
|
+
* `plan-md`'s default is the bare relative `'PLAN.md'`, resolved against `cwd`.
|
|
112
|
+
* Running the CLI from a subdirectory therefore found the adapter and then
|
|
113
|
+
* reported `queue-unreadable`, which a run reads as a broken queue rather than as
|
|
114
|
+
* a wrong directory.
|
|
115
|
+
*
|
|
116
|
+
* Two things are left exactly as they were, and both are load-bearing. An
|
|
117
|
+
* explicit `options.planPath` still wins. And when the config implies no project
|
|
118
|
+
* root, nothing is injected — so `plan-md`'s own `'PLAN.md'` still resolves
|
|
119
|
+
* against the caller's cwd, which is the right answer when the adapter is
|
|
120
|
+
* imported directly rather than through this CLI, as the `loop` skill does for
|
|
121
|
+
* `proposeTriage`.
|
|
122
|
+
*/
|
|
123
|
+
export const optionsWithPlanPath = (options, configPath) => {
|
|
124
|
+
const root = projectRootOfConfig(configPath);
|
|
125
|
+
if (options?.planPath || root === null) return { ...options };
|
|
126
|
+
return { ...options, planPath: join(root, 'PLAN.md') };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Every tier a state file may carry, and the reason there are four.
|
|
131
|
+
*
|
|
132
|
+
* `state.mjs` writes three of them: `normal`, and the elevated tier split into
|
|
133
|
+
* `elevated-prose` and `elevated-mechanism` — only the last of which spaces the
|
|
134
|
+
* next item (`core.mjs`). The fourth, the bare `elevated`, is what a state file
|
|
135
|
+
* written before that split still holds; it stays **readable** because a
|
|
136
|
+
* checkout that upgrades mid-run would otherwise refuse to select at all, and
|
|
137
|
+
* `core.mjs` reads it restrictively, so the legacy value rations exactly as it
|
|
138
|
+
* did when it was written.
|
|
139
|
+
*/
|
|
140
|
+
const KNOWN_TIERS = ['normal', 'elevated', 'elevated-prose', 'elevated-mechanism'];
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Per-checkout state, kept OUT of the composed config.
|
|
144
|
+
*
|
|
145
|
+
* The config is a template-layer file: a runtime value written into it is drift
|
|
146
|
+
* here and an upgrade conflict in a generated project. So the tier of the last
|
|
147
|
+
* closed item lands beside it, in a gitignored file.
|
|
148
|
+
*
|
|
149
|
+
* Missing is not an error — it means no item has closed yet, and selection
|
|
150
|
+
* proceeds with no spacing. Present-and-unparseable IS: the tier is then
|
|
151
|
+
* *unknown*, and the silent reading of unknown is `null`, which is the
|
|
152
|
+
* permissive value that lets a second elevated item straight through. That is
|
|
153
|
+
* the exact failure this state file exists to end, so it refuses instead.
|
|
154
|
+
*/
|
|
155
|
+
export const loadState = (statePath) => {
|
|
156
|
+
let raw;
|
|
157
|
+
try {
|
|
158
|
+
raw = readFileSync(statePath, 'utf8');
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// 🔴 ONLY a genuinely absent file means "nothing has closed yet". A bare
|
|
161
|
+
// `catch` here read EACCES, a directory at the path, and a file left by a
|
|
162
|
+
// container run under another uid as "absent" — each of them silently
|
|
163
|
+
// yielding the permissive `null`, with nothing printed. One failed write
|
|
164
|
+
// would have disabled the ration for good, and no adversary is needed.
|
|
165
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return {};
|
|
166
|
+
throw new Error(
|
|
167
|
+
`${statePath} exists but could not be read (${error?.code ?? 'unknown error'}), ` +
|
|
168
|
+
'so the last completed tier is unknown. Refusing rather than continuing: ' +
|
|
169
|
+
'unknown reads as "nothing has closed yet", which is the value that lets a ' +
|
|
170
|
+
'second elevated item through.',
|
|
171
|
+
{ cause: error },
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let parsed;
|
|
176
|
+
try {
|
|
177
|
+
parsed = JSON.parse(raw);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`${statePath} exists but is not valid JSON, so the last completed tier cannot ` +
|
|
181
|
+
`be read: ${String(error?.message ?? error).split('\n')[0]}. Delete the file ` +
|
|
182
|
+
'if you are unsure — an absent state means "nothing has closed yet", which is ' +
|
|
183
|
+
'safe; a tier that cannot be read would silently disable the elevated ration.',
|
|
184
|
+
{ cause: error },
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Syntax is not shape. `"elevated"`, `["elevated"]`, `123` and `"Elevated"` all
|
|
189
|
+
// parse, and a state file shaped like any of them carries no tier at all — the
|
|
190
|
+
// dereference below answers `undefined`, which reads as "nothing has closed
|
|
191
|
+
// yet" and hands out an elevated item while the file looks like a working one.
|
|
192
|
+
// (`core.mjs` now holds on an unrecognised tier rather than shrugging, but a
|
|
193
|
+
// top-level array or number never reaches it as a tier in the first place.)
|
|
194
|
+
// A top-level `null` was worse still: it parsed, then threw a raw TypeError
|
|
195
|
+
// from the dereference, past the message that says the file is safe to delete.
|
|
196
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`${statePath} is valid JSON but not a state object, so the last completed tier ` +
|
|
199
|
+
'cannot be read. Expected `{ "lastCompletedTier": "normal" | "elevated-prose" ' +
|
|
200
|
+
'| "elevated-mechanism" }` — the legacy `"elevated"` is still accepted, and ' +
|
|
201
|
+
'held. ' +
|
|
202
|
+
'Delete the file if you are unsure — an absent state means "nothing has ' +
|
|
203
|
+
'closed yet", which is safe.',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// `null` is allowed and means exactly what an absent file means: nothing has
|
|
208
|
+
// closed yet. It is the honest spelling of an absence, and refusing it would
|
|
209
|
+
// protect nothing — deleting the file has the identical effect.
|
|
210
|
+
const tier = parsed.lastCompletedTier;
|
|
211
|
+
if (tier !== undefined && tier !== null && !KNOWN_TIERS.includes(tier)) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`${statePath} carries lastCompletedTier: ${JSON.stringify(tier)}, which is not ` +
|
|
214
|
+
`one of ${KNOWN_TIERS.map((t) => `"${t}"`).join(' | ')}. A tier outside the ` +
|
|
215
|
+
'vocabulary rations on a value nobody wrote, so it would report a working ' +
|
|
216
|
+
'state file while the spacing rule ran on a guess. Delete the file if you ' +
|
|
217
|
+
'are unsure.',
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return parsed;
|
|
222
|
+
};
|
|
223
|
+
|
|
63
224
|
const parseArgs = (argv) => {
|
|
64
|
-
const args = { command: argv[0] ?? 'next', json: false, config: null };
|
|
225
|
+
const args = { command: argv[0] ?? 'next', json: false, config: null, branch: null };
|
|
65
226
|
for (let i = 1; i < argv.length; i += 1) {
|
|
66
227
|
if (argv[i] === '--json') args.json = true;
|
|
67
228
|
else if (argv[i] === '--config') args.config = argv[++i];
|
|
229
|
+
else if (argv[i] === '--branch') args.branch = argv[++i];
|
|
68
230
|
}
|
|
69
231
|
return args;
|
|
70
232
|
};
|
|
71
233
|
|
|
72
|
-
const renderNext = (result, stop) => {
|
|
234
|
+
const renderNext = (result, stop, revalidation = null) => {
|
|
73
235
|
if (stop) {
|
|
74
236
|
const label = stop.kind.replaceAll('-', ' ');
|
|
75
237
|
return `queue: ${label}${stop.success ? '' : ' (needs attention)'}\n ${stop.why}\n`;
|
|
76
238
|
}
|
|
77
239
|
const lines = [`next: ${result.ticket.id} — ${result.ticket.title} [${result.ticket.tier}]`];
|
|
240
|
+
// Only a marker that MOVED earns a line: the unchanged case stays quiet and
|
|
241
|
+
// cheap, and the no-marker case is in the JSON and the event log, not here.
|
|
242
|
+
if (revalidation?.changed === true) {
|
|
243
|
+
lines.push(
|
|
244
|
+
`revalidate: ${revalidation.ticket} hold — ${revalidation.source.join(', ')} ` +
|
|
245
|
+
`(${revalidation.task.from} → ${revalidation.task.to}) — re-read the item before acting`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
78
248
|
if (result.skipped.length > 0) {
|
|
79
249
|
lines.push('', 'skipped:');
|
|
80
250
|
for (const skip of result.skipped) lines.push(` ${skip.id} — ${skip.reason}`);
|
|
@@ -118,21 +288,204 @@ if (invokedDirectly()) {
|
|
|
118
288
|
process.exit(1);
|
|
119
289
|
}
|
|
120
290
|
|
|
291
|
+
// 🔴 **`gate-round` returns before the tracker is ever touched, and that is
|
|
292
|
+
// load-bearing rather than an optimisation.** `pr-ship` calls this once per
|
|
293
|
+
// round, and every other command below reaches the adapter — so routing this one
|
|
294
|
+
// through the normal path would spend a network call per gate round and, worse,
|
|
295
|
+
// would refuse to count a round while the tracker is unreachable. The counter is
|
|
296
|
+
// a local file and a local config; a Jira outage must not hand a run unlimited
|
|
297
|
+
// rounds.
|
|
298
|
+
if (args.command === 'gate-round') {
|
|
299
|
+
try {
|
|
300
|
+
// 🔴 Inside the `try`, and a review round is why. Left outside it, a rig whose
|
|
301
|
+
// `.claude/scripts/` predates this CLI printed a raw `ERR_MODULE_NOT_FOUND`
|
|
302
|
+
// and exited 1 — which `pr-ship` step 0 would have read as "rounds
|
|
303
|
+
// exhausted" and escalated a healthy item on. This file already records that
|
|
304
|
+
// exact lesson about `resolveAdapter` fifty lines below; the reason it
|
|
305
|
+
// repeated is that the lesson was written as prose and not as a test.
|
|
306
|
+
const { gateRoundVerdict } = await import('./core.mjs');
|
|
307
|
+
const { recordGateRound, gateRoundsPathFor } = await import('./gate-rounds.mjs');
|
|
308
|
+
|
|
309
|
+
const configPath = args.config ?? join(projectRoot, '.claude', 'queue.json');
|
|
310
|
+
const config = loadConfig(configPath);
|
|
311
|
+
// An explicit `--config` keeps its counter beside it, so a run pointed at a
|
|
312
|
+
// temp config never touches this checkout's real counts.
|
|
313
|
+
const roundsPath = args.config
|
|
314
|
+
? configPath.replace(/(\.json)?$/, '.gate-rounds.json')
|
|
315
|
+
: gateRoundsPathFor(projectRoot);
|
|
316
|
+
|
|
317
|
+
// 🔴 Validate the cap BEFORE counting. A review round measured what the other
|
|
318
|
+
// order costs: with `maxGateRounds: 0` every attempt recorded a round and then
|
|
319
|
+
// exited 1, while this command's own message told the caller to fix the cause
|
|
320
|
+
// and run step 0 again — so three attempts at a broken config left the branch
|
|
321
|
+
// at three rounds and the next honest call refused a healthy item. A failed
|
|
322
|
+
// run must not spend budget, and the message below must not have to lie about
|
|
323
|
+
// whether it did.
|
|
324
|
+
const verdictFor = (rounds) => gateRoundVerdict(rounds, config.options?.maxGateRounds);
|
|
325
|
+
verdictFor(0);
|
|
326
|
+
|
|
327
|
+
// 🔴 And refuse a checkout that cannot ship BEFORE counting (AR-141): a
|
|
328
|
+
// dirty tree or an unpushed HEAD means the round — and the fan-out's
|
|
329
|
+
// verdicts — would name a head that never reaches CI. Two rounds were once
|
|
330
|
+
// counted ahead of a commit pre-commit then refused. Thrown, so it lands
|
|
331
|
+
// in the exit-1 path below: not an exhausted cap, nothing counted.
|
|
332
|
+
const gitRoot = projectRootOfConfig(configPath) ?? projectRoot;
|
|
333
|
+
const shippable = checkoutIsShippable(gitRoot);
|
|
334
|
+
if (!shippable.ok) throw new Error(`${shippable.why} (nothing was counted)`);
|
|
335
|
+
|
|
336
|
+
const { rounds } = recordGateRound({ branch: args.branch, roundsPath });
|
|
337
|
+
const verdict = verdictFor(rounds);
|
|
338
|
+
|
|
339
|
+
if (!verdict.exceeded) {
|
|
340
|
+
process.stdout.write(
|
|
341
|
+
`gate round ${verdict.rounds} of ${verdict.max} on ${args.branch}.\n` +
|
|
342
|
+
(verdict.rounds === verdict.max
|
|
343
|
+
? ' This is the last round this branch gets. If it holds again, the item ' +
|
|
344
|
+
'is escalated rather than re-reviewed.\n'
|
|
345
|
+
: ''),
|
|
346
|
+
);
|
|
347
|
+
process.exit(0);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
process.stderr.write(
|
|
351
|
+
`GATE ROUNDS EXHAUSTED — ${verdict.rounds} rounds on ${args.branch}, cap is ` +
|
|
352
|
+
`${verdict.max}: ${verdict.stop}.\n` +
|
|
353
|
+
' Do not run another round. The item stops here and goes back to a human ' +
|
|
354
|
+
'with the round count and whatever the last gate reported. Whether those ' +
|
|
355
|
+
'rounds were paying off is the reader\'s judgement — this command measured ' +
|
|
356
|
+
'only the count (AR-115).\n' +
|
|
357
|
+
' Raising the cap to get one more pass on THIS item is the move this ' +
|
|
358
|
+
'refusal exists to prevent.\n',
|
|
359
|
+
);
|
|
360
|
+
process.exit(2);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
// 🔴 Exit 1, never 2, and the message says so. Exit 2 means one thing only —
|
|
363
|
+
// the rounds are spent — because `pr-ship` acts on it by ending the task. A
|
|
364
|
+
// broken config, an unreadable counter or a detached checkout must not be
|
|
365
|
+
// read as a spent cap.
|
|
366
|
+
process.stderr.write(
|
|
367
|
+
`gate-round could not run: ${error.message}\n` +
|
|
368
|
+
' This is NOT an exhausted cap (that is exit 2). Fix the cause and run step ' +
|
|
369
|
+
'0 again: a bad config, a bad cap, a detached checkout, a dirty tree or an ' +
|
|
370
|
+
'unpushed HEAD is refused before any round is counted, so retrying costs nothing.\n',
|
|
371
|
+
);
|
|
372
|
+
process.exit(1);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// A missing module is NOT read as "no state". A run that could not read its
|
|
377
|
+
// state would silently lose the escalation streak and the regression verdict
|
|
378
|
+
// — the two conditions that exist to stop it — so this fails closed, into a
|
|
379
|
+
// message the operator can act on.
|
|
380
|
+
//
|
|
381
|
+
// 🔴 **Loaded unconditionally, and ahead of the adapter, because the adapters
|
|
382
|
+
// import it statically.** An earlier version loaded it dynamically and only
|
|
383
|
+
// behind `RIG_RUN_DIR`, reasoning that a rig carrying an older
|
|
384
|
+
// `.claude/scripts/` should still get a readable message. That reasoning was
|
|
385
|
+
// sound and the code did not deliver it: all three adapters now
|
|
386
|
+
// `import { recordEscalation } from '../run-state.mjs'`, so a rig missing the
|
|
387
|
+
// module died inside `resolveAdapter` below and the operator saw a raw
|
|
388
|
+
// `Cannot find module` naming an adapter. The refusal was unreachable — and
|
|
389
|
+
// its test passed anyway, because the module's own filename satisfied the
|
|
390
|
+
// pattern it matched on. The load lives here; the READ stays behind the
|
|
391
|
+
// declaration, because a session with no run directory has no state to read
|
|
392
|
+
// and must keep working exactly as before.
|
|
393
|
+
let readState;
|
|
394
|
+
let stopInputsOf;
|
|
395
|
+
let recordTakeUp;
|
|
396
|
+
let previousTakeUp;
|
|
397
|
+
try {
|
|
398
|
+
({ readState, stopInputsOf, recordTakeUp, previousTakeUp } = await import('../run-state.mjs'));
|
|
399
|
+
} catch (error) {
|
|
400
|
+
process.stderr.write(
|
|
401
|
+
`run state: ${error.message}\n` +
|
|
402
|
+
' the run state module could not be loaded, so the escalation streak and the ' +
|
|
403
|
+
'deploy verdict cannot be read. Those are stop conditions, and a run that ' +
|
|
404
|
+
'cannot read them must not select work.\n',
|
|
405
|
+
);
|
|
406
|
+
process.exit(1);
|
|
407
|
+
}
|
|
408
|
+
|
|
121
409
|
let config;
|
|
410
|
+
let state;
|
|
122
411
|
let adapter;
|
|
412
|
+
// Declared out here because the plan path is derived from it further down.
|
|
413
|
+
let configPath;
|
|
123
414
|
try {
|
|
124
|
-
|
|
415
|
+
configPath = args.config ?? join(projectRoot, '.claude', 'queue.json');
|
|
416
|
+
config = loadConfig(configPath);
|
|
417
|
+
// An explicit `--config` keeps its own state beside it, verbatim: a run
|
|
418
|
+
// pointed at a temp config must not pick up this checkout's real tier.
|
|
419
|
+
// Without one, the state comes from the MAIN checkout, so a selection made
|
|
420
|
+
// inside a worktree sees what a close made anywhere recorded.
|
|
421
|
+
state = loadState(
|
|
422
|
+
args.config
|
|
423
|
+
? statePathFor(configPath)
|
|
424
|
+
: join(mainCheckoutRoot(projectRoot), '.claude', 'queue.state.json'),
|
|
425
|
+
);
|
|
125
426
|
adapter = await resolveAdapter(config.adapter ?? 'plan-md');
|
|
126
427
|
} catch (error) {
|
|
127
428
|
process.stderr.write(`${error.message}\n`);
|
|
128
429
|
process.exit(1);
|
|
129
430
|
}
|
|
130
431
|
|
|
432
|
+
// 🔴 The run-level stop is decided BEFORE the queue is read, because those
|
|
433
|
+
// conditions are not about the queue. A run that has escalated twice in a row
|
|
434
|
+
// has hit a systemic wall, and a run that deployed a regression must start no
|
|
435
|
+
// new work on top of it — asking the tracker first would spend a network call
|
|
436
|
+
// and, worse, would report `queue-unreadable` for an unreachable tracker when
|
|
437
|
+
// the truthful answer is that this run was already over.
|
|
438
|
+
//
|
|
439
|
+
// The values come from the run's own state file rather than from the
|
|
440
|
+
// session's memory, which is what three of `stopConditionOf`'s parameters
|
|
441
|
+
// were waiting for: every branch was live, and nothing ever supplied them.
|
|
442
|
+
//
|
|
443
|
+
// The fourth, `killSwitch`, is still not passed here and that is deliberate —
|
|
444
|
+
// `guard-bash` denies the merge at the tool layer and preflight reads the flag,
|
|
445
|
+
// so a second answer to "is the brake on" would be the disagreement
|
|
446
|
+
// `invariants.md` forbids. Selection therefore does NOT stop on the brake, and
|
|
447
|
+
// the `loop` skill tells the run to keep checking it between tasks.
|
|
448
|
+
//
|
|
449
|
+
const runState = process.env.RIG_RUN_DIR ? readState(process.env.RIG_RUN_DIR) : {};
|
|
450
|
+
let stopInputs;
|
|
451
|
+
try {
|
|
452
|
+
// Read through the module that owns the vocabulary, never field by field
|
|
453
|
+
// here: a value the reader cannot interpret must not arrive as "no stop",
|
|
454
|
+
// and deciding that at the call site is how the two halves drift.
|
|
455
|
+
stopInputs = stopInputsOf(runState);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
process.stderr.write(
|
|
458
|
+
`${error.message}\n the run declared a state file this cannot act on, so no ` +
|
|
459
|
+
'item is selected. Stop conditions read from it, and an unreadable one is ' +
|
|
460
|
+
'not the same as an absent one.\n',
|
|
461
|
+
);
|
|
462
|
+
process.exit(1);
|
|
463
|
+
}
|
|
464
|
+
const runStop = stopConditionOf({
|
|
465
|
+
// `candidates: 1` says "not the empty-queue case" — the queue has not been
|
|
466
|
+
// read yet and must not be reported on here. Only the conditions that
|
|
467
|
+
// outrank it can fire from this call.
|
|
468
|
+
candidates: 1,
|
|
469
|
+
...stopInputs,
|
|
470
|
+
});
|
|
471
|
+
if (runStop) {
|
|
472
|
+
// `renderNext`, not a second copy of its format — an operator reading two
|
|
473
|
+
// differently-worded stop lines has no way to know they came from one rule.
|
|
474
|
+
process.stdout.write(
|
|
475
|
+
args.json ? `${JSON.stringify({ stop: runStop }, null, 2)}\n` : renderNext(null, runStop),
|
|
476
|
+
);
|
|
477
|
+
// The exit code follows `stop.success`, exactly as the empty-queue path
|
|
478
|
+
// does. A budget stop is a clean end of session — reporting it as a failure
|
|
479
|
+
// would tell a wrapper that the run broke, and `--json` already says
|
|
480
|
+
// `"success": true` right beside the code that contradicted it.
|
|
481
|
+
process.exit(runStop.success ? 0 : 1);
|
|
482
|
+
}
|
|
483
|
+
|
|
131
484
|
let tickets;
|
|
132
485
|
try {
|
|
133
486
|
// Awaited so an adapter may be async (jira) or plain (plan-md, github-issues)
|
|
134
487
|
// without the CLI caring which.
|
|
135
|
-
tickets = await adapter.listEligible(config.options
|
|
488
|
+
tickets = await adapter.listEligible(optionsWithPlanPath(config.options, configPath));
|
|
136
489
|
} catch (error) {
|
|
137
490
|
// Never fall back to memory or to a stale copy for a queue.
|
|
138
491
|
const stop = stopConditionOf({ queueReadable: false });
|
|
@@ -145,31 +498,206 @@ if (invokedDirectly()) {
|
|
|
145
498
|
}
|
|
146
499
|
|
|
147
500
|
if (args.command === 'hygiene') {
|
|
148
|
-
|
|
501
|
+
// The proposals on file are checked too (AR-116): a proposal names the commit
|
|
502
|
+
// it was measured against, and one whose cited paths moved since is reported
|
|
503
|
+
// as possibly overtaken. Git runs here, once per distinct `asOf`, against the
|
|
504
|
+
// project this script belongs to; `core.mjs` only decides.
|
|
505
|
+
const proposals = await adapter.listProposals(optionsWithPlanPath(config.options, configPath));
|
|
506
|
+
// The checkout the proposals describe is the project the config names — an
|
|
507
|
+
// explicit `--config <root>/.claude/queue.json` points at that root — and
|
|
508
|
+
// this script's own project only when the config implies none.
|
|
509
|
+
const gitRoot = projectRootOfConfig(configPath) ?? projectRoot;
|
|
510
|
+
const head = headShaOf({ cwd: gitRoot });
|
|
511
|
+
const changedByAsOf = new Map();
|
|
512
|
+
const overtaken = proposals
|
|
513
|
+
.map((proposal) => {
|
|
514
|
+
const asOf = asOfOf(proposal.body);
|
|
515
|
+
if (asOf && !changedByAsOf.has(asOf)) {
|
|
516
|
+
changedByAsOf.set(asOf, changedSinceOf({ cwd: gitRoot, asOf, head: head ?? 'HEAD' }));
|
|
517
|
+
}
|
|
518
|
+
return overtakenOf({
|
|
519
|
+
id: proposal.id,
|
|
520
|
+
asOf,
|
|
521
|
+
citedPaths: citedPathsOf(proposal.body),
|
|
522
|
+
head,
|
|
523
|
+
changedSince: asOf ? changedByAsOf.get(asOf) : null,
|
|
524
|
+
});
|
|
525
|
+
})
|
|
526
|
+
.filter(Boolean);
|
|
527
|
+
const owner = config.options?.owner ?? null;
|
|
528
|
+
const findings = [
|
|
529
|
+
...tickets.map((ticket) => hygieneOf(ticket, { owner })).filter(Boolean),
|
|
530
|
+
...overtaken,
|
|
531
|
+
];
|
|
149
532
|
process.stdout.write(
|
|
150
533
|
args.json
|
|
151
534
|
? `${JSON.stringify({ findings }, null, 2)}\n`
|
|
152
535
|
: findings.length === 0
|
|
153
|
-
? `queue hygiene: ${tickets.length} item(s)
|
|
536
|
+
? `queue hygiene: ${tickets.length} item(s) and ${proposals.length} proposal(s) ` +
|
|
537
|
+
'checked — nothing stale.\n'
|
|
154
538
|
: `${findings.map((f) => ` [${f.kind}] ${f.id} — ${f.why}`).join('\n')}\n`,
|
|
155
539
|
);
|
|
156
540
|
process.exit(0);
|
|
157
541
|
}
|
|
158
542
|
|
|
159
543
|
const result = selectNext(tickets, {
|
|
160
|
-
|
|
161
|
-
|
|
544
|
+
// The state file wins: it is what a close actually recorded. A tier left in
|
|
545
|
+
// the config is a hand-written hint at best, and it is the composed file, so
|
|
546
|
+
// it cannot be the live value.
|
|
547
|
+
lastCompletedTier: state.lastCompletedTier ?? config.lastCompletedTier ?? null,
|
|
548
|
+
// Same precedence, for the same reason as the tier above: `queue.json` is
|
|
549
|
+
// composed by the sync script and drift-checked, so declaring a trigger
|
|
550
|
+
// fired there means editing a generated file — and the declaration is a
|
|
551
|
+
// fact about THIS run, not about the rig's configuration.
|
|
552
|
+
//
|
|
553
|
+
// The config keeps working as a fallback rather than being dropped: nothing
|
|
554
|
+
// in this repository or its templates ever writes the key, but a rig owner
|
|
555
|
+
// who hand-added one would otherwise find their auto-trigger items silently
|
|
556
|
+
// unselectable, and an item that stops being offered announces itself
|
|
557
|
+
// nowhere.
|
|
558
|
+
//
|
|
559
|
+
// Replacement, not a merge: a per-key merge would make a stale config entry
|
|
560
|
+
// impossible to retract, so "not this time" would again require editing the
|
|
561
|
+
// generated file this move exists to get out of.
|
|
562
|
+
triggersFired: runState.triggersFired ?? config.triggersFired ?? null,
|
|
563
|
+
// This checkout's name, for the owner marker (AR-132). Absent means the
|
|
564
|
+
// checkout cannot confirm a match, and an owned item is held.
|
|
565
|
+
owner: config.options?.owner ?? null,
|
|
162
566
|
});
|
|
163
|
-
|
|
567
|
+
// The skipped records travel with the count: without them "nothing left" and
|
|
568
|
+
// "everything left is held back" both print as an empty queue, and only one of
|
|
569
|
+
// the two means the queue needs refilling.
|
|
570
|
+
const stop = result.ticket
|
|
571
|
+
? null
|
|
572
|
+
: stopConditionOf({ candidates: 0, skipped: result.skipped });
|
|
164
573
|
|
|
165
574
|
if (args.command === 'list') {
|
|
166
575
|
process.stdout.write(`${JSON.stringify({ tickets, ...result }, null, 2)}\n`);
|
|
167
576
|
process.exit(0);
|
|
168
577
|
}
|
|
169
578
|
|
|
579
|
+
// 🔴 A journal nothing calls records nothing. Selection is a gate — it decides
|
|
580
|
+
// what the run works on and why every other item was passed over — so this is
|
|
581
|
+
// the call site the run journal ships with, rather than a writer nobody
|
|
582
|
+
// invokes.
|
|
583
|
+
//
|
|
584
|
+
// It writes only when the run DECLARED its directory. Inventing one here would
|
|
585
|
+
// make this CLI a second owner of the `.claude/runs/<run-id>/` convention, and
|
|
586
|
+
// a default derived from `projectRoot` would land the trace inside the very
|
|
587
|
+
// template tree this repository publishes.
|
|
588
|
+
const runDir = process.env.RIG_RUN_DIR;
|
|
589
|
+
// Revalidation at SELECT (AR-133): the selected item against the marker this
|
|
590
|
+
// run recorded at its last take-up. Computed only under a declared run —
|
|
591
|
+
// there is no snapshot to compare against anywhere else — and `null` in the
|
|
592
|
+
// output then, so a reader can tell "not compared" from "compared, unchanged".
|
|
593
|
+
//
|
|
594
|
+
// The baseline is this run's take-up when it has one; otherwise the newest
|
|
595
|
+
// earlier run's (AR-138) — an item taken up yesterday and re-offered today
|
|
596
|
+
// used to compare against nothing and report a first sight. `baseline` says
|
|
597
|
+
// which it was (`this-run` | `previous-run` | null), and `baselineRun` names
|
|
598
|
+
// the earlier run, so the report can tell the three apart. A rig whose
|
|
599
|
+
// run-state module predates `previousTakeUp` keeps the per-run behaviour.
|
|
600
|
+
let revalidation = null;
|
|
601
|
+
if (runDir && result.ticket) {
|
|
602
|
+
const own = runState.takeUps?.[result.ticket.id];
|
|
603
|
+
const prior =
|
|
604
|
+
own === undefined && typeof previousTakeUp === 'function'
|
|
605
|
+
? previousTakeUp(runDir, result.ticket.id)
|
|
606
|
+
: null;
|
|
607
|
+
const snapshot = own ?? prior?.updatedAt ?? null;
|
|
608
|
+
revalidation = {
|
|
609
|
+
...revalidationOf({ ticket: result.ticket, snapshot }),
|
|
610
|
+
baseline: own !== undefined ? 'this-run' : prior ? 'previous-run' : null,
|
|
611
|
+
...(prior ? { baselineRun: prior.runDir } : {}),
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
if (runDir) {
|
|
615
|
+
let journal = null;
|
|
616
|
+
try {
|
|
617
|
+
journal = await import('../run-journal.mjs');
|
|
618
|
+
journal.recordDecision({
|
|
619
|
+
runDir,
|
|
620
|
+
gate: 'item-selection',
|
|
621
|
+
verdict: result.ticket ? `taken ${result.ticket.id}` : `stopped ${stop.kind}`,
|
|
622
|
+
why: result.ticket ? result.ticket.title : stop.why,
|
|
623
|
+
// The edge is where the clock is read: the journal itself takes `now` as
|
|
624
|
+
// an argument, which is what keeps its records reproducible.
|
|
625
|
+
now: new Date().toISOString(),
|
|
626
|
+
});
|
|
627
|
+
if (revalidation) {
|
|
628
|
+
// The selection decision keeps its place as the run's first record; the
|
|
629
|
+
// evidence log comes next, and only then (below, after the journal) does
|
|
630
|
+
// the baseline move — a snapshot written before its event would leave a
|
|
631
|
+
// crashed run with a marker and no record of what it was compared against.
|
|
632
|
+
if (typeof journal.recordEvent === 'function') {
|
|
633
|
+
journal.recordEvent({
|
|
634
|
+
runDir,
|
|
635
|
+
kind: 'revalidation',
|
|
636
|
+
data: revalidation,
|
|
637
|
+
now: new Date().toISOString(),
|
|
638
|
+
});
|
|
639
|
+
} else {
|
|
640
|
+
// A rig carrying a run journal older than this CLI: the selection
|
|
641
|
+
// stands, the comparison was made, only its record has nowhere to go.
|
|
642
|
+
process.stderr.write(
|
|
643
|
+
'run journal: this journal predates revalidation events, so the ' +
|
|
644
|
+
'revalidation was not recorded.\n',
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
} catch (error) {
|
|
649
|
+
// 🔴 Two failures wearing one face, and treating them alike was a defect
|
|
650
|
+
// this gate caught. The journal asks the module which one this is — never
|
|
651
|
+
// the message text, which would put the decision in two files and let them
|
|
652
|
+
// drift the day someone improves the wording.
|
|
653
|
+
// `?.` would guard a MISSING module and not a missing export. A rig can
|
|
654
|
+
// carry a run journal older than this CLI — `upgrade` leaves a
|
|
655
|
+
// locally-edited copy beside a new caller — and calling through to an
|
|
656
|
+
// absent export crashed the CLI inside its own error handler: a raw stack
|
|
657
|
+
// trace instead of the failure it was reporting.
|
|
658
|
+
const classify = journal?.isTraceExhausted;
|
|
659
|
+
if (typeof classify === 'function' && classify(error)) {
|
|
660
|
+
// The trace is over and the work is not. This journal cannot accept
|
|
661
|
+
// another record — it is append-only and every write re-reads it — so
|
|
662
|
+
// exiting here would make one collision between two sessions leave the
|
|
663
|
+
// queue unselectable FOREVER. Loud on stderr, and the selection still
|
|
664
|
+
// goes out on stdout, because the queue was never the thing that failed.
|
|
665
|
+
process.stderr.write(
|
|
666
|
+
`run journal: ${error.message}\n` +
|
|
667
|
+
` the selection below was NOT recorded in ${runDir}. This run's trace ends ` +
|
|
668
|
+
'here; the queue is fine, and a new run needs a new run directory.\n',
|
|
669
|
+
);
|
|
670
|
+
} else {
|
|
671
|
+
// The other half: the declaration is empty, its directory is not there,
|
|
672
|
+
// the path is not a directory, or the journal module is missing. Each is
|
|
673
|
+
// the run pointing at something that was never set up — one `mkdir` or
|
|
674
|
+
// one corrected variable away — and continuing would produce a run with
|
|
675
|
+
// no trace at all, so this half does stop the selection. (A directory
|
|
676
|
+
// deleted mid-run lands here too, and the same `mkdir` restores it.)
|
|
677
|
+
process.stderr.write(`run journal: ${error.message}\n`);
|
|
678
|
+
process.exit(1);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (revalidation) {
|
|
684
|
+
// Its own try, after the journal's: a state file that cannot be written is
|
|
685
|
+
// not the journal failing, and the selection stands either way — the
|
|
686
|
+
// comparison was made and recorded; only the next baseline is lost.
|
|
687
|
+
try {
|
|
688
|
+
recordTakeUp(runDir, { id: result.ticket.id, updatedAt: result.ticket.updatedAt });
|
|
689
|
+
} catch (error) {
|
|
690
|
+
process.stderr.write(
|
|
691
|
+
`run state: the take-up snapshot was NOT recorded in ${runDir} — ${error.message}\n` +
|
|
692
|
+
' the selection below stands; the baseline was not moved — the next revalidation ' +
|
|
693
|
+
'of this item compares against the previous one, if any, or has none.\n',
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
170
698
|
process.stdout.write(
|
|
171
699
|
args.json
|
|
172
|
-
? `${JSON.stringify({ ticket: result.ticket, skipped: result.skipped, stop }, null, 2)}\n`
|
|
173
|
-
: renderNext(result, stop),
|
|
700
|
+
? `${JSON.stringify({ ticket: result.ticket, skipped: result.skipped, stop, revalidation }, null, 2)}\n`
|
|
701
|
+
: renderNext(result, stop, revalidation),
|
|
174
702
|
);
|
|
175
703
|
}
|