@event4u/agent-config 17.0.0 → 17.1.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 (119) 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 +104 -485
  5. package/README.md +2 -1
  6. package/dist/agent-src/contexts/execution/host-capability-manifest.md +5 -3
  7. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  8. package/dist/agent-src/templates/roadmaps.md +5 -1
  9. package/dist/cli-delegate/{chunk-IROAQ77A.js → chunk-3PK3VZHB.js} +107 -9
  10. package/dist/cli-delegate/{chunk-2SFOOFJJ.js → chunk-BZJ4Y3CI.js} +1 -1
  11. package/dist/cli-delegate/{chunk-CAL76ADI.js → chunk-CMZEKSNN.js} +52 -10
  12. package/dist/cli-delegate/{chunk-ESUNQ5XR.js → chunk-E3CFULNX.js} +22 -4
  13. package/dist/cli-delegate/{chunk-GOMILC3O.js → chunk-QBHEI5N4.js} +4 -3
  14. package/dist/cli-delegate/{chunk-SIOMDAKL.js → chunk-VYZGJLCA.js} +29 -39
  15. package/dist/cli-delegate/chunk-W5JXMF2T.js +77 -0
  16. package/dist/cli-delegate/{chunk-4SHNERBL.js → chunk-XIAPY44N.js} +1 -1
  17. package/dist/cli-delegate/{chunk-JHMMWNJ2.js → chunk-YHAPONWV.js} +1 -1
  18. package/dist/cli-delegate/cmd_conformance.js +9 -8
  19. package/dist/cli-delegate/cmd_doctor.js +4 -4
  20. package/dist/cli-delegate/cmd_doctor_neighbours.js +2 -2
  21. package/dist/cli-delegate/cmd_explain.js +1 -1
  22. package/dist/cli-delegate/cmd_export.js +6 -6
  23. package/dist/cli-delegate/cmd_fleet.js +3 -3
  24. package/dist/cli-delegate/cmd_preflight.js +3 -3
  25. package/dist/cli-delegate/cmd_prune.js +6 -6
  26. package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
  27. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  28. package/dist/cli-delegate/cmd_sync.js +6 -6
  29. package/dist/cli-delegate/cmd_uninstall.js +6 -6
  30. package/dist/cli-delegate/cmd_update.js +9 -13
  31. package/dist/cli-delegate/cmd_validate.js +6 -6
  32. package/dist/discovery/deprecation-report.md +1 -1
  33. package/dist/discovery/discovery-manifest.json +4 -4
  34. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  35. package/dist/discovery/discovery-manifest.summary.md +1 -1
  36. package/dist/discovery/dormancy-report.md +1 -1
  37. package/dist/discovery/orphan-report.md +1 -1
  38. package/dist/discovery/packs.json +2 -2
  39. package/dist/discovery/trust-report.md +1 -1
  40. package/dist/discovery/workspaces.json +2 -2
  41. package/dist/hooks/dispatch.js +98 -74
  42. package/dist/hooks/dispatch.sha256 +1 -1
  43. package/dist/install/claudeRuleRewrite.js +10 -3
  44. package/dist/install/conflict.js +24 -2
  45. package/dist/install/conflictTracker.js +14 -3
  46. package/dist/install/install.mjs +222 -117
  47. package/dist/install/installThinLayer.js +1 -0
  48. package/dist/install/loggedWrites.js +142 -0
  49. package/dist/install/paths.js +12 -2
  50. package/dist/install/preserve.js +25 -4
  51. package/dist/install/txlog.js +10 -6
  52. package/dist/mcp/registry-manifest.json +2 -2
  53. package/dist/mcp/server.json +2 -2
  54. package/dist/mcp/server.mjs +845 -294
  55. package/dist/scripts/_lib/egress_rules.js +4 -1
  56. package/dist/scripts/_lib/identifier_redaction.js +35 -2
  57. package/dist/scripts/ai_council/seat_policy.js +7 -1
  58. package/dist/server/schemas/settings.js +1 -1
  59. package/package.json +1 -1
  60. package/src/config/ci-local-parity.yml +1 -1
  61. package/src/config/gate-coverage.yml +27 -7
  62. package/src/config/gate-violation-baselines.json +8 -2
  63. package/src/config/hook-bundle-budget.json +2 -2
  64. package/src/config/production-entries.json +18 -0
  65. package/src/config/release-gate-locality.yml +0 -7
  66. package/src/install/claudeRuleRewrite.ts +10 -1
  67. package/src/install/conflict.ts +24 -1
  68. package/src/install/conflictTracker.ts +18 -2
  69. package/src/install/installThinLayer.ts +6 -0
  70. package/src/install/loggedWrites.ts +148 -0
  71. package/src/install/paths.ts +11 -2
  72. package/src/install/preserve.ts +40 -4
  73. package/src/install/txlog.ts +10 -6
  74. package/src/scripts/_cli/cmd_conformance.ts +3 -2
  75. package/src/scripts/_cli/cmd_update.ts +15 -17
  76. package/src/scripts/_lib/council_settings_block.ts +9 -2
  77. package/src/scripts/_lib/egress_rules.ts +4 -1
  78. package/src/scripts/_lib/host_capability.ts +10 -5
  79. package/src/scripts/_lib/identifier_redaction.ts +38 -2
  80. package/src/scripts/_lib/price_freshness.ts +112 -0
  81. package/src/scripts/_lib/production_reach.ts +194 -0
  82. package/src/scripts/_lib/quota_parked.ts +17 -28
  83. package/src/scripts/_lib/release_findings_ingest.ts +13 -335
  84. package/src/scripts/_lib/tier_downshift_verdict.ts +81 -35
  85. package/src/scripts/ai_council/chairman.ts +5 -2
  86. package/src/scripts/ai_council/clients.ts +4 -3
  87. package/src/scripts/ai_council/free_hand_shadow.ts +24 -12
  88. package/src/scripts/ai_council/free_route_catalog.ts +142 -5
  89. package/src/scripts/ai_council/free_route_client.ts +4 -0
  90. package/src/scripts/ai_council/hand_payload.ts +115 -7
  91. package/src/scripts/ai_council/orchestrator.ts +7 -10
  92. package/src/scripts/ai_council/route_chooser.ts +1 -1
  93. package/src/scripts/ai_council/route_member.ts +66 -4
  94. package/src/scripts/ai_council/seat_policy.ts +8 -1
  95. package/src/scripts/ai_council/self_hosted_probe.ts +327 -0
  96. package/src/scripts/ai_council/status_surface.ts +2 -0
  97. package/src/scripts/ask_transport.ts +137 -15
  98. package/src/scripts/check_finding_dispositions.ts +25 -71
  99. package/src/scripts/check_release_pr_shape.ts +3 -3
  100. package/src/scripts/check_step_production_reach.ts +257 -0
  101. package/src/scripts/council_cli.ts +10 -8
  102. package/src/scripts/hook_manifest.json +1 -1
  103. package/src/scripts/hook_manifest.yaml +3 -0
  104. package/src/scripts/hooks/dispatch_hook.ts +2 -2
  105. package/src/scripts/hooks/payload_stub.ts +27 -0
  106. package/src/scripts/hooks/spawn_guard_shadow_hook.ts +29 -70
  107. package/src/scripts/hooks/subagent_ledger_hook.ts +80 -0
  108. package/src/scripts/install.ts +2 -2
  109. package/src/scripts/lint_decision_classes.ts +34 -0
  110. package/src/scripts/lint_hook_manifest.ts +24 -0
  111. package/src/scripts/lint_roadmap_blockers.ts +52 -3
  112. package/src/scripts/release.ts +5 -9
  113. package/src/scripts/release_drill.ts +11 -165
  114. package/src/scripts/release_publication.ts +0 -9
  115. package/src/scripts/report_production_reach.ts +35 -0
  116. package/src/scripts/worker_reuse_shadow_report.ts +399 -1
  117. package/src/server/schemas/settings.ts +1 -1
  118. package/dist/cli-delegate/chunk-A4QJ6ENT.js +0 -50
  119. /package/dist/cli-delegate/{chunk-E6XNBVE4.js → chunk-KR5ZIFPO.js} +0 -0
