@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.
Files changed (78) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +142 -348
  5. package/dist/agent-src/commands/roadmap/create.md +3 -2
  6. package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +50 -0
  7. package/dist/agent-src/rules/user-interaction.md +31 -30
  8. package/dist/agent-src/skills/error-handling-patterns/SKILL.md +9 -0
  9. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  10. package/dist/cli-delegate/{chunk-HZ52BRGG.js → chunk-H32DGUJH.js} +14 -89
  11. package/dist/cli-delegate/chunk-NUHLI2RX.js +80 -0
  12. package/dist/cli-delegate/{chunk-TYSY33MD.js → chunk-QJWAUMJW.js} +12 -2
  13. package/dist/cli-delegate/{chunk-2KKXJW25.js → chunk-S3VFXMTM.js} +1 -1
  14. package/dist/cli-delegate/cmd_conformance.js +831 -272
  15. package/dist/cli-delegate/cmd_explain.js +4 -2
  16. package/dist/cli-delegate/cmd_handoff.js +2 -2
  17. package/dist/cli-delegate/cmd_packs_active.js +2 -1
  18. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  19. package/dist/cli-delegate/cmd_settings_set.js +13 -0
  20. package/dist/discovery/deprecation-report.md +1 -1
  21. package/dist/discovery/discovery-manifest.json +5 -5
  22. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  23. package/dist/discovery/discovery-manifest.summary.md +1 -1
  24. package/dist/discovery/dormancy-report.md +1 -1
  25. package/dist/discovery/orphan-report.md +1 -1
  26. package/dist/discovery/packs.json +2 -2
  27. package/dist/discovery/trust-report.md +1 -1
  28. package/dist/discovery/workspaces.json +2 -2
  29. package/dist/hooks/dispatch.js +1274 -571
  30. package/dist/install/install.mjs +1 -0
  31. package/dist/mcp/registry-manifest.json +2 -2
  32. package/dist/mcp/server.json +2 -2
  33. package/dist/mcp/server.mjs +1218 -632
  34. package/dist/scripts/_lib/installed_lock.js +1 -1
  35. package/dist/server/io/yamlIO.js +50 -0
  36. package/package.json +1 -1
  37. package/src/config/continuity-surface.json +7 -0
  38. package/src/config/gate-violation-baselines.json +3 -3
  39. package/src/config/host-injection-effect.json +1 -1
  40. package/src/config/preamble-payload-budget.json +12 -7
  41. package/src/config/preamble-payload-exceptions.json +28 -0
  42. package/src/scripts/_dispatch.bash +3 -1
  43. package/src/scripts/_lib/base_ref_payload.ts +315 -0
  44. package/src/scripts/_lib/dropped_decision.ts +142 -0
  45. package/src/scripts/_lib/installed_lock.ts +1 -1
  46. package/src/scripts/_lib/measured_payload_ceiling.ts +424 -0
  47. package/src/scripts/_lib/payload_catalogue_completeness.ts +211 -0
  48. package/src/scripts/_lib/release_findings_ingest.ts +324 -0
  49. package/src/scripts/_lib/release_tag_dispatch.ts +63 -0
  50. package/src/scripts/_lib/review_baseline.ts +254 -0
  51. package/src/scripts/_lib/review_skipped_record.ts +61 -1
  52. package/src/scripts/_lib/standing_bound_ratchet.ts +216 -103
  53. package/src/scripts/_lib/turn_end_refusals.ts +51 -4
  54. package/src/scripts/check_beta_review_markers.ts +35 -4
  55. package/src/scripts/check_finding_dispositions.ts +167 -16
  56. package/src/scripts/check_held_object_arrivals.ts +381 -0
  57. package/src/scripts/check_preamble_payload_budget.ts +399 -122
  58. package/src/scripts/check_release_published.ts +1 -1
  59. package/src/scripts/check_reply_consistency.ts +86 -17
  60. package/src/scripts/hook_manifest.json +1 -1
  61. package/src/scripts/hook_manifest.yaml +39 -3
  62. package/src/scripts/hooks/block_kernel_rule_writes.ts +12 -2
  63. package/src/scripts/hooks/concern_registry.ts +2 -0
  64. package/src/scripts/hooks/end_review_nudge_hook.ts +101 -21
  65. package/src/scripts/hooks/review_baseline_hook.ts +130 -0
  66. package/src/scripts/hooks/turn_end_gate_hook.ts +232 -14
  67. package/src/scripts/hooks_doctor.ts +4 -1
  68. package/src/scripts/install.ts +1 -1
  69. package/src/scripts/interruption_report.ts +8 -2
  70. package/src/scripts/janitor.ts +8 -1
  71. package/src/scripts/measure_turn_end_gate.ts +302 -5
  72. package/src/scripts/release.ts +52 -64
  73. package/src/scripts/release_drill.ts +193 -14
  74. package/src/scripts/release_env.ts +1 -1
  75. package/src/scripts/release_publication.ts +1 -1
  76. package/src/scripts/report_held_object_arrivals.ts +456 -0
  77. package/src/scripts/sync_agent_settings.ts +140 -18
  78. package/src/server/io/yamlIO.ts +48 -0
