instar 1.3.759 → 1.3.760

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.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * selfActionRegistry.ts — the self-action controller registry (Part D1 of
3
+ * docs/specs/self-action-convergence.md). This is the single list the
4
+ * convergence ratchet (`tests/unit/self-action-convergence.test.ts`) iterates,
5
+ * the direct analog of `lint-no-unbounded-llm-spawn.js`'s one closed set of
6
+ * provider classes: one place to look, one thing the ratchet drives.
7
+ *
8
+ * WHY a modeled registry (not the live controllers): the real controllers
9
+ * (SubscriptionPool proactive swap, SessionReaper age-kill, PromiseBeacon) are
10
+ * bound to tmux, HTTP, config, and a real wall clock — undrivable
11
+ * deterministically. Each entry here is a FAITHFUL, minimal convergence MODEL of
12
+ * a real controller's trigger -> brake -> emit shape under the pinned
13
+ * sustained-pressure fixture (Part D2), with a pointer to the real source. The
14
+ * ratchet proves the BRAKE converges; the forcing lint
15
+ * (`scripts/lint-no-unregistered-self-action.js`) proves COMPLETENESS (a new
16
+ * self-action emit must register here). The two together make the closure
17
+ * genuine rather than opt-in.
18
+ *
19
+ * Constitution: "Capacity Safety — No Unbounded Self-Action" (the temporal twin
20
+ * of "Bounded Blast Radius": BBR bounds instantaneous MASS, this bounds
21
+ * steady-state FREQUENCY under feedback).
22
+ */
23
+ /** A deterministic virtual clock — no real time, no randomness (a fixed adversary). */
24
+ export interface VirtualClock {
25
+ nowMs(): number;
26
+ advance(ms: number): void;
27
+ }
28
+ /**
29
+ * The sustained worst case that never clears on its own — the exact condition
30
+ * swap-thrash ran under. Deterministic by construction (no fuzzing): a ping-pong
31
+ * cannot hide behind randomness.
32
+ */
33
+ export interface PressureFixture {
34
+ clock: VirtualClock;
35
+ /** All quota readings >= threshold, forever (the all-hot condition). */
36
+ everyAccountHot(): boolean;
37
+ /** All sessions mid-turn / carrying subagents (no idle window ever opens). */
38
+ everySessionBusy(): boolean;
39
+ /** The peer/CI/flush the loop retries against always rejects (veto never clears). */
40
+ targetAlwaysRejects(): boolean;
41
+ /** A polled quota value that LAGS real state — so a just-vacated account still reads sub-threshold. */
42
+ staleQuotaReading(accountId: string): number;
43
+ }
44
+ /**
45
+ * Where a controller's actions land. `considered` proves the fixture actually
46
+ * pressured the controller (a controller cannot pass by being inertly idle);
47
+ * `count` is the invariant subject; `perTarget` feeds the anti-ping-pong check.
48
+ */
49
+ export interface ActionSink {
50
+ emit(action: {
51
+ verb: string;
52
+ target: string;
53
+ }): void;
54
+ count: number;
55
+ perTarget: Map<string, number>;
56
+ considered: number;
57
+ /** Virtual-clock timestamps of each emit, for the eternal-sentinel rate-floor assertion. */
58
+ emitTimesMs: number[];
59
+ }
60
+ /** Create a fresh sink. */
61
+ export declare function makeActionSink(): ActionSink;
62
+ export interface SelfActionController {
63
+ /** Stable id (greppable by the forcing lint). */
64
+ id: string;
65
+ /** The action verb — MUST contain a detector token (SELF_ACTION_VERB_TOKENS). */
66
+ actionVerb: string;
67
+ /** Real source this models (documentation + audit trail). */
68
+ models: string;
69
+ /** Build the controller under a fixture + sink; returns something with tick(). */
70
+ makeUnderPressure(f: PressureFixture, sink: ActionSink): {
71
+ tick(): void;
72
+ };
73
+ /** Proven max total actions over `ticks` under the pinned pressure. */
74
+ boundK: number;
75
+ /** Proven max actions against ANY single target (anti-ping-pong). */
76
+ perTargetBoundK: number;
77
+ /** N — the sustained-pressure horizon (ticks driven). */
78
+ ticks: number;
79
+ /** Virtual ms the clock advances each tick. */
80
+ tickMs: number;
81
+ /** A declared Eternal Sentinel (P19 exemption) — replaces the count bound with a rate-floor bound. */
82
+ eternalSentinel?: {
83
+ reason: string;
84
+ rateFloorMs: number;
85
+ };
86
+ }
87
+ export declare const SELF_ACTION_CONTROLLERS: SelfActionController[];
88
+ //# sourceMappingURL=selfActionRegistry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"selfActionRegistry.d.ts","sourceRoot":"","sources":["../../src/testing/selfActionRegistry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,uFAAuF;AACvF,MAAM,WAAW,YAAY;IAC3B,KAAK,IAAI,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,YAAY,CAAC;IACpB,wEAAwE;IACxE,eAAe,IAAI,OAAO,CAAC;IAC3B,8EAA8E;IAC9E,gBAAgB,IAAI,OAAO,CAAC;IAC5B,qFAAqF;IACrF,mBAAmB,IAAI,OAAO,CAAC;IAC/B,uGAAuG;IACvG,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;CAC9C;AAED;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACrD,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,4FAA4F;IAC5F,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,2BAA2B;AAC3B,wBAAgB,cAAc,IAAI,UAAU,CAY3C;AAED,MAAM,WAAW,oBAAoB;IACnC,iDAAiD;IACjD,EAAE,EAAE,MAAM,CAAC;IACX,iFAAiF;IACjF,UAAU,EAAE,MAAM,CAAC;IACnB,6DAA6D;IAC7D,MAAM,EAAE,MAAM,CAAC;IACf,kFAAkF;IAClF,iBAAiB,CAAC,CAAC,EAAE,eAAe,EAAE,IAAI,EAAE,UAAU,GAAG;QAAE,IAAI,IAAI,IAAI,CAAA;KAAE,CAAC;IAC1E,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,eAAe,EAAE,MAAM,CAAC;IACxB,yDAAyD;IACzD,KAAK,EAAE,MAAM,CAAC;IACd,+CAA+C;IAC/C,MAAM,EAAE,MAAM,CAAC;IACf,sGAAsG;IACtG,eAAe,CAAC,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3D;AAiKD,eAAO,MAAM,uBAAuB,EAAE,oBAAoB,EAKzD,CAAC"}
@@ -0,0 +1,202 @@
1
+ /**
2
+ * selfActionRegistry.ts — the self-action controller registry (Part D1 of
3
+ * docs/specs/self-action-convergence.md). This is the single list the
4
+ * convergence ratchet (`tests/unit/self-action-convergence.test.ts`) iterates,
5
+ * the direct analog of `lint-no-unbounded-llm-spawn.js`'s one closed set of
6
+ * provider classes: one place to look, one thing the ratchet drives.
7
+ *
8
+ * WHY a modeled registry (not the live controllers): the real controllers
9
+ * (SubscriptionPool proactive swap, SessionReaper age-kill, PromiseBeacon) are
10
+ * bound to tmux, HTTP, config, and a real wall clock — undrivable
11
+ * deterministically. Each entry here is a FAITHFUL, minimal convergence MODEL of
12
+ * a real controller's trigger -> brake -> emit shape under the pinned
13
+ * sustained-pressure fixture (Part D2), with a pointer to the real source. The
14
+ * ratchet proves the BRAKE converges; the forcing lint
15
+ * (`scripts/lint-no-unregistered-self-action.js`) proves COMPLETENESS (a new
16
+ * self-action emit must register here). The two together make the closure
17
+ * genuine rather than opt-in.
18
+ *
19
+ * Constitution: "Capacity Safety — No Unbounded Self-Action" (the temporal twin
20
+ * of "Bounded Blast Radius": BBR bounds instantaneous MASS, this bounds
21
+ * steady-state FREQUENCY under feedback).
22
+ */
23
+ /** Create a fresh sink. */
24
+ export function makeActionSink() {
25
+ const sink = {
26
+ count: 0,
27
+ considered: 0,
28
+ perTarget: new Map(),
29
+ emitTimesMs: [],
30
+ emit(action) {
31
+ sink.count += 1;
32
+ sink.perTarget.set(action.target, (sink.perTarget.get(action.target) ?? 0) + 1);
33
+ },
34
+ };
35
+ return sink;
36
+ }
37
+ // ── The seeded controllers ─────────────────────────────────────────────────
38
+ /**
39
+ * proactive-swap-monitor — THE presenting case (#1035, ff3083a31). The real
40
+ * proactive pre-limit account swap self-triggered ~72 swaps/day via a
41
+ * quota-POSITIVE kill+respawn. Models the shipped "three brakes" fix
42
+ * (SubscriptionPool proactiveSwap antiThrash): under the pinned all-hot
43
+ * fixture, the ALL-HOT brake refuses every candidate swap (STAY PUT) and the
44
+ * projected-post-swap-load gate refuses a target that a stale reading makes look
45
+ * cool-but-actually-hot — so the count settles to 0, horizon-independent, not
46
+ * 72/day.
47
+ */
48
+ const proactiveSwapMonitor = {
49
+ id: 'proactive-swap-monitor',
50
+ actionVerb: 'account-swap',
51
+ models: 'src/monitoring/SubscriptionPool.ts (proactive pre-limit swap, antiThrash all-hot + projected-load brake)',
52
+ boundK: 1,
53
+ perTargetBoundK: 1,
54
+ ticks: 20,
55
+ tickMs: 5 * 60_000, // 5 min/tick — crosses the ~45-min dwell window several times
56
+ makeUnderPressure(f, sink) {
57
+ const CANDIDATES = ['acct-A', 'acct-B'];
58
+ const REHYDRATION_BURST_PCT = 15; // a swap's own kill+re-hydrate lands on the destination
59
+ return {
60
+ tick() {
61
+ sink.considered += 1;
62
+ // Brake 1 — ALL-HOT: when every account reads hot, staying put beats a
63
+ // pointless kill+re-hydrate. Under the pinned fixture this is true
64
+ // forever, so the loop converges to 0 (the swap-thrash fix).
65
+ if (f.everyAccountHot())
66
+ return;
67
+ // Brake 2 — PROJECTED-POST-SWAP-LOAD: gate on (target current + this
68
+ // swap's re-hydration burst), NOT the stale current reading. A target
69
+ // that a lagging poll makes look cool but that this swap would itself
70
+ // push hot is refused — the amplifying edge that caused the ping-pong.
71
+ const target = CANDIDATES.find((id) => {
72
+ const projected = f.staleQuotaReading(id) + REHYDRATION_BURST_PCT;
73
+ return projected < 80;
74
+ });
75
+ if (!target)
76
+ return; // no MATERIALLY-cooler destination -> stay put
77
+ sink.emit({ verb: 'account-swap', target });
78
+ },
79
+ };
80
+ },
81
+ };
82
+ /**
83
+ * age-kill-backoff — the 2026-06-05 reaper age-gate that fired 17,503 identical
84
+ * kill requests/day (request -> veto -> re-request every 5s). Models the P19
85
+ * fix: exponential backoff between attempts + a breaker cap that gives up
86
+ * LOUDLY. Under the pinned targetAlwaysRejects fixture (the veto never clears —
87
+ * a protected/in-flight session), the count settles to `maxAttempts` and the
88
+ * breaker opens, horizon-independent.
89
+ */
90
+ const ageKillBackoff = {
91
+ id: 'age-kill-backoff',
92
+ actionVerb: 'age-kill',
93
+ models: 'src/monitoring/SessionReaper.ts (age-limit kill-request; AgeKillBackoff — P19 backoff + breaker)',
94
+ boundK: 5,
95
+ perTargetBoundK: 5, // legitimately the SAME session, bounded by the breaker (not a ping-pong)
96
+ // Horizon chosen so N ticks FULLY reaches the breaker cap (cumulative
97
+ // exponential backoff to the 5th attempt is ~150s = 30 ticks); N=50 and
98
+ // 2N=100 both settle at 5, so the horizon-independence check is exact.
99
+ ticks: 50,
100
+ tickMs: 5_000, // 5s/tick — the original re-request cadence
101
+ eternalSentinel: undefined,
102
+ makeUnderPressure(f, sink) {
103
+ const MAX_ATTEMPTS = 5;
104
+ const BACKOFF_BASE_MS = 5_000;
105
+ const TARGET = 'over-age-session';
106
+ let attempts = 0;
107
+ let nextAllowedAtMs = 0;
108
+ let breakerOpen = false;
109
+ return {
110
+ tick() {
111
+ sink.considered += 1;
112
+ if (breakerOpen)
113
+ return; // P19 breaker — gave up LOUDLY (in real code: one aggregated attention item)
114
+ if (f.clock.nowMs() < nextAllowedAtMs)
115
+ return; // dwell inside the backoff window
116
+ sink.emit({ verb: 'requestKill', target: TARGET });
117
+ attempts += 1;
118
+ if (f.targetAlwaysRejects()) {
119
+ // Exponential backoff, then trip the breaker at the cap.
120
+ nextAllowedAtMs = f.clock.nowMs() + BACKOFF_BASE_MS * 2 ** attempts;
121
+ if (attempts >= MAX_ATTEMPTS)
122
+ breakerOpen = true;
123
+ }
124
+ },
125
+ };
126
+ },
127
+ };
128
+ /**
129
+ * promise-beacon-notify — the ⏳ "still on it, no new output" heartbeat that
130
+ * used to fire every tick (zero-information spam). Models the honest-progress
131
+ * fix (PromiseBeacon suppressUnchangedHeartbeats): under the worst case
132
+ * (everySessionBusy with NO genuine new progress ever), the progress heartbeat
133
+ * is SUPPRESSED every tick -> count 0, horizon-independent. (The sparse
134
+ * once-per-interval liveness line is the SEPARATE eternal-sentinel controller
135
+ * below — a rate-floored constant-cost heartbeat, not a bounded action.)
136
+ */
137
+ const promiseBeaconNotify = {
138
+ id: 'promise-beacon-notify',
139
+ actionVerb: 'beacon-notify',
140
+ models: 'src/monitoring/PromiseBeacon.ts (suppressUnchangedHeartbeats — progress-only heartbeat)',
141
+ boundK: 1,
142
+ perTargetBoundK: 1,
143
+ ticks: 20,
144
+ tickMs: 20 * 60_000, // 20 min/tick (the relaxed base cadence)
145
+ makeUnderPressure(f, sink) {
146
+ return {
147
+ tick() {
148
+ sink.considered += 1;
149
+ // The worst case: work is happening (everySessionBusy) but the terminal
150
+ // frame never CHANGES — the exact "busy long task looks identical to a
151
+ // frozen one" the fix addresses. No new progress -> suppress. Emitting a
152
+ // filler line here is precisely the zero-info spam the fix removed.
153
+ const hasGenuineNewProgress = false;
154
+ if (hasGenuineNewProgress) {
155
+ sink.emit({ verb: 'notify', target: 'topic-progress' });
156
+ }
157
+ },
158
+ };
159
+ },
160
+ };
161
+ /**
162
+ * liveness-heartbeat — a DECLARED Eternal Sentinel (P19 exemption). A sparse,
163
+ * constant-cost "I'm alive" line on a rate FLOOR (the once-per-60m liveness the
164
+ * PromiseBeacon still emits). It is EXEMPT from a total-count bound by design;
165
+ * the ratchet asserts the P19 four conditions instead — a constant per-attempt
166
+ * cost + a rate floor that prevents accumulation (emits never exceed
167
+ * elapsed/rateFloorMs). This exercises the ratchet's eternal-sentinel path so
168
+ * the exemption is a tested contract, not prose.
169
+ */
170
+ const livenessHeartbeat = {
171
+ id: 'liveness-heartbeat',
172
+ actionVerb: 'liveness-notify',
173
+ models: 'src/monitoring/PromiseBeacon.ts (beaconLivenessIntervalMs — sparse once-per-interval liveness line)',
174
+ boundK: Number.POSITIVE_INFINITY, // exempt — bounded by the rate floor, not a total count
175
+ perTargetBoundK: Number.POSITIVE_INFINITY,
176
+ ticks: 24,
177
+ tickMs: 20 * 60_000, // 20 min/tick
178
+ eternalSentinel: { reason: 'sparse constant-cost liveness heartbeat', rateFloorMs: 60 * 60_000 },
179
+ makeUnderPressure(f, sink) {
180
+ const RATE_FLOOR_MS = 60 * 60_000;
181
+ let lastEmitAtMs = Number.NEGATIVE_INFINITY;
182
+ return {
183
+ tick() {
184
+ sink.considered += 1;
185
+ // Constant per-attempt cost (one fixed-size line) + a hard rate floor:
186
+ // emit ONLY if at least RATE_FLOOR_MS has elapsed since the last line.
187
+ if (f.clock.nowMs() - lastEmitAtMs >= RATE_FLOOR_MS) {
188
+ sink.emit({ verb: 'notify', target: 'topic-liveness' });
189
+ sink.emitTimesMs.push(f.clock.nowMs());
190
+ lastEmitAtMs = f.clock.nowMs();
191
+ }
192
+ },
193
+ };
194
+ },
195
+ };
196
+ export const SELF_ACTION_CONTROLLERS = [
197
+ proactiveSwapMonitor,
198
+ ageKillBackoff,
199
+ promiseBeaconNotify,
200
+ livenessHeartbeat,
201
+ ];
202
+ //# sourceMappingURL=selfActionRegistry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"selfActionRegistry.js","sourceRoot":"","sources":["../../src/testing/selfActionRegistry.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.759",
3
+ "version": "1.3.760",
4
4
  "description": "Coherence infrastructure for self-evolving AI agents — on the Claude Code or Codex subscription you already have.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,7 +28,7 @@
