@event4u/agent-config 14.13.0 → 14.14.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 (101) 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 +116 -387
  5. package/dist/agent-src/commands/analyze/inbox.md +36 -0
  6. package/dist/agent-src/rules/code-comment-discipline.md +6 -0
  7. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
  8. package/dist/agent-src/scripts/guarded_baseline.ts +22 -7
  9. package/dist/agent-src/scripts/stubs_due.ts +118 -2
  10. package/dist/agent-src/scripts/update_roadmap_progress.ts +8 -8
  11. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  12. package/dist/cli-delegate/cmd_conformance.js +38 -1
  13. package/dist/discovery/deprecation-report.md +1 -1
  14. package/dist/discovery/discovery-manifest.json +4 -4
  15. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  16. package/dist/discovery/discovery-manifest.summary.md +1 -1
  17. package/dist/discovery/dormancy-report.md +1 -1
  18. package/dist/discovery/orphan-report.md +1 -1
  19. package/dist/discovery/packs.json +2 -2
  20. package/dist/discovery/trust-report.md +1 -1
  21. package/dist/discovery/workspaces.json +2 -2
  22. package/dist/hooks/dispatch.js +1438 -711
  23. package/dist/mcp/registry-manifest.json +2 -2
  24. package/dist/mcp/server.json +2 -2
  25. package/dist/mcp/server.mjs +112 -17
  26. package/docs/guidelines/agent-infra/guarded-baseline.md +1 -1
  27. package/docs/guidelines/code-clarity.md +18 -0
  28. package/package.json +1 -1
  29. package/src/agent-src/scripts/archive_completed_roadmaps.ts +6 -6
  30. package/src/agent-src/scripts/guarded_baseline.ts +22 -7
  31. package/src/agent-src/scripts/stubs_due.ts +118 -2
  32. package/src/agent-src/scripts/update_roadmap_progress.ts +8 -8
  33. package/src/config/estate-count-budget.json +20 -1
  34. package/src/config/gate-coverage.yml +150 -0
  35. package/src/config/gate-violation-baselines.json +8 -3
  36. package/src/config/preamble-payload-budget.json +19 -11
  37. package/src/config/rule-stub-ceilings.json +14 -7
  38. package/src/scripts/_dispatch.bash +0 -4
  39. package/src/scripts/_lib/activation_ladder.ts +47 -3
  40. package/src/scripts/_lib/activation_receipt_producer.ts +341 -0
  41. package/src/scripts/_lib/adr_frontmatter.ts +26 -0
  42. package/src/scripts/_lib/candidate_pair_delta.ts +349 -0
  43. package/src/scripts/_lib/carrier_status.ts +118 -0
  44. package/src/scripts/_lib/cascade_stage_enumeration.ts +92 -0
  45. package/src/scripts/_lib/corpus_manifest.ts +726 -0
  46. package/src/scripts/_lib/delivery_arm_experiment.ts +18 -2
  47. package/src/scripts/_lib/estate_carrier_cases.ts +72 -0
  48. package/src/scripts/_lib/evaluation_cascade.ts +452 -0
  49. package/src/scripts/_lib/evolution_roi.ts +562 -0
  50. package/src/scripts/_lib/harness_evolution_guards.ts +10 -0
  51. package/src/scripts/_lib/lexical_shortlist.ts +215 -0
  52. package/src/scripts/_lib/llm_candidate_proposer.ts +461 -0
  53. package/src/scripts/_lib/llm_proposer_transport.ts +153 -0
  54. package/src/scripts/_lib/pathology_archive.ts +393 -0
  55. package/src/scripts/_lib/promotion_capability.ts +315 -0
  56. package/src/scripts/_lib/promotion_evidence.ts +414 -0
  57. package/src/scripts/_lib/promotion_review.ts +294 -0
  58. package/src/scripts/_lib/prompt_shape.ts +92 -0
  59. package/src/scripts/_lib/release_highlights.ts +70 -1
  60. package/src/scripts/_lib/release_material.ts +125 -2
  61. package/src/scripts/_lib/routing_corpus.ts +307 -0
  62. package/src/scripts/_lib/routing_index_input.ts +104 -0
  63. package/src/scripts/_lib/rule_injection.ts +22 -1
  64. package/src/scripts/_lib/semantic_noop.ts +167 -0
  65. package/src/scripts/activation_receipt.ts +258 -0
  66. package/src/scripts/ai_council/cross_exam.ts +128 -0
  67. package/src/scripts/ai_council/explain_route.ts +157 -0
  68. package/src/scripts/ai_council/information_gain.ts +293 -0
  69. package/src/scripts/ai_council/leakage_corpus.ts +385 -0
  70. package/src/scripts/ai_council/leakage_patterns.ts +453 -0
  71. package/src/scripts/ai_council/minority_retention.ts +171 -0
  72. package/src/scripts/ai_council/recouncil_savings.ts +299 -0
  73. package/src/scripts/ai_council/replay_route.ts +223 -0
  74. package/src/scripts/ai_council/reviewer_assignment.ts +279 -0
  75. package/src/scripts/ai_council/routing_training_row.ts +197 -0
  76. package/src/scripts/ai_council/synthesis_strategy.ts +159 -0
  77. package/src/scripts/check_adr_frontmatter.ts +4 -9
  78. package/src/scripts/check_estate_count.ts +205 -24
  79. package/src/scripts/check_release_highlights.ts +96 -13
  80. package/src/scripts/check_roadmap_trackable.ts +17 -2
  81. package/src/scripts/corpus_manifest.ts +235 -0
  82. package/src/scripts/evolution_lab.ts +181 -8
  83. package/src/scripts/hook_manifest.json +1 -1
  84. package/src/scripts/hook_manifest.yaml +28 -6
  85. package/src/scripts/hooks/comment_discipline_hook.ts +146 -0
  86. package/src/scripts/hooks/concern_registry.ts +2 -0
  87. package/src/scripts/hooks/turn_end_gate_hook.ts +9 -1
  88. package/src/scripts/language_mirror_hook.ts +14 -2
  89. package/src/scripts/lint_carrier_integrity.ts +820 -0
  90. package/src/scripts/lint_code_comments.ts +643 -0
  91. package/src/scripts/lint_plan_risk_register.ts +39 -19
  92. package/src/scripts/lint_promotion_paths.ts +779 -0
  93. package/src/scripts/llm_propose.ts +256 -0
  94. package/src/scripts/measure_delivery_sets.ts +361 -0
  95. package/src/scripts/measure_routing_signal.ts +307 -0
  96. package/src/scripts/model_rule_injection.ts +19 -3
  97. package/src/scripts/probe_council_retention.ts +495 -0
  98. package/src/scripts/release.ts +21 -37
  99. package/src/scripts/release_drill.ts +143 -0
  100. package/src/scripts/release_env.ts +35 -0
  101. package/src/scripts/release_publication.ts +84 -0