@@ -103,6 +103,121 @@ export interface Ledger {
103
103
  findings: LedgerFinding[];
104
104
  /** Why the finding set is empty. Required when it is; see empty_ledger_problem. */
105
105
  no_findings_reason?: string;
106
+ // The six integrity fields, declared rather than swept into an index
107
+ // signature. An index signature would hold them, and would also disable
108
+ // excess-property and typo detection on the whole type for every consumer:
109
+ // under `--strict`, `l.findigs` type-checks against a Ledger carrying one.
110
+ // The set is known and finite, so it costs six lines to keep the type.
111
+ review_independence?: string;
112
+ context_relation?: string;
113
+ acceptance_status?: string;
114
+ assurance?: string;
115
+ reviewers?: string[];
116
+ coverage?: unknown;
117
+ }
118
+
119
+ /**
120
+ * Fields the self-review artifact carries that say how much a ledger's silence
121
+ * is worth, and which `--ingest` used to drop on the floor.
122
+ *
123
+ * `check_review_schema` derives `acceptance_status` and `assurance` from
124
+ * `review_independence` and fails a ledger whose declarations disagree — so an
125
+ * ingested ledger missing all three was not merely thinner than a hand-written
126
+ * one, it could not satisfy the gate that reads it. Every committed ledger from
127
+ * 14.19.0 onward carries the full set; only the ingest path produced files
128
+ * without it, which is one of the two reasons nobody could run the ingest and
129
+ * get a green tree.
130
+ *
131
+ * Carried rather than recomputed: the producing run is the only thing that
132
+ * knows how many reviewers answered and how much of the diff they saw, and a
133
+ * consumer inferring it from the findings would be inventing the integrity
134
+ * claim the fields exist to record.
135
+ */
136
+ export const INTEGRITY_FIELDS = [
137
+ 'review_independence',
138
+ 'context_relation',
139
+ 'acceptance_status',
140
+ 'assurance',
141
+ 'reviewers',
142
+ 'coverage',
143
+ ] as const;
144
+
145
+ /**
146
+ * Merge a self-review artifact into a ledger: new findings by id, the integrity
147
+ * fields on first write, and — when the review found nothing — the reason that
148
+ * makes an empty ledger legible.
149
+ *
150
+ * MUTATES AND RETURNS THE SAME OBJECT. Callers must use the returned `ledger`
151
+ * rather than relying on the mutation; the two are the same reference today and
152
+ * the signature is what a later reader will trust.
153
+ *
154
+ * The integrity fields are written only when the ledger does not already carry
155
+ * them. A second ingest into a ledger a human has since adjudicated must not
156
+ * silently restate what the earlier run claimed about independence — the first
157
+ * producing run is the one whose claim the dispositions were filled against.
158
+ *
159
+ * The empty-findings case is the one that used to deadlock a release. A clean
160
+ * review produces `findings: []`, `empty_ledger_problem` refuses an empty
161
+ * ledger with no `no_findings_reason`, and the release then stopped demanding
162
+ * dispositions for zero findings — an instruction nobody can follow. An ingest
163
+ * knows exactly what a clean review means and writes it, with the coverage
164
+ * numbers as the checkable part rather than a bare assurance.
165
+ */
166
+ export function merge_ingest(
167
+ ledger: Ledger,
168
+ artifact: Record<string, unknown>,
169
+ ): { ledger: Ledger; added: number; carried: string[]; reasoned: boolean } {
170
+ const known = new Set(ledger.findings.map((f) => f.finding_id));
171
+ let added = 0;
172
+ for (const f of (artifact['findings'] as LedgerFinding[] | undefined) ?? []) {
173
+ if (!known.has(f.finding_id)) {
174
+ ledger.findings.push(f);
175
+ added++;
176
+ }
177
+ }
178
+ // Assigned through one `unknown` view rather than per field: the six are a
179
+ // closed set the type declares, and spelling out six near-identical
180
+ // if-blocks to satisfy the checker would make the set harder to extend than
181
+ // the type is to read. The view is local to this loop.
182
+ const carried: string[] = [];
183
+ const sink = ledger as unknown as Record<string, unknown>;
184
+ for (const key of INTEGRITY_FIELDS) {
185
+ if (sink[key] === undefined && artifact[key] !== undefined) {
186
+ sink[key] = artifact[key];
187
+ carried.push(key);
188
+ }
189
+ }
190
+ // Only on an artifact that actually SAYS it found nothing. Without the key
191
+ // check, any JSON object without a `findings` key reads as an empty finding
192
+ // set, and the sentence below would assert a review that never happened —
193
+ // into the permanent record, which is worse than the deadlock it replaces.
194
+ let reasoned = false;
195
+ const declaresFindings = Array.isArray(artifact['findings']);
196
+ if (
197
+ declaresFindings &&
198
+ ledger.findings.length === 0 &&
199
+ (ledger.no_findings_reason ?? '').trim() === ''
200
+ ) {
201
+ ledger.no_findings_reason = _clean_review_reason(artifact);
202
+ reasoned = true;
203
+ }
204
+ return { ledger, added, carried, reasoned };
205
+ }
206
+
207
+ /** The `no_findings_reason` an ingest of a zero-findings artifact writes. */
208
+ function _clean_review_reason(artifact: Record<string, unknown>): string {
209
+ const cov = artifact['coverage'] as Record<string, unknown> | undefined;
210
+ const reviewed = cov?.['filesReviewed'];
211
+ const total = cov?.['filesTotal'];
212
+ const scope =
213
+ typeof reviewed === 'number' && typeof total === 'number'
214
+ ? ` over ${reviewed} of ${total} changed file(s)`
215
+ : '';
216
+ return (
217
+ `The self-review ran and reported no findings${scope}; ingested from its own ` +
218
+ 'artifact by the release, so the empty set is a result rather than an absence. ' +
219
+ 'Check it against the coverage block above and the run that produced it.'
220
+ );
106
221
  }
