instar 1.3.338 → 1.3.340

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +166 -10
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  5. package/dist/config/ConfigDefaults.js +17 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/AutonomousSessions.d.ts +17 -2
  8. package/dist/core/AutonomousSessions.d.ts.map +1 -1
  9. package/dist/core/AutonomousSessions.js +30 -4
  10. package/dist/core/AutonomousSessions.js.map +1 -1
  11. package/dist/core/CoherenceJournal.d.ts +326 -0
  12. package/dist/core/CoherenceJournal.d.ts.map +1 -0
  13. package/dist/core/CoherenceJournal.js +999 -0
  14. package/dist/core/CoherenceJournal.js.map +1 -0
  15. package/dist/core/CoherenceJournalReader.d.ts +158 -0
  16. package/dist/core/CoherenceJournalReader.d.ts.map +1 -0
  17. package/dist/core/CoherenceJournalReader.js +450 -0
  18. package/dist/core/CoherenceJournalReader.js.map +1 -0
  19. package/dist/core/JournalSyncApplier.d.ts +272 -0
  20. package/dist/core/JournalSyncApplier.d.ts.map +1 -0
  21. package/dist/core/JournalSyncApplier.js +835 -0
  22. package/dist/core/JournalSyncApplier.js.map +1 -0
  23. package/dist/core/LearningVelocityScorer.d.ts +40 -0
  24. package/dist/core/LearningVelocityScorer.d.ts.map +1 -0
  25. package/dist/core/LearningVelocityScorer.js +67 -0
  26. package/dist/core/LearningVelocityScorer.js.map +1 -0
  27. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  28. package/dist/core/PostUpdateMigrator.js +17 -0
  29. package/dist/core/PostUpdateMigrator.js.map +1 -1
  30. package/dist/core/StateManager.d.ts +41 -0
  31. package/dist/core/StateManager.d.ts.map +1 -1
  32. package/dist/core/StateManager.js +100 -0
  33. package/dist/core/StateManager.js.map +1 -1
  34. package/dist/core/types.d.ts +35 -0
  35. package/dist/core/types.d.ts.map +1 -1
  36. package/dist/core/types.js.map +1 -1
  37. package/dist/messaging/TelegramAdapter.d.ts +14 -0
  38. package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
  39. package/dist/messaging/TelegramAdapter.js +16 -1
  40. package/dist/messaging/TelegramAdapter.js.map +1 -1
  41. package/dist/scaffold/templates.d.ts.map +1 -1
  42. package/dist/scaffold/templates.js +11 -0
  43. package/dist/scaffold/templates.js.map +1 -1
  44. package/dist/server/routes.d.ts.map +1 -1
  45. package/dist/server/routes.js +116 -3
  46. package/dist/server/routes.js.map +1 -1
  47. package/package.json +2 -2
  48. package/scripts/lint-cas-emit-placement.js +87 -0
  49. package/scripts/lint-journal-actuation-ban.js +63 -0
  50. package/scripts/lint-state-registry.js +313 -0
  51. package/src/data/builtin-manifest.json +63 -63
  52. package/src/data/state-coherence-registry.json +880 -0
  53. package/src/scaffold/templates.ts +11 -0
  54. package/upgrades/1.3.340.md +132 -0
  55. package/upgrades/coherence-journal-p1-1.eli16.md +11 -0
  56. package/upgrades/coherence-journal-p1-2.eli16.md +11 -0
  57. package/upgrades/coherence-journal-p1-3.eli16.md +13 -0
  58. package/upgrades/side-effects/coherence-journal-p1-1.md +68 -0
  59. package/upgrades/side-effects/coherence-journal-p1-2.md +54 -0
  60. package/upgrades/side-effects/coherence-journal-p1-3.md +49 -0
  61. package/upgrades/side-effects/exo3-g5-ci-greening.md +29 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.338",
3
+ "version": "1.3.340",
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/check-codex-rule1-drift.js",
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-state-registry.js && node scripts/lint-cas-emit-placement.js && node scripts/lint-journal-actuation-ban.js && node scripts/check-codex-rule1-drift.js",
32
32
  "lint:destructive": "node scripts/lint-no-direct-destructive.js",
33
33
  "lint:destructive:staged": "node scripts/lint-no-direct-destructive.js --staged",
