create-agent-rig 0.8.0 → 0.9.1
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 +152 -1
- package/README.md +99 -4
- package/package.json +4 -3
- package/packages/cli/dist/commands/init.js +43 -7
- package/packages/cli/dist/commands/memory.js +182 -0
- package/packages/cli/dist/commands/setup.js +45 -0
- package/packages/cli/dist/commands/upgrade.js +22 -4
- package/packages/cli/dist/index.js +110 -3
- package/packages/cli/dist/lib/manifest.js +23 -5
- package/packages/cli/dist/lib/subsystems.js +269 -0
- package/packages/cli/dist/lib/version.js +15 -0
- package/packages/cli/dist/policy/benchmark/corpus.js +165 -0
- package/packages/cli/dist/policy/core/coverage.js +253 -0
- package/packages/cli/dist/policy/core/decision-record.js +130 -44
- package/packages/cli/dist/policy/core/declaration.js +58 -17
- package/packages/cli/dist/policy/core/evidence-matrix.js +94 -0
- package/packages/cli/dist/policy/core/probe.js +442 -0
- package/packages/cli/dist/policy/core/validation.js +194 -1
- package/packages/cli/dist/policy/core/vocabulary.js +70 -3
- package/packages/cli/dist/policy/harness/claude.js +9 -1
- package/packages/cli/dist/policy/harness/codex.js +48 -1
- package/packages/cli/dist/policy/harness/shared-hooks.js +18 -0
- package/packages/cli/dist/policy/index.js +9 -2
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +2 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +2 -0
- package/templates/agent-os/subagent-routing.json +32 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +41 -4
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +2 -0
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +2 -0
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +2 -0
- package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -0
- package/templates/agent-os/universal/.claude/hooks/guard-subagent-model.mjs +234 -0
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +75 -32
- package/templates/agent-os/universal/.claude/hooks/warn-subagent-routing.mjs +120 -0
- package/templates/agent-os/universal/.claude/rules/workflow.md +5 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -3
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +43 -0
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +70 -2
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +12 -4
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +18 -1
- package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +269 -32
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +64 -1
- package/templates/agent-os/universal/.claude/settings.json +16 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +41 -4
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +2 -0
- package/templates/agent-os/universal/.codex/config.toml +3 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +31 -5
- package/templates/agent-os/universal/docs/decisions/subagent-routing.md +142 -0
- package/templates/agent-os/universal/layers.json +4 -0
- package/templates/hash-history.json +71 -22
- package/templates/release-ledger.json +3 -1
- package/templates/skeleton/node-service/services/api/test/artifact.test.ts +3 -4
- package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +40 -0
- package/templates/skeleton/node-service/services/api/test/package-manager.ts +51 -0
- package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +9 -8
|
@@ -49,9 +49,73 @@ const MAX_PATCH_SECTIONS = 128;
|
|
|
49
49
|
const MAX_MULTI_EDITS = 256;
|
|
50
50
|
const MAX_PATCH_PATH_COMPONENTS = 512;
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The surfaces this normaliser answers for. A tool outside the set is one the
|
|
54
|
+
* hook does not understand, and the refusal below must not reach it: a guard
|
|
55
|
+
* that blocks payloads it was never asked about is a guard that gets deleted.
|
|
56
|
+
*/
|
|
57
|
+
const EDIT_TOOL_NAMES = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The one refusal for a container this file cannot read, on every surface it
|
|
61
|
+
* owns. `apply_patch` keeps its own wording because the shape it expects is a
|
|
62
|
+
* different shape — a patch string — and a remedy naming an object would be
|
|
63
|
+
* advice its caller cannot act on.
|
|
64
|
+
*/
|
|
65
|
+
const unreadableToolInput = (toolName) =>
|
|
66
|
+
toolName === 'apply_patch'
|
|
67
|
+
? {
|
|
68
|
+
filePath: '',
|
|
69
|
+
fragment: '',
|
|
70
|
+
inspectionRefusal:
|
|
71
|
+
'the apply_patch command arrived in a shape this guard cannot read — it is a ' +
|
|
72
|
+
'string, or a list of strings, and nothing else. Nothing was inspected, so ' +
|
|
73
|
+
'nothing about this patch is vouched for.',
|
|
74
|
+
remedy: 'Send the command as a patch string, or a list of strings.',
|
|
75
|
+
appliesToAll: true,
|
|
76
|
+
}
|
|
77
|
+
: {
|
|
78
|
+
filePath: '',
|
|
79
|
+
fragment: '',
|
|
80
|
+
inspectionRefusal:
|
|
81
|
+
'the tool_input arrived in a shape this guard cannot read — it is an object ' +
|
|
82
|
+
'carrying the edit fields, and nothing else. Nothing was inspected, so nothing ' +
|
|
83
|
+
'about this edit is vouched for.',
|
|
84
|
+
remedy: 'Send the tool_input as an object carrying the edit fields.',
|
|
85
|
+
appliesToAll: true,
|
|
86
|
+
};
|
|
87
|
+
|
|
52
88
|
export function editFragments(input) {
|
|
53
89
|
const toolName = input?.tool_name;
|
|
54
|
-
const
|
|
90
|
+
const rawToolInput = input?.tool_input;
|
|
91
|
+
// 🔴 **"I could not look" is not "there was nothing to look at" — and this
|
|
92
|
+
// file used to answer both ways depending on which arm you reached.**
|
|
93
|
+
// `?? {}` substitutes only for null/undefined, so a `tool_input` PRESENT as a
|
|
94
|
+
// string, a number or an array flowed on as that value: every field read came
|
|
95
|
+
// back `undefined`, and the Write/Edit/NotebookEdit arms returned a fragment
|
|
96
|
+
// with an empty path and empty text — which every consuming guard reads as a
|
|
97
|
+
// clean edit. Measured on master `6589db36`: a `tool_input` that is a string
|
|
98
|
+
// carrying a credential exited 0 through Write, Edit, MultiEdit and
|
|
99
|
+
// NotebookEdit, while the SAME shape through `apply_patch` exited 2. The
|
|
100
|
+
// divergence was between arms of this one file, which is why the contract
|
|
101
|
+
// belongs here rather than in each guard (RP-85, applying RP-80's ruling).
|
|
102
|
+
//
|
|
103
|
+
// ⚠ **ABSENT stays fail-open, and the difference is the whole rule.**
|
|
104
|
+
// `.claude/rules/invariants.md`: a field that is simply absent is the case the
|
|
105
|
+
// guard has nothing to judge — it must allow, exactly as a `Write` carrying no
|
|
106
|
+
// content does. A field PRESENT in a shape the guard does not accept is the
|
|
107
|
+
// case where it was handed something and could tell it could not read it,
|
|
108
|
+
// which is the one thing reporting is for. Getting these backwards costs a
|
|
109
|
+
// credential in either direction.
|
|
110
|
+
if (
|
|
111
|
+
EDIT_TOOL_NAMES.has(toolName) &&
|
|
112
|
+
rawToolInput !== null &&
|
|
113
|
+
rawToolInput !== undefined &&
|
|
114
|
+
(typeof rawToolInput !== 'object' || Array.isArray(rawToolInput))
|
|
115
|
+
) {
|
|
116
|
+
return [unreadableToolInput(toolName)];
|
|
117
|
+
}
|
|
118
|
+
const toolInput = rawToolInput ?? {};
|
|
55
119
|
if (toolName === 'Write' || toolName === 'Edit') {
|
|
56
120
|
return [
|
|
57
121
|
{
|
|
@@ -107,21 +171,10 @@ export function editFragments(input) {
|
|
|
107
171
|
// they exited 1 with a stack trace, which neither harness treats as blocking, so
|
|
108
172
|
// a crash here was an ALLOW. A `tool_input` that is not an object is the same
|
|
109
173
|
// case as a `command` whose container this guard cannot read: detected, not
|
|
110
|
-
// readable, refused.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
filePath: '',
|
|
115
|
-
fragment: '',
|
|
116
|
-
inspectionRefusal:
|
|
117
|
-
'the apply_patch command arrived in a shape this guard cannot read — it is a ' +
|
|
118
|
-
'string, or a list of strings, and nothing else. Nothing was inspected, so ' +
|
|
119
|
-
'nothing about this patch is vouched for.',
|
|
120
|
-
remedy: 'Send the command as a patch string, or a list of strings.',
|
|
121
|
-
appliesToAll: true,
|
|
122
|
-
},
|
|
123
|
-
];
|
|
124
|
-
}
|
|
174
|
+
// readable, refused. That check used to sit HERE, guarding this one arm; it is
|
|
175
|
+
// now the first thing `editFragments` does, for every surface in
|
|
176
|
+
// `EDIT_TOOL_NAMES`, so `toolInput` is an object by the time this line runs and
|
|
177
|
+
// `in` cannot throw (RP-85). One mechanism, one implementation.
|
|
125
178
|
if (!('command' in toolInput)) return [];
|
|
126
179
|
if (
|
|
127
180
|
typeof rawCommand !== 'string' &&
|
|
@@ -140,22 +193,12 @@ export function editFragments(input) {
|
|
|
140
193
|
// "a crashed guard that blocks everything gets deleted within the hour" —
|
|
141
194
|
// and this branch is neither. Two opposite answers to one question, ten
|
|
142
195
|
// lines apart, was the real defect.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
'string, or a list of strings, and nothing else. Nothing was inspected, so ' +
|
|
150
|
-
'nothing about this patch is vouched for.',
|
|
151
|
-
// 🔴 The remedy travels WITH the refusal that earns it. It was chosen by
|
|
152
|
-
// `/shape/i.test(reason)` in six copies — correct only by coincidence of
|
|
153
|
-
// wording, so rewording the reason silently restored the retry loop this
|
|
154
|
-
// remedy exists to replace. One field, one place.
|
|
155
|
-
remedy: 'Send the command as a patch string, or a list of strings.',
|
|
156
|
-
appliesToAll: true,
|
|
157
|
-
},
|
|
158
|
-
];
|
|
196
|
+
// 🔴 The remedy travels WITH the refusal that earns it. It was chosen by
|
|
197
|
+
// `/shape/i.test(reason)` in six copies — correct only by coincidence of
|
|
198
|
+
// wording, so rewording the reason silently restored the retry loop this
|
|
199
|
+
// remedy exists to replace. One field, one place — and since RP-85 that
|
|
200
|
+
// place is `unreadableToolInput`, shared with the four edit surfaces.
|
|
201
|
+
return [unreadableToolInput(toolName)];
|
|
159
202
|
}
|
|
160
203
|
const command = typeof rawCommand === 'string' ? rawCommand : rawCommand.join('\n');
|
|
161
204
|
if (command.length > MAX_PATCH_CHARACTERS) {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// SessionStart hook (Claude Code): says out loud when this session's
|
|
2
|
+
// environment may keep the pinned subagent routing from holding.
|
|
3
|
+
//
|
|
4
|
+
// The pins live in each agent definition (`model:`, `effort:`) and in the
|
|
5
|
+
// shipped settings (`env.CLAUDE_CODE_SUBAGENT_MODEL`). Four conditions are
|
|
6
|
+
// invisible from inside the rulebook, and each is reported:
|
|
7
|
+
// - CLAUDE_CODE_SUBAGENT_MODEL_FORCE is set: Claude Code applies one model to
|
|
8
|
+
// every subagent and ignores each definition's `model:` (its 2.1.257
|
|
9
|
+
// changelog);
|
|
10
|
+
// - CLAUDE_CODE_EFFORT_LEVEL is set: its level replaced a definition's
|
|
11
|
+
// `effort:` in a live run — the generator's docs/capability-evidence.json
|
|
12
|
+
// (absent in a generated rig), mechanism `subagent-effort-pin`, surface
|
|
13
|
+
// `environment: CLAUDE_CODE_EFFORT_LEVEL=low`;
|
|
14
|
+
// - Claude Code is older than MINIMUM_CLAUDE_CODE_VERSION: until 2.1.251,
|
|
15
|
+
// CLAUDE_CODE_SUBAGENT_MODEL overrode an agent definition's `model:` (its
|
|
16
|
+
// 2.1.251 changelog), so the shipped unnamed default replaced every model pin;
|
|
17
|
+
// - the version cannot be read: then whether the pins hold cannot be confirmed.
|
|
18
|
+
//
|
|
19
|
+
// The version is read from AI_AGENT, which Claude Code sets for its subprocesses
|
|
20
|
+
// (its 2.1.120 changelog) and which a SessionStart hook's environment was
|
|
21
|
+
// observed to carry (the same evidence file, mechanism
|
|
22
|
+
// `claude-code-version-signal`). Its shape,
|
|
23
|
+
// `claude-code_<major>-<minor>-<patch>_<role>`, is observed and not documented —
|
|
24
|
+
// so a value this hook cannot parse is reported as an unknown version, never
|
|
25
|
+
// taken for a supported one.
|
|
26
|
+
//
|
|
27
|
+
// It warns and never blocks: exit 0 always, the warning on stdout, which Claude
|
|
28
|
+
// Code adds to the session's context at SessionStart. An empty variable counts
|
|
29
|
+
// as unset. The rationale is docs/decisions/subagent-routing.md.
|
|
30
|
+
//
|
|
31
|
+
// Pinned in the generator's test/template/subagent-routing-hooks.test.ts
|
|
32
|
+
// (absent in a generated rig) › "warns when %s is set, and never blocks the
|
|
33
|
+
// session", › "warns on a Claude Code older than 2.1.251 and names the minimum"
|
|
34
|
+
// and › "warns that it could not determine the version when %s".
|
|
35
|
+
import { realpathSync } from 'node:fs';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
import { readHookInput } from './lib/hook-input.mjs';
|
|
38
|
+
|
|
39
|
+
/** The first Claude Code release in which an agent definition's `model:` outranks the env default. */
|
|
40
|
+
export const MINIMUM_CLAUDE_CODE_VERSION = '2.1.251';
|
|
41
|
+
|
|
42
|
+
const PIN_REPLACING_VARIABLES = [
|
|
43
|
+
[
|
|
44
|
+
'CLAUDE_CODE_SUBAGENT_MODEL_FORCE',
|
|
45
|
+
"replaces the `model:` every agent definition pins with one model for all subagents",
|
|
46
|
+
],
|
|
47
|
+
['CLAUDE_CODE_EFFORT_LEVEL', "replaces the `effort:` every agent definition pins with its own level"],
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const VERSION = /^claude-code_(\d{1,6})-(\d{1,6})-(\d{1,6})(?:_|$)/;
|
|
51
|
+
|
|
52
|
+
const numbers = (version) => version.split('.').map(Number);
|
|
53
|
+
|
|
54
|
+
const olderThan = (found, minimum) => {
|
|
55
|
+
for (let index = 0; index < minimum.length; index += 1) {
|
|
56
|
+
if (found[index] !== minimum[index]) return found[index] < minimum[index];
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Every routing problem this environment has, as sentences; empty when there is none. */
|
|
62
|
+
export function routingProblems(env) {
|
|
63
|
+
const problems = [];
|
|
64
|
+
for (const [variable, effect] of PIN_REPLACING_VARIABLES) {
|
|
65
|
+
const value = env[variable];
|
|
66
|
+
if (typeof value === 'string' && value !== '') {
|
|
67
|
+
problems.push(`${variable} is set: it ${effect}. Unset it for those pins to hold.`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const agent = typeof env.AI_AGENT === 'string' ? VERSION.exec(env.AI_AGENT) : null;
|
|
71
|
+
if (agent === null) {
|
|
72
|
+
problems.push(
|
|
73
|
+
`could not determine the Claude Code version from AI_AGENT, so it cannot be confirmed ` +
|
|
74
|
+
`that the model pins hold: they need Claude Code ${MINIMUM_CLAUDE_CODE_VERSION} or later.`,
|
|
75
|
+
);
|
|
76
|
+
} else {
|
|
77
|
+
const found = [Number(agent[1]), Number(agent[2]), Number(agent[3])];
|
|
78
|
+
if (olderThan(found, numbers(MINIMUM_CLAUDE_CODE_VERSION))) {
|
|
79
|
+
problems.push(
|
|
80
|
+
`Claude Code ${found.join('.')} is older than ${MINIMUM_CLAUDE_CODE_VERSION}: before it, ` +
|
|
81
|
+
"CLAUDE_CODE_SUBAGENT_MODEL overrides every agent definition's `model:`. Upgrade Claude Code.",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return problems;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function invokedDirectly() {
|
|
89
|
+
if (!process.argv[1]) return false;
|
|
90
|
+
const real = (p) => {
|
|
91
|
+
try {
|
|
92
|
+
return realpathSync(p);
|
|
93
|
+
} catch {
|
|
94
|
+
return p;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function main() {
|
|
101
|
+
try {
|
|
102
|
+
const input = readHookInput();
|
|
103
|
+
if (input === null || input.hook_event_name !== 'SessionStart') return 0;
|
|
104
|
+
const problems = routingProblems(process.env);
|
|
105
|
+
if (problems.length === 0) return 0;
|
|
106
|
+
process.stdout.write(
|
|
107
|
+
'[agent-os] WARNING — the pinned subagent routing may not hold in this session:\n' +
|
|
108
|
+
problems.map((problem) => `- ${problem}`).join('\n') +
|
|
109
|
+
'\nA gate may run on a model or effort the routing policy did not choose ' +
|
|
110
|
+
'(docs/decisions/subagent-routing.md).\n',
|
|
111
|
+
);
|
|
112
|
+
} catch {
|
|
113
|
+
// a broken warning must never make the session unusable
|
|
114
|
+
}
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (invokedDirectly()) {
|
|
119
|
+
process.exit(main());
|
|
120
|
+
}
|
|
@@ -38,6 +38,11 @@ subagent with a fresh context, and why the `pr-ship` gate fans reviewers out
|
|
|
38
38
|
instead of self-checking. This isolation is load-bearing, not ceremony — do
|
|
39
39
|
not "optimise" it away by reviewing in the authoring session.
|
|
40
40
|
|
|
41
|
+
A reviewer whose definition pins its model is never dispatched with a call-site
|
|
42
|
+
`model`: which model reads a change is the routing policy's decision, not the
|
|
43
|
+
dispatching session's, and `guard-subagent-model` refuses the override
|
|
44
|
+
(`docs/decisions/subagent-routing.md`).
|
|
45
|
+
|
|
41
46
|
## PR flow
|
|
42
47
|
|
|
43
48
|
This applies **once the project has a remote and CI checks** — a freshly
|
|
@@ -26,10 +26,10 @@ import { fileURLToPath } from 'node:url';
|
|
|
26
26
|
// cycle, because preflight is the only scripted brake check and had no test.
|
|
27
27
|
import { brakeIsOn } from './stop-flag.mjs';
|
|
28
28
|
import { readRevalidationContract } from './lib/claim-records.mjs';
|
|
29
|
+
import { loadConfig, optionsWithPlanPath, resolveAdapter } from './queue/index.mjs';
|
|
29
30
|
|
|
30
31
|
/** The items this script cannot check: judgement, or a call worth more than it saves. */
|
|
31
32
|
export const UNCHECKED = [
|
|
32
|
-
'the queue is reachable through its adapter (`node .claude/scripts/queue/index.mjs next`)',
|
|
33
33
|
'no stray worktree from a dead session that this run might mistake for its own',
|
|
34
34
|
'a budget is declared for this run, and it is written down somewhere the run can re-read',
|
|
35
35
|
];
|
|
@@ -136,6 +136,30 @@ export const checkDetectionContract = (projectRoot) => {
|
|
|
136
136
|
}
|
|
137
137
|
};
|
|
138
138
|
|
|
139
|
+
// See test/template/preflight-queue.test.ts (absent in a generated rig) ›
|
|
140
|
+
// "reads exactly one adapter listing without selecting, claiming, or writing queue and run files".
|
|
141
|
+
export const checkQueue = async (projectRoot) => {
|
|
142
|
+
try {
|
|
143
|
+
const configPath = join(projectRoot, '.claude', 'queue.json');
|
|
144
|
+
const config = loadConfig(configPath, { strictRead: true });
|
|
145
|
+
const adapterName = config.adapter ?? 'plan-md';
|
|
146
|
+
const adapter = await resolveAdapter(adapterName);
|
|
147
|
+
await adapter.listEligible(optionsWithPlanPath(config.options, configPath));
|
|
148
|
+
return { ok: true, detail: `queue readable through ${adapterName}` };
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const diagnostic = Array.from(String(error?.message ?? error), (character) => {
|
|
151
|
+
const code = character.codePointAt(0);
|
|
152
|
+
return code < 0x20 || (code >= 0x7f && code <= 0x9f)
|
|
153
|
+
? `\\u${code.toString(16).padStart(4, '0')}`
|
|
154
|
+
: character;
|
|
155
|
+
}).join('');
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
detail: `could not read queue: ${diagnostic}`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
139
163
|
/** The last deploy must have concluded successfully — never start on a broken runtime. */
|
|
140
164
|
export const checkLastDeploy = ({ workflow = 'deploy' } = {}) => {
|
|
141
165
|
try {
|
|
@@ -157,8 +181,7 @@ export const checkLastDeploy = ({ workflow = 'deploy' } = {}) => {
|
|
|
157
181
|
|
|
158
182
|
/**
|
|
159
183
|
* STOP on any hard failure; CAUTION on anything that is not a clean pass; GO only
|
|
160
|
-
* when every scripted item genuinely passed.
|
|
161
|
-
* the reader's.
|
|
184
|
+
* when every scripted item genuinely passed. Unscripted checks remain the reader's.
|
|
162
185
|
*
|
|
163
186
|
* `stale` and `unknown` both give CAUTION but are never merged into one word:
|
|
164
187
|
* "I looked and it is stale" is actionable, "I could not look" is not, and neither
|
|
@@ -216,6 +239,7 @@ if (invokedDirectly()) {
|
|
|
216
239
|
killSwitch: checkKillSwitch(),
|
|
217
240
|
runDirNotExported: checkRunDirNotExported(),
|
|
218
241
|
detectionContract: checkDetectionContract(projectRoot),
|
|
242
|
+
queue: await checkQueue(projectRoot),
|
|
219
243
|
defaultBranchFresh: checkDefaultBranchFresh(),
|
|
220
244
|
lastDeploy: checkLastDeploy(),
|
|
221
245
|
};
|
|
@@ -485,6 +485,18 @@ export const hygieneOf = (ticket, { owner = null } = {}) => {
|
|
|
485
485
|
};
|
|
486
486
|
}
|
|
487
487
|
|
|
488
|
+
const removed = removedDependencyIn(body, ticket);
|
|
489
|
+
if (removed) {
|
|
490
|
+
return {
|
|
491
|
+
kind: 'link-contradicted-by-body',
|
|
492
|
+
id: ticket.id,
|
|
493
|
+
why:
|
|
494
|
+
`body line ${removed.line} declares "The Blocks link ${removed.source} -> ` +
|
|
495
|
+
`${removed.target} is removed", but that dependency is still carried in ` +
|
|
496
|
+
`${removed.direction}. Reconcile the declaration and link before relying on either`,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
488
500
|
return null;
|
|
489
501
|
};
|
|
490
502
|
|
|
@@ -502,6 +514,37 @@ export const hygieneOf = (ticket, { owner = null } = {}) => {
|
|
|
502
514
|
*/
|
|
503
515
|
const BLOCKER_IN_BODY = /^[-*\t ]{0,4}(?:blocked by|depends on|blocker)[ \t:]{0,8}[#A-Za-z0-9]/im;
|
|
504
516
|
|
|
517
|
+
// See test/template/queue.test.ts (absent in a generated rig) ›
|
|
518
|
+
// "reports its carried outgoing Blocks link that its own body says was removed".
|
|
519
|
+
const REMOVAL_ID = '(?:[A-Z][A-Z0-9_]{0,31}-[0-9]{1,16}|#[0-9]{1,16})';
|
|
520
|
+
const REMOVED_DEPENDENCY = new RegExp(
|
|
521
|
+
`^[ \\t]{0,4}(?:[-*][ \\t]{1,4})?The[ \\t]{1,8}Blocks[ \\t]{1,8}link[ \\t]{1,8}` +
|
|
522
|
+
`(${REMOVAL_ID})[ \\t]{1,8}->[ \\t]{1,8}(${REMOVAL_ID})[ \\t]{1,8}is[ \\t]{1,8}removed` +
|
|
523
|
+
'(?=[ \\t]{0,8}(?:$|[.;(]))',
|
|
524
|
+
'i',
|
|
525
|
+
);
|
|
526
|
+
const dependencyId = (value) => String(value).replace(/^#/, '').toUpperCase();
|
|
527
|
+
const removedDependencyIn = (body, ticket) => {
|
|
528
|
+
const ownId = dependencyId(ticket.id);
|
|
529
|
+
const outgoingIds = new Set((ticket.blocks ?? []).map(dependencyId));
|
|
530
|
+
const incomingIds = new Set((ticket.blockedBy ?? []).map((link) => dependencyId(link.id)));
|
|
531
|
+
const lines = body.split(/\r?\n/);
|
|
532
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
533
|
+
const match = REMOVED_DEPENDENCY.exec(lines[index]);
|
|
534
|
+
if (!match) continue;
|
|
535
|
+
const [, source, target] = match;
|
|
536
|
+
const outgoing =
|
|
537
|
+
dependencyId(source) === ownId &&
|
|
538
|
+
outgoingIds.has(dependencyId(target));
|
|
539
|
+
const incoming =
|
|
540
|
+
dependencyId(target) === ownId &&
|
|
541
|
+
incomingIds.has(dependencyId(source));
|
|
542
|
+
if (outgoing || incoming)
|
|
543
|
+
return { source, target, line: index + 1, direction: outgoing ? 'blocks' : 'blockedBy' };
|
|
544
|
+
}
|
|
545
|
+
return null;
|
|
546
|
+
};
|
|
547
|
+
|
|
505
548
|
/** The item saying, in its own words, that it was broken into other items. */
|
|
506
549
|
const SPLIT_IN_BODY = /\b(?:split into|split up into|broken into|broken up into|superseded by|subtasks?:)/i;
|
|
507
550
|
|
|
@@ -25,9 +25,26 @@
|
|
|
25
25
|
* and the failure is bounded and in the generous direction. What was worth fixing is
|
|
26
26
|
* the crash it came with — a fixed temp filename made the losers of that race fail
|
|
27
27
|
* with `ENOENT` on rename, reporting "could not run" for a condition nothing named.
|
|
28
|
+
* Pinned by the generator's `test/template/concurrent-sessions.test.ts`
|
|
29
|
+
* (absent in a generated rig) › "eight concurrent recordGateRound calls all exit 0
|
|
30
|
+
* and leave one parseable counter between one and eight".
|
|
31
|
+
*
|
|
32
|
+
* ⚠ **And a second crash, Windows-only, measured on the same race (RP-120):** a
|
|
33
|
+
* rename over a file another process holds open is refused there with `EPERM`, for
|
|
34
|
+
* exactly as long as the handle is open — a reader's `readFileSync` is enough. Eight
|
|
35
|
+
* racing callers lost one to it in four rounds of thirty, and each loser left its
|
|
36
|
+
* temp file behind. So the rename is retried within a fixed budget
|
|
37
|
+
* (`RENAME_BUDGET_MS`: the pauses between attempts never exceed it; the rename
|
|
38
|
+
* calls themselves add their own wall time), and a loser that still cannot rename
|
|
39
|
+
* removes its temp file and reports the code and the file rather than a bare
|
|
40
|
+
* `EPERM`. That is a bounded retry, not a lock: the count can still lose an
|
|
41
|
+
* increment, and nothing waits on a holder past the budget. Pinned by the
|
|
42
|
+
* generator's `test/template/gate-rounds.test.ts` (absent in a generated rig) ›
|
|
43
|
+
* "retries the rename while another process holds the counter open, and still counts the round"
|
|
44
|
+
* and › "gives up past its budget, removes its temp file, keeps the old count, and names the code and the file".
|
|
28
45
|
*/
|
|
29
46
|
|
|
30
|
-
import { renameSync, readFileSync, writeFileSync } from 'node:fs';
|
|
47
|
+
import { renameSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
31
48
|
import { join } from 'node:path';
|
|
32
49
|
|
|
33
50
|
import { mainCheckoutRoot } from './checkout.mjs';
|
|
@@ -154,7 +171,58 @@ export const recordGateRound = ({ branch, projectRoot, roundsPath } = {}) => {
|
|
|
154
171
|
const updated = Object.assign(Object.create(null), rounds, { [key]: next });
|
|
155
172
|
const temp = `${file}.${process.pid}.tmp`;
|
|
156
173
|
writeFileSync(temp, `${JSON.stringify(updated, null, 2)}\n`);
|
|
157
|
-
|
|
174
|
+
replaceWithRetry(temp, file);
|
|
158
175
|
|
|
159
176
|
return { rounds: next };
|
|
160
177
|
};
|
|
178
|
+
|
|
179
|
+
// How many times the rename is tried, and how long each retry waits.
|
|
180
|
+
const RENAME_ATTEMPTS = 20;
|
|
181
|
+
const RENAME_BACKOFF_MS = 10;
|
|
182
|
+
/** The whole budget a caller can spend pausing on a held-open counter. */
|
|
183
|
+
export const RENAME_BUDGET_MS = RENAME_ATTEMPTS * RENAME_BACKOFF_MS;
|
|
184
|
+
|
|
185
|
+
const RETRIED_CODES = new Set(['EPERM', 'EBUSY']);
|
|
186
|
+
|
|
187
|
+
// A synchronous pause: this module is synchronous end to end (the CLI counts a round
|
|
188
|
+
// and exits), and `Atomics.wait` on a throwaway buffer is the one sleep that shape
|
|
189
|
+
// allows without a spin.
|
|
190
|
+
const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Rename `temp` over `file`, retrying a Windows-style refusal within the budget.
|
|
194
|
+
*
|
|
195
|
+
* Every attempt past the last, and every failure of another kind, ends the same way:
|
|
196
|
+
* the temp file is removed so the counter's directory holds nothing but the counter,
|
|
197
|
+
* and the error names the file and the code — the CLI prints that as "could not run",
|
|
198
|
+
* which pr-ship reads as a command failure to retry, not as an exhausted cap.
|
|
199
|
+
*/
|
|
200
|
+
const replaceWithRetry = (temp, file) => {
|
|
201
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
202
|
+
try {
|
|
203
|
+
renameSync(temp, file);
|
|
204
|
+
return;
|
|
205
|
+
} catch (error) {
|
|
206
|
+
const code = error?.code ?? 'unknown error';
|
|
207
|
+
if (RETRIED_CODES.has(code) && attempt < RENAME_ATTEMPTS) {
|
|
208
|
+
pause(RENAME_BACKOFF_MS);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
// The cleanup must not displace the diagnosis: a temp file that cannot be
|
|
212
|
+
// removed (held open too, on Windows) is reported beside the rename
|
|
213
|
+
// failure, and the rename failure stays the error the caller sees.
|
|
214
|
+
let cleanup = 'and the temp file was removed';
|
|
215
|
+
try {
|
|
216
|
+
rmSync(temp, { force: true });
|
|
217
|
+
} catch (removal) {
|
|
218
|
+
cleanup = `but the temp file ${temp} could not be removed (${removal?.code ?? 'unknown error'})`;
|
|
219
|
+
}
|
|
220
|
+
throw new Error(
|
|
221
|
+
`${file} could not be replaced after ${attempt} attempt${attempt === 1 ? '' : 's'} ` +
|
|
222
|
+
`(${code}): another process may be holding it open. The round was NOT counted ` +
|
|
223
|
+
`${cleanup}; run the command again.`,
|
|
224
|
+
{ cause: error },
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
};
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// defaults to `plan-md`, which is the only adapter that works in a freshly
|
|
13
13
|
// generated project. An unknown adapter is a hard error, never a fallback: a loop
|
|
14
14
|
// that silently reads the wrong queue is worse than one that refuses to start.
|
|
15
|
-
import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { lstatSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
17
|
import { basename, dirname, join } from 'node:path';
|
|
18
18
|
import {
|
|
@@ -57,11 +57,19 @@ export const COMMANDS = ['next', 'list', 'hygiene', 'gate-round', 'board'];
|
|
|
57
57
|
* trailing comma in `queue.json` made the loop read a different queue than the one
|
|
58
58
|
* configured, which is the exact failure this file's header refuses for adapters.
|
|
59
59
|
*/
|
|
60
|
-
export const loadConfig = (configPath) => {
|
|
60
|
+
export const loadConfig = (configPath, { strictRead = false } = {}) => {
|
|
61
61
|
let raw;
|
|
62
62
|
try {
|
|
63
63
|
raw = readFileSync(configPath, 'utf8');
|
|
64
|
-
} catch {
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (
|
|
66
|
+
strictRead &&
|
|
67
|
+
(error?.code !== 'ENOENT' || lstatSync(configPath, { throwIfNoEntry: false }) !== undefined)
|
|
68
|
+
) {
|
|
69
|
+
throw new Error(`${configPath} could not be read (${error?.code ?? 'unknown error'})`, {
|
|
70
|
+
cause: error,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
65
73
|
return {};
|
|
66
74
|
}
|
|
67
75
|
let parsed;
|
|
@@ -70,7 +78,7 @@ export const loadConfig = (configPath) => {
|
|
|
70
78
|
} catch (error) {
|
|
71
79
|
throw new Error(
|
|
72
80
|
`${configPath} exists but is not valid JSON, so the configured queue cannot be ` +
|
|
73
|
-
|
|
81
|
+
'read. Fix the file — ' +
|
|
74
82
|
'silently reading a different queue is worse than refusing to start.',
|
|
75
83
|
{ cause: error },
|
|
76
84
|
);
|
|
@@ -680,6 +680,23 @@ export const escalate = async (ticket, diagnosis, { env = process.env } = {}) =>
|
|
|
680
680
|
return { ok: true };
|
|
681
681
|
};
|
|
682
682
|
|
|
683
|
+
/**
|
|
684
|
+
* Jira refuses a summary longer than this, and creates nothing: `400
|
|
685
|
+
* {"errors":{"summary":"Summary can't exceed 255 characters."}}` (RP-121). The
|
|
686
|
+
* summary is therefore a prefix; the full `change` stays in the description,
|
|
687
|
+
* where the fingerprint lives too, so the dedupe does not depend on the cut.
|
|
688
|
+
* The cut never leaves half of a surrogate pair behind. Pinned in
|
|
689
|
+
* `test/template/queue-jira.test.ts` › "bounds the POSTed summary so a
|
|
690
|
+
* 2186-character change still files, keeping the full text in the body".
|
|
691
|
+
*/
|
|
692
|
+
const MAX_SUMMARY_LENGTH = 255;
|
|
693
|
+
|
|
694
|
+
const boundedSummary = (text) => {
|
|
695
|
+
if (text.length <= MAX_SUMMARY_LENGTH) return text;
|
|
696
|
+
const cut = text.slice(0, MAX_SUMMARY_LENGTH);
|
|
697
|
+
return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
|
|
698
|
+
};
|
|
699
|
+
|
|
683
700
|
/**
|
|
684
701
|
* 🔴 INVARIANT 2: the agent never creates its own work. A proposal is labelled
|
|
685
702
|
* `triage`, which `buildJql` excludes explicitly, and it never receives a ready
|
|
@@ -689,7 +706,7 @@ export const triageItemFor = (proposal) => {
|
|
|
689
706
|
validateProposal(proposal);
|
|
690
707
|
const fingerprint = fingerprintOf(proposal);
|
|
691
708
|
return {
|
|
692
|
-
title: `proposal: ${proposal.change}
|
|
709
|
+
title: boundedSummary(`proposal: ${proposal.change}`),
|
|
693
710
|
body: [
|
|
694
711
|
`- finding — ${proposal.finding}`,
|
|
695
712
|
`- part to change — ${proposal.part}`,
|