28
28
  "test:contract": "node scripts/run-contract-tests.js",
29
29
  "test:contract:raw": "vitest run --config vitest.contract.config.ts",
30
30
  "test:all": "vitest run && vitest run --config vitest.integration.config.ts && vitest run --config vitest.e2e.config.ts",
31
- "lint": "tsc --noEmit && node scripts/lint-no-direct-destructive.js && node scripts/lint-no-direct-llm-http.js && node scripts/lint-no-direct-url-log.js && node scripts/lint-no-unfunneled-topic-creation.js && node scripts/lint-no-unfunneled-headless-launch.js && node scripts/lint-no-unbounded-llm-spawn.js && node scripts/lint-no-unfunneled-credential-write.js && node scripts/lint-state-registry.js && node scripts/lint-store-retention-declared.js && node scripts/lint-no-wholefile-sync-read.js && node scripts/lint-cas-emit-placement.js && node scripts/lint-journal-actuation-ban.js && node scripts/lint-no-blocking-process-scans.js && node scripts/lint-sync-subprocess-chokepoint.js && node scripts/lint-dev-agent-dark-gate.js && node scripts/lint-guard-manifest.js && node scripts/lint-llm-attribution.js && node scripts/lint-no-mainthread-cartographer-walk.js && node scripts/lint-scrape-fixture-realness.js && node scripts/check-codex-rule1-drift.js && node scripts/lint-routing-registry-freshness.js && node scripts/lint-no-opus-claude-cli-gating.js && node scripts/lint-model-registry-freshness.mjs",
31
+ "lint": "tsc --noEmit && node scripts/lint-no-direct-destructive.js && node scripts/lint-no-direct-llm-http.js && node scripts/lint-no-direct-url-log.js && node scripts/lint-no-unfunneled-topic-creation.js && node scripts/lint-no-unfunneled-headless-launch.js && node scripts/lint-no-unbounded-llm-spawn.js && node scripts/lint-no-unregistered-self-action.js && node scripts/lint-no-unfunneled-credential-write.js && node scripts/lint-state-registry.js && node scripts/lint-store-retention-declared.js && node scripts/lint-no-wholefile-sync-read.js && node scripts/lint-cas-emit-placement.js && node scripts/lint-journal-actuation-ban.js && node scripts/lint-no-blocking-process-scans.js && node scripts/lint-sync-subprocess-chokepoint.js && node scripts/lint-dev-agent-dark-gate.js && node scripts/lint-guard-manifest.js && node scripts/lint-llm-attribution.js && node scripts/lint-no-mainthread-cartographer-walk.js && node scripts/lint-scrape-fixture-realness.js && node scripts/check-codex-rule1-drift.js && node scripts/lint-routing-registry-freshness.js && node scripts/lint-no-opus-claude-cli-gating.js && node scripts/lint-model-registry-freshness.mjs",
32
32
  "lint:routing-registry": "node scripts/lint-routing-registry-freshness.js",