34
34
  "lint:llm-http": "node scripts/lint-no-direct-llm-http.js",
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lint-cas-emit-placement — COHERENCE-JOURNAL-SPEC §3.3 structural guard.
4
+ *
5
+ * Every ownership-CAS call site must pair with an `emitPlacement` so topic
6
+ * placement history can never silently grow a hole: session-lifecycle gets a
7
+ * single saveSession funnel (status is derivable inside), but placement
8
+ * `reason` is CALLER knowledge — the emit lives at the call sites, and THIS
9
+ * lint is what makes call-site completeness structural instead of memorable
10
+ * (the "three saveSession sites" lesson, applied via CI instead of a funnel).
11
+ *
12
+ * Carve-outs (pinned in the converged spec):
13
+ * - the `cas(` method DEFINITION (SessionOwnershipRegistry.ts) is excluded;
14
+ * - emits are counted per `cas(` TOKEN, not per function (the mesh ownAction
15
+ * dispatcher packs four calls in one body);
16
+ * - the injected `casClaimOwnership(` token is part of the funnel set — but
17
+ * only its IMPLEMENTATION site needs the pairing (the SessionRouter calls
18
+ * the injected dep whose impl carries the emit), so consumer `.deps.`/`d.`
19
+ * invocations are exempt;
20
+ * - tests and the journal/lint files themselves are exempt.
21
+ *
22
+ * Guardrail, not proof: a wrapper could still hide a CAS. The declared duty
23
+ * stays "every ownership mutation pairs an emit"; this catches the direct
24
+ * patterns (Signal vs Authority — the lint is the signal, review the authority).
25
+ */
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
+
32
+ const ROOT = process.argv.includes('--root')
33
+ ? process.argv[process.argv.indexOf('--root') + 1]
34
+ : path.resolve(__dirname, '..');
35
+ const SRC = path.join(ROOT, 'src');
36
+
37
+ /** Lines around a hit in which the paired emitPlacement must appear. */
38
+ const PAIR_WINDOW = 12;
39
+
40
+ const CAS_TOKEN = /\bownReg\.cas\(|\bownershipRegistry\.cas\(|\bsessionOwnershipRegistry\.cas\(/;
41
+ const EXEMPT_FILES = new Set([
42
+ path.join('src', 'core', 'SessionOwnershipRegistry.ts'), // the definition + internal store
43
+ path.join('src', 'core', 'CoherenceJournal.ts'),
44
+ ]);
45
+
46
+ function* walk(dir) {
47
+ for (const name of fs.readdirSync(dir, { withFileTypes: true })) {
48
+ const p = path.join(dir, name.name);
49
+ if (name.isDirectory()) {
50
+ if (name.name === 'node_modules' || name.name === 'dist') continue;
51
+ yield* walk(p);
52
+ } else if (/\.(ts|js|mjs)$/.test(name.name)) {
53
+ yield p;
54
+ }
55
+ }
56
+ }
57
+
58
+ const violations = [];
59
+ let casSites = 0;
60
+
61
+ for (const file of walk(SRC)) {
62
+ const rel = path.relative(ROOT, file);
63
+ if (EXEMPT_FILES.has(rel)) continue;
64
+ const lines = fs.readFileSync(file, 'utf-8').split('\n');
65
+ for (let i = 0; i < lines.length; i++) {
66
+ if (!CAS_TOKEN.test(lines[i])) continue;
67
+ casSites++;
68
+ const lo = Math.max(0, i - PAIR_WINDOW);
69
+ const hi = Math.min(lines.length - 1, i + PAIR_WINDOW);
70
+ const windowText = lines.slice(lo, hi + 1).join('\n');
71
+ if (!/emitPlacement\s*\(/.test(windowText)) {
72
+ violations.push(`${rel}:${i + 1}: ownership CAS call without a paired emitPlacement within ±${PAIR_WINDOW} lines`);
73
+ }
74
+ }
75
+ }
76
+
77
+ if (violations.length > 0) {
78
+ console.error('lint-cas-emit-placement: VIOLATIONS\n');
79
+ for (const v of violations) console.error(' ' + v);
80
+ console.error(
81
+ `\nEvery ownership-CAS call site must pair an emitPlacement (COHERENCE-JOURNAL-SPEC §3.3).` +
82
+ `\nIf the reason is caller knowledge you do not have here, you are probably at the wrong layer.`,
83
+ );
84
+ process.exit(1);
85
+ }
86
+
87
+ console.log(`lint-cas-emit-placement: clean (${casSites} CAS call site(s), all paired)`);
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lint-journal-actuation-ban — COHERENCE-JOURNAL-SPEC §3.9 structural guard.
4
+ *
5
+ * The journal is SIGNAL, never AUTHORITY: replicated journal data is stale by
6
+ * construction (heartbeat-cadenced), and an actuator that trusted a replica's
7
+ * "session closed" could kill or double-place against reality — the journal
8
+ * would CAUSE the duplicate-session incidents it exists to diagnose.
9
+ *
10
+ * Enforcement: no ACTUATOR module (kill / spawn / place / transfer / reap
11
+ * surfaces) may import the journal READER. The reader is deliberately a
12
+ * separate module from the writer so this ban has a precise import target —
13
+ * actuators MAY hold the writer (they emit), they may never read.
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.
17
+ */
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
+ const ROOT = process.argv.includes('--root')
24
+ ? process.argv[process.argv.indexOf('--root') + 1]
25
+ : path.resolve(__dirname, '..');
26
+
27
+ /**
28
+ * Actuator modules: anything holding kill/spawn/place/transfer/reap authority.
29
+ * Grow this list when a new actuator class lands — adding here is cheap;
30
+ * debugging a journal-driven double-kill is not.
31
+ */
32
+ const ACTUATOR_FILES = [
33
+ 'src/core/SessionManager.ts',
34
+ 'src/core/SessionRouter.ts',
35
+ 'src/core/SessionOwnershipRegistry.ts',
36
+ 'src/monitoring/SessionWatchdog.ts',
37
+ 'src/monitoring/SessionMonitor.ts',
38
+ 'src/core/SessionMaintenanceRunner.ts',
39
+ 'src/core/AutonomousSessions.ts',
40
+ 'src/lifeline/ServerSupervisor.ts',
41
+ ];
42
+
43
+ const READER_IMPORT = /from\s+['"][^'"]*CoherenceJournalReader(\.js)?['"]/;
44
+
45
+ const violations = [];
46
+ for (const rel of ACTUATOR_FILES) {
47
+ const file = path.join(ROOT, rel);
48
+ if (!fs.existsSync(file)) continue;
49
+ const lines = fs.readFileSync(file, 'utf-8').split('\n');
50
+ 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
+ }
54
+ }
55
+ }
56
+
57
+ if (violations.length > 0) {
58
+ console.error('lint-journal-actuation-ban: VIOLATIONS\n');
59
+ for (const v of violations) console.error(' ' + v);
60
+ console.error('\nReplicated journal data is stale by construction. Read the live store instead.');
61
+ process.exit(1);
62
+ }
63
+ console.log(`lint-journal-actuation-ban: clean (${ACTUATOR_FILES.length} actuator modules, none import the reader)`);
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lint-state-registry.js — every direct durable-write site that targets a
4
+ * state dir / `.instar/` must be declared in the State-Coherence Registry.
5
+ *
6
+ * Part of the multi-machine-coherence P0 enforcement, landing with its first
7
+ * consumer (the Coherence Journal, COHERENCE-JOURNAL-SPEC §3.6). The census
8
+ * (docs/specs/STATE-COHERENCE-REGISTRY.md) classifies ~100 durable state
9
+ * categories; its machine form is src/data/state-coherence-registry.json. The
10
+ * principle: "Unclassified state = accidentally machine-local = the EXO
11
+ * failure." New state must declare its coherence class at birth.
12
+ *
13
+ * HONEST FRAMING (per §3.6): this lint is a GUARDRAIL, not complete
14
+ * enforcement. Durable writes can hide behind wrappers, libraries, and dynamic
15
+ * paths the lint cannot statically resolve; the declared duty remains "a new
16
+ * store registers itself," and this lint catches the COMMON DIRECT patterns —
17
+ * a `writeFileSync`/`appendFileSync`/`createWriteStream`/sqlite-open whose line
18
+ * statically names BOTH a state-dir indicator (`stateDir` / `state/` /
19
+ * `.instar/`) AND a store-path literal. That paired requirement is the
20
+ * deliberate false-positive floor: a write to a bare variable path (assigned
21
+ * elsewhere) is invisible to a static line scan, and flagging it would be noise
22
+ * a reviewer cannot action at the write site.
23
+ *
24
+ * A flagged site is SATISFIED when either:
25
+ * (a) its store-path literal matches the `paths` of some registry entry, OR
26
+ * (b) an inline `/* state-registry: <category> *​/` annotation appears within
27
+ * 3 lines and names an existing registry entry — the greppable,
28
+ * reviewable escape for a registered store reached via a dynamic path.
29
+ *
30
+ * Exit codes: 0 — clean; 1 — at least one undeclared store, or a malformed
31
+ * registry / dangling annotation.
32
+ *
33
+ * Usage:
34
+ * node scripts/lint-state-registry.js # full src/ tree
35
+ * node scripts/lint-state-registry.js --root <dir> # alternate scan root
36
+ * # (tests point this
37
+ * # at a fixture tree)
38
+ * node scripts/lint-state-registry.js <file> [<file>] # explicit files
39
+ *
40
+ * No deps; plain node stdlib.
41
+ */
42
+
43
+ import fs from 'node:fs';
44
+ import path from 'node:path';
45
+ import { fileURLToPath } from 'node:url';
46
+
47
+ const __filename = fileURLToPath(import.meta.url);
48
+ const REPO_ROOT = path.resolve(path.dirname(__filename), '..');
49
+
50
+ // ── Args ────────────────────────────────────────────────────────────────
51
+ const argv = process.argv.slice(2);
52
+ let scanRoot = path.join(REPO_ROOT, 'src');
53
+ const explicitFiles = [];
54
+ let registryPathArg = null;
55
+ for (let i = 0; i < argv.length; i++) {
56
+ const a = argv[i];
57
+ if (a === '--root') {
58
+ scanRoot = path.resolve(argv[++i]);
59
+ } else if (a === '--registry') {
60
+ registryPathArg = path.resolve(argv[++i]);
61
+ } else if (!a.startsWith('--')) {
62
+ explicitFiles.push(path.resolve(a));
63
+ }
64
+ }
65
+
66
+ const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs']);
67
+
68
+ // ── Load the registry ─────────────────────────────────────────────────────
69
+ const REGISTRY_PATH =
70
+ registryPathArg || path.join(REPO_ROOT, 'src', 'data', 'state-coherence-registry.json');
71
+
72
+ function loadRegistry() {
73
+ let raw;
74
+ try {
75
+ raw = fs.readFileSync(REGISTRY_PATH, 'utf-8');
76
+ } catch {
77
+ console.error(
78
+ `lint-state-registry: registry JSON not found at ${path.relative(REPO_ROOT, REGISTRY_PATH)}. ` +
79
+ `The registry and this lint ship in the same PR (COHERENCE-JOURNAL-SPEC §3.6).`,
80
+ );
81
+ process.exit(1);
82
+ }
83
+ let parsed;
84
+ try {
85
+ parsed = JSON.parse(raw);
86
+ } catch (e) {
87
+ console.error(`lint-state-registry: registry JSON is not valid JSON — ${(e && e.message) || e}`);
88
+ process.exit(1);
89
+ }
90
+ const entries = parsed && Array.isArray(parsed.entries) ? parsed.entries : null;
91
+ if (!entries) {
92
+ console.error('lint-state-registry: registry JSON has no `entries` array.');
93
+ process.exit(1);
94
+ }
95
+ const VALID_SCOPE = new Set([
96
+ 'machine-local',
97
+ 'must-be-coherent',
98
+ 'derived-cache',
99
+ 'coherent-on-demand',
100
+ ]);
101
+ const byCategory = new Map();
102
+ for (const e of entries) {
103
+ if (!e || typeof e.category !== 'string' || !e.category) {
104
+ console.error('lint-state-registry: registry entry missing a string `category`.');
105
+ process.exit(1);
106
+ }
107
+ if (!VALID_SCOPE.has(e.scope)) {
108
+ console.error(`lint-state-registry: entry "${e.category}" has invalid scope "${e.scope}".`);
109
+ process.exit(1);
110
+ }
111
+ if (!Array.isArray(e.paths)) {
112
+ console.error(`lint-state-registry: entry "${e.category}" has no `+'`paths` array.');
113
+ process.exit(1);
114
+ }
115
+ if (byCategory.has(e.category)) {
116
+ console.error(`lint-state-registry: duplicate category "${e.category}".`);
117
+ process.exit(1);
118
+ }
119
+ byCategory.set(e.category, e);
120
+ }
121
+ return { entries, byCategory };
122
+ }
123
+
124
+ const { entries: REGISTRY, byCategory: REGISTRY_BY_CATEGORY } = loadRegistry();
125
+
126
+ // ── Path-glob matching (glob-ish hints → literal) ─────────────────────────
127
+ // Registry `paths` are glob-ish hints. A write-site literal matches an entry
128
+ // when the literal's basename or a trailing path segment is named by one of
129
+ // the entry's path hints. We compare on normalized POSIX-ish strings and
130
+ // support `*` wildcards and trailing-`/` directory prefixes.
131
+ function normalize(p) {
132
+ return String(p).replace(/\\/g, '/');
133
+ }
134
+
135
+ function globToRegExp(glob) {
136
+ const g = normalize(glob);
137
+ // Escape regex specials except `*`, then translate `*` → `[^/]*`.
138
+ const escaped = g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*');
139
+ return new RegExp(escaped);
140
+ }
141
+
142
+ /**
143
+ * Does write-site `literal` (a quoted path fragment from source) fall under
144
+ * registry path-hint `hint`?
145
+ *
146
+ * Matching is suffix/segment oriented because write sites compose paths
147
+ * (`path.join(stateDir, 'state', 'foo.json')`) so the literal is usually only
148
+ * the tail. A hint matches when:
149
+ * - hint ends with `/` (a directory prefix) and the literal contains that
150
+ * directory segment, OR
151
+ * - the hint's basename (last segment) equals/glob-matches the literal's
152
+ * basename, OR
153
+ * - the literal (or its tail) glob-matches the whole hint.
154
+ */
155
+ function literalMatchesHint(literal, hint) {
156
+ const lit = normalize(literal).replace(/^\.?\//, '');
157
+ const h = normalize(hint).replace(/^\.?\//, '');
158
+
159
+ // Directory-prefix hint: literal lives under it.
160
+ if (h.endsWith('/')) {
161
+ const dir = h.slice(0, -1);
162
+ const dirRe = new RegExp('(^|/)' + globToRegExp(dir).source + '(/|$)');
163
+ return dirRe.test(lit) || dirRe.test(lit + '/');
164
+ }
165
+
166
+ // Bare-filename hint (no path separator): match by basename glob. This is
167
+ // the common write-site case (hint 'security.jsonl' vs literal
168
+ // 'security.jsonl', or hint '*.local.md' vs 'topic.local.md'). A bare
169
+ // wildcard hint like '*.json' would be dangerously broad, so it is rejected
170
+ // — a wildcard basename is only honored when the hint also pins a directory
171
+ // (handled by the suffix match below).
172
+ if (!h.includes('/')) {
173
+ if (h.includes('*')) return false;
174
+ const litBase = lit.split('/').pop();
175
+ const baseRe = new RegExp('^' + globToRegExp(h).source + '$');
176
+ return baseRe.test(litBase);
177
+ }
178
+
179
+ // Path-qualified hint: require the WHOLE hint to suffix-match the literal
180
+ // tail, so a wildcard basename ('relationships/*.json') only matches when
181
+ // its directory segment ('relationships/') is present in the literal too.
182
+ const wholeRe = new RegExp('(^|/)' + globToRegExp(h).source + '$');
183
+ return wholeRe.test(lit);
184
+ }
185
+
186
+ function literalIsRegistered(literal) {
187
+ for (const e of REGISTRY) {
188
+ for (const hint of e.paths) {
189
+ if (literalMatchesHint(literal, hint)) return e.category;
190
+ }
191
+ }
192
+ return null;
193
+ }
194
+
195
+ // ── Write-site detection ──────────────────────────────────────────────────
196
+ // A line is a durable-write site when it calls one of these sinks…
197
+ const WRITE_SINK = /\b(?:writeFileSync|appendFileSync|createWriteStream|writeFile|appendFile)\s*\(/;
198
+ // …or opens a sqlite database.
199
+ const SQLITE_OPEN =
200
+ /\b(?:new\s+(?:Database|BetterSqlite3|DatabaseSync)|sqlite3?\.Database|openDatabase)\s*\(/;
201
+
202
+ // A state-dir indicator must be present on the line for it to be IN SCOPE
203
+ // (the deliberate false-positive floor — see header).
204
+ const STATE_DIR_INDICATOR = /\bstateDir\b|\.instar\/|(?:^|[^A-Za-z0-9])state\//;
205
+
206
+ // Store-path literal: a quoted string that ends in a durable-state extension,
207
+ // OR a known bare control-file name (no extension).
208
+ const STORE_LITERAL_RE =
209
+ /['"`]([^'"`]*?(?:\.(?:json|jsonl|db|sqlite3?|enc|pem|md|cache)|autonomous-emergency-stop|boot-id))['"`]/g;
210
+
211
+ const ANNOTATION_RE = /\/\*\s*state-registry:\s*([a-z0-9-]+)\s*\*\//;
212
+
213
+ function listFiles() {
214
+ if (explicitFiles.length) {
215
+ return explicitFiles.filter((f) => EXTENSIONS.has(path.extname(f)));
216
+ }
217
+ const files = [];
218
+ const walk = (dir) => {
219
+ let dirents;
220
+ try {
221
+ dirents = fs.readdirSync(dir, { withFileTypes: true });
222
+ } catch {
223
+ return;
224
+ }
225
+ for (const d of dirents) {
226
+ if (d.name === 'node_modules' || d.name === '.git' || d.name === 'dist') continue;
227
+ const full = path.join(dir, d.name);
228
+ if (d.isDirectory()) walk(full);
229
+ else if (EXTENSIONS.has(path.extname(d.name))) files.push(full);
230
+ }
231
+ };
232
+ walk(scanRoot);
233
+ return files;
234
+ }
235
+
236
+ // Returns the annotated category if a `/* state-registry: cat */` annotation
237
+ // is present within `radius` lines of `idx` (inclusive both directions).
238
+ function annotationNear(lines, idx, radius) {
239
+ for (let j = Math.max(0, idx - radius); j <= Math.min(lines.length - 1, idx + radius); j++) {
240
+ const m = ANNOTATION_RE.exec(lines[j]);
241
+ if (m) return m[1];
242
+ }
243
+ return null;
244
+ }
245
+
246
+ let violations = 0;
247
+ let danglingAnnotations = 0;
248
+
249
+ for (const file of listFiles()) {
250
+ let content;
251
+ try {
252
+ content = fs.readFileSync(file, 'utf-8');
253
+ } catch {
254
+ continue;
255
+ }
256
+ const rel = path.relative(REPO_ROOT, file).split(path.sep).join('/');
257
+ const lines = content.split('\n');
258
+
259
+ for (let i = 0; i < lines.length; i++) {
260
+ const line = lines[i];
261
+
262
+ // First, validate any annotation actually names a real category — a
263
+ // dangling annotation is a silent-failure shape we refuse (Distrust
264
+ // Temporary Success).
265
+ const annHere = ANNOTATION_RE.exec(line);
266
+ if (annHere && !REGISTRY_BY_CATEGORY.has(annHere[1])) {
267
+ console.error(
268
+ `${rel}:${i + 1} — state-registry annotation names unknown category "${annHere[1]}". ` +
269
+ `Add it to src/data/state-coherence-registry.json or fix the name.`,
270
+ );
271
+ danglingAnnotations++;
272
+ }
273
+
274
+ const isWriteSite = WRITE_SINK.test(line) || SQLITE_OPEN.test(line);
275
+ if (!isWriteSite) continue;
276
+ if (!STATE_DIR_INDICATOR.test(line)) continue;
277
+
278
+ // Collect store-path literals on this line.
279
+ STORE_LITERAL_RE.lastIndex = 0;
280
+ const literals = [];
281
+ let m;
282
+ while ((m = STORE_LITERAL_RE.exec(line)) !== null) literals.push(m[1]);
283
+ if (literals.length === 0) continue; // sink + state-dir but no resolvable literal → out of scope
284
+
285
+ // Annotation escape (within 3 lines) satisfies the whole site.
286
+ const ann = annotationNear(lines, i, 3);
287
+ if (ann && REGISTRY_BY_CATEGORY.has(ann)) continue;
288
+
289
+ // Each literal must be registered.
290
+ const unregistered = literals.filter((lit) => !literalIsRegistered(lit));
291
+ if (unregistered.length > 0) {
292
+ for (const lit of unregistered) {
293
+ console.error(
294
+ `${rel}:${i + 1} — durable write to "${lit}" has no State-Coherence Registry entry. ` +
295
+ `Declare it in src/data/state-coherence-registry.json (pick a coherence scope), ` +
296
+ `or add an inline /* state-registry: <category> */ annotation if the store is already registered.`,
297
+ );
298
+ violations++;
299
+ }
300
+ }
301
+ }
302
+ }
303
+
304
+ const total = violations + danglingAnnotations;
305
+ if (total > 0) {
306
+ console.error(
307
+ `\nlint-state-registry: ${total} issue(s) ` +
308
+ `(${violations} undeclared store(s), ${danglingAnnotations} dangling annotation(s)). ` +
309
+ `See docs/specs/STATE-COHERENCE-REGISTRY.md and COHERENCE-JOURNAL-SPEC §3.6.`,
310
+ );
311
+ process.exit(1);
312
+ }
313
+ console.log(`lint-state-registry: clean (${REGISTRY.length} registry categories)`);