@@ -55,7 +55,7 @@ import {
55
55
  import { emitFor, type PermissionDecision, type Severity } from "./host_semantics.js";
56
56
  import { _concern_permission_verdict, _payload_tool_name, _permission_for } from "./permission_gate.js";
57
57
  export { _concern_permission_verdict, _payload_tool_input, _payload_tool_name, _permission_for, _working_tree_root } from "./permission_gate.js";
58
- import { _concern_body_classes, planPayloadShapes } from "./payload_stub.js";
58
+ import { _concern_body_classes, applyPayloadFields, planPayloadShapes } from "./payload_stub.js";
59
59
  import { attributeRefusal, egressInjection, egressViewFor, runEgressStage } from "./egress_stage.js";
60
60
  import { resolveSessionRole, type SessionRole } from "../_lib/session_role.js";
61
61
  import { stdinReadFailure, denyOnStdinFailure } from './stdin_failure_policy.js';
@@ -1299,7 +1299,7 @@ export function main(argv?: string[]): number {
1299
1299
  if (!_concern_matches_tool(concern, tool_name)) {
1300
1300
  continue;
1301
1301
  }
1302
- const concern_envelope = shapes.shapeFor(keep_classes, egressViewFor(concern));
1302
+ const concern_envelope = applyPayloadFields(concern, shapes.shapeFor(keep_classes, egressViewFor(concern)));
1303
1303
  const stubs_served = shapes.stubsFor(keep_classes);
1304
1304
  // What was actually SERVED, not what was declared. Recording the
1305
1305
  // declaration made the record wrong in both directions on a body-less
@@ -387,6 +387,33 @@ export function _concern_body_classes(concern: JsonObject): Set<BodyClass> {
387
387
  return parseBodyClasses(concern["needs_payload_bodies"]);
388
388
  }
389
389
 
390
+ /**
391
+ * Field-level projection: a concern declaring `payload_fields: [k, …]` gets
392
+ * each tool body reduced to those top-level keys, and a body that is not an
393
+ * object as a stub. It reads only what it was handed, so its privacy does not
394
+ * rest on what it chooses to keep. The same guard floor as the body classes:
395
+ * a blocking or fail-closed concern is never narrowed.
396
+ */
397
+ export function applyPayloadFields(concern: JsonObject, env: JsonObject): JsonObject {
398
+ const fields = concern["payload_fields"];
399
+ const payload = env["payload"];
400
+ if (!Array.isArray(fields) || !isObject(payload) || concern["fail_closed"] === true || concern["severity"] === "blocking") {
401
+ return env;
402
+ }
403
+ const keep = new Set(fields);
404
+ const out: JsonObject = { ...payload };
405
+ for (const cls of BODY_CLASSES) {
406
+ for (const key of BODY_KEYS[cls]) {
407
+ const v = payload[key];
408
+ if (v === undefined || v === null || isPayloadStub(v)) continue;
409
+ out[key] = isObject(v)
410
+ ? Object.fromEntries(Object.entries(v).filter(([k]) => keep.has(k)))
411
+ : makePayloadStub(key, cls, v, new Map([[key, bodyBytes(v)]]));
412
+ }
413
+ }
414
+ return { ...env, payload: out };
415
+ }
416
+
390
417
  /** The per-dispatch shaping plan. Built once, read per concern. */
391
418
  export interface PayloadShapePlan {
392
419
  /**
@@ -61,12 +61,15 @@ import { probeHostCapabilities } from '../_lib/host_capability.js';
61
61
  import { recentHostMarker } from '../_lib/quota_parked.js';
62
62
  import { shadowReuseVerdict, type ReuseContext, type WorkerState } from '../_lib/worker_reuse.js';
63
63
  import {
64
- LEDGER_DIR,
65
64
  appendLedgerLine,
66
65
  openRecordStats,
66
+ readIndexEntry,
67
67
  readOpenRecords,
68
68
  refFor,
69
69
  resolveConsumerRoot,
70
+ stateStamp,
71
+ stopIndexKey,
72
+ writeIndexEntry,
70
73
  } from './subagent_ledger_hook.js';
71
74
  import { readHookStdin } from './hook_stdin.js';
72
75
  import { EXIT_ALLOW } from './exit_codes.js';
@@ -104,49 +107,6 @@ export const RESUME_TOOL_NAMES: ReadonlySet<string> = new Set(['SendMessage']);
104
107
  */
105
108
  export type SendKind = 'resume' | 'message_running' | 'message_or_resume';
106
109
 
107
- /** Bytes read from the end of each monthly ledger file when looking for a stop. */
108
- export const STOP_SCAN_BYTES = 1024 * 1024;
109
-
110
- function tailOf(file: string): string {
111
- let fd: number;
112
- try {
113
- fd = fs.openSync(file, 'r');
114
- } catch {
115
- return '';
116
- }
117
- try {
118
- const size = fs.fstatSync(fd).size;
119
- const length = Math.min(size, STOP_SCAN_BYTES);
120
- const buf = Buffer.alloc(length);
121
- fs.readSync(fd, buf, 0, length, size - length);
122
- return buf.toString('utf8');
123
- } finally {
124
- fs.closeSync(fd);
125
- }
126
- }
127
-
128
- function stoppedInSession(root: string, ref: string, sessionId: string, now: Date): boolean {
129
- const prev = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
130
- for (const month of [now.toISOString().slice(0, 7), prev.toISOString().slice(0, 7)]) {
131
- for (const line of tailOf(path.join(root, LEDGER_DIR, `${month}.jsonl`)).split('\n')) {
132
- if (!line.includes(ref)) continue;
133
- try {
134
- const rec = JSON.parse(line) as Record<string, unknown>;
135
- if (rec['event'] === 'subagent_stop' && rec['ref'] === ref && rec['session_id'] === sessionId) {
136
- return true;
137
- }
138
- } catch {
139
- // The first line of a tail is usually cut; a torn line is no evidence.
140
- }
141
- }
142
- }
143
- return false;
144
- }
145
-
146
- /**
147
- * The ref a named spawn of this session left in the register, read from the
148
- * same bounded tails as a stop; null when this session named no such worker.
149
- */
150
110
  /** Longer names are neither recorded nor resolved: a name is an address, not a payload. */
151
111
  export const NAME_MAX = 128;
152
112
 
@@ -155,27 +115,6 @@ function nameKey(sessionId: string, name: string): string {
155
115
  return refFor(`subagent-name\0${sessionId}\0${name}`);
156
116
  }
157
117
 
158
- function refForName(root: string, name: string, sessionId: string, now: Date): string | null {
159
- if (name.length > NAME_MAX) return null;
160
- const key = nameKey(sessionId, name);
161
- let found: string | null = null;
162
- const prev = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
163
- for (const month of [prev.toISOString().slice(0, 7), now.toISOString().slice(0, 7)]) {
164
- for (const line of tailOf(path.join(root, LEDGER_DIR, `${month}.jsonl`)).split('\n')) {
165
- if (!line.includes('subagent_named')) continue;
166
- try {
167
- const rec = JSON.parse(line) as Record<string, unknown>;
168
- if (rec['event'] === 'subagent_named' && rec['name_key'] === key && typeof rec['ref'] === 'string') {
169
- found = rec['ref'];
170
- }
171
- } catch {
172
- // A torn line is no evidence.
173
- }
174
- }
175
- }
176
- return found;
177
- }
178
-
179
118
  /**
180
119
  * Classify one send. `recipient_kind: name` is resolved through the register a
181
120
  * named spawn of this session left; any other recipient is taken as an agent id
@@ -192,10 +131,15 @@ export function classifySend(
192
131
  const recipient = input['to'];
193
132
  if (typeof recipient !== 'string' || recipient === '') return 'message_or_resume';
194
133
  if (sessionId === null || sessionId === '') return 'message_or_resume';
195
- const ref = input['recipient_kind'] === 'name' ? refForName(root, recipient, sessionId, now) : refFor(recipient);
196
- if (ref === null) return 'message_or_resume';
134
+ const ref =
135
+ input['recipient_kind'] !== 'name'
136
+ ? refFor(recipient)
137
+ : recipient.length > NAME_MAX
138
+ ? null
139
+ : readIndexEntry(root, nameKey(sessionId, recipient), now);
140
+ if (!ref) return 'message_or_resume';
197
141
  if (readOpenRecords(root).has(ref)) return 'message_running';
198
- return stoppedInSession(root, ref, sessionId, now) ? 'resume' : 'message_or_resume';
142
+ return readIndexEntry(root, stopIndexKey(sessionId, ref), now) === null ? 'message_or_resume' : 'resume';
199
143
  }
200
144
 
201
145
  /**
@@ -208,8 +152,10 @@ export function recordNamedSpawn(root: string, payload: JsonObject, sessionId: s
208
152
  const name = input['name'];
209
153
  const agentId = result['agentId'];
210
154
  if (typeof name !== 'string' || name === '' || name.length > NAME_MAX || typeof agentId !== 'string' || agentId === '') return;
211
- if (sessionId === null) return;
212
- appendLedgerLine(root, nowIso, { event: 'subagent_named', ts: nowIso, ref: refFor(agentId), name_key: nameKey(sessionId, name), session_id: sessionId });
155
+ if (!sessionId) return;
156
+ const key = nameKey(sessionId, name);
157
+ appendLedgerLine(root, nowIso, { event: 'subagent_named', ts: nowIso, ref: refFor(agentId), name_key: key, session_id: sessionId });
158
+ writeIndexEntry(root, nowIso, key, refFor(agentId));
213
159
  }
214
160
 
215
161
  /**
@@ -318,6 +264,18 @@ export function processEnvelope(envelope: JsonValue, consumerRoot: string, deps:
318
264
  const sessionRaw = payload['session_id'] ?? envelope['session_id'];
319
265
  const sessionId = typeof sessionRaw === 'string' ? sessionRaw : null;
320
266
  const sendKind: SendKind | null = isResume ? classifySend(consumerRoot, toolInput, sessionId) : null;
267
+ // A spawn's definition and requested name (as its session digest), so a
268
+ // report can join the request to the named worker a reuse would address.
269
+ const reqName = toolInput['name'];
270
+ const spawnFacts = isResume
271
+ ? {}
272
+ : {
273
+ ...stateStamp(payload['cwd']),
274
+ agent_type: typeof toolInput['subagent_type'] === 'string' ? toolInput['subagent_type'] : null,
275
+ ...(typeof reqName === 'string' && reqName && reqName.length <= NAME_MAX && sessionId
276
+ ? { name_key: nameKey(sessionId, reqName) }
277
+ : {}),
278
+ };
321
279
  // Only the verdict's label and reason codes reach disk — never the tool input.
322
280
  const reuse = isResume
323
281
  ? null
@@ -357,6 +315,7 @@ export function processEnvelope(envelope: JsonValue, consumerRoot: string, deps:
357
315
  ? {}
358
316
  : { candidates: verdicts.map((v) => ({ label: v.label, would_deny: v.would_deny, on: v.on })) }),
359
317
  dispatch_kind: sendKind ?? 'spawn',
318
+ ...spawnFacts,
360
319
  resume_admission: platform !== null && RESUME_ADMISSION_OBSERVED.has(platform) ? 'counted' : 'unobserved',
361
320
  ...(reuse === null ? {} : { reuse }),
362
321
  // A recent host-sourced rate marker: a new spawn would have waited.
@@ -107,6 +107,7 @@
107
107
  * undercount landed precisely in parallel fan-out, the mode this instrument
108
108
  * exists to measure.
109
109
  */
110
+ import { execFileSync } from 'node:child_process';
110
111
  import * as crypto from 'node:crypto';
111
112
  import * as fs from 'node:fs';
112
113
  import * as path from 'node:path';
@@ -500,6 +501,78 @@ export function appendLedgerLine(root: string, ts: string, line: Record<string,
500
501
  fs.appendFileSync(file, `${JSON.stringify(line)}\n`, 'utf8');
501
502
  }
502
503
 
504
+ /**
505
+ * The register's index: one file per key under `index/<YYYY-MM>/`, so a reader
506
+ * answers "did this session stop that leg" or "which ref does this name
507
+ * address" with one stat or read instead of scanning a ledger tail that heavy
508
+ * traffic can push the answer out of. Keys are digests only (`refFor` over a
509
+ * domain-separated string), so no session id, agent id or name is a file name.
510
+ */
511
+ const INDEX_SUBDIR = 'index';
512
+
513
+ export function writeIndexEntry(root: string, ts: string, key: string, value: string): void {
514
+ if (is_replay_mode()) return;
515
+ const dir = path.join(root, LEDGER_DIR, INDEX_SUBDIR, ts.slice(0, 7));
516
+ fs.mkdirSync(dir, { recursive: true });
517
+ const tmp = path.join(dir, `.${key}.${process.pid}.tmp`);
518
+ try {
519
+ fs.writeFileSync(tmp, value, 'utf8');
520
+ fs.renameSync(tmp, path.join(dir, key));
521
+ } finally {
522
+ fs.rmSync(tmp, { force: true });
523
+ }
524
+ }
525
+
526
+ /** The entry for `key` in this month or the previous one; null when neither holds it. */
527
+ export function readIndexEntry(root: string, key: string, now: Date): string | null {
528
+ const prev = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
529
+ for (const month of [now.toISOString().slice(0, 7), prev.toISOString().slice(0, 7)]) {
530
+ try {
531
+ return fs.readFileSync(path.join(root, LEDGER_DIR, INDEX_SUBDIR, month, key), 'utf8');
532
+ } catch {
533
+ // Not in this month.
534
+ }
535
+ }
536
+ return null;
537
+ }
538
+
539
+ /** The commit the checkout containing `dir` is on; null outside a repository or on any failure. */
540
+ export function headOf(dir: string): string | null {
541
+ try {
542
+ // An inherited GIT_DIR / GIT_WORK_TREE would override `-C`.
543
+ const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('GIT_')));
544
+ return execFileSync('git', ['-C', dir, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], env }).trim() || null;
545
+ } catch {
546
+ return null;
547
+ }
548
+ }
549
+
550
+ /**
551
+ * What a reuse decision compares between a worker's stop and a later spawn,
552
+ * stamped at both so a report can reconstruct it after the fact: the commit,
553
+ * a digest of the directory, and the install epoch (the running bundle's
554
+ * mtime, which changes whenever the package is rebuilt or reinstalled).
555
+ */
556
+ export function stateStamp(cwd: unknown): { head: string | null; cwd_key: string | null; epoch: number | null } {
557
+ let epoch: number | null = null;
558
+ try {
559
+ epoch = fs.statSync(fileURLToPath(import.meta.url)).mtimeMs;
560
+ } catch {
561
+ // No file behind the module; the epoch stays unknown.
562
+ }
563
+ return typeof cwd === 'string' && cwd
564
+ ? { head: headOf(cwd), cwd_key: refFor(cwd), epoch }
565
+ : { head: null, cwd_key: null, epoch };
566
+ }
567
+
568
+ export function stopIndexKey(sessionId: string, ref: string): string {
569
+ return refFor(`subagent-stop\0${sessionId}\0${ref}`);
570
+ }
571
+
572
+ export function recordStopIndex(root: string, ts: string, sessionId: string, ref: string): void {
573
+ writeIndexEntry(root, ts, stopIndexKey(sessionId, ref), '');
574
+ }
575
+
503
576
  /**
504
577
  * Candidate wall-clock stop-loss arms — Phase 3 Step 3, shadow only.
505
578
  *
@@ -750,7 +823,14 @@ function handleStop(root: string, payload: JsonObject, nowIso: string, sessionId
750
823
  // candidate was, this says whether it was aimed at the envelope.
751
824
  envelope_field_hits: parse.field_hits,
752
825
  concurrent_open: countOpen(root),
826
+ ...stateStamp(payload['cwd']),
753
827
  });
828
+ // After the line, and on its own: a failed index write must not cost the stop record.
829
+ try {
830
+ if (sessionId !== null) recordStopIndex(root, nowIso, sessionId, ref);
831
+ } catch {
832
+ // The guard then reads this stop as unplaced, the safe reading.
833
+ }
754
834
  }
755
835
 
756
836
  /** Process an ALREADY-PARSED dispatcher envelope. Always returns EXIT_ALLOW. */
