@event4u/agent-config 15.0.0 → 16.0.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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +142 -348
- package/dist/agent-src/commands/roadmap/create.md +3 -2
- package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +50 -0
- package/dist/agent-src/rules/user-interaction.md +31 -30
- package/dist/agent-src/skills/error-handling-patterns/SKILL.md +9 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/{chunk-HZ52BRGG.js → chunk-H32DGUJH.js} +14 -89
- package/dist/cli-delegate/chunk-NUHLI2RX.js +80 -0
- package/dist/cli-delegate/{chunk-TYSY33MD.js → chunk-QJWAUMJW.js} +12 -2
- package/dist/cli-delegate/{chunk-2KKXJW25.js → chunk-S3VFXMTM.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +831 -272
- package/dist/cli-delegate/cmd_explain.js +4 -2
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -1
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +13 -0
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +5 -5
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +1274 -571
- package/dist/install/install.mjs +1 -0
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1218 -632
- package/dist/scripts/_lib/installed_lock.js +1 -1
- package/dist/server/io/yamlIO.js +50 -0
- package/package.json +1 -1
- package/src/config/continuity-surface.json +7 -0
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/host-injection-effect.json +1 -1
- package/src/config/preamble-payload-budget.json +12 -7
- package/src/config/preamble-payload-exceptions.json +28 -0
- package/src/scripts/_dispatch.bash +3 -1
- package/src/scripts/_lib/base_ref_payload.ts +315 -0
- package/src/scripts/_lib/dropped_decision.ts +142 -0
- package/src/scripts/_lib/installed_lock.ts +1 -1
- package/src/scripts/_lib/measured_payload_ceiling.ts +424 -0
- package/src/scripts/_lib/payload_catalogue_completeness.ts +211 -0
- package/src/scripts/_lib/release_findings_ingest.ts +324 -0
- package/src/scripts/_lib/release_tag_dispatch.ts +63 -0
- package/src/scripts/_lib/review_baseline.ts +254 -0
- package/src/scripts/_lib/review_skipped_record.ts +61 -1
- package/src/scripts/_lib/standing_bound_ratchet.ts +216 -103
- package/src/scripts/_lib/turn_end_refusals.ts +51 -4
- package/src/scripts/check_beta_review_markers.ts +35 -4
- package/src/scripts/check_finding_dispositions.ts +167 -16
- package/src/scripts/check_held_object_arrivals.ts +381 -0
- package/src/scripts/check_preamble_payload_budget.ts +399 -122
- package/src/scripts/check_release_published.ts +1 -1
- package/src/scripts/check_reply_consistency.ts +86 -17
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +39 -3
- package/src/scripts/hooks/block_kernel_rule_writes.ts +12 -2
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +101 -21
- package/src/scripts/hooks/review_baseline_hook.ts +130 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +232 -14
- package/src/scripts/hooks_doctor.ts +4 -1
- package/src/scripts/install.ts +1 -1
- package/src/scripts/interruption_report.ts +8 -2
- package/src/scripts/janitor.ts +8 -1
- package/src/scripts/measure_turn_end_gate.ts +302 -5
- package/src/scripts/release.ts +52 -64
- package/src/scripts/release_drill.ts +193 -14
- package/src/scripts/release_env.ts +1 -1
- package/src/scripts/release_publication.ts +1 -1
- package/src/scripts/report_held_object_arrivals.ts +456 -0
- package/src/scripts/sync_agent_settings.ts +140 -18
- package/src/server/io/yamlIO.ts +48 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* How many distinct consumed-inbox rounds raise a held object's subject.
|
|
4
|
+
*
|
|
5
|
+
* WHY A REPORTER AND NOT A GATE
|
|
6
|
+
* -----------------------------
|
|
7
|
+
* The count belongs on the held object as a human-authored line, so that a
|
|
8
|
+
* number arrives with a reason attached. This script derives the figure and
|
|
9
|
+
* prints it; it writes nothing, and the absence of any write is the contract.
|
|
10
|
+
* A generated counter would be a number nobody chose, and the first time it
|
|
11
|
+
* disagreed with the prose beside it the prose would be believed.
|
|
12
|
+
*
|
|
13
|
+
* THE UNIT IS A ROUND, NEVER A FILE
|
|
14
|
+
* ---------------------------------
|
|
15
|
+
* One round can carry a dozen revisions of the same argument. Counting files
|
|
16
|
+
* turns a single arrival into twelve, which inflates exactly the figure this
|
|
17
|
+
* practice exists to make trustworthy. So the unit is a top-level entry of the
|
|
18
|
+
* consumed-inbox tree: a directory is one round however many files it holds,
|
|
19
|
+
* and a loose file dropped at the top level is a round of one. Both are one
|
|
20
|
+
* arrival of the subject, and the report prints the split so a reader can tell
|
|
21
|
+
* which kind they are looking at.
|
|
22
|
+
*
|
|
23
|
+
* NOT-READABLE IS NOT ZERO
|
|
24
|
+
* ------------------------
|
|
25
|
+
* The consumed-inbox tree is gitignored and machine-local. A clone, a fresh
|
|
26
|
+
* worktree and CI all see no tree at all. Reporting that as "0 arrivals" would
|
|
27
|
+
* publish the strongest possible claim from the weakest possible evidence, so
|
|
28
|
+
* an unreachable tree exits 0 saying no prior rounds are readable, and prints
|
|
29
|
+
* no count at all.
|
|
30
|
+
*
|
|
31
|
+
* WHAT THE NUMBER IS NOT
|
|
32
|
+
* ----------------------
|
|
33
|
+
* It is a floor on recurrence over one machine's store, under one pattern. A
|
|
34
|
+
* narrower pattern reads lower and a broader one sweeps in incidental
|
|
35
|
+
* mentions, which is why the pattern and its provenance are printed beside
|
|
36
|
+
* every figure. Two counters written on different days under different
|
|
37
|
+
* patterns are not comparable, and this script's whole purpose is to make the
|
|
38
|
+
* pattern explicit so the next one can be.
|
|
39
|
+
*
|
|
40
|
+
* Exit: 0 always, except a usage error (2). It gates on nothing.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import * as fs from 'node:fs';
|
|
44
|
+
import * as path from 'node:path';
|
|
45
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
46
|
+
|
|
47
|
+
const _HERE = path.resolve(fileURLToPath(import.meta.url));
|
|
48
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
49
|
+
|
|
50
|
+
/** Where a held object lives, relative to a repository root. */
|
|
51
|
+
export const HELD_DIRS = ['agents/roadmaps/stubs', 'agents/roadmaps/later'] as const;
|
|
52
|
+
|
|
53
|
+
/** The consumed-inbox tree, relative to a repository root. */
|
|
54
|
+
export const INBOX_REL = 'agents/tmp.old';
|
|
55
|
+
|
|
56
|
+
/** A held object may declare the pattern its counter was measured under. */
|
|
57
|
+
const SUBJECT_MARKER = /<!--\s*arrival-subject:\s*(.+?)\s*-->/;
|
|
58
|
+
|
|
59
|
+
/** Biggest file the scanner will read. Above it the file is skipped and counted as skipped. */
|
|
60
|
+
const MAX_FILE_BYTES = 8 * 1024 * 1024;
|
|
61
|
+
|
|
62
|
+
export type TreeOrigin =
|
|
63
|
+
| 'flag'
|
|
64
|
+
| 'env'
|
|
65
|
+
| 'repo-root'
|
|
66
|
+
| 'main-checkout'
|
|
67
|
+
| 'project-dir'
|
|
68
|
+
| 'unreadable';
|
|
69
|
+
|
|
70
|
+
export interface TreeResolution {
|
|
71
|
+
/** Absolute path to the tree, or null when nothing readable was found. */
|
|
72
|
+
dir: string | null;
|
|
73
|
+
/** Which candidate answered — printed so a figure carries its provenance. */
|
|
74
|
+
origin: TreeOrigin;
|
|
75
|
+
/** Every candidate considered, in order, with whether it resolved. */
|
|
76
|
+
tried: ReadonlyArray<{ origin: TreeOrigin; dir: string; readable: boolean }>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isReadableDir(dir: string): boolean {
|
|
80
|
+
try {
|
|
81
|
+
return fs.statSync(dir).isDirectory();
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The main checkout behind a linked git worktree, or null.
|
|
89
|
+
*
|
|
90
|
+
* Read off the filesystem rather than by shelling out. In a linked worktree
|
|
91
|
+
* `.git` is a file whose one line points at `<main>/.git/worktrees/<name>`, so
|
|
92
|
+
* the main root is three levels above it. No subprocess, which matters because
|
|
93
|
+
* an inherited git directory has already broken gates in this tree once.
|
|
94
|
+
*/
|
|
95
|
+
export function mainCheckoutOf(root: string): string | null {
|
|
96
|
+
const dotGit = path.join(root, '.git');
|
|
97
|
+
let raw: string;
|
|
98
|
+
try {
|
|
99
|
+
if (!fs.statSync(dotGit).isFile()) return null;
|
|
100
|
+
raw = fs.readFileSync(dotGit, 'utf8');
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const m = /^gitdir:\s*(.+)$/m.exec(raw);
|
|
105
|
+
if (m === null) return null;
|
|
106
|
+
const gitdir = path.resolve(root, m[1]?.trim() ?? '');
|
|
107
|
+
// <main>/.git/worktrees/<name> -> <main>
|
|
108
|
+
const worktrees = path.dirname(gitdir);
|
|
109
|
+
if (path.basename(worktrees) !== 'worktrees') return null;
|
|
110
|
+
return path.dirname(path.dirname(worktrees));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Find the consumed-inbox tree, reporting which candidate answered.
|
|
115
|
+
*
|
|
116
|
+
* AN EXPLICIT TREE OVERRIDES; IT DOES NOT COMPETE
|
|
117
|
+
* -----------------------------------------------
|
|
118
|
+
* A tree named on the command line or in the environment is the answer, even
|
|
119
|
+
* when it is unreadable. Treating it as the first of several candidates would
|
|
120
|
+
* mean that naming a path that does not exist silently reads some other tree
|
|
121
|
+
* instead — and on every machine where the real tree exists, that makes the
|
|
122
|
+
* unreadable branch impossible to reach. The one behaviour this reporter most
|
|
123
|
+
* needs to be able to demonstrate would be the one behaviour nobody could
|
|
124
|
+
* exercise, which is how a branch ends up shipping with unknown sensitivity.
|
|
125
|
+
*
|
|
126
|
+
* The implicit chain runs only when neither was given. Its order is this root,
|
|
127
|
+
* then the main checkout behind it, because a worktree carries no tree of its
|
|
128
|
+
* own and the honest answers there are either the main checkout's tree or none
|
|
129
|
+
* at all. The host-provided project directory comes last: it is known to point
|
|
130
|
+
* at the parent checkout even from inside a worktree, which is useful and is
|
|
131
|
+
* also a host's opinion rather than a fact about this repository.
|
|
132
|
+
*/
|
|
133
|
+
export function resolveTree(opts: {
|
|
134
|
+
root: string;
|
|
135
|
+
flag?: string | undefined;
|
|
136
|
+
env?: NodeJS.ProcessEnv;
|
|
137
|
+
}): TreeResolution {
|
|
138
|
+
const env = opts.env ?? process.env;
|
|
139
|
+
const fromEnv = env['AGENT_CONFIG_INBOX_TREE'];
|
|
140
|
+
const explicit: { origin: TreeOrigin; dir: string } | null =
|
|
141
|
+
opts.flag !== undefined && opts.flag !== ''
|
|
142
|
+
? { origin: 'flag', dir: path.resolve(opts.root, opts.flag) }
|
|
143
|
+
: fromEnv !== undefined && fromEnv !== ''
|
|
144
|
+
? { origin: 'env', dir: path.resolve(opts.root, fromEnv) }
|
|
145
|
+
: null;
|
|
146
|
+
if (explicit !== null) {
|
|
147
|
+
const readable = isReadableDir(explicit.dir);
|
|
148
|
+
const tried = [{ ...explicit, readable }];
|
|
149
|
+
return readable
|
|
150
|
+
? { dir: explicit.dir, origin: explicit.origin, tried }
|
|
151
|
+
: { dir: null, origin: 'unreadable', tried };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const candidates: Array<{ origin: TreeOrigin; dir: string }> = [];
|
|
155
|
+
candidates.push({ origin: 'repo-root', dir: path.join(opts.root, INBOX_REL) });
|
|
156
|
+
const main = mainCheckoutOf(opts.root);
|
|
157
|
+
if (main !== null && path.resolve(main) !== path.resolve(opts.root)) {
|
|
158
|
+
candidates.push({ origin: 'main-checkout', dir: path.join(main, INBOX_REL) });
|
|
159
|
+
}
|
|
160
|
+
const projectDir = env['CLAUDE_PROJECT_DIR'];
|
|
161
|
+
if (projectDir !== undefined && projectDir !== '') {
|
|
162
|
+
candidates.push({ origin: 'project-dir', dir: path.join(projectDir, INBOX_REL) });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const tried = candidates.map((c) => ({ ...c, readable: isReadableDir(c.dir) }));
|
|
166
|
+
const hit = tried.find((c) => c.readable);
|
|
167
|
+
return hit === undefined
|
|
168
|
+
? { dir: null, origin: 'unreadable', tried }
|
|
169
|
+
: { dir: hit.dir, origin: hit.origin, tried };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface RoundMatch {
|
|
173
|
+
/** Top-level entry name — the round's codename. */
|
|
174
|
+
name: string;
|
|
175
|
+
/** `dir` = a round directory. `file` = a loose drop at the top level. */
|
|
176
|
+
kind: 'dir' | 'file';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface RoundCount {
|
|
180
|
+
matches: RoundMatch[];
|
|
181
|
+
/** Top-level entries examined — the denominator the count was taken over. */
|
|
182
|
+
examined: number;
|
|
183
|
+
/** Files the scanner could not read, so a low count is never silently a clean one. */
|
|
184
|
+
unreadable: number;
|
|
185
|
+
/** Files skipped for size. */
|
|
186
|
+
oversized: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function fileRaises(file: string, re: RegExp, tally: { unreadable: number; oversized: number }): boolean {
|
|
190
|
+
let size: number;
|
|
191
|
+
try {
|
|
192
|
+
const st = fs.statSync(file);
|
|
193
|
+
if (!st.isFile()) return false;
|
|
194
|
+
size = st.size;
|
|
195
|
+
} catch {
|
|
196
|
+
tally.unreadable += 1;
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
if (size > MAX_FILE_BYTES) {
|
|
200
|
+
tally.oversized += 1;
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
return re.test(fs.readFileSync(file, 'utf8'));
|
|
205
|
+
} catch {
|
|
206
|
+
tally.unreadable += 1;
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function dirRaises(dir: string, re: RegExp, tally: { unreadable: number; oversized: number }): boolean {
|
|
212
|
+
let entries: fs.Dirent[];
|
|
213
|
+
try {
|
|
214
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
215
|
+
} catch {
|
|
216
|
+
tally.unreadable += 1;
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
for (const e of entries) {
|
|
220
|
+
const full = path.join(dir, e.name);
|
|
221
|
+
if (e.isSymbolicLink()) continue;
|
|
222
|
+
if (e.isDirectory()) {
|
|
223
|
+
if (dirRaises(full, re, tally)) return true;
|
|
224
|
+
} else if (e.isFile()) {
|
|
225
|
+
if (fileRaises(full, re, tally)) return true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Count distinct rounds whose contents match `re`.
|
|
233
|
+
*
|
|
234
|
+
* A round stops being examined the moment one of its files matches: the
|
|
235
|
+
* question is whether the round raised the subject, not how loudly.
|
|
236
|
+
*/
|
|
237
|
+
export function countRounds(tree: string, re: RegExp): RoundCount {
|
|
238
|
+
let entries: fs.Dirent[];
|
|
239
|
+
try {
|
|
240
|
+
entries = fs.readdirSync(tree, { withFileTypes: true });
|
|
241
|
+
} catch {
|
|
242
|
+
return { matches: [], examined: 0, unreadable: 1, oversized: 0 };
|
|
243
|
+
}
|
|
244
|
+
const tally = { unreadable: 0, oversized: 0 };
|
|
245
|
+
const matches: RoundMatch[] = [];
|
|
246
|
+
let examined = 0;
|
|
247
|
+
for (const e of entries.slice().sort((a, b) => a.name.localeCompare(b.name))) {
|
|
248
|
+
if (e.name.startsWith('.')) continue;
|
|
249
|
+
if (e.isSymbolicLink()) continue;
|
|
250
|
+
const full = path.join(tree, e.name);
|
|
251
|
+
if (e.isDirectory()) {
|
|
252
|
+
examined += 1;
|
|
253
|
+
if (dirRaises(full, re, tally)) matches.push({ name: e.name, kind: 'dir' });
|
|
254
|
+
} else if (e.isFile()) {
|
|
255
|
+
examined += 1;
|
|
256
|
+
if (fileRaises(full, re, tally)) matches.push({ name: e.name, kind: 'file' });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { matches, examined, unreadable: tally.unreadable, oversized: tally.oversized };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface HeldObject {
|
|
263
|
+
/** Repo-relative path. */
|
|
264
|
+
rel: string;
|
|
265
|
+
/** File stem. */
|
|
266
|
+
slug: string;
|
|
267
|
+
/** Declared subject pattern, when the object carries one. */
|
|
268
|
+
declared: string | null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Locate a held object by path or by slug. */
|
|
272
|
+
export function findHeldObject(root: string, ref: string): HeldObject | null {
|
|
273
|
+
const direct = path.isAbsolute(ref) ? ref : path.join(root, ref);
|
|
274
|
+
const candidates = [direct];
|
|
275
|
+
const bare = path.basename(ref).replace(/\.md$/, '');
|
|
276
|
+
for (const d of HELD_DIRS) candidates.push(path.join(root, d, `${bare}.md`));
|
|
277
|
+
for (const c of candidates) {
|
|
278
|
+
let text: string;
|
|
279
|
+
try {
|
|
280
|
+
if (!fs.statSync(c).isFile()) continue;
|
|
281
|
+
text = fs.readFileSync(c, 'utf8');
|
|
282
|
+
} catch {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const m = SUBJECT_MARKER.exec(text);
|
|
286
|
+
return {
|
|
287
|
+
rel: path.relative(root, c),
|
|
288
|
+
slug: path.basename(c).replace(/\.md$/, ''),
|
|
289
|
+
declared: m === null ? null : (m[1] ?? null),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* A pattern derived from a slug, used only when the object declares none.
|
|
297
|
+
*
|
|
298
|
+
* Deliberately weak, and printed as such. The recorded counters in this tree
|
|
299
|
+
* were taken under hand-written subject patterns, and a slug is a filename
|
|
300
|
+
* rather than a subject — so this reads as a hard floor and usually as zero.
|
|
301
|
+
* It exists so the tool answers for any object rather than refusing, and the
|
|
302
|
+
* output says which kind of pattern produced the figure.
|
|
303
|
+
*/
|
|
304
|
+
export function slugPattern(slug: string): string {
|
|
305
|
+
return slug.replace(/^road-to-/, '').replace(/-/g, '[-_ .]');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
type PatternOrigin = 'flag' | 'declared' | 'slug';
|
|
309
|
+
|
|
310
|
+
function buildPattern(
|
|
311
|
+
obj: HeldObject | null,
|
|
312
|
+
flag: string | undefined,
|
|
313
|
+
): { source: string; origin: PatternOrigin } {
|
|
314
|
+
if (flag !== undefined && flag !== '') return { source: flag, origin: 'flag' };
|
|
315
|
+
if (obj !== null && obj.declared !== null) return { source: obj.declared, origin: 'declared' };
|
|
316
|
+
if (obj !== null) return { source: slugPattern(obj.slug), origin: 'slug' };
|
|
317
|
+
return { source: '', origin: 'slug' };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const PATTERN_NOTE: Record<PatternOrigin, string> = {
|
|
321
|
+
flag: 'given on the command line',
|
|
322
|
+
declared: 'declared on the object itself',
|
|
323
|
+
slug: 'derived from the slug — a hard floor, and usually the wrong question; pass --pattern',
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const ORIGIN_NOTE: Record<TreeOrigin, string> = {
|
|
327
|
+
flag: '--tree',
|
|
328
|
+
env: 'AGENT_CONFIG_INBOX_TREE',
|
|
329
|
+
'repo-root': 'this repository root',
|
|
330
|
+
'main-checkout': "the main checkout behind this worktree",
|
|
331
|
+
'project-dir': 'CLAUDE_PROJECT_DIR',
|
|
332
|
+
unreadable: 'nothing',
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
export interface ReportResult {
|
|
336
|
+
exit: number;
|
|
337
|
+
text: string;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function report(argv: readonly string[], root: string): ReportResult {
|
|
341
|
+
const out: string[] = [];
|
|
342
|
+
const arg = (name: string): string | undefined => {
|
|
343
|
+
const i = argv.indexOf(name);
|
|
344
|
+
return i === -1 ? undefined : argv[i + 1];
|
|
345
|
+
};
|
|
346
|
+
// Only these flags consume the token after them. The earlier form treated
|
|
347
|
+
// EVERY `--`-prefixed token as value-taking, which silently ate the held
|
|
348
|
+
// object ref after `--list`, and let the documented short form `-i` through
|
|
349
|
+
// as a positional because it carries one dash rather than two. Both
|
|
350
|
+
// spellings are in the help text, so both have to parse.
|
|
351
|
+
const VALUE_FLAGS = new Set(['--pattern', '--tree', '--root']);
|
|
352
|
+
const positional = argv.filter(
|
|
353
|
+
(a, i) => !a.startsWith('-') && !VALUE_FLAGS.has(String(argv[i - 1] ?? '')),
|
|
354
|
+
);
|
|
355
|
+
const ref = positional[0];
|
|
356
|
+
if (ref === undefined) {
|
|
357
|
+
return { exit: 2, text: 'usage: report_held_object_arrivals <held-object> [--pattern RE] [--tree DIR] [--root DIR] [--ignore-case] [--list]\n' };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const obj = findHeldObject(root, ref);
|
|
361
|
+
if (obj === null) {
|
|
362
|
+
out.push(`held object not found: ${ref}`);
|
|
363
|
+
out.push(` looked in: ${HELD_DIRS.join(', ')}`);
|
|
364
|
+
return { exit: 2, text: `${out.join('\n')}\n` };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const { source, origin: patternOrigin } = buildPattern(obj, arg('--pattern'));
|
|
368
|
+
// Case-sensitive by default, so the tool reproduces a plain `grep -rl`.
|
|
369
|
+
// Every recorded counter in this tree was taken that way, and a figure
|
|
370
|
+
// that quietly reads two rounds higher than the line it is checking is
|
|
371
|
+
// worse than no figure: it looks like drift in the store rather than a
|
|
372
|
+
// difference in the question asked.
|
|
373
|
+
const fold = argv.includes('--ignore-case') || argv.includes('-i');
|
|
374
|
+
let re: RegExp;
|
|
375
|
+
try {
|
|
376
|
+
re = new RegExp(source, fold ? 'i' : '');
|
|
377
|
+
} catch (e) {
|
|
378
|
+
return { exit: 2, text: `bad --pattern: ${String(e)}\n` };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const tree = resolveTree({ root, flag: arg('--tree') });
|
|
382
|
+
|
|
383
|
+
out.push(`object: ${obj.rel}`);
|
|
384
|
+
out.push(`pattern: /${source}/${fold ? 'i' : ''} (${PATTERN_NOTE[patternOrigin]})`);
|
|
385
|
+
|
|
386
|
+
if (tree.dir === null) {
|
|
387
|
+
out.push('tree: unreadable');
|
|
388
|
+
for (const t of tree.tried) out.push(` · ${t.origin}: ${t.dir} — absent`);
|
|
389
|
+
out.push('');
|
|
390
|
+
out.push('no prior rounds readable — the consumed-inbox tree is gitignored and');
|
|
391
|
+
out.push('machine-local, so this environment cannot see it. This is NOT a count of');
|
|
392
|
+
out.push('zero arrivals: no reading was taken. Point --tree at the tree, or run');
|
|
393
|
+
out.push('where it lives.');
|
|
394
|
+
return { exit: 0, text: `${out.join('\n')}\n` };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const counted = countRounds(tree.dir, re);
|
|
398
|
+
// A tree that STATS as a directory but cannot be LISTED reaches here with
|
|
399
|
+
// examined 0 and unreadable 1. Printing "0 distinct round(s) of 0 examined"
|
|
400
|
+
// would be the zero-versus-unreadable conflation this tool exists to avoid,
|
|
401
|
+
// one layer in from the absent-tree branch above: `isReadableDir` passed, so
|
|
402
|
+
// resolution succeeded and only enumeration failed. Same message, because it
|
|
403
|
+
// is the same fact — no reading was taken.
|
|
404
|
+
if (counted.examined === 0 && counted.unreadable > 0) {
|
|
405
|
+
out.push(`tree: ${tree.dir} (via ${ORIGIN_NOTE[tree.origin]}, but not listable)`);
|
|
406
|
+
out.push('');
|
|
407
|
+
out.push('no prior rounds readable — the tree resolved but could not be enumerated');
|
|
408
|
+
out.push('(permissions, or a race with something rewriting it). This is NOT a count');
|
|
409
|
+
out.push('of zero arrivals: no reading was taken.');
|
|
410
|
+
return { exit: 0, text: `${out.join('\n')}\n` };
|
|
411
|
+
}
|
|
412
|
+
const dirs = counted.matches.filter((m) => m.kind === 'dir').length;
|
|
413
|
+
const files = counted.matches.length - dirs;
|
|
414
|
+
out.push(`tree: ${tree.dir} (via ${ORIGIN_NOTE[tree.origin]})`);
|
|
415
|
+
out.push('');
|
|
416
|
+
out.push(
|
|
417
|
+
`arrivals: ${String(counted.matches.length)} distinct round(s) of ${String(counted.examined)} examined` +
|
|
418
|
+
` — ${String(dirs)} round director(ies), ${String(files)} loose drop(s)`,
|
|
419
|
+
);
|
|
420
|
+
if (counted.unreadable > 0) out.push(` note: ${String(counted.unreadable)} path(s) unreadable — the count is a floor`);
|
|
421
|
+
if (counted.oversized > 0) out.push(` note: ${String(counted.oversized)} file(s) skipped for size`);
|
|
422
|
+
out.push('');
|
|
423
|
+
out.push('The figure is a floor on recurrence over one machine\'s store under one');
|
|
424
|
+
out.push('pattern. Write it onto the object by hand, with this pattern and this');
|
|
425
|
+
out.push('denominator beside it, so the next reader can tell a measured count from a');
|
|
426
|
+
out.push('remembered one.');
|
|
427
|
+
if (argv.includes('--list') || counted.matches.length <= 40) {
|
|
428
|
+
out.push('');
|
|
429
|
+
out.push('rounds:');
|
|
430
|
+
for (const m of counted.matches) out.push(` · ${m.name}${m.kind === 'file' ? ' (loose drop)' : ''}`);
|
|
431
|
+
}
|
|
432
|
+
return { exit: 0, text: `${out.join('\n')}\n` };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function main(argv: readonly string[]): number {
|
|
436
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
437
|
+
process.stdout.write(
|
|
438
|
+
'usage: report_held_object_arrivals <held-object> [--pattern RE] [--tree DIR] [--root DIR] [--list]\n\n' +
|
|
439
|
+
' <held-object> a path, or a bare slug resolved under the held-object directories\n' +
|
|
440
|
+
' --pattern RE the subject pattern; overrides any the object declares\n' +
|
|
441
|
+
' --tree DIR the consumed-inbox tree; otherwise resolved and reported\n' +
|
|
442
|
+
' --ignore-case fold case; the default is a case-sensitive grep\n' +
|
|
443
|
+
' --list list every matched round even when there are many\n',
|
|
444
|
+
);
|
|
445
|
+
return 0;
|
|
446
|
+
}
|
|
447
|
+
const i = argv.indexOf('--root');
|
|
448
|
+
const root = i === -1 ? REPO_ROOT : (argv[i + 1] ?? REPO_ROOT);
|
|
449
|
+
const res = report(argv, root);
|
|
450
|
+
process.stdout.write(res.text);
|
|
451
|
+
return res.exit;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
455
|
+
process.exit(main(process.argv.slice(2)));
|
|
456
|
+
}
|
|
@@ -24,13 +24,22 @@
|
|
|
24
24
|
* Usage:
|
|
25
25
|
* sync_agent_settings # write (default)
|
|
26
26
|
* sync_agent_settings --dry-run # show diff, no write
|
|
27
|
-
* sync_agent_settings --check # exit 2
|
|
27
|
+
* sync_agent_settings --check # exit 2 if a sync is needed (CI)
|
|
28
28
|
* sync_agent_settings --profile balanced # use a specific profile
|
|
29
29
|
* sync_agent_settings --path path/to/.agent-settings.yml
|
|
30
30
|
*
|
|
31
31
|
* Exit codes:
|
|
32
32
|
* 0 — already in sync, or changes applied (or --dry-run ran cleanly)
|
|
33
|
-
* 2 —
|
|
33
|
+
* 2 — under --check, synchronization requires intervention; also invalid
|
|
34
|
+
* arguments, missing files, and a duplicate-key repair this tool
|
|
35
|
+
* refuses to perform.
|
|
36
|
+
*
|
|
37
|
+
* `--check` has two diagnostic cases and its stderr line already separates
|
|
38
|
+
* them, because the remedies differ: template drift ("drift detected"), and a
|
|
39
|
+
* duplicate-key repair with no drift ("duplicate keys need collapsing"). Both
|
|
40
|
+
* exit 2 — the target needs a write either way, and a green `--check` over a
|
|
41
|
+
* file the strict reader cannot parse would hide the breakage. A caller that
|
|
42
|
+
* needs the distinction reads the message, not the code.
|
|
34
43
|
*
|
|
35
44
|
* No behaviour changes vs. the retired Python implementation — historical quirks preserved (consumers pin the exact behaviour).
|
|
36
45
|
*/
|
|
@@ -441,6 +450,52 @@ function loadTemplate(p: string, profileValues: Record<string, string>): string
|
|
|
441
450
|
return renderTemplate(fs.readFileSync(p, 'utf-8'), profileValues);
|
|
442
451
|
}
|
|
443
452
|
|
|
453
|
+
/**
|
|
454
|
+
* The comment `mergeIntoTemplate` writes above its appended flat-key block.
|
|
455
|
+
*
|
|
456
|
+
* Duplicated here rather than imported: the writer holds it as a literal in
|
|
457
|
+
* `src/server/io/yamlIO.ts` and exports no constant, and that module belongs
|
|
458
|
+
* to the server tree this CLI does not otherwise depend on. Keep the two in
|
|
459
|
+
* step — a drift makes the sweep below silently stop matching.
|
|
460
|
+
*/
|
|
461
|
+
const WIZARD_BLOCK_HEADER = '# Wizard-added keys (no template entry)';
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Drop a wizard-block header the collapse emptied.
|
|
465
|
+
*
|
|
466
|
+
* The non-idempotent writer appended the header AND its keys on every save, so
|
|
467
|
+
* a collapse that removes the later block's key lines leaves that block's
|
|
468
|
+
* header behind with nothing under it — and the next append puts a fresh
|
|
469
|
+
* header below the orphan, one more per save. A header names the keys beneath
|
|
470
|
+
* it; with none beneath it, it names nothing and misleads the next reader.
|
|
471
|
+
*
|
|
472
|
+
* A header counts as emptied only when no key line stands between it and the
|
|
473
|
+
* next header or EOF. Blanks and further comments do not count: a user comment
|
|
474
|
+
* written between the header and its first key is part of the block, not a
|
|
475
|
+
* replacement for it.
|
|
476
|
+
*/
|
|
477
|
+
function dropOrphanedWizardHeaders(lines: readonly string[]): string[] {
|
|
478
|
+
const drop = new Set<number>();
|
|
479
|
+
for (let i = 0; i < lines.length; i++) {
|
|
480
|
+
if (lines[i]?.trim() !== WIZARD_BLOCK_HEADER) continue;
|
|
481
|
+
let hasKey = false;
|
|
482
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
483
|
+
const l = lines[j];
|
|
484
|
+
if (l === undefined) continue;
|
|
485
|
+
if (l.trim() === WIZARD_BLOCK_HEADER) break;
|
|
486
|
+
if (l.trim() === '' || l.trimStart().startsWith('#')) continue;
|
|
487
|
+
hasKey = true;
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
if (hasKey) continue;
|
|
491
|
+
drop.add(i);
|
|
492
|
+
// The writer emits the blank line above the header as part of the block,
|
|
493
|
+
// so removing the header alone widens the gap by one line per repair.
|
|
494
|
+
if (i > 0 && lines[i - 1]?.trim() === '') drop.add(i - 1);
|
|
495
|
+
}
|
|
496
|
+
return drop.size === 0 ? [...lines] : lines.filter((_, i) => !drop.has(i));
|
|
497
|
+
}
|
|
498
|
+
|
|
444
499
|
export interface DuplicateRepair {
|
|
445
500
|
/** The document with every safely-collapsible duplicate run reduced to one. */
|
|
446
501
|
text: string;
|
|
@@ -482,6 +537,9 @@ export interface DuplicateRepair {
|
|
|
482
537
|
* A multi-document stream (`---` / `...`) is not repaired either. The reader
|
|
483
538
|
* takes a single document and rejects such a file whatever this pass does, so
|
|
484
539
|
* collapsing across a boundary could only merge two documents' keys into one.
|
|
540
|
+
*
|
|
541
|
+
* When — and only when — something did collapse, a wizard-block header the
|
|
542
|
+
* collapse emptied is removed with it; see `dropOrphanedWizardHeaders`.
|
|
485
543
|
*/
|
|
486
544
|
export function collapseDuplicateFlatKeys(text: string): DuplicateRepair {
|
|
487
545
|
// Line endings are preserved: splitting on `\n` alone leaves a trailing
|
|
@@ -630,10 +688,53 @@ export function collapseDuplicateFlatKeys(text: string): DuplicateRepair {
|
|
|
630
688
|
if (collapsed.length === 0) {
|
|
631
689
|
return { text, collapsed, unsafe };
|
|
632
690
|
}
|
|
633
|
-
const kept = lines.filter((_, i) => !drop.has(i));
|
|
691
|
+
const kept = dropOrphanedWizardHeaders(lines.filter((_, i) => !drop.has(i)));
|
|
634
692
|
return { text: kept.join(eol), collapsed, unsafe };
|
|
635
693
|
}
|
|
636
694
|
|
|
695
|
+
/**
|
|
696
|
+
* The parse error an input still carries once its duplicate keys are excused,
|
|
697
|
+
* or null when the duplicates were its only problem.
|
|
698
|
+
*
|
|
699
|
+
* `uniqueKeys: false` is what excuses them: the duplicate-key rejection is the
|
|
700
|
+
* single error the collapse has a mandate to remove, so anything the parser
|
|
701
|
+
* still raises is an error the collapse was never licensed to touch.
|
|
702
|
+
*/
|
|
703
|
+
function residualParseError(text: string): Error | null {
|
|
704
|
+
try {
|
|
705
|
+
parseYaml(text, { version: '1.1', uniqueKeys: false });
|
|
706
|
+
return null;
|
|
707
|
+
} catch (err) {
|
|
708
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* What the parser reliably exposes about a failure, as one indented block.
|
|
714
|
+
*
|
|
715
|
+
* Available on every `YAMLParseError` observed from this parser: `message`,
|
|
716
|
+
* whose first line already carries `at line L, column C` plus the detail, and
|
|
717
|
+
* `code`, a stable identifier. `linePos` is populated too, but only while
|
|
718
|
+
* `prettyErrors` stays on (the library's own condition), so it is printed when
|
|
719
|
+
* present and never relied on. Nothing here is asserted beyond the parser's
|
|
720
|
+
* own first message line, which is derived in the test rather than hardcoded —
|
|
721
|
+
* pinning a format this module does not own would make a safety test fail on a
|
|
722
|
+
* dependency's wording change.
|
|
723
|
+
*/
|
|
724
|
+
function formatParserDetail(err: Error): string {
|
|
725
|
+
const first = err.message.split('\n')[0] ?? err.message;
|
|
726
|
+
const parts: string[] = [` ${first}`];
|
|
727
|
+
const code = (err as { code?: unknown }).code;
|
|
728
|
+
const linePos = (err as { linePos?: { line: number; col: number }[] }).linePos;
|
|
729
|
+
const at = linePos?.[0];
|
|
730
|
+
if (typeof code === 'string' && at !== undefined) {
|
|
731
|
+
parts.push(` (${code} at line ${at.line}, column ${at.col})`);
|
|
732
|
+
} else if (typeof code === 'string') {
|
|
733
|
+
parts.push(` (${code})`);
|
|
734
|
+
}
|
|
735
|
+
return parts.join('\n') + '\n';
|
|
736
|
+
}
|
|
737
|
+
|
|
637
738
|
function loadUserText(raw: string): Record<string, unknown> {
|
|
638
739
|
const data = parseYaml(raw, { version: '1.1' });
|
|
639
740
|
if (data === null || data === undefined) {
|
|
@@ -863,6 +964,42 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
|
863
964
|
return 2;
|
|
864
965
|
}
|
|
865
966
|
if (repair.collapsed.length > 0) {
|
|
967
|
+
// Refuse before anything else in this branch, including the notice below:
|
|
968
|
+
// in write mode that notice is past tense ("collapsed …"), so announcing
|
|
969
|
+
// it and then refusing describes a write that never happened.
|
|
970
|
+
//
|
|
971
|
+
// The collapse's safety rests on preserving the original's last-wins
|
|
972
|
+
// reading, and a document that is invalid for some FURTHER reason has no
|
|
973
|
+
// reading to preserve — the pass can then emit valid YAML carrying
|
|
974
|
+
// structure nobody authored, which every downstream check accepts. A
|
|
975
|
+
// stderr warning (what this branch used to do) is a weak control against a
|
|
976
|
+
// silent outcome; a refusal is loud and the operator can still recover.
|
|
977
|
+
// AI council, 2026-09-11, 2 of 2 seats, converged — including a rejection
|
|
978
|
+
// of any `--repair-anyway` escape: an operator who understands the file
|
|
979
|
+
// well enough to use it understands it well enough to fix the error first,
|
|
980
|
+
// and "read the diff" is not a trustworthy oracle for YAML semantics.
|
|
981
|
+
//
|
|
982
|
+
// Exit 2, per the taxonomy in this file's header: 2 is "this tool cannot
|
|
983
|
+
// proceed with this input", which is also what the sibling refusal above
|
|
984
|
+
// (duplicates it will not collapse) returns. 1 is spoken for — it is the
|
|
985
|
+
// mirrored `install.fail` exit and means a broken install asset.
|
|
986
|
+
//
|
|
987
|
+
// Nothing durable precedes this point: the only write in `main` is the
|
|
988
|
+
// single `writeFileSync` at the end, there is no temporary file and no
|
|
989
|
+
// rename, so the refusal leaves the target byte-for-byte unchanged.
|
|
990
|
+
const residual = residualParseError(rawText);
|
|
991
|
+
if (residual !== null) {
|
|
992
|
+
process.stderr.write(
|
|
993
|
+
`❌ ${target}: refusing the automatic duplicate repair — the file has ` +
|
|
994
|
+
`another YAML error besides its duplicate keys.\n` +
|
|
995
|
+
formatParserDetail(residual) +
|
|
996
|
+
` Collapsing changes only WHETHER a file parses, never what it parses as, ` +
|
|
997
|
+
`and that promise is empty over a document with no valid reading.\n` +
|
|
998
|
+
` Fix the syntax error by hand, then re-run to apply the duplicate repair.\n`,
|
|
999
|
+
);
|
|
1000
|
+
return 2;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
866
1003
|
// NOT gated on --quiet, and not on stdout. Both callers that matter run
|
|
867
1004
|
// quiet — the release's `sync-agent-settings` step and the pre-release
|
|
868
1005
|
// probe — so gating this would let a release rewrite the operator's own
|
|
@@ -878,21 +1015,6 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
|
878
1015
|
? `collapsed ${repair.collapsed.length} duplicate key(s), last value kept`
|
|
879
1016
|
: `would collapse ${repair.collapsed.length} duplicate key(s), keeping the last value (no write in this mode)`;
|
|
880
1017
|
process.stderr.write(`🔧 ${target}: ${verb} — ${repair.collapsed.join(', ')}\n`);
|
|
881
|
-
|
|
882
|
-
// The collapse promises to change only WHETHER the file parses, never what
|
|
883
|
-
// it parses as — and that promise is only meaningful over an input that had
|
|
884
|
-
// a reading to preserve. If the original was unparseable for some further
|
|
885
|
-
// reason (an unterminated quote, say), the repaired file may parse to
|
|
886
|
-
// structure no human wrote, and the next `git diff` shows keys nobody
|
|
887
|
-
// added. Cheap to say, and the operator cannot recover it from the output.
|
|
888
|
-
try {
|
|
889
|
-
parseYaml(rawText, { version: '1.1', uniqueKeys: false });
|
|
890
|
-
} catch {
|
|
891
|
-
process.stderr.write(
|
|
892
|
-
`⚠️ ${target}: the original had a syntax error beyond the duplicate keys, so the ` +
|
|
893
|
-
`result may differ in structure, not only in duplicates — read the diff before trusting it.\n`,
|
|
894
|
-
);
|
|
895
|
-
}
|
|
896
1018
|
}
|
|
897
1019
|
const sourceText = repair.text;
|
|
898
1020
|
|