@@ -0,0 +1,294 @@
1
+ /**
2
+ * The lifecycle AFTER promotion — `road-to-harness-promotion-bridge` steps 7.6
3
+ * and 7.7.
4
+ *
5
+ * ## 7.6 — a promoted artefact is not immortal
6
+ *
7
+ * > *post-promotion re-evaluation with `KEEP / REVISE / MERGE / SPLIT / RETIRE`.
8
+ * > The master's promotion phase ends at the evidence package plus a cooldown,
9
+ * > so nothing reopens a promoted artefact and the lifecycle is manual-only at
10
+ * > exactly the point where growth accumulates.*
11
+ * > verify: **a promoted artefact reaching its review trigger produces one of
12
+ * > the five verdicts, and at least one `RETIRE` path is exercised in a
13
+ * > fixture.**
14
+ *
15
+ * Two halves were already in the tree and one was missing. Four of the five
16
+ * verdicts are E6 curator ops (`_lib/curator_ops.ts`) — `REVISE` is not — and
17
+ * `promoted -> retired` is already the one legal retirement edge
18
+ * (`_lib/candidate_record.ts:210-219`). What did not exist was the REVIEW
19
+ * TRIGGER: nothing decided that a promoted artefact was due, and nothing turned
20
+ * that into a verdict. {@link reviewPromoted} is that.
21
+ *
22
+ * **The verdict set is NOT a subset of `CURATOR_OPS`, and finding that out is
23
+ * part of what this step buys.** E6 argued for seven curator ops on the ground
24
+ * that 7.6 specifies five verdicts a four-op curator could not execute — and the
25
+ * shipped seven still cannot execute one of them, because `REVISE` is not among
26
+ * them. The relationship is pinned by a test in both directions rather than
27
+ * papered over with a mapping nobody chose; see {@link POST_PROMOTION_VERDICTS}.
28
+ *
29
+ * ## 7.7 — best-known-state reference on regression
30
+ *
31
+ * > *Roll back to the recorded best-known state; lineage, not endless append.*
32
+ * > verify: **an injected regression triggers the rollback path in a fixture.**
33
+ *
34
+ * {@link BestKnownState} is the record, {@link planRollback} is the decision.
35
+ * "Lineage, not endless append" is carried in the type: a rollback names the
36
+ * state it returns to and the state it leaves, so the history is a chain rather
37
+ * than a pile of snapshots nobody can order.
38
+ *
39
+ * ## This module promotes nothing
40
+ *
41
+ * It reads promoted artefacts and it can RETIRE one — the only transition it
42
+ * expresses is `promoted -> retired`, which `assertTransition` accepts without
43
+ * an approver because retirement is the direction that shrinks the estate.
44
+ * There is no path here that moves anything INTO `promoted`; that stays behind
45
+ * `_lib/promotion_capability.ts`, and `lint_promotion_paths.ts` is what proves
46
+ * this file did not grow one.
47
+ */
48
+ import { ACCEPTED_STATE, type LifecycleState, assertTransition } from './candidate_record.js';
49
+ import { CURATOR_OPS, type CuratorOp } from './curator_ops.js';
50
+
51
+ /**
52
+ * The five verdicts step 7.6 names, verbatim.
53
+ *
54
+ * **Four of the five are E6 curator ops. `REVISE` is not, and that is a finding
55
+ * rather than a detail.** `_lib/curator_ops.ts:48-56` is `KEEP · ADD · MERGE ·
56
+ * REPLACE · SPLIT · RETIRE · SKIP`, and its own header argues for seven ops on
57
+ * the ground that *"step 7.6 of the same roadmap already specifies the verdict
58
+ * set KEEP / REVISE / MERGE / SPLIT / RETIRE, so a four-op curator would emit
59
+ * verdicts it cannot execute"*. Measured against the shipped set: `REVISE` is
60
+ * still a verdict the curator cannot execute — `REPLACE` is the nearest op and
61
+ * it is not the same thing, since `REPLACE` is 1→1 with a new artefact while
62
+ * `REVISE` edits the one in place and keeps its identity.
63
+ *
64
+ * The list is therefore written out rather than derived, because deriving it
65
+ * would require inventing the mapping the two sets do not have. What IS pinned,
66
+ * by `tests/scripts/promotion_review.test.ts`, is the relationship in both
67
+ * directions: the four shared verdicts really are curator ops, and `REVISE`
68
+ * really is not — so a later change that adds `REVISE` to `CURATOR_OPS`, or
69
+ * drops one of the four, fails a test instead of drifting.
70
+ */
71
+ export const POST_PROMOTION_VERDICTS = ['KEEP', 'REVISE', 'MERGE', 'SPLIT', 'RETIRE'] as const;
72
+
73
+ /** The four verdicts the curator can actually execute today. */
74
+ export const VERDICTS_EXECUTABLE_BY_CURATOR: readonly CuratorOp[] = CURATOR_OPS.filter(
75
+ (op) => (POST_PROMOTION_VERDICTS as readonly string[]).includes(op),
76
+ );
77
+
78
+ export type PostPromotionVerdict = (typeof POST_PROMOTION_VERDICTS)[number];
79
+
80
+ /** Why a promoted artefact came up for review. Absent means it is not due. */
81
+ export const REVIEW_TRIGGERS = [
82
+ 'regression',
83
+ 'usage-floor',
84
+ 'duplicate-detected',
85
+ 'scope-overgrown',
86
+ 'age',
87
+ ] as const;
88
+ export type ReviewTrigger = (typeof REVIEW_TRIGGERS)[number];
89
+
90
+ /** What a review reads. Every field is an observation, not a judgement. */
91
+ export interface PromotedArtefactState {
92
+ readonly id: string;
93
+ readonly lifecycle: LifecycleState;
94
+ /** Measured effect now, in the same units as the promotion evidence's delta. */
95
+ readonly currentDeltaPercent: number;
96
+ /** The delta recorded at promotion. The regression test is `current < promoted`. */
97
+ readonly promotedDeltaPercent: number;
98
+ /** How many times the artefact was actually reached since promotion. */
99
+ readonly invocations: number;
100
+ /** Ids of promoted artefacts whose text this one now duplicates. */
101
+ readonly duplicates: readonly string[];
102
+ /** Distinct responsibilities the artefact has accumulated. */
103
+ readonly responsibilities: number;
104
+ readonly daysSincePromotion: number;
105
+ }
106
+
107
+ export class NotPromotedError extends Error {
108
+ constructor(id: string, lifecycle: LifecycleState) {
109
+ super(
110
+ `'${id}' is '${lifecycle}', not '${ACCEPTED_STATE}' — post-promotion review acts on a ` +
111
+ 'PROMOTED artefact. A candidate that never landed is rejected, not retired, and ' +
112
+ 'reviewing one here would report a lifecycle event that did not happen.',
113
+ );
114
+ this.name = 'NotPromotedError';
115
+ }
116
+ }
117
+
118
+ export interface ReviewOutcome {
119
+ readonly id: string;
120
+ readonly trigger: ReviewTrigger;
121
+ readonly verdict: PostPromotionVerdict;
122
+ readonly reason: string;
123
+ }
124
+
125
+ /** The floors the review reads. Stated defaults; each carries its own revisit-if. */
126
+ export const REVIEW_THRESHOLDS = {
127
+ /** Below this many invocations the artefact is not earning its place. `revisit-if` a
128
+ * legitimately rare artefact is retired for being rare. */
129
+ usageFloor: 1,
130
+ /** Distinct responsibilities above which the artefact should be SPLIT. `revisit-if` a
131
+ * cohesive artefact with several facets is split for having them. */
132
+ responsibilityCeiling: 2,
133
+ /** Days after which an unreviewed artefact is due. `revisit-if` the cadence is noise. */
134
+ ageDays: 180,
135
+ } as const;
136
+
137
+ /**
138
+ * Is this artefact due for review, and why? `null` means not due.
139
+ *
140
+ * Order is precedence: a regression outranks a usage question, which outranks a
141
+ * duplicate, which outranks overgrowth, which outranks mere age. Without a fixed
142
+ * order two triggers firing at once would make the verdict depend on evaluation
143
+ * order, which is how a review becomes irreproducible.
144
+ */
145
+ export function reviewTriggerFor(state: PromotedArtefactState): ReviewTrigger | null {
146
+ if (state.currentDeltaPercent < state.promotedDeltaPercent) return 'regression';
147
+ if (state.invocations < REVIEW_THRESHOLDS.usageFloor) return 'usage-floor';
148
+ if (state.duplicates.length > 0) return 'duplicate-detected';
149
+ if (state.responsibilities > REVIEW_THRESHOLDS.responsibilityCeiling) return 'scope-overgrown';
150
+ if (state.daysSincePromotion >= REVIEW_THRESHOLDS.ageDays) return 'age';
151
+ return null;
152
+ }
153
+
154
+ /**
155
+ * Produce exactly ONE of the five verdicts for a promoted artefact that reached
156
+ * its review trigger.
157
+ *
158
+ * @throws {NotPromotedError} when the artefact is not promoted.
159
+ */
160
+ export function reviewPromoted(state: PromotedArtefactState, trigger: ReviewTrigger): ReviewOutcome {
161
+ if (state.lifecycle !== ACCEPTED_STATE) {
162
+ throw new NotPromotedError(state.id, state.lifecycle);
163
+ }
164
+ const outcome = (verdict: PostPromotionVerdict, reason: string): ReviewOutcome => ({
165
+ id: state.id,
166
+ trigger,
167
+ verdict,
168
+ reason,
169
+ });
170
+ switch (trigger) {
171
+ case 'regression':
172
+ // A measured effect that fell BELOW zero is not worth revising; it is
173
+ // now costing what it was promoted to save.
174
+ return state.currentDeltaPercent <= 0
175
+ ? outcome('RETIRE', `effect fell to ${String(state.currentDeltaPercent)} pp — it no longer earns its place`)
176
+ : outcome(
177
+ 'REVISE',
178
+ `effect fell ${String(state.promotedDeltaPercent)} -> ${String(state.currentDeltaPercent)} pp ` +
179
+ 'but is still positive',
180
+ );
181
+ case 'usage-floor':
182
+ return outcome('RETIRE', `reached ${String(state.invocations)} time(s) since promotion`);
183
+ case 'duplicate-detected':
184
+ return outcome('MERGE', `now duplicates ${state.duplicates.join(', ')}`);
185
+ case 'scope-overgrown':
186
+ return outcome('SPLIT', `carries ${String(state.responsibilities)} distinct responsibilities`);
187
+ case 'age':
188
+ return outcome('KEEP', `${String(state.daysSincePromotion)} days since promotion, effect held`);
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Execute the RETIRE verdict.
194
+ *
195
+ * Routes through `assertTransition`, so the ONE legal retirement edge is the one
196
+ * this takes and a caller cannot retire something that never landed. Returns the
197
+ * next lifecycle state rather than writing anything — nothing in this module
198
+ * touches the filesystem.
199
+ *
200
+ * @throws {LifecycleTransitionError} from `assertTransition`.
201
+ */
202
+ export function retirePromoted(state: PromotedArtefactState): LifecycleState {
203
+ assertTransition(state.lifecycle, 'retired');
204
+ return 'retired';
205
+ }
206
+
207
+ // --- 7.7 — best-known state and rollback ------------------------------------
208
+
209
+ /** A recorded good state, and the state it came from. Lineage, not endless append. */
210
+ export interface BestKnownState {
211
+ readonly artefactId: string;
212
+ /** The revision or bundle identifier this state is recoverable from. */
213
+ readonly reference: string;
214
+ readonly deltaPercent: number;
215
+ /** The best-known state this one replaced. `null` only for the first. */
216
+ readonly supersedes: string | null;
217
+ readonly recordedAt: string;
218
+ }
219
+
220
+ export interface RollbackPlan {
221
+ readonly artefactId: string;
222
+ readonly from: string;
223
+ readonly to: string;
224
+ readonly reason: string;
225
+ /** Oldest first. The chain that makes this a lineage rather than a pile. */
226
+ readonly lineage: readonly string[];
227
+ }
228
+
229
+ export class NoBestKnownStateError extends Error {
230
+ constructor(artefactId: string) {
231
+ super(
232
+ `'${artefactId}' regressed and has no recorded best-known state to return to. A rollback ` +
233
+ 'target is recorded at promotion time, not reconstructed after the regression — by ' +
234
+ 'then the state that worked is exactly what is missing.',
235
+ );
236
+ this.name = 'NoBestKnownStateError';
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Walk the `supersedes` chain, oldest first.
242
+ *
243
+ * Cycle-guarded: a chain that points at itself is a corrupted lineage, and
244
+ * following it forever would hang the caller rather than report the corruption.
245
+ */
246
+ export function lineageOf(head: BestKnownState, all: readonly BestKnownState[]): string[] {
247
+ const byRef = new Map(all.map((s) => [s.reference, s]));
248
+ const chain: string[] = [];
249
+ const seen = new Set<string>();
250
+ let cursor: BestKnownState | undefined = head;
251
+ while (cursor !== undefined && !seen.has(cursor.reference)) {
252
+ seen.add(cursor.reference);
253
+ chain.push(cursor.reference);
254
+ cursor = cursor.supersedes === null ? undefined : byRef.get(cursor.supersedes);
255
+ }
256
+ return chain.reverse();
257
+ }
258
+
259
+ /**
260
+ * Plan a rollback when the current state regressed against the best known one.
261
+ *
262
+ * `null` when there is no regression — the caller does not have to know the
263
+ * comparison, and a rollback that fires on an improvement is worse than none.
264
+ *
265
+ * @throws {NoBestKnownStateError} when a regression has nowhere to return to.
266
+ */
267
+ export function planRollback(
268
+ artefactId: string,
269
+ currentReference: string,
270
+ currentDeltaPercent: number,
271
+ history: readonly BestKnownState[],
272
+ ): RollbackPlan | null {
273
+ const mine = history.filter((s) => s.artefactId === artefactId);
274
+ const best = mine.reduce<BestKnownState | null>(
275
+ (acc, s) => (acc === null || s.deltaPercent > acc.deltaPercent ? s : acc),
276
+ null,
277
+ );
278
+ if (best === null) {
279
+ if (currentDeltaPercent < 0) throw new NoBestKnownStateError(artefactId);
280
+ return null;
281
+ }
282
+ if (currentDeltaPercent >= best.deltaPercent) {
283
+ return null;
284
+ }
285
+ return {
286
+ artefactId,
287
+ from: currentReference,
288
+ to: best.reference,
289
+ reason:
290
+ `regressed ${String(best.deltaPercent)} -> ${String(currentDeltaPercent)} pp against the ` +
291
+ `best-known state '${best.reference}'`,
292
+ lineage: lineageOf(best, mine),
293
+ };
294
+ }
@@ -86,3 +86,95 @@ export function humanAuthoredLead(text: string): string {
86
86
  }
87
87
  return kept.join("\n");
88
88
  }
89
+
90
+ /** A line that is nothing but an opening tag, with the tag name captured. */
91
+ const REGION_OPEN = /^\s*<([A-Za-z][\w-]*)>\s*$/;
92
+
93
+ /**
94
+ * The host's own framing of an injected region — the sentence it writes ABOUT
95
+ * the block, not a sentence the user typed.
96
+ *
97
+ * Named literally, for the same reason `isSyntheticPrompt` names wrapper tags:
98
+ * this is host furniture, and furniture is identified by what it says about
99
+ * itself. It is deliberately NOT a language rule — the clauses describe the
100
+ * region's provenance and its data status, and a user who types one is quoting
101
+ * the host, which is exactly the case that should be dropped too.
102
+ */
103
+ const REGION_NOTE =
104
+ /\b(content (above|below) is from|treat (it|this|that|the (content|text|above|following)) as data)\b/i;
105
+
106
+ /**
107
+ * Remove host-injected wrapper regions — `<launch-selected-element>` and
108
+ * friends — leaving the text the human actually typed around them.
109
+ *
110
+ * MEASURED, from a 14.13.0 field report (2026-09-01) reproduced against this
111
+ * source. `DOCUMENT_HEAD` matches a bare `<tag>` line, and Claude Code PREPENDS
112
+ * exactly that when the user picks a DOM element in the browser pane. So
113
+ * `humanAuthoredLead` broke at line zero and returned `""` for every such
114
+ * prompt — the lead-first isolation was not degraded, it was OFF — and the
115
+ * whole-text fallback then scored ~4 KB of class-heavy markup plus the host's
116
+ * own advisory line. Four consecutive German turns pinned English, and the
117
+ * turn-end gate refused each one.
118
+ *
119
+ * Two things had to move, and both are visible in the reproduction:
120
+ *
121
+ * 1. A bare `<tag>` line that HAS a matching close is the START of a region
122
+ * to skip, never the end of the human lead. That is this function.
123
+ * 2. It runs BEFORE `instructionText`. The block's indented lines arm that
124
+ * filter's paste-state machine, and where no blank line separates the
125
+ * block from the user's sentence, the sentence itself was deleted — the
126
+ * German markers did not merely lose the count, they reached zero.
127
+ *
128
+ * Conservative by construction, in three ways. An UNBALANCED bare tag is left
129
+ * in place, so a pasted document opening with one still terminates the lead
130
+ * exactly as before. A prompt with no balanced region is returned untouched,
131
+ * so nothing outside this shape reclassifies. And the framing sentence is
132
+ * dropped LINE BY LINE against `REGION_NOTE` rather than as a paragraph: in the
133
+ * reported shape the advisory and the user's sentence share one unbroken run,
134
+ * so dropping the run would have taken the sentence with it.
135
+ *
136
+ * Not `isSyntheticPrompt`: that answers "is this whole turn machine-written",
137
+ * and these turns are not — a human sentence follows the block. The wrapper is
138
+ * removed; the turn is kept.
139
+ *
140
+ * Nested same-name regions resolve to the FIRST matching close tag. Host
141
+ * wrappers do not nest by name, and stopping early keeps more human text than
142
+ * scanning past the wrong close would.
143
+ */
144
+ export function stripInjectedRegions(text: string): string {
145
+ const lines = text.split("\n");
146
+ const kept: string[] = [];
147
+ for (let i = 0; i < lines.length; i++) {
148
+ const line = lines[i] ?? "";
149
+ const open = REGION_OPEN.exec(line);
150
+ if (open === null) {
151
+ kept.push(line);
152
+ continue;
153
+ }
154
+ // The tag name comes from `[A-Za-z][\w-]*`, so it carries no regex
155
+ // metacharacter and needs no escaping.
156
+ const close = new RegExp(`^\\s*</${open[1] ?? ""}>\\s*$`);
157
+ let end = -1;
158
+ for (let j = i + 1; j < lines.length; j++) {
159
+ if (close.test(lines[j] ?? "")) {
160
+ end = j;
161
+ break;
162
+ }
163
+ }
164
+ if (end === -1) {
165
+ kept.push(line);
166
+ continue;
167
+ }
168
+ if (kept.length > 0 && REGION_NOTE.test(kept[kept.length - 1] ?? "")) {
169
+ kept.pop();
170
+ }
171
+ i = end;
172
+ while (i + 1 < lines.length && (lines[i + 1] ?? "").trim() !== "") {
173
+ if (!REGION_NOTE.test(lines[i + 1] ?? "")) {
174
+ break;
175
+ }
176
+ i++;
177
+ }
178
+ }
179
+ return kept.join("\n");
180
+ }
@@ -25,6 +25,8 @@
25
25
  */
26
26
  import { spawnSync } from 'node:child_process';
27
27
 
28
+ import { CURATED_HEAD_INSTRUCTION } from './release_material.js';
29
+
28
30
  /** The five curated labels, in the order an operator reads them. */
29
31
  export const HEAD_LABELS: readonly string[] = [
30
32
  'Behaviour changes',
@@ -39,11 +41,27 @@ export const HEAD_NONE = '_none_';
39
41
  /**
40
42
  * Prefix marking a head line the generator derived rather than a human wrote.
41
43
  *
42
- * Machine-checkable so the gate can warn when one survives to merge, and
44
+ * Machine-checkable so the gate can REFUSE when one survives to merge, and
43
45
  * readable so the line still says something true to anyone who ships it
44
46
  * unedited. It is not a placeholder token: the line it introduces carries real
45
47
  * evidence (the reason plus the citing SHAs), so nothing here is
46
48
  * wrong-if-shipped — only unpolished-if-unedited.
49
+ *
50
+ * **REVERSED 2026-09-01 — "only unpolished-if-unedited" no longer implies
51
+ * "therefore advisory".** The clause above is kept because it is TRUE, and the
52
+ * conclusion that used to follow from it is the part that was falsified: the
53
+ * gate warned, and the marker shipped anyway in five consecutive released
54
+ * sections (14.9.0 through 14.13.0, eighteen lines, re-derived at
55
+ * `b50b27281`). `check_release_highlights` now exits non-zero on a marker in
56
+ * the section under release, and the sibling annotation in that file's header
57
+ * carries the full reversal.
58
+ *
59
+ * **Instructing authority:** the maintainer's closing instruction in the
60
+ * 2026-09-a inbox round, carried into
61
+ * `agents/roadmaps/archive/road-to-publication-integrity-hard-fail.md` § Phase 1.
62
+ *
63
+ * Nothing about the marker's own shape changes here — it is still one
64
+ * definition, still shared with the generator that writes it.
47
65
  */
48
66
  export const DERIVED_MARKER = '_auto-derived, rewrite before merge:_';
49
67
 
@@ -283,6 +301,57 @@ export function render_derived_head_values(
283
301
  return out;
284
302
  }
285
303
 
304
+ /**
305
+ * Reasons the section under release must NOT be published, in the order a
306
+ * reader should act on them. Empty means nothing here blocks publication.
307
+ *
308
+ * Added 2026-09-01 for roadmap § 3.1. It exists because the two IRREVERSIBLE
309
+ * transitions — the annotated tag and the GitHub Release body — happen inside
310
+ * `release.ts` after the PR gate has already passed, and nothing re-read the
311
+ * section between them. `check_release_highlights` guards the PR; this guards
312
+ * the publish.
313
+ *
314
+ * **It takes a section body, never a path and never a repository.** A guard
315
+ * that reads a file decides for itself what it is guarding, and the two
316
+ * refused earlier attempts both died there: one placed the check inside a pure
317
+ * formatter, which has no idea whether it is publishing, and one read the live
318
+ * changelog from a drill fixture. The caller cuts the section and owns the
319
+ * decision to publish; this function only answers whether that text is
320
+ * publishable.
321
+ *
322
+ * **Two markers since 2026-09-01, and the second one had to wait for a writer
323
+ * change.** This function used to check `DERIVED_MARKER` only, and said so:
324
+ * the generator still wrote its authoring comment into every section it cut,
325
+ * so refusing on that comment would have redded every release on its first run
326
+ * — Risk 1 of the roadmap, reproduced exactly. Roadmap § Phase 2 Option A
327
+ * removed the emission (`render_release_head` in `release.ts`), which is what
328
+ * makes refusing on it safe. The order was forced and it is the reason the
329
+ * check arrives second rather than late.
330
+ *
331
+ * `CURATED_HEAD_INSTRUCTION` is a named sentinel imported from the module that
332
+ * defines it — never a shape match over comment prose, which would reject
333
+ * unrelated legitimate comments and miss this one after a reword.
334
+ */
335
+ export function publication_blockers(sectionBody: string, version: string): string[] {
336
+ const out: string[] = [];
337
+ if (sectionBody.includes(DERIVED_MARKER)) {
338
+ out.push(
339
+ `the ${version} section still carries \`${DERIVED_MARKER}\` — the generator's ` +
340
+ 'draft head, not a curated claim. Rewrite those line(s) on ' +
341
+ `${'`main`'} and re-run.`,
342
+ );
343
+ }
344
+ if (sectionBody.includes(CURATED_HEAD_INSTRUCTION)) {
345
+ out.push(
346
+ `the ${version} section still carries the authoring instruction ` +
347
+ `(\`${CURATED_HEAD_INSTRUCTION}\`) — a reminder to the releaser, not ` +
348
+ 'release content, and `CHANGELOG.md` is published to npm. Delete that ' +
349
+ 'comment line from the section and re-run.',
350
+ );
351
+ }
352
+ return out;
353
+ }
354
+
286
355
  /** Labels whose curated value is still the generator's unedited draft. */
287
356
  export function stale_draft_labels(
288
357
  curated: Readonly<Record<string, string>>,
@@ -61,6 +61,116 @@ export function extract_changelog_section(text: string, version: string): Change
61
61
  return { heading: m[0], body };
62
62
  }
63
63
 
64
+ /**
65
+ * The generator's authoring instruction, identified by ONE named sentinel.
66
+ *
67
+ * Roadmap `road-to-publication-integrity-hard-fail` § Phase 2, Option A. Until
68
+ * 2026-09-01 `render_release_head` wrote this comment into every changelog
69
+ * section it cut, and nothing removed it at release time — so it shipped. At
70
+ * that commit `grep -c 'Curated head: fill before merge' CHANGELOG.md` read 2,
71
+ * and `package.json` `files` carries the bare `"CHANGELOG.md"`, which is the
72
+ * npm-published artifact. The instruction now lives in the release-PR body,
73
+ * which is never published, and the writer emits none.
74
+ *
75
+ * **A sentinel, deliberately not a shape match.** A council seat warned that a
76
+ * pattern such as `/<!-- .* should(?: only)? be .* -->/` is overbroad and
77
+ * under-specified: it rejects unrelated legitimate comments while missing the
78
+ * prohibited instruction after harmless rewording. This follows the discipline
79
+ * `DERIVED_MARKER` already follows — one definition, imported by every
80
+ * consumer, including the gates that refuse on it.
81
+ */
82
+ export const CURATED_HEAD_INSTRUCTION = 'Curated head: fill before merge';
83
+
84
+ /**
85
+ * The full authoring instruction as the release-PR body carries it.
86
+ *
87
+ * Parameterized rather than importing `RELEASE_HEAD_CAP_LINES` / `HEAD_NONE`,
88
+ * because this module has no imports and that is load-bearing: it is the one
89
+ * place the equality gate, the drill fixtures and the unit tests all reach, so
90
+ * a dependency here is a dependency in all three.
91
+ */
92
+ /**
93
+ * Cap on curated-head lines, and the placeholder a label carries when the
94
+ * answer is genuinely nothing.
95
+ *
96
+ * Relocated here from `release.ts` on 2026-09-01 with the instruction text:
97
+ * this module builds the release-PR body, so the instruction and the two
98
+ * values it interpolates belong on the same side of the import boundary. The
99
+ * generator re-exports `RELEASE_HEAD_CAP_LINES` under its original name, so
100
+ * nothing that referenced it there has to change.
101
+ */
102
+ export const RELEASE_HEAD_CAP_LINES = 10;
103
+
104
+ /**
105
+ * The fallback value of a head line the span does not substantiate.
106
+ *
107
+ * `_none_` and not a placeholder token: where nothing was derived it is the
108
+ * **true** answer, and a release that genuinely changed no defaults should say
109
+ * so rather than carry an unfilled marker.
110
+ *
111
+ * It is a fallback and no longer a blanket default. Writing `_none_` into
112
+ * every field made the generator assert five things it had not checked, and
113
+ * `check_release_highlights` rejected exactly that assertion the moment the
114
+ * span contradicted it — which for this package is every release, because every
115
+ * release touches `src/rules/` or `src/scripts/schemas/`. Substantiated labels
116
+ * are now pre-filled from the span (`_derive_head_prefill`), so the tool states
117
+ * only what it can support and `_none_` means what it says.
118
+ */
119
+ export const RELEASE_HEAD_DEFAULT = '_none_';
120
+
121
+ export function render_curated_head_instruction(capLines: number, headDefault: string): string {
122
+ return (
123
+ `<!-- ${CURATED_HEAD_INSTRUCTION}, keep it under ${capLines} lines, and leave ` +
124
+ `\`${headDefault}\` where it is genuinely the answer. The generated log below is ` +
125
+ 'unchanged. -->'
126
+ );
127
+ }
128
+
129
+ /** The instruction exactly as the release-PR body carries it. */
130
+ export const CURATED_HEAD_INSTRUCTION_COMMENT = render_curated_head_instruction(
131
+ RELEASE_HEAD_CAP_LINES,
132
+ RELEASE_HEAD_DEFAULT,
133
+ );
134
+
135
+ /**
136
+ * Delimiters bounding the release-PR-only region.
137
+ *
138
+ * The region exists so the PR body can carry something the changelog must not,
139
+ * WITHOUT relaxing surface equality anywhere else. `strip_pr_wrapper` removes
140
+ * exactly what sits between these two markers and nothing else — never a prose
141
+ * match, never a whole-body relaxation. Relaxing equality wholesale would
142
+ * remove the release-truth guarantee that the PR body and the changelog say
143
+ * the same thing, which is a larger floor than the one this change
144
+ * strengthens.
145
+ *
146
+ * An UNTERMINATED region is deliberately left in place: it then surfaces as an
147
+ * ordinary divergence instead of silently swallowing the rest of the body.
148
+ */
149
+ export const PR_ONLY_START = '<!-- release-pr-only:start -->';
150
+ export const PR_ONLY_END = '<!-- release-pr-only:end -->';
151
+
152
+ /** Remove every complete PR-only region. Delimiter-bounded, never a prose match. */
153
+ export function strip_pr_only_regions(s: string): string {
154
+ const out: string[] = [];
155
+ let rest = s;
156
+ for (;;) {
157
+ const i = rest.indexOf(PR_ONLY_START);
158
+ if (i === -1) {
159
+ out.push(rest);
160
+ break;
161
+ }
162
+ const j = rest.indexOf(PR_ONLY_END, i + PR_ONLY_START.length);
163
+ if (j === -1) {
164
+ // Unterminated — leave it standing so equality reports it.
165
+ out.push(rest);
166
+ break;
167
+ }
168
+ out.push(rest.slice(0, i));
169
+ rest = rest.slice(j + PR_ONLY_END.length);
170
+ }
171
+ return out.join('');
172
+ }
173
+
64
174
  /** Footer appended to the release-PR body; the strip helper accepts legacy variants. */
65
175
  export const PR_BODY_FOOTER = 'Created by `src/scripts/release.ts`.';
66
176
  const _LEGACY_PR_FOOTERS = ['Created by `scripts/release.py`.', PR_BODY_FOOTER];
@@ -68,9 +178,18 @@ const _LEGACY_PR_FOOTERS = ['Created by `scripts/release.py`.', PR_BODY_FOOTER];
68
178
  /** Marker `_cap_body` embeds when a surface was truncated to a GitHub limit. */
69
179
  export const TRUNCATION_MARKER = '_Changelog truncated to fit GitHub';
70
180
 
71
- /** Release-PR body derived from the (possibly capped) changelog-section body. */
181
+ /**
182
+ * Release-PR body derived from the (possibly capped) changelog-section body.
183
+ *
184
+ * Carries the authoring instruction inside the PR-only region. The region is
185
+ * emitted unconditionally rather than passed in by each caller: there are two
186
+ * PR-creation paths (`release.ts` and `release_publication.ts`), and a caller
187
+ * that forgot the argument would silently ship a PR with no reminder — the
188
+ * failure this change exists to make impossible, reintroduced one level up.
189
+ */
72
190
  export function pr_body_from_section(cappedBody: string, version: string): string {
73
- return `Release ${version}.\n\n${cappedBody}\n\n${PR_BODY_FOOTER}`;
191
+ const prOnly = `${PR_ONLY_START}\n${CURATED_HEAD_INSTRUCTION_COMMENT}\n${PR_ONLY_END}`;
192
+ return `Release ${version}.\n\n${prOnly}\n\n${cappedBody}\n\n${PR_BODY_FOOTER}`;
74
193
  }
75
194
 
76
195
  /** GitHub-release notes derived from the (possibly capped) changelog-section body. */
@@ -96,6 +215,10 @@ export function strip_pr_wrapper(prBody: string, version: string): string | null
96
215
  return null;
97
216
  }
98
217
  s = s.slice(prefix.length);
218
+ // Delimiter-bounded, before the footer walk: the region is PR-only material
219
+ // by construction, so it is removed from the comparison rather than
220
+ // compared. Everything outside it still has to match the changelog.
221
+ s = strip_pr_only_regions(s);
99
222
  for (const footer of _LEGACY_PR_FOOTERS) {
100
223
  if (s.trimEnd().endsWith(footer)) {
101
224
  s = s.trimEnd();