33
33
  "lint:no-opus-claude-cli-gating": "node scripts/lint-no-opus-claude-cli-gating.js",
34
34
  "lint:model-freshness": "node scripts/lint-model-registry-freshness.mjs",
@@ -27,6 +27,7 @@ import fs from 'node:fs';
27
27
  import path from 'node:path';
28
28
 
29
29
  const DECISIONS_REL = path.join('.instar', 'instar-dev-decisions');
30
+ const TRACES_REL = path.join('.instar', 'instar-dev-traces');
30
31
 
31
32
  /** Parse `--key value` / `--flag` pairs into a plain object. */
32
33
  export function parseArgs(argv) {
@@ -67,10 +68,20 @@ export function buildClassClosureBlock(args) {
67
68
  if (!args.class || typeof args.class !== 'string') throw new Error('--class <id|novel> is required');
68
69
  block.defectClass = args.class;
69
70
  const closure = args.closure;
70
- if (closure !== 'guard' && closure !== 'gap') throw new Error("--closure must be 'guard' or 'gap'");
71
+ // 'n/a' is the NEGATIVE declaration (docs/specs/self-action-convergence.md
72
+ // E3): a genuine one-shot / user-driven action, not a self-triggered loop —
73
+ // the trace-level analog of the D4 lint's allowlist entry. Requires a reason.
74
+ if (closure !== 'guard' && closure !== 'gap' && closure !== 'n/a') {
75
+ throw new Error("--closure must be 'guard', 'gap', or 'n/a' (negative declaration)");
76
+ }
71
77
  block.closure = closure;
72
78
 
73
- if (closure === 'guard') {
79
+ if (closure === 'n/a') {
80
+ if (!args.reason || typeof args.reason !== 'string') {
81
+ throw new Error("closure 'n/a' (negative declaration) requires --reason \"<why this is a one-shot / user-driven action, not a loop>\"");
82
+ }
83
+ block.reason = args.reason;
84
+ } else if (closure === 'guard') {
74
85
  if (!args.citation || typeof args.citation !== 'string') throw new Error("closure 'guard' requires --citation <path|route|symbol>");
75
86
  block.guardEvidence = {
76
87
  enforcementType: typeof args.enforcement === 'string' ? args.enforcement : undefined,
@@ -166,11 +177,65 @@ export function applyDeclaration(repoRoot, block) {
166
177
  return { changed: true, file: target.file, block };
167
178
  }
168
179
 
180
+ /** Find the most-recent instar-dev TRACE file (by mtime). */
181
+ export function findMostRecentTrace(repoRoot) {
182
+ const dir = path.join(repoRoot, TRACES_REL);
183
+ let files;
184
+ try {
185
+ files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
186
+ } catch {
187
+ return null;
188
+ }
189
+ if (files.length === 0) return null;
190
+ let best = null;
191
+ for (const f of files) {
192
+ const full = path.join(dir, f);
193
+ let mtime;
194
+ try {
195
+ mtime = fs.statSync(full).mtimeMs;
196
+ } catch {
197
+ continue;
198
+ }
199
+ if (best === null || mtime > best.mtime) best = { file: full, mtime };
200
+ }
201
+ if (!best) return null;
202
+ let entry;
203
+ try {
204
+ entry = JSON.parse(fs.readFileSync(best.file, 'utf-8'));
205
+ } catch {
206
+ return null;
207
+ }
208
+ return { file: best.file, entry };
209
+ }
210
+
211
+ /**
212
+ * Merge the block into the most-recent instar-dev TRACE (the E3 declaration
213
+ * host — the same place `specPath`/`tier`/`causalAutopsy` live, authored BEFORE
214
+ * the commit). Idempotent. The precommit's writeDecisionAudit then persists it
215
+ * into the machine-readable decision-audit entry.
216
+ * @returns {{ changed: boolean, file: string, block: object }}
217
+ */
218
+ export function applyDeclarationToTrace(repoRoot, block) {
219
+ const target = findMostRecentTrace(repoRoot);
220
+ if (!target) {
221
+ throw new Error(`no instar-dev trace found under ${TRACES_REL} to attach the declaration to (run the /instar-dev skill first)`);
222
+ }
223
+ const before = JSON.stringify(target.entry.classClosure ?? null);
224
+ const after = JSON.stringify(block);
225
+ if (before === after) {
226
+ return { changed: false, file: target.file, block };
227
+ }
228
+ target.entry.classClosure = block;
229
+ fs.writeFileSync(target.file, `${JSON.stringify(target.entry, null, 2)}\n`, 'utf-8');
230
+ return { changed: true, file: target.file, block };
231
+ }
232
+
169
233
  // ── CLI entrypoint ──────────────────────────────────────────────────────────
170
234
  const invokedDirectly = process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop());
171
235
  if (invokedDirectly) {
172
236
  const repoRoot = process.env.CLASS_CLOSURE_REPO_ROOT || process.cwd();
173
237
  const args = parseArgs(process.argv.slice(2));
238
+ const toTrace = args['to-trace'] === true;
174
239
  let block;
175
240
  try {
176
241
  block = buildClassClosureBlock(args);
@@ -180,12 +245,12 @@ if (invokedDirectly) {
180
245
  }
181
246
  let result;
182
247
  try {
183
- result = applyDeclaration(repoRoot, block);
248
+ result = toTrace ? applyDeclarationToTrace(repoRoot, block) : applyDeclaration(repoRoot, block);
184
249
  } catch (err) {
185
250
  console.error(`class-closure-declare: ${err instanceof Error ? err.message : String(err)}`);
186
251
  process.exit(2);
187
252
  }
188
- console.log(`class-closure-declare: ${result.changed ? 'wrote' : 'no change (idempotent)'} classClosure into ${path.relative(repoRoot, result.file)}`);
253
+ console.log(`class-closure-declare: ${result.changed ? 'wrote' : 'no change (idempotent)'} classClosure into ${path.relative(repoRoot, result.file)}${toTrace ? ' (trace host)' : ''}`);
189
254
  console.log(JSON.stringify(result.block, null, 2));
190
255
  process.exit(0);
191
256
  }
@@ -54,9 +54,27 @@ import {
54
54
  REGISTRY_REL_PATH,
55
55
  } from './lib/defect-class-registry.mjs';
56
56
  import { evaluateGuardClosure } from './lib/class-closure-grader.mjs';
57
+ import { isSelfActionControllerFile } from './lib/self-action-detect.mjs';
57
58
 
58
59
  const DEFAULT_CONFIG = { enabled: false, dryRun: true, escalatorDrafting: false };
59
60
 
61
+ // The self-action class id + the convergence-argument regex (Part E2). A
62
+ // `defectClass: unbounded-self-action` + `closure: guard` declaration's
63
+ // guardEvidence.howCaught must ADDRESS the convergence argument (control-loop
64
+ // edge + steady-state bound + settling brake) — a per-tick-cap-only
65
+ // justification does NOT address it.
66
+ const SELF_ACTION_CLASS_ID = 'unbounded-self-action';
67
+ const CONVERGENCE_ADDRESSED = /converge|steady[- ]?state|bound|dwell|hysteresis|all[- ]?hot|projected.*load|breaker/i;
68
+
69
+ /** Read a repo file, or null (a deleted/binary path in a diff). */
70
+ function readFileMaybe(repoRoot, rel) {
71
+ try {
72
+ return fs.readFileSync(path.join(repoRoot, rel), 'utf-8');
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
60
78
  /**
61
79
  * The agent-authored-artifact predicate — a fix touching one of these is
62
80
  * IN SCOPE for the class declaration (docs/specs/class-closure-gate.md →
@@ -92,7 +110,14 @@ export function isGateSourceFile(file) {
92
110
  f === 'docs/defect-classes.json' ||
93
111
  f === '.github/workflows/class-closure-gate.yml' ||
94
112
  f === 'docs/specs/class-closure-gate.md' ||
95
- /^tests\/(unit|integration)\/class-closure-/.test(f)
113
+ /^tests\/(unit|integration)\/class-closure-/.test(f) ||
114
+ // The self-action gate's own source (docs/specs/self-action-convergence.md
115
+ // → Part E5): a broken self-action gate can never block its own repair.
116
+ f === 'scripts/lint-no-unregistered-self-action.js' ||
117
+ f === 'scripts/lib/self-action-detect.mjs' ||
118
+ f === 'src/testing/selfActionRegistry.ts' ||
119
+ f === 'docs/specs/self-action-convergence.md' ||
120
+ /^tests\/(unit|integration)\/self-action-/.test(f)
96
121
  );
97
122
  }
98
123
 
@@ -169,6 +194,17 @@ function validateDeclaration(repoRoot, decl, knownClassIds) {
169
194
  findings.push(`${where}: guard citation resolves (${verdict.gradedKind}) — closure:'guard' upheld`);
170
195
  }
171
196
  }
197
+ // Class-specific arm (Part E2): for the unbounded-self-action class, the
198
+ // convergence ARGUMENT must be addressed in howCaught — a per-tick-cap-only
199
+ // justification bounds one pass, never the loop. A hard violation when
200
+ // enforcing (returned to the caller so it can gate).
201
+ if (decl.defectClass === SELF_ACTION_CLASS_ID) {
202
+ const howCaught = decl.guardEvidence && decl.guardEvidence.howCaught;
203
+ if (typeof howCaught !== 'string' || !CONVERGENCE_ADDRESSED.test(howCaught)) {
204
+ findings.push(`${where}: unbounded-self-action closure:'guard' howCaught does NOT address convergence (steady-state bound / settling brake / dwell / all-hot / breaker) — a per-tick-cap-only justification is insufficient`);
205
+ hardViolations.push(`${where}: unbounded-self-action guard howCaught fails the convergence-addressed check (E2)`);
206
+ }
207
+ }
172
208
  } else if (decl.closure === 'gap') {
173
209
  if (!decl.gapItem || typeof decl.gapItem !== 'string') {
174
210
  findings.push(`${where}: closure:'gap' should cite a tracked gap item (gapItem) — none provided`);
@@ -247,12 +283,19 @@ export function runClassClosureLint(input) {
247
283
  findings.push('mirror-consistency: counted the decision-audit host only (side-effects mirror is display-only) — trivially satisfied');
248
284
  }
249
285
 
250
- // Diff-scope: does this PR touch an agent-authored artifact needing a declaration?
286
+ // Diff-scope: does this PR touch an agent-authored artifact OR a self-action
287
+ // controller (Part E2 — the concrete realization of #1347 Frontloaded
288
+ // Decision 1) needing a declaration?
251
289
  let inScope = false;
252
290
  let exempt;
253
291
  if (changedFiles && changedFiles.length > 0) {
254
292
  const agentFiles = changedFiles.filter(isAgentAuthoredArtifact);
255
- inScope = agentFiles.length > 0;
293
+ // A diff touching a self-action controller file (name-shape or marker) is
294
+ // in scope for the unbounded-self-action declaration.
295
+ const selfActionFiles = changedFiles.filter((f) =>
296
+ isSelfActionControllerFile(f, readFileMaybe(repoRoot, f)),
297
+ );
298
+ inScope = agentFiles.length > 0 || selfActionFiles.length > 0;
256
299
  if (inScope) {
257
300
  // Gate-source-ONLY self-wedge exemption: applies ONLY when the diff
258
301
  // touches EXCLUSIVELY the gate's own source (a mixed PR cannot ride it).
@@ -260,8 +303,21 @@ export function runClassClosureLint(input) {
260
303
  if (allGateSource) {
261
304
  exempt = 'gate-source-only';
262
305
  findings.push('exempt: diff touches EXCLUSIVELY the gate\'s own source (gate-source-only self-wedge exemption, logged)');
263
- } else if (declarations.length === 0) {
264
- findings.push('missing class declaration (report-only) — this PR touches agent-authored artifacts but carries no classClosure declaration');
306
+ } else {
307
+ if (declarations.length === 0) {
308
+ findings.push('missing class declaration (report-only) — this PR touches agent-authored artifacts or self-action controllers but carries no classClosure declaration');
309
+ }
310
+ // Enforcement condition (i) (Part E2): an in-scope SELF-ACTION diff with
311
+ // no unbounded-self-action declaration is a hard violation when
312
+ // enforcing (report-only until prGate.classClosure.dryRun:false).
313
+ if (selfActionFiles.length > 0) {
314
+ const hasSelfActionDecl = declarations.some((d) => d.defectClass === SELF_ACTION_CLASS_ID);
315
+ if (!hasSelfActionDecl) {
316
+ const msg = `self-action controller(s) touched (${selfActionFiles.join(', ')}) but no unbounded-self-action classClosure declaration is present`;
317
+ findings.push(`missing unbounded-self-action declaration — ${msg}`);
318
+ hardViolations.push(msg);
319
+ }
320
+ }
265
321
  }
266
322
  }
267
323
  }