@@ -3010,14 +3010,14 @@ function _deploy_global_content(
3010
3010
  info,
3011
3011
  warn,
3012
3012
  res,
3013
- preserved_now,
3013
+ preserved_now, claude_rule_rewrite.onlyDeployed(anchor, current_files, (p) => _log_tx_entry('write', p)),
3014
3014
  );
3015
3015
  // Steps 1.1 and 1.4 of road-to-an-installed-layer-that-is-thinned;
3016
3016
  // both modules' headers carry the reasoning.
3017
3017
  deployClaudeCarrier({
3018
3018
  packageRoot: package_root, rulesDir: path.join(anchor, 'rules'), quiet: state.QUIET, info, warn, userPin: state.FULL_PIN,
3019
3019
  wantsThin: installerThinsHost(tool_id, { packageRoot: package_root }),
3020
- thin: (bodyLinkRoot) => describeThinInstalledLayer(thinInstalledRuleLayer({ rulesDir: path.join(anchor, 'rules'), packageRoot: package_root, preserved: preserved_now, bodyLinkRoot })),
3020
+ thin: (bodyLinkRoot) => describeThinInstalledLayer(thinInstalledRuleLayer({ rulesDir: path.join(anchor, 'rules'), packageRoot: package_root, preserved: preserved_now, bodyLinkRoot, onWrite: claude_rule_rewrite.onlyDeployed(anchor, current_files, (p) => _log_tx_entry('write', p)) })),
3021
3021
  });
