instar 1.3.1143 → 1.3.1145

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.
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "generatedFrom": "source-tree",
4
4
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
5
- "packageVersion": "1.3.1143",
5
+ "packageVersion": "1.3.1145",
6
6
  "guards": [
7
7
  {
8
8
  "ref": "docs/audits/phase-b/f10-triage.md",
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha256": "c72f4bfcc850a6eac1406c3592cf3eb5d8bfbdad13c090be45803c4b2277ed15",
2
+ "sha256": "4a5b88caa62ae256f4868735dc66735c52ae30604a8272c977ca1f4564086e43",
3
3
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
4
- "packageVersion": "1.3.1143"
4
+ "packageVersion": "1.3.1145"
5
5
  }
@@ -2,5 +2,5 @@
2
2
  "sha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
3
3
  "articleCount": 88,
4
4
  "generatedFrom": "docs/STANDARDS-REGISTRY.md",
5
- "packageVersion": "1.3.1143"
5
+ "packageVersion": "1.3.1145"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.1143",
3
+ "version": "1.3.1145",
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",
@@ -12,8 +12,37 @@
12
12
  * separate module from the writer so this ban has a precise import target —
13
13
  * actuators MAY hold the writer (they emit), they may never read.
14
14
  *
15
- * Guardrail, not proof (a consumer could re-read the JSONL by hand); the
16
- * declared §3.9 duty is the authority, this catches the direct pattern.
15
+ * ── 2026-08-14: the header used to declare TWO limitations. One is now closed,
16
+ * one is NOT, and pretending otherwise would be the worse outcome.
17
+ *
18
+ * CLOSED — "this catches the direct pattern". The match required the `from`
19
+ * keyword, so `await import(...)` and `require(...)` walked straight past it.
20
+ * Reproduced against the shipped lint on a REAL listed actuator: it reported
21
+ * "clean". Now every module-loading form is matched, comments are stripped
22
+ * first (so a §3.9 reference in prose can never be a violation), and a
23
+ * runtime-erased `import type` is correctly NOT a violation.
24
+ *
25
+ * NOT CLOSED — "grow this list when a new actuator class lands". The population
26
+ * is still curated by hand. Automatic discovery was BUILT, MEASURED AGAINST THIS
27
+ * TREE, AND REJECTED on the evidence: inferring "is this an actuator?" from
28
+ * declared names flagged nine sites in three files, and every one was a
29
+ * granularity or part-of-speech error rather than a §3.9 breach —
30
+ * · `src/server/routes.ts` matched on `readReaperPeerText`, `isReaperSnapshot`,
31
+ * `reaperPoolHealth`: "reaper" as a NOUN in code that REPORTS ON the reaper,
32
+ * which is exactly the correct-code case (a reporting surface may read).
33
+ * · `src/commands/server.ts` is the 22k-line composition root — every module's
34
+ * authority is wired through it, so any file-level verdict on it is wrong in
35
+ * one direction or the other.
36
+ * · `src/core/WorkingSetPull.ts` matched a runtime-erased `import type`.
37
+ * A guard that blocks commits must not rest on a heuristic with that error rate;
38
+ * over-blocking correct code is the more expensive failure here. What IS closed
39
+ * mechanically is the STALENESS half: a curated entry that no longer exists on
40
+ * disk means an actuator was renamed or moved and silently fell off the ban —
41
+ * that is now a hard failure instead of a skipped line.
42
+ *
43
+ * Still a guardrail, not a proof: a determined consumer can re-read the JSONL by
44
+ * hand or reach the reader through a re-export. The declared §3.9 duty is the
45
+ * authority; this catches the mechanical patterns.
17
46
  */
18
47
  import fs from 'node:fs';
19
48
  import path from 'node:path';
@@ -27,7 +56,8 @@ const ROOT = process.argv.includes('--root')
27
56
  /**
28
57
  * Actuator modules: anything holding kill/spawn/place/transfer/reap authority.
29
58
  * Grow this list when a new actuator class lands — adding here is cheap;
30
- * debugging a journal-driven double-kill is not.
59
+ * debugging a journal-driven double-kill is not. See the header for why this
60
+ * stays curated rather than inferred.
31
61
  */
32
62
  const ACTUATOR_FILES = [
33
63
  'src/core/SessionManager.ts',
@@ -40,17 +70,93 @@ const ACTUATOR_FILES = [
40
70
  'src/lifeline/ServerSupervisor.ts',
41
71
  ];
42
72
 
43
- const READER_IMPORT = /from\s+['"][^'"]*CoherenceJournalReader(\.js)?['"]/;
73
+ /**
74
+ * Every way a module can LOAD the reader at runtime. The old pattern required
75
+ * `from`, which is precisely the token a dynamic import does not have.
76
+ */
77
+ const LOADS_READER =
78
+ /(?:\bfrom\s+|\bimport\s*\(\s*|\brequire\s*\(\s*)['"][^'"]*CoherenceJournalReader(?:\.js)?['"]/;
79
+
80
+ /**
81
+ * `import type { X } from '…Reader.js'` is erased at compile time — it creates
82
+ * no runtime coupling and therefore cannot act on stale data. Borrowing a TYPE
83
+ * from the reader is legal; holding the reader is not. A MIXED import such as
84
+ * `import { type A, CoherenceJournalReader }` does not match this and is still
85
+ * caught, which is correct — it pulls in the runtime binding.
86
+ */
87
+ const TYPE_ONLY_IMPORT = /^\s*import\s+type\s/;
88
+
89
+ /**
90
+ * Blank out comments, preserving length and line count so reported line numbers
91
+ * stay true. Quote-aware, so a `//` inside a string literal is not mistaken for
92
+ * a comment. This is what keeps "the reader named in a comment" legal no matter
93
+ * how wide the load-matching gets.
94
+ */
95
+ function stripComments(src) {
96
+ const out = src.split('');
97
+ let i = 0;
98
+ let state = 'code'; // code | line | block | single | double | tick
99
+ while (i < src.length) {
100
+ const c = src[i];
101
+ const d = src[i + 1];
102
+ if (state === 'code') {
103
+ if (c === '/' && d === '/') { state = 'line'; out[i] = ' '; out[i + 1] = ' '; i += 2; continue; }
104
+ if (c === '/' && d === '*') { state = 'block'; out[i] = ' '; out[i + 1] = ' '; i += 2; continue; }
105
+ if (c === "'") state = 'single';
106
+ else if (c === '"') state = 'double';
107
+ else if (c === '`') state = 'tick';
108
+ i++; continue;
109
+ }
110
+ if (state === 'line') {
111
+ if (c === '\n') { state = 'code'; i++; continue; }
112
+ out[i] = ' '; i++; continue;
113
+ }
114
+ if (state === 'block') {
115
+ if (c === '*' && d === '/') { state = 'code'; out[i] = ' '; out[i + 1] = ' '; i += 2; continue; }
116
+ if (c !== '\n') out[i] = ' ';
117
+ i++; continue;
118
+ }
119
+ // inside a string literal: honour escapes, then look for the closing quote
120
+ if (c === '\\') { i += 2; continue; }
121
+ if ((state === 'single' && c === "'") || (state === 'double' && c === '"') || (state === 'tick' && c === '`')) {
122
+ state = 'code';
123
+ }
124
+ i++;
125
+ }
126
+ return out.join('');
127
+ }
128
+
129
+ /**
130
+ * A lint that reports "clean" because it scanned NOTHING is worse than no lint:
131
+ * absence is the cheapest result to obtain, and it is indistinguishable from a
132
+ * genuinely clean tree. Refuse to render a verdict on a root with no src/.
133
+ */
134
+ if (!fs.existsSync(path.join(ROOT, 'src'))) {
135
+ console.error(`lint-journal-actuation-ban: no src/ under ${ROOT} — scanned nothing, so no verdict.`);
136
+ process.exit(2);
137
+ }
44
138
 
45
139
  const violations = [];
140
+
46
141
  for (const rel of ACTUATOR_FILES) {
47
142
  const file = path.join(ROOT, rel);
48
- if (!fs.existsSync(file)) continue;
49
- const lines = fs.readFileSync(file, 'utf-8').split('\n');
143
+
144
+ // The staleness half of the declared-population gap: a curated actuator that
145
+ // is no longer here was renamed or moved, and silently left the ban behind.
146
+ if (!fs.existsSync(file)) {
147
+ violations.push(
148
+ `${rel}: listed actuator is missing from the tree — renamed or moved? It has silently left the §3.9 ban. Update ACTUATOR_FILES.`,
149
+ );
150
+ continue;
151
+ }
152
+
153
+ const lines = stripComments(fs.readFileSync(file, 'utf-8')).split('\n');
50
154
  for (let i = 0; i < lines.length; i++) {
51
- if (READER_IMPORT.test(lines[i])) {
52
- violations.push(`${rel}:${i + 1}: actuator imports the journal READER (forbidden by §3.9 — the journal answers questions, live systems decide)`);
53
- }
155
+ if (!LOADS_READER.test(lines[i])) continue;
156
+ if (TYPE_ONLY_IMPORT.test(lines[i])) continue;
157
+ violations.push(
158
+ `${rel}:${i + 1}: actuator loads the journal READER (forbidden by §3.9 — the journal answers questions, live systems decide)`,
159
+ );
54
160
  }
55
161
  }
56
162
 
@@ -60,4 +166,4 @@ if (violations.length > 0) {
60
166
  console.error('\nReplicated journal data is stale by construction. Read the live store instead.');
61
167
  process.exit(1);
62
168
  }
63
- console.log(`lint-journal-actuation-ban: clean (${ACTUATOR_FILES.length} actuator modules, none import the reader)`);
169
+ console.log(`lint-journal-actuation-ban: clean (${ACTUATOR_FILES.length} actuator modules, none load the reader)`);
@@ -14,23 +14,80 @@
14
14
  *
15
15
  * Rule: outside the allowlist below, no source file may reference
16
16
  * `buildHeadlessLaunch` (import OR call — an import is the bypass's first
17
- * commit, flag it at the door).
17
+ * commit, flag it at the door), NOR any name an allowlisted module hands out
18
+ * that resolves to it.
19
+ *
20
+ * ── Evasion resistance (2026-08-14) ──────────────────────────────────────
21
+ * A peer audit classed this check DEFEATABLE and SAFETY-FLOOR, with this
22
+ * bypass stated verbatim: "Export a wrapper or alias from an allowlisted
23
+ * module, then call makeHeadlessLaunch(...) elsewhere; the non-funnel launch
24
+ * path is real, but the name is gone."
25
+ *
26
+ * Both halves were reproduced against the shipped check before this change,
27
+ * with a positive control caught in the same run:
28
+ *
29
+ * // in src/core/frameworkSessionLaunch.ts — ALLOWLISTED, so free
30
+ * export const makeHeadlessLaunch = buildHeadlessLaunch;
31
+ * // anywhere else — shipped lint said "clean", exit 0
32
+ * import { makeHeadlessLaunch } from './frameworkSessionLaunch.js';
33
+ * makeHeadlessLaunch(fw, opts);
34
+ *
35
+ * import * as m from './frameworkSessionLaunch.js'; // computed + split literal
36
+ * const fn = m['buildHeadless' + 'Launch'];
37
+ * fn(fw, opts);
38
+ *
39
+ * The first was reproduced end-to-end in the real tree: a live non-funnel
40
+ * launch path existed while this script printed `clean`.
41
+ *
42
+ * The close has two parts. Locally, bindings resolve to a fixpoint (aliased
43
+ * import, `{ X: Alias }` destructure, `const C = X` re-binding, namespace
44
+ * member, computed access over a collapsed concatenation). Across modules,
45
+ * the CLOSED allowlist is parsed for names it hands out that resolve to the
46
+ * builder, and those names are guarded at any non-allowlisted importer.
47
+ *
48
+ * ── What this deliberately does NOT classify as an alias ─────────────────
49
+ * Only a DIRECT re-binding (`export const X = buildHeadlessLaunch`,
50
+ * `export { buildHeadlessLaunch as X }`) or a PASS-THROUGH wrapper (a single
51
+ * `return buildHeadlessLaunch(...)`) counts. An exported function that does
52
+ * real work around the builder is NOT an alias — that is precisely the shape
53
+ * of the funnel itself, so treating it as one would flag every caller of
54
+ * `SessionManager.spawnSession()`. This check blocks commits; a widening that
55
+ * flags correct code is worse than the hole, because a noisy check gets
56
+ * switched off. The boundary is drawn there on purpose.
57
+ *
58
+ * ── Residuals, named rather than left to be discovered ───────────────────
59
+ * - A NON-pass-through wrapper in an allowlisted module (two statements
60
+ * instead of one) is not an alias. This is the price of the rule above.
61
+ * - A computed member resolved at RUNTIME (`m[process.env.K]`) cannot be
62
+ * read statically.
63
+ * - Re-assignment after declaration (`let mk; mk = buildHeadlessLaunch;`)
64
+ * is caught at the ASSIGNMENT, not at the later call.
65
+ * - A consumer importing an alias through a BARREL is not itself checked —
66
+ * but the barrel is not allowlisted, so re-exporting the alias through it
67
+ * fails here first; the chain cannot be built without tripping this.
68
+ * All four are pinned by tests asserting exactly this, so the boundary is
69
+ * documented rather than assumed.
18
70
  *
19
71
  * Exit codes: 0 — clean; 1 — at least one violation.
20
72
  *
21
73
  * Usage:
22
74
  * node scripts/lint-no-unfunneled-headless-launch.js # full repo
23
75
  * node scripts/lint-no-unfunneled-headless-launch.js --staged # staged files
76
+ * node scripts/lint-no-unfunneled-headless-launch.js <file…> # explicit files (tests)
24
77
  */
25
78
 
26
79
  import fs from 'node:fs';
27
80
  import path from 'node:path';
28
81
  import { execSync } from 'node:child_process';
29
82
  import { fileURLToPath } from 'node:url';
83
+ import ts from 'typescript';
30
84
 
31
85
  const __filename = fileURLToPath(import.meta.url);
32
86
  const ROOT = path.resolve(path.dirname(__filename), '..');
33
87
 
88
+ /** The one name the funnel is built on. Every other guarded name derives from it. */
89
+ export const CANONICAL = 'buildHeadlessLaunch';
90
+
34
91
  // ── Allowlist (closed). Adding an entry requires review of WHY the callsite
35
92
  // cannot route through SessionManager.spawnSession() (where the
36
93
  // subscription-path reroute lives), and how its post-June-15 billing is
@@ -51,11 +108,233 @@ const ALLOWLIST = new Set([
51
108
  const SCAN_DIRS = ['src', 'scripts', 'templates'];
52
109
  const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.sh']);
53
110
 
54
- const PATTERNS = [
55
- // Any reference import, re-export, or call. An import IS the violation:
56
- // there is no legitimate non-funnel consumer of the headless builder.
57
- /\bbuildHeadlessLaunch\b/,
58
- ];
111
+ const VIOLATION_MSG =
112
+ `direct ${CANONICAL} reference outside the subscription-path funnel. ` +
113
+ `Spawn through SessionManager.spawnSession() (which carries the June-15 reroute), ` +
114
+ `or add an allowlist entry here with a billing-accountability justification.`;
115
+
116
+ const aliasMsg = (name, from) =>
117
+ `'${name}' resolves to ${CANONICAL} (handed out by ${from}) — reaching the headless ` +
118
+ `builder under another name is the same bypass of the subscription-path funnel. ` +
119
+ `Spawn through SessionManager.spawnSession(), or add an allowlist entry here with a ` +
120
+ `billing-accountability justification.`;
121
+
122
+ /** Comment lines are documentation, not a bypass — code cannot call through one. */
123
+ export function isCommentLine(line) {
124
+ const t = line.trimStart();
125
+ return t.startsWith('//') || t.startsWith('*') || t.startsWith('/*') || t.startsWith('#');
126
+ }
127
+
128
+ /**
129
+ * Collapse simple adjacent string concatenation so `'A' + 'B'` reads as `AB`.
130
+ * Applied PER LINE so reported line numbers stay exact.
131
+ */
132
+ export function collapseConcatenation(text) {
133
+ let out = text;
134
+ for (let i = 0; i < 5; i++) {
135
+ const next = out.replace(/(['"`])\s*\+\s*(['"`])/g, '');
136
+ if (next === out) break;
137
+ out = next;
138
+ }
139
+ return out;
140
+ }
141
+
142
+ const basenameKey = (p) => path.basename(p).replace(/\.(ts|tsx|js|mjs|cjs)$/, '');
143
+
144
+ // ── Cross-module: what names does the closed allowlist hand out? ──────────
145
+
146
+ /** Is this expression the guarded builder itself (identifier or namespace member)? */
147
+ function denotesGuarded(node, known) {
148
+ if (!node) return false;
149
+ if (ts.isIdentifier(node)) return known.has(node.text);
150
+ if (ts.isPropertyAccessExpression(node)) return known.has(node.name.text);
151
+ if (ts.isElementAccessExpression(node)) {
152
+ const a = node.argumentExpression;
153
+ return !!a && ts.isStringLiteralLike(a) && known.has(a.text);
154
+ }
155
+ if (ts.isParenthesizedExpression(node)) return denotesGuarded(node.expression, known);
156
+ if (ts.isAsExpression(node) || ts.isTypeAssertionExpression?.(node)) {
157
+ return denotesGuarded(node.expression, known);
158
+ }
159
+ return false;
160
+ }
161
+
162
+ /** A body that is nothing but `return <guarded>(...)` — a pass-through wrapper. */
163
+ function isPassThroughBody(body, known) {
164
+ if (!body) return false;
165
+ if (ts.isCallExpression(body)) return denotesGuarded(body.expression, known); // arrow shorthand
166
+ if (!ts.isBlock(body) || body.statements.length !== 1) return false;
167
+ const only = body.statements[0];
168
+ if (!ts.isReturnStatement(only) || !only.expression) return false;
169
+ return ts.isCallExpression(only.expression) && denotesGuarded(only.expression.expression, known);
170
+ }
171
+
172
+ const isExported = (node) =>
173
+ !!node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
174
+
175
+ /**
176
+ * Names each allowlisted module hands out that resolve to the builder.
177
+ *
178
+ * `sources` is [{ path, content }]. Returns Map<aliasName, sourcePath>, run to
179
+ * a fixpoint across files so an allowlisted module re-exporting ANOTHER
180
+ * allowlisted module's alias closes too.
181
+ */
182
+ export function collectFunnelAliasExports(sources) {
183
+ const aliases = new Map();
184
+ for (let pass = 0; pass < 10; pass++) {
185
+ const before = aliases.size;
186
+ for (const { path: p, content } of sources) {
187
+ let sf;
188
+ try {
189
+ sf = ts.createSourceFile(p, content, ts.ScriptTarget.Latest, true);
190
+ } catch {
191
+ continue;
192
+ }
193
+ // Names bound to the builder INSIDE this module, to a fixpoint.
194
+ const known = new Set([CANONICAL, ...aliases.keys()]);
195
+ for (let inner = 0; inner < 10; inner++) {
196
+ const size = known.size;
197
+ const seed = (n) => {
198
+ if (ts.isImportSpecifier(n) && known.has((n.propertyName ?? n.name).text)) {
199
+ known.add(n.name.text);
200
+ }
201
+ if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && denotesGuarded(n.initializer, known)) {
202
+ known.add(n.name.text);
203
+ }
204
+ ts.forEachChild(n, seed);
205
+ };
206
+ ts.forEachChild(sf, seed);
207
+ if (known.size === size) break;
208
+ }
209
+
210
+ const record = (name) => {
211
+ if (name && name !== CANONICAL && !aliases.has(name)) aliases.set(name, p);
212
+ };
213
+ const visit = (n) => {
214
+ // export const X = <guarded>; / export const X = (...) => <guarded>(...)
215
+ if (ts.isVariableStatement(n) && isExported(n)) {
216
+ for (const d of n.declarationList.declarations) {
217
+ if (!ts.isIdentifier(d.name) || !d.initializer) continue;
218
+ if (denotesGuarded(d.initializer, known)) record(d.name.text);
219
+ else if (
220
+ (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer)) &&
221
+ isPassThroughBody(d.initializer.body, known)
222
+ ) record(d.name.text);
223
+ }
224
+ }
225
+ // export function X(...) { return <guarded>(...); }
226
+ if (ts.isFunctionDeclaration(n) && isExported(n) && n.name && isPassThroughBody(n.body, known)) {
227
+ record(n.name.text);
228
+ }
229
+ // export { A as B }; / export { A as B } from '...';
230
+ if (ts.isExportDeclaration(n) && n.exportClause && ts.isNamedExports(n.exportClause)) {
231
+ for (const el of n.exportClause.elements) {
232
+ if (known.has((el.propertyName ?? el.name).text)) record(el.name.text);
233
+ }
234
+ }
235
+ ts.forEachChild(n, visit);
236
+ };
237
+ ts.forEachChild(sf, visit);
238
+ }
239
+ if (aliases.size === before) break;
240
+ }
241
+ return aliases;
242
+ }
243
+
244
+ // ── Per-file: local bindings, resolved to a fixpoint ─────────────────────
245
+
246
+ /** Named specifiers imported (or re-exported) in `content`, with their offsets. */
247
+ function importSpecifiers(content) {
248
+ const out = [];
249
+ const re = /(?:import|export)\s*(?:type\s+)?(?:[\w$]+\s*,\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
250
+ for (const m of content.matchAll(re)) {
251
+ const body = m[1];
252
+ const bodyStart = m.index + m[0].indexOf('{') + 1;
253
+ for (const spec of body.split(',')) {
254
+ const t = spec.trim();
255
+ if (!t) continue;
256
+ const parts = t.replace(/^type\s+/, '').split(/\s+as\s+/);
257
+ const imported = parts[0].trim();
258
+ const local = (parts[1] ?? parts[0]).trim();
259
+ if (!/^[A-Za-z_$][\w$]*$/.test(imported)) continue;
260
+ out.push({ imported, local, from: m[2], offset: bodyStart + body.indexOf(t) });
261
+ }
262
+ }
263
+ return out;
264
+ }
265
+
266
+ const lineOf = (content, offset) => content.slice(0, offset).split('\n').length;
267
+
268
+ /**
269
+ * Local names in `content` bound to something guarded, to a fixpoint.
270
+ *
271
+ * Seeds are the canonical name plus any allowlist-exported alias that is
272
+ * actually IMPORTED here from the module that hands it out — a locally
273
+ * DEFINED function of the same name is not absorbed, which is what keeps this
274
+ * from flagging unrelated code.
275
+ */
276
+ export function collectLocalBindings(content, aliasExports = new Map()) {
277
+ const names = new Set([CANONICAL]);
278
+ const seededAliases = [];
279
+ for (const spec of importSpecifiers(content)) {
280
+ const owner = aliasExports.get(spec.imported);
281
+ if (owner && basenameKey(spec.from) === basenameKey(owner)) {
282
+ names.add(spec.local);
283
+ seededAliases.push({ ...spec, owner });
284
+ }
285
+ }
286
+ // Local re-binding chains, incl. destructures, namespace members and
287
+ // computed access over a collapsed concatenation.
288
+ for (let pass = 0; pass < 10; pass++) {
289
+ const before = names.size;
290
+ for (const known of [...names]) {
291
+ const esc = known.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
292
+ const forms = [
293
+ // import { known as alias } / const { known: alias } = …
294
+ new RegExp(`\\b${esc}\\s*(?:as|:)\\s*([A-Za-z_$][\\w$]*)`, 'g'),
295
+ // const alias = known; / const alias = ns.known; / const alias = ns['known'];
296
+ new RegExp(`\\b(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*(?:[\\w$.]*\\.)?${esc}\\s*[;,\\n)]`, 'g'),
297
+ new RegExp(`\\b(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*[\\w$.]*\\[\\s*['"\`]${esc}['"\`]\\s*\\]`, 'g'),
298
+ ];
299
+ for (const re of forms) {
300
+ for (const m of collapseConcatenation(content).matchAll(re)) names.add(m[1]);
301
+ }
302
+ }
303
+ if (names.size === before) break;
304
+ }
305
+ return { names, seededAliases };
306
+ }
307
+
308
+ /**
309
+ * Violations in `content`, as { line, msg }. Exported so the rules can be
310
+ * driven with fixtures rather than only end-to-end over the tree.
311
+ */
312
+ export function findHeadlessLaunchViolations(content, aliasExports = new Map()) {
313
+ const hits = [];
314
+ const { names, seededAliases } = collectLocalBindings(content, aliasExports);
315
+ // A guarded alias arriving by import is a violation AT THE DOOR, reported
316
+ // against the import even though the name itself is unremarkable.
317
+ const importLines = new Set();
318
+ for (const spec of seededAliases) {
319
+ const line = lineOf(content, spec.offset);
320
+ importLines.add(line);
321
+ hits.push({ line, msg: aliasMsg(spec.imported, path.basename(spec.owner)) });
322
+ }
323
+ const lines = content.split('\n');
324
+ for (let i = 0; i < lines.length; i++) {
325
+ if (isCommentLine(lines[i])) continue;
326
+ if (importLines.has(i + 1)) continue;
327
+ const line = collapseConcatenation(lines[i]);
328
+ for (const name of names) {
329
+ const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
330
+ if (new RegExp(`\\b${esc}\\b`).test(line) || new RegExp(`['"\`]${esc}['"\`]`).test(line)) {
331
+ hits.push({ line: i + 1, msg: name === CANONICAL ? VIOLATION_MSG : aliasMsg(name, 'the funnel') });
332
+ break;
333
+ }
334
+ }
335
+ }
336
+ return hits.sort((a, b) => a.line - b.line);
337
+ }
59
338
 
60
339
  function listFiles() {
61
340
  const staged = process.argv.includes('--staged');
@@ -87,42 +366,54 @@ function listFiles() {
87
366
  return files;
88
367
  }
89
368
 
90
- let violations = 0;
91
- for (const rel of listFiles()) {
92
- const normalized = rel.split(path.sep).join('/');
93
- if (ALLOWLIST.has(normalized)) continue;
94
- if (!EXTENSIONS.has(path.extname(normalized))) continue;
95
- // Explicit args may be absolute (e.g. the lint's own self-test sandbox);
96
- // repo-walk entries are always ROOT-relative.
97
- const full = path.isAbsolute(normalized) ? normalized : path.join(ROOT, normalized);
98
- let content;
99
- try {
100
- content = fs.readFileSync(full, 'utf-8');
101
- } catch {
102
- continue;
103
- }
104
- const lines = content.split('\n');
105
- for (let i = 0; i < lines.length; i++) {
106
- // Comment-only mentions are documentation, not a bypass — code can't
107
- // call through a comment. (`//`, `*`, `/*` line starts and `#` for .sh.)
108
- const trimmed = lines[i].trimStart();
109
- if (/^(\/\/|\*|\/\*|#)/.test(trimmed)) continue;
110
- for (const pattern of PATTERNS) {
111
- if (pattern.test(lines[i])) {
112
- console.error(
113
- `${normalized}:${i + 1} — direct buildHeadlessLaunch reference outside the subscription-path funnel. ` +
114
- `Spawn through SessionManager.spawnSession() (which carries the June-15 reroute), ` +
115
- `or add an allowlist entry here with a billing-accountability justification.`,
116
- );
117
- violations++;
118
- }
369
+ /** Read the allowlisted SOURCE modules — the only places an alias can be minted. */
370
+ export function readAllowlistSources(root = ROOT) {
371
+ const sources = [];
372
+ for (const rel of ALLOWLIST) {
373
+ if (!rel.startsWith('src/')) continue; // the lint script itself is not an alias source
374
+ try {
375
+ sources.push({ path: rel, content: fs.readFileSync(path.join(root, rel), 'utf-8') });
376
+ } catch {
377
+ /* a missing allowlist entry is the allowlist test's problem, not this scan's */
119
378
  }
120
379
  }
380
+ return sources;
121
381
  }
122
382
 
123
- if (violations > 0) {
124
- console.error(`\nlint-no-unfunneled-headless-launch: ${violations} violation(s). ` +
125
- `See docs/specs/june15-headless-spawn-reroute.md (finding F5).`);
126
- process.exit(1);
383
+ // ── CLI body ─────────────────────────────────────────────────────────────
384
+ // Guarded so the exported detectors can be imported by tests WITHOUT running
385
+ // the scan: this module calls process.exit(1) on a violation, so an unguarded
386
+ // import would kill any test run the moment the repo had one. Same pattern as
387
+ // lint-no-unfunneled-credential-write.js and lint-telegram-egress-boundary.mjs.
388
+ const invokedDirectly =
389
+ process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href;
390
+
391
+ if (invokedDirectly) {
392
+ const aliasExports = collectFunnelAliasExports(readAllowlistSources());
393
+ let violations = 0;
394
+ for (const rel of listFiles()) {
395
+ const normalized = rel.split(path.sep).join('/');
396
+ if (ALLOWLIST.has(normalized)) continue;
397
+ if (!EXTENSIONS.has(path.extname(normalized))) continue;
398
+ // Explicit args may be absolute (e.g. the lint's own self-test sandbox);
399
+ // repo-walk entries are always ROOT-relative.
400
+ const full = path.isAbsolute(normalized) ? normalized : path.join(ROOT, normalized);
401
+ let content;
402
+ try {
403
+ content = fs.readFileSync(full, 'utf-8');
404
+ } catch {
405
+ continue;
406
+ }
407
+ for (const hit of findHeadlessLaunchViolations(content, aliasExports)) {
408
+ console.error(`${normalized}:${hit.line} — ${hit.msg}`);
409
+ violations++;
410
+ }
411
+ }
412
+
413
+ if (violations > 0) {
414
+ console.error(`\nlint-no-unfunneled-headless-launch: ${violations} violation(s). ` +
415
+ `See docs/specs/june15-headless-spawn-reroute.md (finding F5).`);
416
+ process.exit(1);
417
+ }
418
+ console.log('lint-no-unfunneled-headless-launch: clean');
127
419
  }
128
- console.log('lint-no-unfunneled-headless-launch: clean');
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "./builtin-manifest.schema.json",
3
3
  "schemaVersion": 1,
4
- "generatedAt": "2026-08-14T20:31:20.547Z",
5
- "instarVersion": "1.3.1143",
4
+ "generatedAt": "2026-08-14T22:33:19.830Z",
5
+ "instarVersion": "1.3.1145",
6
6
  "entryCount": 202,
7
7
  "entries": {
8
8
  "hook:session-start": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "generatedFrom": "source-tree",
4
4
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
5
- "packageVersion": "1.3.1143",
5
+ "packageVersion": "1.3.1145",
6
6
  "guards": [
7
7
  {
8
8
  "ref": "docs/audits/phase-b/f10-triage.md",
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha256": "c72f4bfcc850a6eac1406c3592cf3eb5d8bfbdad13c090be45803c4b2277ed15",
2
+ "sha256": "4a5b88caa62ae256f4868735dc66735c52ae30604a8272c977ca1f4564086e43",
3
3
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
4
- "packageVersion": "1.3.1143"
4
+ "packageVersion": "1.3.1145"
5
5
  }
@@ -2,5 +2,5 @@
2
2
  "sha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
3
3
  "articleCount": 88,
4
4
  "generatedFrom": "docs/STANDARDS-REGISTRY.md",
5
- "packageVersion": "1.3.1143"
5
+ "packageVersion": "1.3.1145"
6
6
  }
@@ -0,0 +1,32 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ The check that keeps every headless subprocess going through a single funnel identified that funnel by its name alone, so putting a second nameplate on it walked straight past.
9
+
10
+ - **A listed file could hand the builder out under another name**, and any file could then call that name — no forbidden text anywhere, and a real non-funnel spawn path. Reproduced in the real codebase before the fix: one appended line plus one new consumer left a working bypass while the check printed **clean**.
11
+ - **The check now follows the function, not just its name.** Inside a file, a name passed along — relabelled on import, pulled out of a bundle, copied to a variable, reached through a bracket with the text split in half — is followed until nothing new turns up. Across files, the short closed list is read for any name it hands out that leads back to the builder, and those names are guarded wherever they are imported.
12
+ - **The widening is deliberately narrow, and that was the harder half.** Only a plain re-labelling or a one-line pass-through counts as handing the builder out. A listed file that does real work around it does not — because that is exactly what the funnel itself looks like, and counting it would have flagged every ordinary spawn in the codebase.
13
+ - **Four remaining gaps are named in the check's own header** and pinned by tests asserting they are still open, so they read as decisions rather than oversights.
14
+ - **Nothing new is forbidden**, and the codebase passes cleanly before and after.
15
+
16
+ ## What to Tell Your User
17
+
18
+ Every background process this agent starts is supposed to go through one door, because that door is where the limits are decided. A check enforced it — but only by looking for the door's name, so anyone who gave the door a second name got past without it noticing. That was real, not hypothetical: it was demonstrated on the live codebase while the check reported everything fine.
19
+
20
+ It now follows the door itself rather than the label on it. The care went into not over-correcting: a check that blocks work has to stay quiet about correct work, or someone turns it off, and then it guards nothing at all.
21
+
22
+ ## Summary of New Capabilities
23
+
24
+ None. This widens what an existing check can see. No new command, route, setting, or rule.
25
+
26
+ ## Evidence
27
+
28
+ Proven in both directions. Three deliberate mutations produce three precise failures: removing the cross-file resolution fails exactly the cross-file tests, widening the handout rule fails exactly the control that guards against flagging correct code, and removing the text-splitting defence fails exactly the three forms that depend on it.
29
+
30
+ That third one is worth recording, because it failed to fail the first time. The original test used a shape a different rule already caught, so it proved nothing about the line it was written for and would have shipped as false coverage. It was rewritten to three shapes with nothing else to catch them. A test that passes for the wrong reason is indistinguishable from real coverage until something depends on it.
31
+
32
+ Ten opposite-direction controls hold the other side, including two that would have caught the over-correction: a real-work function in a listed file is not treated as a handout, and the live list is asserted to hand out nothing today, so a future refactor that accidentally creates one becomes visible. The source was restored byte-identical after every mutation, the real codebase lints clean before and after, and the module is now safe to import — it previously had an unguarded exit path that would have stopped a test run the moment the codebase had a violation.
@@ -0,0 +1,57 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ `scripts/lint-journal-actuation-ban.js` enforces COHERENCE-JOURNAL-SPEC §3.9: no actuator (kill / spawn / place /
9
+ transfer / reap) may import the journal READER, because replicated journal data is stale by construction and an
10
+ actuator trusting it can kill live work or double-place a conversation.
11
+
12
+ The check matched `/from\s+['"]…CoherenceJournalReader…['"]/`, which requires the `from` keyword. `await
13
+ import(…)` and `require(…)` do not have it, so both loaded the reader straight past the ban. Reproduced against
14
+ the shipped lint on a real listed actuator (`src/core/SessionManager.ts`): it printed `clean`. instar-codey
15
+ reproduced the same evasion independently and ranked this lint first of ~20 by consequence-of-defeat.
16
+
17
+ - All three load forms now match.
18
+ - Comments are stripped (quote-aware, line-preserving) before matching, so prose describing the ban can never
19
+ itself be a violation.
20
+ - `import type` is now exempt — it is erased at compile time, so it cannot act on stale data. The old pattern
21
+ flagged it, and a live file in the tree has that shape legitimately.
22
+ - A curated actuator that has vanished from the tree is now a violation instead of a silently skipped line: a
23
+ renamed module used to leave the ban without a word.
24
+ - A root with no `src/` exits 2 instead of printing `clean` over zero files.
25
+
26
+ Deliberately NOT changed: the curated actuator list. Automatic discovery by declared-name shape was built and
27
+ measured against this tree — nine flags across three files, every one a part-of-speech or granularity error
28
+ ("reaper" as a noun in reporting code; the 22k-line composition root; an `import type`). This lint blocks
29
+ commits, so over-blocking correct code is the more expensive failure, and the enumerated list is the original
30
+ converged design decision. The limit is now stated plainly in the header and pinned by a test that fails if
31
+ anyone closes it properly.
32
+
33
+ ## What to Tell Your User
34
+
35
+ None — internal change (no user-facing surface).
36
+
37
+ ## Summary of New Capabilities
38
+
39
+ None — internal change (no user-facing surface).
40
+
41
+ ## Evidence
42
+
43
+ - `tests/unit/journal-actuation-ban-lint.test.ts` — 13/13 green.
44
+ - Negative control: with the shipped lint restored, exactly 5 of 13 fail (dynamic import, `require`,
45
+ `import type`, vanished-curated-file, rootless-tree) and all 8 controls still pass — controls should pass both
46
+ ways, which is what makes them controls.
47
+ - Real-tree verdict: `node scripts/lint-journal-actuation-ban.js` → `clean (8 actuator modules, none load the
48
+ reader)`, exit 0. All eight curated actuators verified present, none referencing the reader.
49
+ - Full `npm run lint` chain green; `tests/unit/lint-chain-completeness.test.ts` 3/3.
50
+ - Side-effects review: `upgrades/side-effects/journal-actuation-ban-load-forms.md`.
51
+ - ELI16: `docs/specs/journal-actuation-ban-load-forms.eli16.md`.
52
+
53
+ **Raised separately, not actioned here:** `src/commands/server.ts:20853` wires `OwnershipApplier`, which
54
+ materializes durable topic ownership from the REPLICATED placement journal (it does validate `transferTo`
55
+ against the live known-machine set first, and is specified in
56
+ `docs/specs/ownership-applier-meshself-ordering-fix.md`). Whether §3.9 permits a validated read like that is a
57
+ spec question, not a lint decision; it is on the operator's attention queue. The shipped lint does not flag it.
@@ -0,0 +1,127 @@
1
+ # Side-Effects Review — journal-actuation-ban: every load form, and the staleness half of the population
2
+
3
+ **Version / slug:** `journal-actuation-ban-load-forms`
4
+ **Date:** `2026-08-14`
5
+ **Author:** `echo`
6
+ **Second-pass reviewer:** `not required — Tier 1 (classifyTier: suggestedTier 1, riskFloor 1, no reasons). No spec change: COHERENCE-JOURNAL-SPEC §3.9 is unmodified; this makes the existing ban see load forms it already forbade.`
7
+
8
+ ## Summary of the change
9
+
10
+ `scripts/lint-journal-actuation-ban.js` enforced §3.9 with `/from\s+['"]…CoherenceJournalReader…['"]/`. That
11
+ requires the `from` keyword, which `await import(…)` and `require(…)` do not have, so both walked past the ban.
12
+ Reproduced against the shipped lint on a REAL listed actuator (`src/core/SessionManager.ts`): it reported
13
+ `clean`. Independently reproduced by instar-codey, who ranked this lint #1 of ~20 by consequence-of-defeat.
14
+
15
+ Now: all three load forms are matched; comments are stripped (quote-aware, length- and line-preserving) before
16
+ matching, so prose describing the ban is never a violation; a runtime-erased `import type` is correctly NOT a
17
+ violation; a curated actuator that has vanished from the tree is a violation instead of a skipped line; and a
18
+ root with no `src/` exits 2 rather than printing `clean`.
19
+
20
+ The declared-population gap ("grow this list when a new actuator class lands") is **left open deliberately** —
21
+ see §2.
22
+
23
+ ## Decision-point inventory
24
+
25
+ - `LOADS_READER` — WIDEN — now matches `from` / `import(` / `require(`. CI-time only; never runtime.
26
+ - `TYPE_ONLY_IMPORT` — ADD — narrows: `import type` is exempt (erased at compile time).
27
+ - comment stripping — ADD — narrows: commented text cannot be a violation.
28
+ - missing curated file — CHANGE — was `continue` (silent), now a violation.
29
+ - root without `src/` — ADD — exit 2, no verdict.
30
+ - No runtime block/allow decisions added or modified. This script runs in `npm run lint` and CI only.
31
+
32
+ ## 1. Over-block
33
+
34
+ The widened matcher can only fire on the eight curated actuator files, so the blast radius is those eight.
35
+ Verified against the real tree: none of the eight so much as mentions `CoherenceJournalReader`, and the lint
36
+ exits 0. Two narrowings actively REDUCE over-block versus the shipped version: `import type` (which the old
37
+ regex flagged — `src/core/WorkingSetPull.ts` is a live example of that shape) and comment stripping.
38
+
39
+ The one new way to fail a build that is not a §3.9 breach: renaming a curated actuator without updating
40
+ `ACTUATOR_FILES`. That is intended — a renamed actuator silently leaving the ban is the failure this closes —
41
+ and the message names the file and the fix.
42
+
43
+ ## 2. Under-block
44
+
45
+ **Automatic discovery of actuators was built, measured against this tree, and REJECTED.** Inferring "is this an
46
+ actuator?" from declared names (functions, classes, filename) flagged nine sites across three files, and every
47
+ one was a part-of-speech or granularity error rather than a §3.9 breach:
48
+
49
+ - `src/server/routes.ts` — matched `readReaperPeerText`, `isReaperSnapshot`, `reaperPoolHealth`: "reaper" as a
50
+ NOUN in code that REPORTS ON the reaper. A reporting surface reading the journal is correct code.
51
+ - `src/commands/server.ts` — the 22k-line composition root. Every module's authority is wired through it, so any
52
+ file-level verdict on it is wrong in one direction or the other.
53
+ - `src/core/WorkingSetPull.ts` — a runtime-erased `import type`.
54
+
55
+ This lint blocks commits, so over-blocking correct code is the more expensive failure. The enumerated-list shape
56
+ is also the ORIGINAL converged design decision, stated in `upgrades/side-effects/coherence-journal-p1-2.md`:
57
+ "The enumerated-list shape is deliberate: growable, reviewable." Closing it needs an authoritative actuator
58
+ population, not a heuristic; `src/testing/selfActionRegistry.ts` (`modelsPath`, kept complete by
59
+ `lint-no-unregistered-self-action.js`) is the closest candidate but is a superset in KIND — spend-alert
60
+ emitters and sweeps are self-actions, not §3.9 session actuators. Left open, named in the header, and pinned by
61
+ a test that will fail if someone closes it.
62
+
63
+ Still evadable, unchanged from before: a hand-rolled JSONL read, or reaching the reader through a re-export.
64
+ The §3.9 duty remains the authority.
65
+
66
+ ## 3. Level-of-abstraction fit
67
+
68
+ Line-level regex over comment-stripped source, on an enumerated file list. Same layer as the shipped check —
69
+ no AST, no type information, no new dependency. The comment stripper is the only added machinery, and it exists
70
+ so the matcher can widen without making §3.9 prose illegal.
71
+
72
+ ## 4. Signal vs authority compliance
73
+
74
+ Unchanged and reinforced. The lint is a CI guard, not a runtime authority; it forbids actuators from HOLDING the
75
+ reader, which is what keeps replicated journal data signal rather than authority. Nothing here reads the journal
76
+ at runtime.
77
+
78
+ ## 5. Interactions
79
+
80
+ - `npm run lint` chain (`package.json:31`) — position unchanged; `tests/unit/lint-chain-completeness.test.ts`
81
+ passes (3/3).
82
+ - Husky pre-commit / CI run the same chain. Exit 2 on a rootless tree is new; the repo root always has `src/`,
83
+ and the only caller passing `--root` is this test.
84
+ - No source module, route, config key, or state file is touched.
85
+
86
+ ## 6. External surfaces
87
+
88
+ None. No HTTP route, no config key, no user-visible message, no CLAUDE.md template change (the lint is
89
+ developer-facing tooling, not an agent capability). Agent Awareness Standard does not apply.
90
+
91
+ ## 7. Rollback cost
92
+
93
+ `git revert` of one script plus one test file. No migration, no state, no deployed artifact. The lint is
94
+ stateless and runs from source.
95
+
96
+ ## Conclusion
97
+
98
+ Ship. One real evasion closed with a negative control proving each assertion fails without the fix; two
99
+ narrowings that reduce false positives below the shipped baseline; one silent-failure mode of my own making
100
+ (clean verdict over zero files) caught and closed before it shipped.
101
+
102
+ ## Second-pass review (if required)
103
+
104
+ Not required at Tier 1. Independent corroboration of DEFECT 1 exists regardless: instar-codey reproduced the
105
+ dynamic-import evasion separately and ranked this lint first of ~20 by consequence-of-defeat, and recommended
106
+ exactly the scope taken here — "low FP risk if limited to actuator files and comment-stripped `import()`,
107
+ `require()`… Do not ban writer imports."
108
+
109
+ ## Evidence pointers
110
+
111
+ - `tests/unit/journal-actuation-ban-lint.test.ts` — 13/13 green with the fix.
112
+ - Negative control: with the shipped lint restored, exactly 5 of the 13 fail (dynamic import, `require`,
113
+ `import type`, vanished-curated-file, rootless-tree) and all 8 controls still pass — controls should pass
114
+ both ways, which is what makes them controls.
115
+ - Real-tree verdict: `node scripts/lint-journal-actuation-ban.js` → `clean (8 actuator modules, none load the
116
+ reader)`, exit 0.
117
+ - `upgrades/side-effects/coherence-journal-p1-2.md` — the original converged decision to enumerate.
118
+
119
+ ## Finding raised separately (NOT fixed here)
120
+
121
+ Discovery, before it was rejected, surfaced five `await import('../core/CoherenceJournalReader.js')` sites in
122
+ `src/commands/server.ts`. One (`:20853`) wires `OwnershipApplier`, which materializes durable topic ownership
123
+ FROM the replicated placement journal — replicated data feeding an ownership decision that placement and
124
+ session routing then act on. It is deliberate and specified (`docs/specs/ownership-applier-meshself-ordering-fix.md`)
125
+ and validates `transferTo` against the live known-machine set before materializing. Whether §3.9 permits it is a
126
+ spec question with real consequence, and it is not mine to settle inside a lint change. Raised to the operator;
127
+ deliberately NOT actioned here, and the lint does not flag it.