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.
@@ -33,6 +33,7 @@ import { verifyProposalDerivedRunbooks } from '../skills/instar-dev/scripts/veri
33
33
  import { classifyTier, decideRequirementSet } from './lib/classify-tier.mjs';
34
34
  import { recognizeConvergence } from './lib/convergence-recognition.mjs';
35
35
  import { isOperatorSurfaceFile, artifactAddressesOperatorSurfaceQuality, isAuthorizationSurfaceFile, artifactAddressesAgentProposesApproves, operatorSurfaceRequiresRawInput } from './lib/operator-surface.mjs';
36
+ import { selfActionDeclarationVerdict } from './lib/self-action-detect.mjs';
36
37
 
37
38
  // Report-Backed Converging Audit (docs/specs/CONVERGING-AUDIT-DEFAULT.md, Part B).
38
39
  // The precommit reads NO config file and runs pre-compile, so it cannot import
@@ -180,10 +181,14 @@ if (bootstrapTrigger) {
180
181
 
181
182
  let tierSignal = { suggestedTier: 2, sizeTier: 2, riskFloor: 1, reasons: [] };
182
183
  let totalChangedLoc = 0;
184
+ // Hoisted to module scope (docs/specs/self-action-convergence.md → E3 impl
185
+ // note): addedDiffText is computed in the Step-3.5 block but consumed later by
186
+ // assertSelfActionDeclared at BOTH the enforceTier1 and Tier-2 pass-through call
187
+ // sites. It must outlive the block.
188
+ let addedDiffText = '';
183
189
  {
184
190
  let addedLines = 0;
185
191
  let deletedLines = 0;
186
- let addedDiffText = '';
187
192
  try {
188
193
  const numstat = execSync(
189
194
  `git diff --cached --numstat -- ${inScopeFiles.map((f) => JSON.stringify(f)).join(' ')}`,
@@ -337,6 +342,7 @@ const decisionEntryPath = writeDecisionAudit({
337
342
  files: inScopeFiles.length,
338
343
  loc: totalChangedLoc,
339
344
  causalAutopsy,
345
+ classClosure: (freshestTrace && typeof freshestTrace.classClosure === 'object' && freshestTrace.classClosure) || null,
340
346
  });
341
347
  // Malformed autopsy blocks AFTER the audit write — the blocked attempt is
342
348
  // recorded (verdict 'blocked' via the exit handler), same as every gate
@@ -886,6 +892,7 @@ if (!promotionGateResult.ok) {
886
892
 
887
893
  assertFrameworkGenerality(inScopeFiles, validTrace.trace);
888
894
  assertOperatorSurfaceQuality(staged, validTrace.trace);
895
+ assertSelfActionDeclared(addedDiffText, inScopeFiles, validTrace.trace);
889
896
 
890
897
  console.error(
891
898
  `[instar-dev-precommit] OK — trace ${path.basename(validTrace.entry.file)} covers ${inScopeFiles.length} in-scope file(s), artifact ${validTrace.trace.artifactPath} verified, spec ${spec} is converged + approved` +
@@ -1035,6 +1042,90 @@ function assertOperatorSurfaceQuality(stagedFiles, trace) {
1035
1042
  }
1036
1043
  }
1037
1044
 
1045
+ // Self-Action Convergence gate (docs/specs/self-action-convergence.md → E3).
1046
+ // The light (Tier-1) path is where #1035 (swap-thrash) slipped through with no
1047
+ // adversarial reviewer. When an ADDED diff introduces/modifies a self-action
1048
+ // emit (restart|swap|respawn|spawn|notify|retry|re-drive|kill) AND a src/ file
1049
+ // is touched, the change must declare the `unbounded-self-action` class in the
1050
+ // TRACE — either a real classClosure declaration (guard|gap) OR an explicit
1051
+ // negative declaration (closure:'n/a' + reason) — the trace-level analog of the
1052
+ // D4 lint's allowlist. A genuine one-shot user-driven call costs one attested
1053
+ // line, never an unescapable block.
1054
+ //
1055
+ // FAIL-OPEN on tooling failure (the safe asymmetry): empty addedDiffText, no
1056
+ // src/ file, or an unreadable artifact does NOT fire — a false-negative here is
1057
+ // backstopped by the E2 CI lint; a false-positive that blocked all commits
1058
+ // would sever the developer's ability to ship. Called at BOTH pass-through
1059
+ // points (enforceTier1 + the Tier-2 fall-through) — the both-call-sites detail
1060
+ // is load-bearing (the light path is where #1035 escaped).
1061
+ function assertSelfActionDeclared(addedDiffText, inScopeFilesArg, trace) {
1062
+ const verdict = selfActionDeclarationVerdict({
1063
+ addedDiffText,
1064
+ inScopeFiles: inScopeFilesArg,
1065
+ classClosure: trace && trace.classClosure,
1066
+ });
1067
+ if (!verdict.required) return; // fail-open / not a self-action change
1068
+
1069
+ if (!verdict.satisfied) {
1070
+ blockCommit(
1071
+ inScopeFilesArg,
1072
+ [
1073
+ 'Self-Action Convergence gate:',
1074
+ ' This change ADDS or modifies a self-triggered action in src/ (a',
1075
+ ' restart / swap / respawn / spawn / notify / retry / re-drive / kill),',
1076
+ ' but the trace carries no `unbounded-self-action` class declaration.',
1077
+ '',
1078
+ ' A self-triggered action must be proven to CONVERGE under sustained',
1079
+ ' pressure (not just be individually correct). Declare it in your trace:',
1080
+ '',
1081
+ ' • If it is a self-triggered controller — register it in',
1082
+ ' src/testing/selfActionRegistry.ts (so tests/unit/self-action-convergence.test.ts',
1083
+ ' proves it settles) and declare closure:"guard" citing that ratchet:',
1084
+ ' node scripts/class-closure-declare.mjs --to-trace \\',
1085
+ ' --class unbounded-self-action --closure guard \\',
1086
+ ' --citation tests/unit/self-action-convergence.test.ts \\',
1087
+ ' --enforcement ratchet --how-caught "<convergence argument: steady-state bound + settling brake>"',
1088
+ '',
1089
+ ' • If the guard is out of THIS change\'s scope — closure:"gap" with a',
1090
+ ' tracked evolution-action id (--closure gap --gap-item <id>).',
1091
+ '',
1092
+ ' • If it is genuinely a ONE-SHOT / user-driven action (not a',
1093
+ ' self-triggered loop) — an explicit negative declaration:',
1094
+ ' { "defectClass": "unbounded-self-action", "closure": "n/a",',
1095
+ ' "reason": "one-shot user-driven action, not a self-triggered loop" }',
1096
+ '',
1097
+ ' (Standard: docs/STANDARDS-REGISTRY.md → "Capacity Safety — No Unbounded',
1098
+ ' Self-Action". Spec: docs/specs/self-action-convergence.md → Part E3.)',
1099
+ ].join('\n'),
1100
+ );
1101
+ }
1102
+
1103
+ // Mirror check (the display-only human mirror — the two hosts #1347 uses).
1104
+ // Fail-OPEN if the artifact is unreadable.
1105
+ const artifactRel = trace && (trace.sideEffectsPath || trace.artifactPath);
1106
+ if (artifactRel) {
1107
+ const abs = path.resolve(ROOT, artifactRel);
1108
+ if (fs.existsSync(abs)) {
1109
+ const content = fs.readFileSync(abs, 'utf8');
1110
+ if (!/unbounded-self-action/.test(content)) {
1111
+ blockCommit(
1112
+ inScopeFilesArg,
1113
+ [
1114
+ 'Self-Action Convergence gate (mirror):',
1115
+ ' The trace declares the unbounded-self-action class, but the staged',
1116
+ ` side-effects artifact (${artifactRel}) never mentions it — the`,
1117
+ ' display-only human mirror must AGREE with the machine-readable trace',
1118
+ ' declaration (the two hosts #1347 uses).',
1119
+ '',
1120
+ ' Add the class-closure declaration to the side-effects artifact\'s',
1121
+ ' "## Class-Closure Declaration" section.',
1122
+ ].join('\n'),
1123
+ );
1124
+ }
1125
+ }
1126
+ }
1127
+ }
1128
+
1038
1129
  function blockCommit(files, reason) {
1039
1130
  console.error('');
1040
1131
  console.error('╔════════════════════════════════════════════════════════════════════╗');
@@ -1065,7 +1156,7 @@ function blockCommit(files, reason) {
1065
1156
  // fire, the line just evaporated with the worktree). If the commit is later
1066
1157
  // blocked by the gate, the staged line simply rides the retry commit — both
1067
1158
  // lines describe real gate evaluations.
1068
- function writeDecisionAudit({ slug, suggestedTier, declaredTier, riskFloor, riskFloorReasons, belowFloor, files, loc, causalAutopsy = null }) {
1159
+ function writeDecisionAudit({ slug, suggestedTier, declaredTier, riskFloor, riskFloorReasons, belowFloor, files, loc, causalAutopsy = null, classClosure = null }) {
1069
1160
  try {
1070
1161
  fs.mkdirSync(DECISIONS_DIR, { recursive: true });
1071
1162
  const ts = new Date().toISOString();
@@ -1097,6 +1188,12 @@ function writeDecisionAudit({ slug, suggestedTier, declaredTier, riskFloor, risk
1097
1188
  // This is THE meta-analysis substrate: convergence vs whack-a-mole is
1098
1189
  // a query over these entries, not archaeology.
1099
1190
  causalAutopsy,
1191
+ // Class-closure declaration (docs/specs/class-closure-gate.md +
1192
+ // self-action-convergence.md → E3): persisted from the instar-dev TRACE
1193
+ // into the machine-readable decision-audit host the CI class-closure lint
1194
+ // reads — closing the chicken-and-egg (the trace is authored before the
1195
+ // commit; the entry is written by this hook). null = not declared.
1196
+ classClosure,
1100
1197
  // Finalized by the process exit handler below: 'pass' when the gate
1101
1198
  // allowed the commit, 'blocked' otherwise. The riding-the-retry design
1102
1199
  // (a blocked evaluation's entry rides the next successful commit, see
@@ -1231,6 +1328,7 @@ function enforceTier1(trace, traceFile) {
1231
1328
 
1232
1329
  assertFrameworkGenerality(inScopeFiles, trace);
1233
1330
  assertOperatorSurfaceQuality(staged, trace);
1331
+ assertSelfActionDeclared(addedDiffText, inScopeFiles, trace);
1234
1332
 
1235
1333
  console.error(
1236
1334
  `[instar-dev-precommit] OK (Tier 1) — trace ${traceName} covers ${inScopeFiles.length} in-scope file(s), ` +
@@ -0,0 +1,162 @@
1
+ // self-action-detect.mjs — the SINGLE shared detector for the
2
+ // `unbounded-self-action` defect class (docs/specs/self-action-convergence.md
3
+ // → Part E1). Dependency-free ESM, mirroring how the class-closure gate shares
4
+ // `class-closure-grader.mjs` as one library used by both the CI lint and the
5
+ // commit gate.
6
+ //
7
+ // It answers three questions, all deterministically (Signal vs. Authority — it
8
+ // FORCES a declaration, it never LLM-guesses):
9
+ // 1. Does an ADDED diff introduce/modify a self-action EMIT? (the trigger for
10
+ // the class declaration requirement — emit-anchored, comment/prose-safe).
11
+ // 2. Is a file a self-action CONTROLLER source file? (the scope predicate the
12
+ // CI lint + forcing lint use).
13
+ // 3. What is the raw verb-token set? (the single source the D3 ratchet's
14
+ // registry `actionVerb`s must be a subset of — coherence test E5/D5).
15
+ //
16
+ // SAFETY ASYMMETRY (fail-OPEN on tooling failure — the E3/E2 gates rely on it):
17
+ // empty/blank added-diff text returns FALSE (no fire). A false-negative here is
18
+ // backstopped by the CI lint; a false-positive that blocked all commits would
19
+ // sever the developer's ability to ship. The obfuscation limit (a string-based
20
+ // detector cannot beat DELIBERATE evasion — `const v='swap'; self[v]()`) is the
21
+ // known gap named in the spec, tracked to the follow-on funnel (Part B).
22
+
23
+ // ── The self-action verb TOKENS ────────────────────────────────────────────
24
+ // Seeded from the synthesis taxonomy (restart|swap|respawn|spawn|notify|retry|
25
+ // re-drive|kill) and widened to the concrete instar symbols those verbs appear
26
+ // as. This is the SINGLE source: the emit regex is built from it, and the D3
27
+ // ratchet's registry `actionVerb`s are asserted to each contain one of these
28
+ // tokens (so a new controller can never register a verb the detector is blind
29
+ // to). Keep alphabetized-by-family for review.
30
+ export const SELF_ACTION_VERB_TOKENS = Object.freeze([
31
+ // restart / respawn family
32
+ 'refresh', 'respawn', 'restart',
33
+ // reap / kill family
34
+ 'reap', 'requestKill', 'kill',
35
+ // swap family
36
+ 'swap', 'proactiveSwap',
37
+ // spawn family
38
+ 'spawnSession', 'spawn',
39
+ // notify / emit family
40
+ 'notify', 'createForumTopic', 'createAttentionItem', 'sendToTopic',
41
+ // re-drive / re-pin family
42
+ 'reDrive', 'redrive', 'rePin', 'repin',
43
+ // retry / nudge / escalate family
44
+ 'retry', 'nudge', 'escalate',
45
+ ]);
46
+
47
+ // A call in an EMITTING position: `verb(` (a bare/qualified call) OR `.verb(`
48
+ // (a method call). A bare NOUN in a comment or prose line does not match
49
+ // because the line-level filter in addedDiffIntroducesSelfAction strips
50
+ // comment/prose lines before testing; the regex itself additionally requires a
51
+ // `(` so `// swap accounts` (no paren) never matches even if the filter is
52
+ // bypassed.
53
+ const CALL_VERBS = SELF_ACTION_VERB_TOKENS.join('|');
54
+ // Method-position verbs — the subset that commonly appears as `obj.verb(`.
55
+ const METHOD_VERBS = [
56
+ 'refresh', 'respawn', 'restart', 'swap', 'reap', 'kill', 'retry',
57
+ 'escalate', 'nudge', 'notify', 'spawn', 'redrive',
58
+ ].join('|');
59
+
60
+ export const SELF_ACTION_EMIT = new RegExp(
61
+ `\\b(${CALL_VERBS})\\s*\\(|\\.\\s*(${METHOD_VERBS})\\s*\\(`,
62
+ );
63
+
64
+ // ── Comment / prose line detection ─────────────────────────────────────────
65
+ // The added-diff text handed to us is already `+`-stripped by the precommit
66
+ // hook (or a raw file line, for the forcing lint). We skip lines that are
67
+ // obviously NOT an emitting statement — a comment, a markdown/prose line, an
68
+ // import, or a string-literal-only mention — before testing the emit regex.
69
+ function isNonEmitLine(line) {
70
+ const t = line.trimStart();
71
+ if (t === '') return true;
72
+ // Line comments / block-comment continuations / hash comments.
73
+ if (/^(\/\/|\*|\/\*|#)/.test(t)) return true;
74
+ // Import / export-from lines mention symbols but do not call them.
75
+ if (/^(import|export)\b/.test(t) && /\bfrom\b/.test(t)) return true;
76
+ return false;
77
+ }
78
+
79
+ /**
80
+ * Does the ADDED diff text introduce/modify a self-action emit? Emitting
81
+ * position only — a bare noun in a comment/prose line does NOT match. Empty /
82
+ * blank text → false (fail-open on a tooling hiccup).
83
+ * @param {string} addedDiffText concatenation of added lines (`+`-stripped)
84
+ * @returns {boolean}
85
+ */
86
+ export function addedDiffIntroducesSelfAction(addedDiffText) {
87
+ if (typeof addedDiffText !== 'string' || addedDiffText.trim() === '') return false;
88
+ for (const line of addedDiffText.split('\n')) {
89
+ if (isNonEmitLine(line)) continue;
90
+ if (SELF_ACTION_EMIT.test(line)) return true;
91
+ }
92
+ return false;
93
+ }
94
+
95
+ // ── Controller-source scope predicate ──────────────────────────────────────
96
+ // A `src/` file whose NAME matches the controller shape, OR any file that
97
+ // carries the `@self-action-controller` registration marker in its content.
98
+ const CONTROLLER_NAME =
99
+ /(Monitor|Sentinel|Reaper|Beacon|Engine|Scheduler|Watchdog|Poller|Manager)\.ts$/;
100
+ const CONTROLLER_MARKER = /@self-action-controller\s*:/;
101
+
102
+ /**
103
+ * Is `file` a self-action controller source file (for scope)?
104
+ * @param {string} file repo-relative path
105
+ * @param {string} [contentMaybe] optional file content, to catch the marker
106
+ * @returns {boolean}
107
+ */
108
+ export function isSelfActionControllerFile(file, contentMaybe) {
109
+ const f = String(file ?? '');
110
+ if (typeof contentMaybe === 'string' && CONTROLLER_MARKER.test(contentMaybe)) return true;
111
+ if (!f.startsWith('src/')) return false;
112
+ if (f.endsWith('.test.ts')) return false;
113
+ const base = f.split('/').pop() ?? f;
114
+ return CONTROLLER_NAME.test(base);
115
+ }
116
+
117
+ /**
118
+ * Parse the `@self-action-controller: <id>` marker id out of file content, if
119
+ * present. Returns the id string, or null. (Used by the forcing lint to
120
+ * cross-check the marker id against the SELF_ACTION_CONTROLLERS registry.)
121
+ * @param {string} content
122
+ * @returns {string|null}
123
+ */
124
+ export function selfActionControllerMarkerId(content) {
125
+ if (typeof content !== 'string') return null;
126
+ const m = content.match(/@self-action-controller\s*:\s*([A-Za-z0-9_-]+)/);
127
+ return m ? m[1] : null;
128
+ }
129
+
130
+ export const SELF_ACTION_CLASS_ID = 'unbounded-self-action';
131
+
132
+ /**
133
+ * The PURE decision behind the instar-dev pre-commit gate `assertSelfActionDeclared`
134
+ * (Part E3). Extracted here so the decision is a tested function (Structure >
135
+ * Willpower) that BOTH the enforceTier1 and Tier-2 call sites exercise via one
136
+ * gate. FAIL-OPEN: no self-action emit, or no src/ file touched -> not required.
137
+ * A declaration is satisfied by a real classClosure (guard|gap) OR an explicit
138
+ * negative declaration (closure:'n/a' + reason).
139
+ * @param {{ addedDiffText: string, inScopeFiles: string[], classClosure: any }} input
140
+ * @returns {{ required: boolean, satisfied: boolean, reason: string }}
141
+ */
142
+ export function selfActionDeclarationVerdict({ addedDiffText, inScopeFiles, classClosure }) {
143
+ if (!addedDiffIntroducesSelfAction(addedDiffText)) {
144
+ return { required: false, satisfied: true, reason: 'no self-action emit in added diff (fail-open)' };
145
+ }
146
+ const srcTouched = (inScopeFiles || []).some((f) => String(f).startsWith('src/'));
147
+ if (!srcTouched) {
148
+ return { required: false, satisfied: true, reason: 'no src/ file touched (false-positive-safe)' };
149
+ }
150
+ const cc = classClosure;
151
+ const named = cc && typeof cc === 'object' && cc.defectClass === SELF_ACTION_CLASS_ID;
152
+ const isRealDecl = Boolean(named && (cc.closure === 'guard' || cc.closure === 'gap'));
153
+ const isNegative = Boolean(
154
+ named && cc.closure === 'n/a' && typeof cc.reason === 'string' && cc.reason.trim().length > 0,
155
+ );
156
+ const satisfied = isRealDecl || isNegative;
157
+ return {
158
+ required: true,
159
+ satisfied,
160
+ reason: satisfied ? 'unbounded-self-action declared' : 'missing unbounded-self-action declaration',
161
+ };
162
+ }
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ // safe-git-allow: CI-only lint — single read-only `git diff --cached --name-only`
3
+ // to list staged files; runs on runners where the TS SafeGitExecutor is not
4
+ // importable from a standalone .js.
5
+ /**
6
+ * lint-no-unregistered-self-action.js — the FORCING lint for the
7
+ * `unbounded-self-action` defect class (docs/specs/self-action-convergence.md
8
+ * → Part D4). Structural twin of `lint-no-unbounded-llm-spawn.js`, and — per
9
+ * both external reviewers (codex + gemini) — emit-anchored and fail-closed, NOT
10
+ * shape-heuristic: it keys on the self-action EMIT itself (the shared detector,
11
+ * scripts/lib/self-action-detect.mjs), so a developer cannot dodge it by
12
+ * renaming the driver method.
13
+ *
14
+ * THE RULE (fail-closed once enforcing): a self-action CONTROLLER source file
15
+ * (the E1 scope predicate — a src/ *Monitor|*Sentinel|*Reaper|*Beacon|*Engine|
16
+ * *Scheduler|*Watchdog|*Poller|*Manager.ts, or any file carrying the marker)
17
+ * that contains a self-action emit MUST EITHER:
18
+ * - register — carry `@self-action-controller: <id>` AND have <id> present in
19
+ * SELF_ACTION_CONTROLLERS (so the convergence ratchet covers it), OR
20
+ * - be allowlisted — appear in ALLOWLIST with a stated reason that it is a
21
+ * genuine one-shot / user-driven action, not a self-triggered loop.
22
+ * Anything else is a violation.
23
+ *
24
+ * ROLLOUT (matches the class-closure gate it composes with): REPORT-ONLY by
25
+ * default — it prints the population of unregistered controllers and ALWAYS
26
+ * exits 0. It exits nonzero ONLY when `prGate.classClosure.enabled && !dryRun`
27
+ * (the enforcing flip the operator makes on measured population). This is
28
+ * "enforcement first, report-only, graduate after a clean soak."
29
+ *
30
+ * SCOPE (report-only): controller-SHAPE files only (the E1 predicate) — the
31
+ * exact files the class is about — so the telemetry is actionable rather than a
32
+ * repo-wide wall of every `retry(` in src/. Widening to ALL src/ emits is gated
33
+ * behind the enforcing flip + the obfuscation-resistant funnel (Part B).
34
+ * <!-- tracked: CMT-1911 -->
35
+ *
36
+ * HONEST COVERAGE LIMIT (both reviewers, recorded): a string-based lint deters
37
+ * ACCIDENTAL unbounded loops; it is not a hard boundary against DELIBERATE
38
+ * obfuscation (`const v='swap'; self[v]()`). The obfuscation-resistant closure
39
+ * is the funnel (Part B); deliberately obfuscating an emit to evade this gate is
40
+ * a conduct violation, not a clever workaround.
41
+ *
42
+ * Usage:
43
+ * node scripts/lint-no-unregistered-self-action.js # full repo
44
+ * node scripts/lint-no-unregistered-self-action.js --staged # staged files
45
+ */
46
+
47
+ import fs from 'node:fs';
48
+ import path from 'node:path';
49
+ import { execSync } from 'node:child_process';
50
+ import { fileURLToPath } from 'node:url';
51
+
52
+ import {
53
+ SELF_ACTION_EMIT,
54
+ isSelfActionControllerFile,
55
+ selfActionControllerMarkerId,
56
+ } from './lib/self-action-detect.mjs';
57
+
58
+ const __filename = fileURLToPath(import.meta.url);
59
+ const ROOT = path.resolve(path.dirname(__filename), '..');
60
+
61
+ const SCAN_DIRS = ['src'];
62
+ const EXTENSIONS = new Set(['.ts']);
63
+
64
+ // ── Allowlist (closed). Adding an entry requires a stated reason that the
65
+ // file's self-action emit is a genuine one-shot / user-driven action or is
66
+ // otherwise bounded outside the registry — same governance as the spawn
67
+ // lint's allowlist. ─────────────────────────────────────────────────────
68
+ const ALLOWLIST = new Set([
69
+ // The controller-registration harness itself names verbs + the marker.
70
+ 'src/testing/selfActionRegistry.ts',
71
+ ]);
72
+
73
+ /** Read the prGate.classClosure config, or the report-only default. */
74
+ export function loadEnforcementConfig(repoRoot) {
75
+ try {
76
+ const cfg = JSON.parse(fs.readFileSync(path.join(repoRoot, '.instar', 'config.json'), 'utf-8'));
77
+ const cc = cfg && cfg.prGate && cfg.prGate.classClosure;
78
+ if (cc && typeof cc === 'object') {
79
+ return { enabled: cc.enabled === true, dryRun: cc.dryRun !== false };
80
+ }
81
+ } catch {
82
+ /* no config → report-only default */
83
+ }
84
+ return { enabled: false, dryRun: true };
85
+ }
86
+
87
+ /** Parse the registered controller ids out of the registry source (greppable `id: '...'`). */
88
+ export function loadRegistryIds(registrySource) {
89
+ const ids = new Set();
90
+ if (typeof registrySource !== 'string') return ids;
91
+ const re = /\bid:\s*['"`]([A-Za-z0-9_-]+)['"`]/g;
92
+ let m;
93
+ while ((m = re.exec(registrySource)) !== null) ids.add(m[1]);
94
+ return ids;
95
+ }
96
+
97
+ /** A line is an emitting statement if it matches the detector AND is not a comment/prose line. */
98
+ function lineIsEmit(line) {
99
+ const t = line.trimStart();
100
+ if (t === '' || /^(\/\/|\*|\/\*|#)/.test(t)) return false;
101
+ if (/^(import|export)\b/.test(t) && /\bfrom\b/.test(t)) return false;
102
+ return SELF_ACTION_EMIT.test(line);
103
+ }
104
+
105
+ /**
106
+ * Pure evaluation over a prepared file set — exported for tests.
107
+ * @param {{ files: string[], registryIds: Set<string>, allowlist?: Set<string>, readFile: (rel:string)=>string|null }} input
108
+ * @returns {{ violations: Array<{file:string, reason:string, line:number}>, considered: number }}
109
+ */
110
+ export function evaluateSelfActionLint({ files, registryIds, allowlist = ALLOWLIST, readFile }) {
111
+ const violations = [];
112
+ let considered = 0;
113
+ for (const rel of files) {
114
+ const norm = rel.split(path.sep).join('/');
115
+ if (!EXTENSIONS.has(path.extname(norm))) continue;
116
+ if (/(^|\/)tests\//.test(norm) || /\.test\.ts$/.test(norm)) continue;
117
+ if (allowlist.has(norm)) continue;
118
+ const content = readFile(norm);
119
+ if (content == null) continue;
120
+ // Scope: a controller-shape file OR a file carrying the marker.
121
+ if (!isSelfActionControllerFile(norm, content)) continue;
122
+ // Does it emit a self-action?
123
+ const lines = content.split('\n');
124
+ let emitLine = -1;
125
+ for (let i = 0; i < lines.length; i++) {
126
+ if (lineIsEmit(lines[i])) { emitLine = i + 1; break; }
127
+ }
128
+ if (emitLine === -1) continue; // a controller-shape file with no emit is fine
129
+ considered += 1;
130
+ // Registered? — carries the marker AND its id is in the registry.
131
+ const markerId = selfActionControllerMarkerId(content);
132
+ if (markerId && registryIds.has(markerId)) continue; // registered — the ratchet covers it
133
+ if (markerId && !registryIds.has(markerId)) {
134
+ violations.push({
135
+ file: norm,
136
+ line: emitLine,
137
+ reason: `carries @self-action-controller: ${markerId} but that id is NOT in SELF_ACTION_CONTROLLERS (register it in src/testing/selfActionRegistry.ts)`,
138
+ });
139
+ continue;
140
+ }
141
+ violations.push({
142
+ file: norm,
143
+ line: emitLine,
144
+ reason: 'a self-action controller with an unregistered emit — add /* @self-action-controller: <id> */ and register <id> in SELF_ACTION_CONTROLLERS (or allowlist it with a one-shot/user-driven reason)',
145
+ });
146
+ }
147
+ return { violations, considered };
148
+ }
149
+
150
+ // ── CLI: gather files, evaluate, report/enforce ────────────────────────────
151
+ function listFiles() {
152
+ const staged = process.argv.includes('--staged');
153
+ if (staged) {
154
+ const out = execSync('git diff --cached --name-only', { cwd: ROOT, encoding: 'utf-8' });
155
+ return out.split('\n').filter(Boolean).filter((f) => EXTENSIONS.has(path.extname(f)));
156
+ }
157
+ const files = [];
158
+ const walk = (dir) => {
159
+ let entries;
160
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
161
+ for (const e of entries) {
162
+ if (e.name === 'node_modules' || e.name === '.git' || e.name === 'dist') continue;
163
+ const full = path.join(dir, e.name);
164
+ if (e.isDirectory()) walk(full);
165
+ else if (EXTENSIONS.has(path.extname(e.name))) files.push(path.relative(ROOT, full));
166
+ }
167
+ };
168
+ for (const d of SCAN_DIRS) walk(path.join(ROOT, d));
169
+ return files;
170
+ }
171
+
172
+ const invokedDirectly = process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop());
173
+ if (invokedDirectly) {
174
+ const registrySource = (() => {
175
+ try { return fs.readFileSync(path.join(ROOT, 'src', 'testing', 'selfActionRegistry.ts'), 'utf-8'); }
176
+ catch { return ''; }
177
+ })();
178
+ const registryIds = loadRegistryIds(registrySource);
179
+ const files = listFiles();
180
+ const { violations, considered } = evaluateSelfActionLint({
181
+ files,
182
+ registryIds,
183
+ readFile: (rel) => {
184
+ try { return fs.readFileSync(path.join(ROOT, rel), 'utf-8'); } catch { return null; }
185
+ },
186
+ });
187
+ const { enabled, dryRun } = loadEnforcementConfig(ROOT);
188
+ const enforcing = enabled && !dryRun;
189
+
190
+ console.log(`lint-no-unregistered-self-action: ${enforcing ? 'ENFORCING' : 'report-only'} — ` +
191
+ `${considered} controller-shape emitter(s) scanned, ${violations.length} unregistered.`);
192
+ for (const v of violations) {
193
+ console[enforcing ? 'error' : 'log'](` ${v.file}:${v.line} — ${v.reason}`);
194
+ }
195
+ if (enforcing && violations.length > 0) {
196
+ console.error(`\nlint-no-unregistered-self-action: ${violations.length} violation(s). ` +
197
+ `See docs/specs/self-action-convergence.md (Part D4).`);
198
+ process.exit(1);
199
+ }
200
+ if (violations.length > 0) {
201
+ console.log(' (report-only — no build failure; the enforcing flip is gated on prGate.classClosure.dryRun:false after a measured clean soak.)');
202
+ }
203
+ process.exit(0);
204
+ }
@@ -183,7 +183,14 @@ pool-wide question is answered by a proxied-on-read merged view." Not abstractio
183
183
 
184
184
  **REQUIRED whenever this change FIXES a defect in an agent-authored artifact** (an
185
185
  LLM prompt, hook, config, skill, or standards text — see
186
- `docs/specs/class-closure-gate.md`). This section is the human-readable MIRROR of
186
+ `docs/specs/class-closure-gate.md`) **— OR adds/modifies a self-triggered
187
+ controller (the `unbounded-self-action` class: a loop, monitor, sentinel,
188
+ reaper, scheduler, or recovery path that fires a restart / swap / respawn /
189
+ spawn / notify / retry / re-drive / kill on its own — see
190
+ `docs/specs/self-action-convergence.md`).** For the self-action case, author the
191
+ convergence argument (control-loop edge + steady-state bound + settling brake)
192
+ INTO `guardEvidence.howCaught`, and cite the ratchet
193
+ `tests/unit/self-action-convergence.test.ts`. This section is the human-readable MIRROR of
187
194
  the machine-readable `classClosure` block in the commit's decision-audit entry
188
195
  (the host the CI lint validates). **Display-only:** the lint counts the
189
196
  decision-audit host ONLY and NEVER sums this mirror — the two are asserted to