3022
3022
  if (!state.QUIET) for (const l of installReceiptBudgetLines(package_root, undefined, { thisDeploy: new Map([[anchor, current_files]]) })) info(l);
3023
3023
  }
@@ -34,6 +34,7 @@ import process from 'node:process';
34
34
  import { fileURLToPath } from 'node:url';
35
35
 
36
36
  import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
37
+ import { OWNER_LOCKED_CLASSES } from './ai_council/ownership_classes.js';
37
38
 
38
39
  const _HERE = fileURLToPath(import.meta.url);
39
40
  const _PROG = 'lint_decision_classes';
@@ -65,6 +66,19 @@ export const RESOLVER_FORMS: readonly string[] = [
65
66
  'owner',
66
67
  ];
67
68
 
69
+ /**
70
+ * Locked-class rows resolved by the council before the owner lock reached this
71
+ * lint (road-to-a-tick-the-shipped-path-earns 2.2, decision A6). Listed rather
72
+ * than rewritten, so a fifth cannot land; each waits on the owner's ruling on
73
+ * blocker `delegated-owner-defaults`.
74
+ */
75
+ export const LOCKED_RESOLVER_BASELINE: ReadonlySet<string> = new Set([
76
+ 'agents/roadmaps/road-to-an-update-that-installs-itself-only-when-it-is-safe.md#D7',
77
+ 'agents/roadmaps/road-to-an-update-that-installs-itself-only-when-it-is-safe.md#D8',
78
+ 'agents/roadmaps/road-to-authority-routing-mechanism.md#D11',
79
+ 'agents/roadmaps/road-to-placeholders-on-the-wire.md#D7',
80
+ ]);
81
+
68
82
  /**
69
83
  * Unresolved-decision markers. Deliberately narrow: every pattern is a phrase
70
84
  * an author writes when they KNOW a decision is open, never a word that shows
@@ -206,6 +220,21 @@ export function checkOwnershipColumn(rel: string, lines: readonly string[]): Vio
206
220
  `team:<record>, owner`,
207
221
  });
208
222
  }
223
+ const id = (cells[0] ?? '').replace(/`/g, '').trim();
224
+ if (
225
+ OWNER_LOCKED_CLASSES.has(owner) &&
226
+ resolved !== 'owner' &&
227
+ !LOCKED_RESOLVER_BASELINE.has(`${rel}#${id}`)
228
+ ) {
229
+ out.push({
230
+ file: rel,
231
+ line: i + 1,
232
+ reason:
233
+ `ownership=\`${owner}\` is locked to the owner (ownership_classes.ts), ` +
234
+ `so resolved by=\`${resolved}\` cannot close it. Record a council answer ` +
235
+ `as \`owner\` with a decision cell starting "proposed".`,
236
+ });
237
+ }
209
238
  }
210
239
  }
211
240
  return out;
@@ -438,6 +467,11 @@ function _selfTest(): number {
438
467
  if (checkFile('m2.md', resolved).length !== 0) {
439
468
  failures.push('a marker naming its own closure row red');
440
469
  }
470
+ const locked = good.replace('critical-technical | council:rec-1', 'product-owned | council:rec-1');
471
+ if (checkFile('l.md', locked).length === 0) failures.push('a locked class closed by the council did not red');
472
+ if (checkFile('l2.md', locked.replace('council:rec-1', 'owner')).length !== 0) {
473
+ failures.push('a locked class closed by the owner red');
474
+ }
441
475
  const dangling = 'Pick the codec: TBD — closed as D9.\n';
442
476
  if (checkFile('m3.md', dangling).length === 0) {
443
477
  failures.push('a marker naming a row that does not exist did not red');
@@ -276,6 +276,30 @@ function _check_concerns(manifest: YamlObject, errors: string[]): Set<string> {
276
276
  }
277
277
  }
278
278
  }
279
+ // `payload_fields:` — field-level projection of the declared bodies. A
280
+ // non-list is ignored at run time, which would silently hand the concern
281
+ // whole bodies; a projection on a guard is ignored by design, so declaring
282
+ // one there states a narrowing that never happens.
283
+ if ("payload_fields" in spec) {
284
+ const raw = spec["payload_fields"];
285
+ if (!Array.isArray(raw) || raw.length === 0 || raw.some((f) => typeof f !== "string" || f === "")) {
286
+ errors.push(
287
+ `concerns.${name}: 'payload_fields' must be a non-empty list of key names`,
288
+ );
289
+ }
290
+ if (!("needs_payload_bodies" in spec)) {
291
+ errors.push(
292
+ `concerns.${name}: 'payload_fields' without 'needs_payload_bodies' ` +
293
+ `narrows bodies the concern never receives — declare the body classes`,
294
+ );
295
+ }
296
+ if (spec["fail_closed"] === true || spec["severity"] === "blocking") {
297
+ errors.push(
298
+ `concerns.${name}: 'payload_fields' has no effect on a blocking or ` +
299
+ `fail-closed concern, which always receives whole bodies — remove it`,
300
+ );
301
+ }
302
+ }
279
303
  // `re_arm:` — the named boundary at which a rebuilt prompt prefix is
280
304
  // expected and paid for once (`road-to-runtime-context-floors` step 1.2).
281
305
  // `check_prefix_stable_mutation` reads this key to decide whether a
@@ -281,6 +281,27 @@ const TECHNICAL_CLASSES: ReadonlySet<string> = new Set([
281
281
  * parses as `1`: the taxonomy name is documentation for the reader and must
282
282
  * not have to be spelled identically in every entry to stay valid.
283
283
  */
