@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
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Is the payload census still measuring everything payload-shaped?
3
+ *
4
+ * WHY THIS EXISTS — prerequisite 4c of the measured-ceiling verdict
5
+ * -----------------------------------------------------------------
6
+ * An AI council (2/2, 2026-09-10) named a gap neither earlier round had: *"a
7
+ * pull request can move payload into a location the measurement does not
8
+ * enumerate, and no ceiling formula sees that."* Both seats were equally clear
9
+ * that this is NOT a reason to prefer a stored ceiling — the same move defeats
10
+ * a stored number exactly as it defeats a measured one — so it is an
11
+ * independent defect with its own mitigation: *"an exhaustiveness check that
12
+ * fails when a payload-shaped file sits outside every known bucket."*
13
+ *
14
+ * TWO WAYS PAYLOAD ESCAPES THE CENSUS, AND BOTH ARE CHECKED
15
+ * ----------------------------------------------------------
16
+ * 1. A NEW PROJECTED TREE. Something lands under `dist/agent-src/` that a host
17
+ * loads into standing context, and no bucket covers it. Every directory
18
+ * there is therefore either a declared prefix-stable surface or carries an
19
+ * explicit on-demand classification WITH its reason. A tree that is neither
20
+ * reds until a human classifies it. "Unclassified" is the state this check
21
+ * exists to make loud; guessing on its behalf is what produced the gap.
22
+ *
23
+ * 2. A RULE BODY OUTSIDE THE RULES BUCKET. A file with rule frontmatter is
24
+ * standing payload by construction — the router delivers it — so one
25
+ * sitting outside `dist/agent-src/rules` is measured by nothing while
26
+ * behaving exactly like the thing that is measured.
27
+ *
28
+ * WHY THE ON-DEMAND LIST IS HERE AND NOT IN THE BUDGET JSON
29
+ * ----------------------------------------------------------
30
+ * Under prerequisite 3 the gate runs with the code as it exists at the BASE
31
+ * ref, so anything that lives in code pins with it and a pull request cannot
32
+ * widen its own enumeration. A classification kept in the config would be
33
+ * head-side and editable by the change being measured, which is the trust
34
+ * boundary the same verdict spent its sharpest finding on. The measured
35
+ * evidence behind each reason stays in `preamble-payload-budget.json` →
36
+ * `metric.excluded_buckets`, which is where a reader looks for the numbers;
37
+ * this list carries the classification, not the measurement.
38
+ *
39
+ * WHAT IT DOES NOT CLAIM. It sees `dist/agent-src/`. A host that began loading
40
+ * some other tree standing would not appear here at all, and no check in this
41
+ * repository would notice. That reopen condition is the registry's own, stated
42
+ * in `prefix_stable_surfaces.ts`, and this module does not pretend to cover it.
43
+ */
44
+ import * as fs from 'node:fs';
45
+ import * as path from 'node:path';
46
+
47
+ import { PREFIX_STABLE_SURFACES } from './prefix_stable_surfaces.js';
48
+
49
+ /** The root every projected tree lands under. */
50
+ export const PROJECTION_ROOT = 'dist/agent-src';
51
+
52
+ export interface OnDemandTree {
53
+ /** Directory name directly under `dist/agent-src/`. */
54
+ dir: string;
55
+ /** Why it costs no standing tokens. A classification, not a measurement. */
56
+ why: string;
57
+ }
58
+
59
+ /**
60
+ * Trees that are projected but NOT standing payload.
61
+ *
62
+ * Each reason states the mechanism that keeps it off the preamble, because
63
+ * "it is not standing" with no mechanism behind it is the assumption this
64
+ * whole check exists to stop being made silently.
65
+ */
66
+ export const ON_DEMAND_TREES: readonly OnDemandTree[] = [
67
+ { dir: 'commands', why: 'a command body is read when its slash command is invoked, never preloaded' },
68
+ { dir: 'contexts', why: 'loaded through load_context on demand; no rule declares load_context_eager' },
69
+ { dir: 'ghostwriter', why: 'persona corpora read by the ghostwriter surface when it runs' },
70
+ { dir: 'guidelines', why: 'reference material a skill or rule links to; fetched when followed' },
71
+ { dir: 'packs', why: 'pack manifests read by the installer, not delivered into a session' },
72
+ { dir: 'personas', why: 'measured at zero standing cost 2026-08-22 — written by generators, read by no host contract' },
73
+ { dir: 'presets', why: 'installer input, not session content' },
74
+ { dir: 'profiles', why: 'installer input, not session content' },
75
+ { dir: 'scripts', why: 'executable support code; never rendered into a prompt' },
76
+ { dir: 'templates', why: 'scaffolding copied on demand by authoring flows' },
77
+ { dir: 'user-types', why: 'installer input, not session content' },
78
+ ];
79
+
80
+ /** Files under a non-rules tree that legitimately carry rule frontmatter. */
81
+ const RULE_SHAPED_EXEMPT: readonly string[] = [
82
+ // The rule scaffold a new rule is copied FROM. It is frontmatter by
83
+ // definition and is delivered to nobody.
84
+ 'dist/agent-src/templates/rule.md',
85
+ ];
86
+
87
+ const RULE_TYPE_LINE = /^type:\s*"?(always|auto|manual)"?\s*$/m;
88
+
89
+ function normalise(p: string): string {
90
+ return p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
91
+ }
92
+
93
+ /** Directory names directly under the projection root that a surface covers. */
94
+ function declaredTopLevelDirs(): Set<string> {
95
+ const out = new Set<string>();
96
+ const prefix = PROJECTION_ROOT + '/';
97
+ for (const s of PREFIX_STABLE_SURFACES) {
98
+ const root = normalise(s.root);
99
+ if (!root.startsWith(prefix)) continue;
100
+ const rest = root.slice(prefix.length);
101
+ const first = rest.split('/')[0];
102
+ if (first !== undefined && first !== '') out.add(first);
103
+ }
104
+ return out;
105
+ }
106
+
107
+ function walkMarkdown(dir: string, out: string[]): void {
108
+ let entries: fs.Dirent[];
109
+ try {
110
+ entries = fs.readdirSync(dir, { withFileTypes: true });
111
+ } catch {
112
+ return;
113
+ }
114
+ for (const e of entries) {
115
+ const abs = path.join(dir, e.name);
116
+ if (e.isDirectory()) walkMarkdown(abs, out);
117
+ else if (e.isFile() && e.name.endsWith('.md')) out.push(abs);
118
+ }
119
+ }
120
+
121
+ export interface CatalogueAudit {
122
+ /** Empty when every projected tree is accounted for. */
123
+ findings: string[];
124
+ /** Top-level trees inspected — a zero here means the root moved. */
125
+ treesSeen: number;
126
+ /** Markdown files scanned for rule frontmatter outside the rules bucket. */
127
+ filesScanned: number;
128
+ }
129
+
130
+ /**
131
+ * Audit the projection for payload the census does not enumerate.
132
+ *
133
+ * Pure over the filesystem, and returns findings rather than throwing: the
134
+ * caller decides whether an unclassified tree is a refusal or a report, and
135
+ * the tests need to inspect the findings themselves.
136
+ */
137
+ export function auditCatalogue(repoRoot: string): CatalogueAudit {
138
+ const rootAbs = path.join(repoRoot, PROJECTION_ROOT);
139
+ const findings: string[] = [];
140
+ const declared = declaredTopLevelDirs();
141
+ const onDemand = new Set(ON_DEMAND_TREES.map((t) => t.dir));
142
+
143
+ let dirs: fs.Dirent[];
144
+ try {
145
+ dirs = fs.readdirSync(rootAbs, { withFileTypes: true });
146
+ } catch {
147
+ return {
148
+ findings: [
149
+ `${PROJECTION_ROOT} could not be read. The payload catalogue cannot be audited against a ` +
150
+ 'projection root that is not there — regenerate it (`task sync`) before trusting a ' +
151
+ 'green from this gate.',
152
+ ],
153
+ treesSeen: 0,
154
+ filesScanned: 0,
155
+ };
156
+ }
157
+
158
+ let treesSeen = 0;
159
+ for (const d of dirs) {
160
+ if (!d.isDirectory()) continue;
161
+ treesSeen += 1;
162
+ if (declared.has(d.name) || onDemand.has(d.name)) continue;
163
+ findings.push(
164
+ `${PROJECTION_ROOT}/${d.name}/ is projected but classified by nothing. It is neither a declared ` +
165
+ 'prefix-stable surface nor on the on-demand list, so the standing-payload census does not ' +
166
+ 'measure it and no ceiling can see what it costs. Classify it: add it to ' +
167
+ 'PREFIX_STABLE_SURFACES if a host loads it into standing context, or to ON_DEMAND_TREES ' +
168
+ 'with the mechanism that keeps it out. Do not leave it unclassified — the whole point of ' +
169
+ 'this check is that unclassified is the state nobody notices.',
170
+ );
171
+ }
172
+
173
+ // Rule-shaped payload outside the rules bucket.
174
+ const exempt = new Set(RULE_SHAPED_EXEMPT.map(normalise));
175
+ let filesScanned = 0;
176
+ for (const d of dirs) {
177
+ if (!d.isDirectory() || declared.has(d.name)) continue;
178
+ const files: string[] = [];
179
+ walkMarkdown(path.join(rootAbs, d.name), files);
180
+ for (const abs of files) {
181
+ const rel = normalise(path.relative(repoRoot, abs));
182
+ if (exempt.has(rel)) continue;
183
+ filesScanned += 1;
184
+ let head: string;
185
+ try {
186
+ // Frontmatter only — reading whole bodies over ~1,800 files is
187
+ // the difference between a gate people run and one they skip.
188
+ const fd = fs.openSync(abs, 'r');
189
+ const buf = Buffer.alloc(2048);
190
+ const n = fs.readSync(fd, buf, 0, 2048, 0);
191
+ fs.closeSync(fd);
192
+ head = buf.subarray(0, n).toString('utf-8');
193
+ } catch {
194
+ continue;
195
+ }
196
+ if (!head.startsWith('---')) continue;
197
+ const end = head.indexOf('\n---', 3);
198
+ const frontmatter = end === -1 ? head : head.slice(0, end);
199
+ if (RULE_TYPE_LINE.test(frontmatter)) {
200
+ findings.push(
201
+ `${rel} carries rule frontmatter but sits outside the measured rules bucket. A file the ` +
202
+ 'router can deliver is standing payload whatever directory it is in, so this one costs ' +
203
+ 'tokens the ceiling never sees. Move it under the rules surface, or — if it is a ' +
204
+ 'scaffold that is delivered to nobody — add it to RULE_SHAPED_EXEMPT with that reason.',
205
+ );
206
+ }
207
+ }
208
+ }
209
+
210
+ return { findings, treesSeen, filesScanned };
211
+ }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * The release's side of the findings-ledger contract: verification, not
3
+ * production.
4
+ *
5
+ * WHY THE CONTRACT EXISTS. The findings ledger is the durable record a release
6
+ * makes about what its own review found, and `check_finding_dispositions`
7
+ * treats an absent one for a SHIPPED version as a failure — repo-wide, because
8
+ * the same check runs on every pull request. Producing it was documented and
9
+ * unowned: the self-review workflow uploaded an artifact and its own comment
10
+ * said a human should run `--ingest`. Nobody did, so the ledger went missing
11
+ * once per release, six times between 14.19.0 and 15.0.0, and each absence was
12
+ * cleared by whoever happened to be pushing an unrelated change.
13
+ *
14
+ * WHY THE INGEST IS NOT HERE. Two review rounds established that no placement
15
+ * inside `release.ts` works. `finding-dispositions` runs on the release PR and
16
+ * reds while the ledger lacks a finding the review reported, so a step that
17
+ * produced the ledger after the check wait could never run — the wait had
18
+ * already died on that red check — and a step that produced it before the wait
19
+ * pushed a commit the wait then had to redo, on a head whose checks had not
20
+ * started. The owner's decision on 2026-09-11 was to move the ingest into the
21
+ * workflow that already holds the artifact, which is the one place where the
22
+ * ledger can exist before any gate looks for it.
23
+ *
24
+ * WHY THE PUSH NEEDS A PAT. A third review round found that the move alone did
25
+ * not fix the ordering, it relocated it: the ingest job is a check run of the
26
+ * same `pull_request` event, so its push lands inside the release's own check
27
+ * wait — and a push authenticated with GITHUB_TOKEN creates no run, leaving a
28
+ * head with zero checks against a protection that requires one. The job pushes
29
+ * with `RELEASE_PR_TOKEN` instead, the pattern `release.yml` already uses, so
30
+ * the push produces the checks the wait is waiting for. Without that secret the
31
+ * job declines to push at all rather than produce the deadlock, and the absence
32
+ * arrives here as a missing ledger with the cause named.
33
+ *
34
+ * WHAT IS LEFT HERE. Two questions and a stop for each: is the ledger on the
35
+ * REMOTE branch, and does the disposition gate pass. Neither is repaired by
36
+ * the release — filling a disposition states what the release ships, with a
37
+ * rationale and a named verifier, and no automation writes one.
38
+ */
39
+ import * as fs from 'node:fs';
40
+ import * as path from 'node:path';
41
+
42
+ import { isBlocking, parse_ledger } from '../check_finding_dispositions.js';
43
+
44
+
45
+ /** Where a release's ledger lives, relative to the repository root. */
46
+ export function ledgerRelPath(version: string): string {
47
+ return `agents/evidence/release-findings/${version}.json`;
48
+ }
49
+
50
+ /**
51
+ * The workflow that produces and commits the ledger, named so a message can
52
+ * point the operator at the right run log.
53
+ *
54
+ * A rename there without one here is caught by
55
+ * `release_findings_ingest.test.ts`, which reads the workflow file rather than
56
+ * trusting this string.
57
+ */
58
+ export const FINDINGS_WORKFLOW = 'self-review-gate.yml';
59
+
60
+ /**
61
+ * Argv proving the ledger is on the REMOTE branch — the only ref that answers
62
+ * the question the release is asking.
63
+ *
64
+ * Three refs give three different answers and only one is right. The working
65
+ * tree says "a file exists here", which a by-hand ingest also satisfies. The
66
+ * LOCAL branch ref says "a commit here carries it", which a commit that failed
67
+ * to push has already made true — so probing it answers identically to the
68
+ * filesystem in exactly the case the probe exists for, which is the defect a
69
+ * second review round caught in the first attempt at this fix. The
70
+ * remote-tracking ref is the one that goes false when a push fails, and the
71
+ * merge reads the remote.
72
+ *
73
+ * `remote` is a parameter rather than a constant so a fork or a mirror is not
74
+ * silently assumed to be `origin`.
75
+ */
76
+ export function ledgerOnBranchArgv(remote: string, branch: string, rel: string): string[] {
77
+ return ['cat-file', '-e', `${remote}/${branch}:${rel}`];
78
+ }
79
+
80
+ /**
81
+ * Why the release refuses to continue without a ledger on the branch.
82
+ *
83
+ * Continuing would merge and tag with no ledger, and an absent ledger becomes a
84
+ * repo-wide failure the moment the tag exists — that is the six-release failure
85
+ * this mechanism ends, so it is a stop rather than a warning.
86
+ *
87
+ * The causes are named because the operator has to pick one and most of them
88
+ * are ordinary. With the ingest in CI, "it has not happened yet" is the common
89
+ * case and waiting is the answer; a repository without the review secret never
90
+ * gets one at all and needs the ledger by hand, once per release, which is a
91
+ * real cost and is stated here rather than discovered.
92
+ */
93
+ export function ledgerAbsentMessage(version: string, branch: string, remote: string): string {
94
+ return (
95
+ `${ledgerRelPath(version)} is not on ${remote}/${branch}.\n` +
96
+ ' The release stops: continuing would merge and tag with no findings ledger, and an ' +
97
+ 'absent ledger for a shipped version reds every pull request in the repository.\n' +
98
+ ` The ${FINDINGS_WORKFLOW} workflow commits it to the release branch once it has ` +
99
+ 'reviewed the head. Ordinary causes, in the order to check them: the review has not ' +
100
+ 'finished yet (wait, then resume); the review found nothing and its commit is still in ' +
101
+ 'flight (same); no ANTHROPIC_API_KEY, so no review ran and no ledger will appear — write ' +
102
+ `one with a no_findings_reason and push it to ${branch}; no RELEASE_PR_TOKEN, in which ` +
103
+ 'case the job refuses to push rather than deadlock this wait and says so as a warning in ' +
104
+ 'its run log — configure the PAT, or write the ledger by hand; or the workflow could not ' +
105
+ 'push, which its run log will say.\n' +
106
+ ' Then: `git pull` on the release branch, and resume.'
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Why the release refuses to continue when the PR is already merged and the
112
+ * ledger is not on the trunk either.
113
+ *
114
+ * This is the state the whole mechanism exists to prevent, reached the one way
115
+ * that survives every guard before it: the ingest produced nothing, step 7
116
+ * stopped, and the PR was merged by hand anyway. A resumed run then finds
117
+ * `state === 'MERGED'` and — until this check existed — printed "the ledger
118
+ * rode in with it" without reading anything, tagged, and published. The absent
119
+ * ledger for a shipped version is what reds every pull request in the
120
+ * repository, so the assertion had to become a question.
121
+ *
122
+ * The release branch is gone by now, so there is no workflow run left to wait
123
+ * for: the remaining path is a ledger written onto the trunk by hand.
124
+ */
125
+ export function ledgerAbsentAfterMergeMessage(
126
+ version: string,
127
+ trunk: string,
128
+ remote: string,
129
+ ): string {
130
+ return (
131
+ `${ledgerRelPath(version)} is on neither the release branch nor ${remote}/${trunk}, and ` +
132
+ 'the release pull request is already merged.\n' +
133
+ ' The release stops: tagging now ships a version whose findings ledger does not exist, ' +
134
+ 'and an absent ledger for a shipped version reds every pull request in the repository.\n' +
135
+ ` The release branch is gone, so the ${FINDINGS_WORKFLOW} run that would have committed ` +
136
+ 'the ledger cannot be waited for. Write the ledger on the trunk instead — the review for ' +
137
+ 'this version is in its own workflow run, or record a no_findings_reason if none ran — ' +
138
+ 'push it, and resume.'
139
+ );
140
+ }
141
+
142
+ /**
143
+ * The message a release stops with when the disposition gate refuses.
144
+ *
145
+ * `blocking` is what the release could count itself: findings that are blocking
146
+ * and carry no `status`. It is deliberately NOT presented as the reason — the
147
+ * gate refuses for more shapes than that (an unknown status, an empty rationale
148
+ * or verifier, a `fixed` with no commit, a malformed ledger, an undeterminable
149
+ * release status), and an earlier version of this message asserted "N blocking
150
+ * … carry no disposition" for every one of them. At N = 0 that produced an
151
+ * instruction nobody could follow.
152
+ *
153
+ * So the gate's own output is the reason, passed in and printed, and this text
154
+ * only says where the file is, why the release stopped, and how to resume.
155
+ */
156
+ export function dispositionStopMessage(
157
+ version: string,
158
+ gateOutput: string,
159
+ blocking: number,
160
+ resumeCmd: string,
161
+ ): string {
162
+ const count =
163
+ blocking > 0 ? ` ${blocking} of them are blocking findings with no status yet.\n` : '';
164
+ return (
165
+ `the disposition gate refuses ${version}. Its own report:\n` +
166
+ `${gateOutput.trim() || ' (the gate printed nothing — run it directly to see why)'}\n` +
167
+ count +
168
+ ` The ledger is at ${ledgerRelPath(version)} and the release stops here by design: a ` +
169
+ 'disposition is an adjudication of what this release ships, with a rationale and a ' +
170
+ 'named verifier, and no automation may write one.\n' +
171
+ ` Resolve what the report names, push the release branch, then: ${resumeCmd}`
172
+ );
173
+ }
174
+
175
+ /** The release's own subprocess result shape, mirrored so this module stays free of it. */
176
+ interface StepRunResult {
177
+ returncode: number;
178
+ stdout: string;
179
+ stderr: string;
180
+ }
181
+
182
+ /**
183
+ * What the step needs from the release script, injected rather than imported.
184
+ *
185
+ * `release.ts` sits past the source-size cap where every line is charged, and
186
+ * this step is the release's side of the contract this module already owns — so
187
+ * it lives here. Injection rather than an import of `release_publication.ts`
188
+ * keeps `_lib` free of a dependency on its own caller.
189
+ */
190
+ export interface LedgerStepDeps {
191
+ run: (argv: string[], opts: { check: boolean; capture: boolean }) => StepRunResult;
192
+ die: (msg: string) => never;
193
+ /** Prints the step banner, so the caller keeps the step number. */
194
+ step: (msg: string) => void;
195
+ /** Where the step reports the ref it read. */
196
+ write: (s: string) => void;
197
+ /** Absolute repository root, for resolving the ledger on disk. */
198
+ repoRoot: string;
199
+ /** Remote name, never assumed to be `origin`. */
200
+ remote: string;
201
+ /** The trunk the release merges into — the ref to ask once the branch is gone. */
202
+ trunk: string;
203
+ /**
204
+ * The PR number for `--pr`, resolved late.
205
+ *
206
+ * Late because the release probes its PR once at the top and only on a
207
+ * resumed run, so on a first run the number was null here and `--pr` was
208
+ * silently dropped — and the release then passed a strictly weaker check
209
+ * than `finding-dispositions`, learning about an un-ingested finding from
210
+ * CI instead. By the time this step runs the PR exists either way.
211
+ */
212
+ resolvePr: () => number | null;
213
+ }
214
+
215
+ /**
216
+ * Verify the release carries its own findings ledger, or stop.
217
+ *
218
+ * VERIFIES, NEVER PRODUCES — the why is the module docstring above. Placed
219
+ * between the check wait and the merge: the ledger lives on the release branch
220
+ * and the merge deletes it, so this is the last moment the question means
221
+ * anything, and after the wait so a red `finding-dispositions` has already
222
+ * stopped the release with the check's own name rather than a duplicate of it.
223
+ *
224
+ * Two questions, each answered with a stop:
225
+ *
226
+ * 1. Is the ledger on the ref the merge will read? On an open PR that is the
227
+ * remote release branch; once the PR is merged the branch is deleted and
228
+ * the ledger, if it was ever produced, rode into the trunk. The merged
229
+ * path used to ask nothing and assert the good case.
230
+ * 2. Does the disposition gate pass, in `--pr` mode — the same mode CI uses,
231
+ * so the release cannot pass a weaker check than the one it is trying to
232
+ * pre-satisfy.
233
+ *
234
+ * Neither answer is repaired here. Filling a disposition states what the release
235
+ * ships, with a rationale and a named verifier; no automation writes one.
236
+ */
237
+ export function settleFindingsLedger(
238
+ deps: LedgerStepDeps,
239
+ version: string,
240
+ branch: string,
241
+ merged: boolean,
242
+ ): void {
243
+ deps.step(
244
+ merged
245
+ ? 'Verify the findings ledger rode in with the merge'
246
+ : 'Verify the self-review findings ledger',
247
+ );
248
+ const rel = ledgerRelPath(version);
249
+ const ref = merged ? deps.trunk : branch;
250
+
251
+ // REFRESH BEFORE READING, AND FAST-FORWARD BEFORE ASKING THE GATE. Both
252
+ // halves are a defect a fourth review measured, and they are the same
253
+ // defect seen from two sides. `cat-file -e <remote>/<ref>:<path>` reads
254
+ // `.git`, not GitHub — and nothing in a first run refreshes that
255
+ // remote-tracking ref after CI commits the ledger: preflight fetches before
256
+ // the branch exists, and step 4's push sets the ref to the pushed commit,
257
+ // which is strictly older than the ledger commit. So the probe answered
258
+ // "absent" on every first release. The disposition gate then reads the
259
+ // WORKING TREE, which the same push left equally stale, so a fetch alone
260
+ // would fix the probe and hand the gate an empty ledger instead.
261
+ //
262
+ // `--ff-only` on purpose: the release pushed this ref itself, so the only
263
+ // commit ahead of it should be CI's. Anything else means the branch moved
264
+ // under the run, and stopping is better than merging a tree nobody read.
265
+ // Both are `check: false` — a fetch that fails leaves the probe to report
266
+ // the absence with a message that says what to do.
267
+ deps.run(['git', 'fetch', deps.remote, ref], { check: false, capture: true });
268
+ deps.run(['git', 'pull', '--ff-only', deps.remote, ref], { check: false, capture: true });
269
+
270
+ const onBranch =
271
+ deps.run(['git', ...ledgerOnBranchArgv(deps.remote, ref, rel)], {
272
+ check: false,
273
+ capture: true,
274
+ }).returncode === 0;
275
+ if (!onBranch) {
276
+ deps.die(
277
+ merged
278
+ ? ledgerAbsentAfterMergeMessage(version, deps.trunk, deps.remote)
279
+ : ledgerAbsentMessage(version, branch, deps.remote),
280
+ );
281
+ }
282
+ deps.write(` ledger on ${deps.remote}/${ref}: ${rel}\n`);
283
+
284
+ const argv = ['./scripts-run', 'src/scripts/check_finding_dispositions', '--release', version];
285
+ const pr = deps.resolvePr();
286
+ if (pr !== null) {
287
+ argv.push('--pr', String(pr));
288
+ }
289
+ const verdict = deps.run(argv, { check: false, capture: true });
290
+ if (verdict.returncode !== 0) {
291
+ deps.die(
292
+ dispositionStopMessage(
293
+ version,
294
+ // BOTH streams. The gate writes its scan line to stdout and its
295
+ // per-finding diagnosis to stderr, so preferring stdout showed
296
+ // the operator `scanned: N` and discarded the entire reason the
297
+ // release stopped.
298
+ [verdict.stdout, verdict.stderr].map((s) => s.trim()).filter(Boolean).join('\n'),
299
+ blockingWithoutDisposition(path.join(deps.repoRoot, rel)),
300
+ 'task release -- --resume --yes',
301
+ ),
302
+ );
303
+ }
304
+ }
305
+
306
+ /**
307
+ * How many blocking findings carry no status — a figure for the stop message,
308
+ * never the reason for it.
309
+ *
310
+ * The gate refuses for more shapes than this counts (an unknown status, an
311
+ * empty rationale or verifier, a `fixed` with no commit, a malformed ledger),
312
+ * which is why the gate's own output is what the operator is shown.
313
+ */
314
+ function blockingWithoutDisposition(ledgerAbs: string): number {
315
+ if (!fs.existsSync(ledgerAbs)) {
316
+ return 0;
317
+ }
318
+ try {
319
+ const ledger = parse_ledger(fs.readFileSync(ledgerAbs, 'utf-8'), ledgerAbs);
320
+ return ledger.findings.filter((f) => isBlocking(f) && !f.status).length;
321
+ } catch {
322
+ return 0;
323
+ }
324
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The `--ci` fallback dispatch of the three tag-triggered workflows.
3
+ *
4
+ * Extracted from `release.ts` as a lowering commit under
5
+ * `check_source_size_budget`, whose own contract names splitting an
6
+ * over-ceiling file as the improvement it exists to force. The block is
7
+ * self-contained — three workflow names, one loop, one warning — and nothing
8
+ * else in step 10 reads it.
9
+ *
10
+ * WHY IT EXISTS. A tag pushed with the default `GITHUB_TOKEN` fires no
11
+ * tag-triggered workflow: GitHub's recursion guard suppresses the event. All
12
+ * three workflows accept a `tag` input for exactly this recovery, so under
13
+ * `--ci` the release dispatches them rather than waiting for an event that will
14
+ * never arrive. When the tag was pushed with a PAT they already fired and this
15
+ * is redundant.
16
+ *
17
+ * WHY IT NEVER FAILS THE RELEASE. By the time it runs the release is complete —
18
+ * the tag is pushed, the GitHub Release is created, npm publishes
19
+ * asynchronously. Dispatching through the API additionally needs the token's
20
+ * `actions:write` scope, and a 403 for a missing scope must not mark an
21
+ * already-shipped release as failed. So it warns and continues.
22
+ */
23
+
24
+ /** The tag-triggered workflows, in the order the release dispatches them. */
25
+ export const TAG_TRIGGERED_WORKFLOWS = [
26
+ 'release-guard.yml',
27
+ 'publish-npm.yml',
28
+ 'cloud-release.yml',
29
+ ] as const;
30
+
31
+ interface DispatchRunResult {
32
+ returncode: number;
33
+ }
34
+
35
+ export interface TagDispatchDeps {
36
+ run: (argv: string[], opts: { check: boolean }) => DispatchRunResult;
37
+ /** Where a non-fatal dispatch failure is reported. */
38
+ warn: (s: string) => void;
39
+ /** The ref the dispatch runs against — the trunk. */
40
+ trunk: string;
41
+ }
42
+
43
+ /** Why one failed dispatch is a warning and not a stop, said to the operator. */
44
+ export function dispatchWarning(workflow: string, code: number, version: string): string {
45
+ return (
46
+ `⚠️ Could not dispatch ${workflow} (exit ${code}) — the release ${version} is ` +
47
+ `already complete (tag + GitHub Release created; npm publishes async). If the tag was ` +
48
+ `pushed with a PAT, ${workflow} already fired on the tag push. If you rely on the explicit ` +
49
+ `dispatch, grant RELEASE_PR_TOKEN the "Actions: read and write" scope (fine-grained PAT) ` +
50
+ `or the "workflow" scope (classic PAT).\n`
51
+ );
52
+ }
53
+
54
+ export function dispatchTagWorkflows(deps: TagDispatchDeps, version: string): void {
55
+ for (const wf of TAG_TRIGGERED_WORKFLOWS) {
56
+ const r = deps.run(['gh', 'workflow', 'run', wf, '--ref', deps.trunk, '-f', `tag=${version}`], {
57
+ check: false,
58
+ });
59
+ if (r.returncode !== 0) {
60
+ deps.warn(dispatchWarning(wf, r.returncode, version));
61
+ }
62
+ }
63
+ }