107
222
 
108
223
  /** Mirrors self_review_gate.classifyBlocking — security/claim × critical/high. */
@@ -389,32 +504,68 @@ function main(argv: readonly string[]): number {
389
504
  const ledgerPath = _ledger_path(dir, release);
390
505
 
391
506
  if (ingest) {
392
- const incoming = parse_ledger(
393
- JSON.stringify({
394
- schema_version: 1,
395
- release,
396
- findings: (JSON.parse(fs.readFileSync(ingest, 'utf-8')) as { findings: LedgerFinding[] }).findings,
397
- }),
398
- ingest,
399
- );
507
+ // Guarded: a truncated or corrupt artifact otherwise exits the child on
508
+ // an uncaught exception, and the release reports only "command failed"
509
+ // with no indication that the artifact is the problem.
510
+ let artifact: Record<string, unknown>;
511
+ try {
512
+ artifact = JSON.parse(fs.readFileSync(ingest, 'utf-8')) as Record<string, unknown>;
513
+ } catch (e) {
514
+ process.stderr.write(
515
+ `❌ ${ingest}: not readable as JSON (${e instanceof Error ? e.message : String(e)})\n`,
516
+ );
517
+ return 2;
518
+ }
519
+ // Run the incoming findings through the parser the committed ledger goes
520
+ // through, and keep the RESULT rather than discarding it. What that buys
521
+ // is narrow and worth stating: the parser checks the envelope and that
522
+ // every entry has a non-empty `finding_id`. It does NOT check severity,
523
+ // kind or title, so an entry with a bad severity still lands — the
524
+ // schema gate is what reads those, on the committed file.
525
+ let incoming: Ledger;
526
+ try {
527
+ incoming = parse_ledger(
528
+ JSON.stringify({
529
+ schema_version: 1,
530
+ release,
531
+ findings: (artifact['findings'] as LedgerFinding[] | undefined) ?? [],
532
+ }),
533
+ ingest,
534
+ );
535
+ } catch (e) {
536
+ process.stderr.write(`❌ ${e instanceof Error ? e.message : String(e)}\n`);
537
+ return 2;
538
+ }
400
539
  let ledger: Ledger = { schema_version: 1, release, findings: [] };
401
540
  if (fs.existsSync(ledgerPath)) {
402
541
  ledger = parse_ledger(fs.readFileSync(ledgerPath, 'utf-8'), ledgerPath);
403
542
  }
404
- const known = new Set(ledger.findings.map((f) => f.finding_id));
405
- let added = 0;
406
- for (const f of incoming.findings) {
407
- if (!known.has(f.finding_id)) {
408
- ledger.findings.push(f);
409
- added++;
410
- }
411
- }
543
+ const merged = merge_ingest(ledger, { ...artifact, findings: incoming.findings });
544
+ ledger = merged.ledger;
545
+ const { added, carried, reasoned } = merged;
412
546
  fs.mkdirSync(path.dirname(ledgerPath), { recursive: true });
413
547
  fs.writeFileSync(ledgerPath, JSON.stringify(ledger, null, 2) + '\n');
414
548
  process.stdout.write(
415
549
  `📥 ingested ${added} new finding(s) into ${path.relative(REPO_ROOT, ledgerPath)} — ` +
416
550
  'fill dispositions before the release validation goes green\n',
417
551
  );
552
+ if (carried.length > 0) {
553
+ process.stdout.write(` carried integrity fields: ${carried.join(', ')}\n`);
554
+ }
555
+ if (reasoned) {
556
+ process.stdout.write(
557
+ ' the review reported nothing — wrote no_findings_reason so the empty ' +
558
+ 'ledger is a result rather than an absence\n',
559
+ );
560
+ }
561
+ const absent = INTEGRITY_FIELDS.filter((k) => ledger[k] === undefined);
562
+ if (absent.length > 0) {
563
+ process.stdout.write(
564
+ `⚠️ artifact carried no ${absent.join(', ')} — ` +
565
+ 'check_review_schema derives acceptance_status and assurance from ' +
566
+ 'review_independence and will red on a ledger that declares neither\n',
567
+ );
568
+ }
418
569
  return 0;
419
570
  }
420
571
 
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * A held object cited as blocking live work carries an arrival count.
4
+ *
5
+ * WHAT A HELD OBJECT IS, AND WHAT MAKES ONE VISIBLE
6
+ * -------------------------------------------------
7
+ * A stub or a parked roadmap is an argument the estate decided not to run
8
+ * right now. Every one of them was created by a round that raised its subject.
9
+ * That first round is the arrival the object already records by existing, so a
10
+ * held object nobody has raised again owes no counter — there is nothing to
11
+ * count.
12
+ *
13
+ * What changes that is a CITATION: a live roadmap naming the object inside a
14
+ * blocker, which is the estate saying "this held thing is in the way of work
15
+ * we are doing now". That is the subject arriving a second time, and from then
16
+ * on the number is the fact a reader needs. Without it the next round meets a
17
+ * fresh argument instead of a count, re-derives the same conclusion, and the
18
+ * recurrence stays invisible — the failure this check exists to notice.
19
+ *
20
+ * WHY THE CITER SET IS THE LIVE ESTATE AND NOTHING ELSE
21
+ * -----------------------------------------------------
22
+ * Measured before the rule was written, not after. Widening the citers to
23
+ * every markdown file in the tree fires on 56 of 72 held objects, and widening
24
+ * only to archived roadmaps and stored review inputs fires on 54 of 66. Both
25
+ * populations are dominated by records that are historical by construction: an
26
+ * archived roadmap asserts what was true when it closed, and a stored review
27
+ * input asserts what a reviewer saw on a scope that no longer exists. Neither
28
+ * is the estate asking again. A check reading them as live citations reds on
29
+ * four fifths of its corpus on the day it lands, which is a suppression target
30
+ * wearing a control's clothes.
31
+ *
32
+ * So a citer is an active or parked roadmap, and the citation must sit in that
33
+ * roadmap's blocker scope rather than anywhere in its prose. Both narrowings
34
+ * are load-bearing and both were measured.
35
+ *
36
+ * ADVISORY BY DEFAULT, AND THE REASON IS RECORDED
37
+ * -----------------------------------------------
38
+ * It ships reporting rather than blocking. The false-positive rate over the
39
+ * live corpus was measured first and is 0 of 4 firings, which would permit
40
+ * blocking on its own. What forbids it today is that the same reading has the
41
+ * check red on four live objects the moment it lands, and a gate that arrives
42
+ * red is a backlog rather than a control. `--enforce` exits non-zero, so the
43
+ * promotion is a flag rather than an edit, and the condition is written into
44
+ * the measurement record beside the rate.
45
+ *
46
+ * Exit: 0 advisory (always) · with `--enforce`, 1 on any firing · 2 on a
47
+ * usage error or a dead scope.
48
+ */
49
+
50
+ import * as fs from 'node:fs';
51
+ import * as os from 'node:os';
52
+ import * as path from 'node:path';
53
+ import { fileURLToPath, pathToFileURL } from 'node:url';
54
+
55
+ import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
56
+ import { reportScanned } from './_lib/scan_scope.js';
57
+
58
+ const _HERE = path.resolve(fileURLToPath(import.meta.url));
59
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
60
+
61
+ /** Directories whose members are held objects. */
62
+ export const HELD_DIRS = ['agents/roadmaps/stubs', 'agents/roadmaps/later'] as const;
63
+
64
+ /**
65
+ * Directories whose roadmaps are live citers.
66
+ *
67
+ * The active tree, plus the parked tree because a parked roadmap resumes and
68
+ * its blockers resume with it. The archived tree is deliberately absent.
69
+ */
70
+ export const CITER_DIRS = ['agents/roadmaps', 'agents/roadmaps/later'] as const;
71
+
72
+ /** The line a held object carries once its subject has arrived again. */
73
+ export const ARRIVAL_RE = /^>\s*\*\*Arrivals:\*\*\s*\S/m;
74
+
75
+ /** A citation of a held object, by file name, inside a citer's prose. */
76
+ const HELD_REF_RE = /(?:stubs|later)\/([a-z0-9][a-z0-9\-.]*\.md)/g;
77
+
78
+ export interface Finding {
79
+ /** Repo-relative path of the held object with no counter. */
80
+ held: string;
81
+ /** Repo-relative paths of the live citers naming it inside a blocker. */
82
+ citers: string[];
83
+ }
84
+
85
+ export interface Result {
86
+ findings: Finding[];
87
+ /** Held objects cited inside a live blocker — the population the rate is taken over. */
88
+ cited: number;
89
+ /** Held objects found at all, so a collapsed corpus is visible. */
90
+ held: number;
91
+ /**
92
+ * Citer roots that EXIST and could not be read. Neither an empty root nor
93
+ * an absent one qualifies — both are real estate states. A name here means
94
+ * the scan could not see where the citations live, and that must never
95
+ * resolve to "nothing is cited".
96
+ */
97
+ deadCiterRoots: string[];
98
+ }
99
+
100
+ /**
101
+ * List the markdown directly under `dir`.
102
+ *
103
+ * `readable` separates the THREE states an empty list can mean, not two.
104
+ *
105
+ * A directory that exists and holds no markdown is a real estate state. So is a
106
+ * directory that does not exist at all: git cannot track an empty directory, so
107
+ * "nothing is parked" IS an absent `later/` in a fresh clone. Both are
108
+ * `readable` — there is nothing to read and that is the answer.
109
+ *
110
+ * A directory that exists and cannot be read is the failed measurement, and the
111
+ * only one that may block. An earlier form of this function collapsed all three
112
+ * into `[]`, which let a moved citer root report an affirmative clean verdict;
113
+ * the first repair then over-corrected and folded ABSENT in with unreadable,
114
+ * turning a fresh clone with nothing parked into a hard exit 2.
115
+ */
116
+ function listMarkdown(dir: string): { files: string[]; readable: boolean } {
117
+ let entries: fs.Dirent[];
118
+ try {
119
+ entries = fs.readdirSync(dir, { withFileTypes: true });
120
+ } catch (e) {
121
+ const code = (e as NodeJS.ErrnoException).code;
122
+ // ENOENT/ENOTDIR: nothing is there to read, which is a real state.
123
+ // Anything else (EACCES, EMFILE, EIO): it is there and we failed.
124
+ return { files: [], readable: code === 'ENOENT' || code === 'ENOTDIR' };
125
+ }
126
+ return {
127
+ files: entries
128
+ .filter((e) => e.isFile() && e.name.endsWith('.md'))
129
+ .map((e) => path.join(dir, e.name))
130
+ .sort(),
131
+ readable: true,
132
+ };
133
+ }
134
+
135
+ function read(file: string): string {
136
+ try {
137
+ return fs.readFileSync(file, 'utf8');
138
+ } catch {
139
+ return '';
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Held-object names a citer raises inside its blocker scope.
145
+ *
146
+ * Blocker scope opens at a `## Blockers` heading or at any `### blocker:`
147
+ * heading and closes at the next `## ` heading. A mention in a phase step or a
148
+ * risk row is the estate referring to the object, not declaring itself blocked
149
+ * on it, and the two must not read the same.
150
+ */
151
+ export function blockerCitations(text: string): Set<string> {
152
+ const out = new Set<string>();
153
+ let inBlocker = false;
154
+ let inFence = false;
155
+ for (const line of text.split('\n')) {
156
+ // A fenced block is illustration, not structure. Without this the
157
+ // scanner was fence-blind: a roadmap quoting the blocker shape inside
158
+ // a code fence opened blocker scope for the rest of its section, and
159
+ // every held-object mention there counted as a live citation — the
160
+ // exact discrimination this function exists to make, inverted.
161
+ if (/^\s*(?:```|~~~)/.test(line)) {
162
+ inFence = !inFence;
163
+ continue;
164
+ }
165
+ if (inFence) continue;
166
+ if (line.startsWith('## ')) inBlocker = line.trim().toLowerCase().startsWith('## blockers');
167
+ if (line.startsWith('### blocker:')) inBlocker = true;
168
+ if (!inBlocker) continue;
169
+ HELD_REF_RE.lastIndex = 0;
170
+ let m: RegExpExecArray | null;
171
+ while ((m = HELD_REF_RE.exec(line)) !== null) {
172
+ const name = m[1];
173
+ if (name !== undefined) out.add(name);
174
+ }
175
+ }
176
+ return out;
177
+ }
178
+
179
+ export function scan(root: string): Result {
180
+ const held = new Map<string, string>();
181
+ for (const d of HELD_DIRS) {
182
+ for (const f of listMarkdown(path.join(root, d)).files) held.set(path.basename(f), f);
183
+ }
184
+
185
+ const citerFiles = new Set<string>();
186
+ const deadCiterRoots: string[] = [];
187
+ for (const d of CITER_DIRS) {
188
+ const found = listMarkdown(path.join(root, d));
189
+ if (!found.readable) deadCiterRoots.push(d);
190
+ for (const f of found.files) citerFiles.add(f);
191
+ }
192
+ // Parked roadmaps are citers AND held objects, so the two sets overlap by
193
+ // construction. Only SELF-citation is excluded — a file naming itself. A
194
+ // held object citing a DIFFERENT held object still counts, which is
195
+ // deliberate and is what the live corpus is made of: all four current
196
+ // firings have a parked roadmap as the citer. An earlier version of this
197
+ // comment described a held-cites-held exclusion the code has never
198
+ // implemented, which would have read the whole firing population as
199
+ // excluded when auditing the adjudicated rate.
200
+ const citations = new Map<string, Set<string>>();
201
+ for (const f of [...citerFiles].sort()) {
202
+ const self = path.basename(f);
203
+ for (const name of blockerCitations(read(f))) {
204
+ if (name === self) continue;
205
+ if (!held.has(name)) continue;
206
+ const set = citations.get(name) ?? new Set<string>();
207
+ set.add(path.relative(root, f));
208
+ citations.set(name, set);
209
+ }
210
+ }
211
+
212
+ const findings: Finding[] = [];
213
+ for (const [name, citers] of [...citations.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
214
+ const file = held.get(name);
215
+ if (file === undefined) continue;
216
+ if (ARRIVAL_RE.test(read(file))) continue;
217
+ findings.push({ held: path.relative(root, file), citers: [...citers].sort() });
218
+ }
219
+ return { findings, cited: citations.size, held: held.size, deadCiterRoots };
220
+ }
221
+
222
+ export function check(root: string, enforce: boolean, write = process.stdout.write.bind(process.stdout)): number {
223
+ const res = scan(root);
224
+ // Published before the verdict on every path: a gate that says what it read
225
+ // only when it passes leaves the coverage census blind exactly when it
226
+ // matters.
227
+ reportScanned({
228
+ gate: 'check_held_object_arrivals',
229
+ scanned: res.cited,
230
+ units: 'held object(s) cited inside a live blocker',
231
+ roots: [...CITER_DIRS],
232
+ allowEmpty:
233
+ 'EMPTY_VALID: zero held objects cited inside a live blocker is a real and ' +
234
+ 'desirable estate state — nothing is currently blocked on a held argument. Both ' +
235
+ 'the held corpus and every citer root are asserted separately below, so an empty ' +
236
+ 'reading can only mean the estate, never an unread directory.',
237
+ });
238
+ if (res.held === 0) {
239
+ write('❌ no held objects found — the held-object directories are empty or moved.\n');
240
+ return 2;
241
+ }
242
+ // A citer root that EXISTS and cannot be read is a scan that could not see
243
+ // where the citations live. Reporting that as "nothing is cited" is the
244
+ // silent-green this gate exists to avoid, so it is a hard 2 on every path
245
+ // including advisory — the `res.held === 0` guard above does not reach it,
246
+ // because the held corpus stays readable when only a citer root breaks.
247
+ // An ABSENT root is not this case and does not reach here: `listMarkdown`
248
+ // reports ENOENT as readable, because git cannot track an empty directory
249
+ // and a fresh clone with nothing parked has no `later/` at all.
250
+ if (res.deadCiterRoots.length > 0) {
251
+ write(
252
+ `❌ citer root(s) present but unreadable: ${res.deadCiterRoots.join(', ')}. ` +
253
+ 'This is a failed measurement, not a clean estate.\n',
254
+ );
255
+ return 2;
256
+ }
257
+ if (res.findings.length === 0) {
258
+ write(
259
+ `✅ ${String(res.cited)} held object(s) cited inside a live blocker, all carrying an arrival line ` +
260
+ `(of ${String(res.held)} held objects).\n`,
261
+ );
262
+ return 0;
263
+ }
264
+ const label = enforce ? '❌' : '⚠️';
265
+ write(
266
+ `${label} ${String(res.findings.length)} of ${String(res.cited)} held object(s) cited inside a live blocker ` +
267
+ 'carry no arrival line:\n',
268
+ );
269
+ for (const f of res.findings) {
270
+ write(` · ${f.held}\n`);
271
+ for (const c of f.citers) write(` cited by ${c}\n`);
272
+ }
273
+ write(
274
+ '\nEach one is a subject that arrived at least twice — once to create the object, ' +
275
+ 'once in the blocker above. Derive the count with `report_held_object_arrivals ' +
276
+ '<object> --pattern <subject>` and write the line by hand, with its pattern and ' +
277
+ 'denominator, so the number carries a reason.\n',
278
+ );
279
+ if (!enforce) {
280
+ write('\nAdvisory: exiting 0. `--enforce` turns these into a failure.\n');
281
+ }
282
+ return enforce ? 1 : 0;
283
+ }
284
+
285
+ /**
286
+ * The check proving it still discriminates.
287
+ *
288
+ * The accept cases are the two that a firing-on-everything version would get
289
+ * wrong: an uncited held object with no counter is a first arrival and owes
290
+ * nothing, and an object cited outside blocker scope is being referred to
291
+ * rather than blocked on.
292
+ */
293
+ function selfTest(): number {
294
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'held-arrivals-selftest-'));
295
+ const HELD_BODY = '---\ncomplexity: lightweight\n---\n\n# Stub: a held subject\n\nbody\n';
296
+ const COUNTED_BODY =
297
+ '---\ncomplexity: lightweight\n---\n\n# Stub: a held subject\n\n' +
298
+ '> **Arrivals:** 3 — latest `round-b` (2026-01-02); earlier `round-a`.\n\nbody\n';
299
+
300
+ const build = (name: string, heldBody: string, citerBody: string): string => {
301
+ const root = path.join(tmp, name);
302
+ fs.mkdirSync(path.join(root, 'agents', 'roadmaps', 'stubs'), { recursive: true });
303
+ fs.mkdirSync(path.join(root, 'agents', 'roadmaps', 'later'), { recursive: true });
304
+ fs.writeFileSync(path.join(root, 'agents', 'roadmaps', 'stubs', 'road-to-held.md'), heldBody);
305
+ fs.writeFileSync(path.join(root, 'agents', 'roadmaps', 'road-to-live.md'), citerBody);
306
+ return root;
307
+ };
308
+ const citerWithBlocker =
309
+ '# Road to live work\n\n## Phase 1\n\n- [ ] 1.1 do a thing\n\n## Blockers\n\n' +
310
+ '### blocker: the held thing\n- **Status:** open\n- **Blocks:** everything\n' +
311
+ '- **What to do:** read `stubs/road-to-held.md` and decide.\n';
312
+ const citerWithoutBlocker =
313
+ '# Road to live work\n\n## Phase 1\n\n' +
314
+ '- [ ] 1.1 read `stubs/road-to-held.md` for background, then do a thing\n';
315
+
316
+ const run = (root: string, enforce: boolean): number =>
317
+ runGateCli(
318
+ REPO_ROOT,
319
+ 'src/scripts/check_held_object_arrivals.ts',
320
+ enforce ? ['--root', root, '--enforce'] : ['--root', root],
321
+ root,
322
+ );
323
+
324
+ try {
325
+ return runSelfTest({
326
+ gate: 'check_held_object_arrivals',
327
+ minCases: 5,
328
+ minRejectCases: 2,
329
+ cases: [
330
+ {
331
+ name: 'a held object cited inside a blocker with no arrival line is rejected',
332
+ expect: 'reject',
333
+ run: () => run(build('cited-uncounted', HELD_BODY, citerWithBlocker), true),
334
+ },
335
+ {
336
+ name: 'an empty held corpus is rejected rather than reported clean',
337
+ expect: 'reject',
338
+ run: () => {
339
+ const root = path.join(tmp, 'dead-scope');
340
+ fs.mkdirSync(path.join(root, 'agents', 'roadmaps'), { recursive: true });
341
+ return run(root, false);
342
+ },
343
+ },
344
+ {
345
+ name: 'a first-arrival held object with no counter and no citation passes',
346
+ expect: 'accept',
347
+ run: () => run(build('uncited', HELD_BODY, '# Road to live work\n\n## Phase 1\n\n- [ ] 1.1 x\n'), true),
348
+ },
349
+ {
350
+ name: 'a held object mentioned outside blocker scope passes',
351
+ expect: 'accept',
352
+ run: () => run(build('outside-blocker', HELD_BODY, citerWithoutBlocker), true),
353
+ },
354
+ {
355
+ name: 'a cited held object that carries its arrival line passes',
356
+ expect: 'accept',
357
+ run: () => run(build('counted', COUNTED_BODY, citerWithBlocker), true),
358
+ },
359
+ ],
360
+ });
361
+ } finally {
362
+ fs.rmSync(tmp, { recursive: true, force: true });
363
+ }
364
+ }
365
+
366
+ export function main(argv: readonly string[]): number {
367
+ if (argv.includes('--help') || argv.includes('-h')) {
368
+ process.stdout.write(
369
+ 'usage: check_held_object_arrivals [--root DIR] [--enforce] [--self-test]\n\n' +
370
+ ' --enforce exit 1 on any firing; the default is advisory\n',
371
+ );
372
+ return 0;
373
+ }
374
+ if (argv.includes('--self-test')) return selfTest();
375
+ const i = argv.indexOf('--root');
376
+ return check(i === -1 ? REPO_ROOT : (argv[i + 1] ?? REPO_ROOT), argv.includes('--enforce'));
377
+ }
378
+
379
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
380
+ process.exit(main(process.argv.slice(2)));
381
+ }