284
+ /** A `## Decisions` row resolved by `owner`, dated, that names blocker `id`. */
285
+ function _ownerDecisionNames(text: string, id: string): boolean {
286
+ const lines = text.split('\n');
287
+ const start = lines.findIndex((l) => /^##\s+Decisions\s*$/.test(l));
288
+ if (start < 0) return false;
289
+ let resolvedCol = -1;
290
+ for (let i = start + 1; i < lines.length && !/^##\s/.test(lines[i] as string); i += 1) {
291
+ const line = (lines[i] as string).trim();
292
+ if (!line.startsWith('|')) continue;
293
+ const cells = line.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
294
+ if (resolvedCol < 0) {
295
+ resolvedCol = cells.findIndex((c) => /^resolved[ _]by$/i.test(c));
296
+ continue;
297
+ }
298
+ if (cells[resolvedCol]?.replace(/`/g, '') !== 'owner') continue;
299
+ if (!line.includes(id) || !/\b\d{4}-\d{2}-\d{2}\b/.test(line)) continue;
300
+ return true;
301
+ }
302
+ return false;
303
+ }
304
+
284
305
  function _blockerClass(body: string): string {
285
306
  const m = CLASS_FIELD_RE.exec(body);
286
307
  if (!m) {
@@ -341,6 +362,8 @@ interface ScanResult {
341
362
  decidability: Violation[];
342
363
  /** Open human-owned entries with no `Routed:` field — counted against its own ratchet. */
343
364
  routing: Violation[];
365
+ /** Resolved human-only entries no owner `## Decisions` row names — ratcheted (K1). */
366
+ ownerClose: Violation[];
344
367
  }
345
368
 
346
369
  /** What a scan needs to resolve a qualified reference: who it is, and where it may point. */
@@ -354,6 +377,7 @@ function _scanBoth(rawText: string, ctx: ScanContext = {}): ScanResult {
354
377
  const violations: Violation[] = [];
355
378
  const decidability: Violation[] = [];
356
379
  const routing: Violation[] = [];
380
+ const ownerClose: Violation[] = [];
357
381
  const text = _stripFencedCode(rawText);
358
382
  const declaredIds = new Set<string>();
359
383
  const userDecisionIds = new Set<string>();
@@ -416,6 +440,15 @@ function _scanBoth(rawText: string, ctx: ScanContext = {}): ScanResult {
416
440
  // Resolved entries are history — re-litigating a decision already
417
441
  // made would be churn, and the ratchet would never reach zero.
418
442
  const isResolved = /^-[ \t]*\*\*Status:\*\*[ \t]*resolved/im.test(body);
443
+ if (isResolved && ['', '3'].includes(_blockerClass(body)) && !_ownerDecisionNames(text, cur.id)) {
444
+ ownerClose.push({
445
+ line: _lineAt(text, sectionStart + cur.start),
446
+ message:
447
+ `blocker '${cur.id}' is human-only and resolved, but no \`## Decisions\` row ` +
448
+ 'resolved by `owner` names it with a date — a class-3 blocker closes only on an ' +
449
+ 'owner decision (road-to-a-tick-the-shipped-path-earns 2.3)',
450
+ });
451
+ }
419
452
  if (!isResolved) {
420
453
  openIds.add(cur.id);
421
454
  // The class/run contract is HARD rather than ratcheted, and it
@@ -552,7 +585,7 @@ function _scanBoth(rawText: string, ctx: ScanContext = {}): ScanResult {
552
585
  violations.sort((a, b) => a.line - b.line);
553
586
  decidability.sort((a, b) => a.line - b.line);
554
587
  routing.sort((a, b) => a.line - b.line);
555
- return { hard: violations, decidability, routing };
588
+ return { hard: violations, decidability, routing, ownerClose };
556
589
  }
557
590
 
558
591
  interface LocalBlockers {
@@ -920,10 +953,11 @@ function main(): number {
920
953
  let failed = 0;
921
954
  const undecidable: Array<{ rel: string; v: Violation }> = [];
922
955
  const unrouted: Array<{ rel: string; v: Violation }> = [];
956
+ const unowned: Array<{ rel: string; v: Violation }> = [];
923
957
  for (const roadmap of roadmaps) {
924
958
  const rel = _relPosix(roadmap, REPO_ROOT);
925
959
  const text = fs.readFileSync(roadmap, 'utf-8');
926
- const { hard, decidability, routing } = _scanBoth(text, {
960
+ const { hard, decidability, routing, ownerClose } = _scanBoth(text, {
927
961
  selfStem: path.basename(roadmap, '.md'),
928
962
  index,
929
963
  });
@@ -933,6 +967,9 @@ function main(): number {
933
967
  for (const v of routing) {
934
968
  unrouted.push({ rel, v });
935
969
  }
970
+ for (const v of ownerClose) {
971
+ unowned.push({ rel, v });
972
+ }
936
973
  if (hard.length) {
937
974
  failed += 1;
938
975
  process.stderr.write(`❌ ${rel}\n`);
@@ -1021,12 +1058,24 @@ function main(): number {
1021
1058
  ' See templates/roadmaps.md rule 20 "Route before you file".\n',
1022
1059
  );
1023
1060
  }
1024
- if (!verdict.ok || !routingVerdict.ok) {
1061
+ const ownerVerdict = checkRatchet({
1062
+ gate: 'lint_roadmap_blockers:owner-close',
1063
+ actual: unowned.length,
1064
+ repoRoot: REPO_ROOT,
1065
+ });
1066
+ if (!ownerVerdict.ok) {
1067
+ process.stderr.write(`❌ ${ownerVerdict.message}\n`);
1068
+ for (const { rel, v } of unowned) {
1069
+ process.stderr.write(` ${rel}:${v.line}: ${v.message}\n`);
1070
+ }
1071
+ }
1072
+ if (!verdict.ok || !routingVerdict.ok || !ownerVerdict.ok) {
1025
1073
  return 1;
1026
1074
  }
1027
1075
  if (!QUIET) {
1028
1076
  process.stdout.write(`\n✅ ${roadmaps.length} roadmap(s) blocker-contract-clean\n`);
1029
1077
  process.stdout.write(`✅ ${routingVerdict.message}\n`);
1078
+ process.stdout.write(`✅ ${ownerVerdict.message}\n`);
1030
1079
  process.stdout.write(
1031
1080
  `✅ 0 blocker id(s) open in both an active and an archived roadmap ` +
1032
1081
  `(${_globArchivedRoadmaps().length} archived file(s) read)\n`,
@@ -36,8 +36,9 @@
36
36
  * the PR's own consistency check fails — PR #226).
37
37
  * 5. Commit + push — `release: X.Y.Z`, push branch, open PR.
38
38
  * 6. Wait for CI — `gh pr checks --watch` (skippable with --no-wait).
39
- * 7. Findings ledger — VERIFY it is on the branch and dispositioned, and
40
- * STOP otherwise; `self-review-gate.yml` builds it.
39
+ * 7. No review gate — a release waits for no review ledger: every commit
40
+ * on main is already reviewed and merged (owner,
41
+ * 2026-10-22). The self-review stays advisory.
41
42
  * 8. Merge — `gh pr merge --merge --delete-branch`.
42
43
  * 9. Tag main — fast-forward main, tag the merge commit, push the
43
44
  * tag (triggers publish-npm.yml, except under --ci).
@@ -128,7 +129,6 @@ import { canPrompt, promptLine } from './_lib/tty_prompt.js';
128
129
  import type { CommitRecord, ImpactTrailers } from './_lib/consumer_impact.js';
129
130
  import { COMMIT_LOG_FORMAT, NO_IMPACT_TRAILERS, is_breaking_commit, parse_commit_log, parse_impact_trailers, write_impact_map } from './_lib/consumer_impact.js';
130
131
  import { preflightPosition } from './_lib/release_position.js';
131
- import { settleFindingsLedger } from './_lib/release_findings_ingest.js';
132
132
  import { TAG_TRIGGERED_WORKFLOWS, dispatchTagWorkflows } from './_lib/release_tag_dispatch.js';
133
133
  import {
134
134
  RELEASE_HEAD_DEFAULT,
@@ -1041,12 +1041,8 @@ function execute(
1041
1041
  _step(6, total, 'Skip waiting for checks (--no-wait)');
1042
1042
  }
1043
1043
 
1044
- // Step 7 — the findings ledger: verify, never produce; the module says why.
1045
- settleFindingsLedger({
1046
- run, die, repoRoot: REPO_ROOT, remote: REMOTE, trunk: MAIN_BRANCH,
1047
- step: (m) => { _step(7, total, m); }, write: (s) => process.stdout.write(s),
1048
- resolvePr: () => Number((pr_info ?? _pr_for_branch(branch))?.['number'] ?? NaN) || null,
1049
- }, plan.target, branch, pr_merged);
1044
+ // Step 7 — kept as a numbered line so the step anchors stay stable.
1045
+ _step(7, total, 'No review gate — main is already reviewed and merged');
1050
1046
  // Step 8 — merge
1051
1047
  if (pr_merged) {
1052
1048
  _step(8, total, `PR #${pr_info!['number']} already merged — skip`);