context-mode 1.0.126 → 1.0.127

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.
@@ -29,7 +29,7 @@
29
29
  * developer ran during `pretest`.
30
30
  */
31
31
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
32
- import { join, relative } from "node:path";
32
+ import { join, relative, sep } from "node:path";
33
33
 
34
34
  /**
35
35
  * Walk a directory recursively, returning a flat list of relative file
@@ -102,22 +102,15 @@ export function derivePluginManifest({ pkg, pluginRoot }) {
102
102
  }
103
103
 
104
104
  /**
105
- * REQUIRED_RUNTIME_SIBLINGS — the minimum set of files start.mjs must
106
- * find at boot. These are the files start.mjs actively `import()`s or
107
- * needs to re-symlink against. The check is intentionally narrower
108
- * than the full manifest:
105
+ * LEGACY_FALLBACK — the v1.0.126 hardcoded REQUIRED_RUNTIME_SIBLINGS,
106
+ * preserved verbatim. Forms the union seed for the algorithmic set so
107
+ * the post-558 contract is strictly additive over the pre-558 contract
108
+ * (no required sibling ever silently disappears).
109
109
  *
110
- * - server.bundle.mjs / cli.bundle.mjs are produced by `npm run
111
- * bundle`. Without server.bundle.mjs the server can't start;
112
- * without cli.bundle.mjs `context-mode doctor` can't run.
113
- * - hooks/{5 hook scripts}.mjs are spawned per Claude Code event.
114
- * Missing any one produces a silent hook failure.
115
- *
116
- * Other files in package.json files[] (insight/, configs/, README, …)
117
- * are not boot-critical, so missing them is a "warn"-class issue
118
- * surfaced only via the doctor — never enough to fail-fast at boot.
110
+ * Also acts as a safety net when `package.json` is unreadable — the
111
+ * boot gate stays loud even if the publish manifest is corrupted.
119
112
  */
120
- const REQUIRED_RUNTIME_SIBLINGS = Object.freeze([
113
+ const LEGACY_FALLBACK = Object.freeze([
121
114
  "server.bundle.mjs",
122
115
  "cli.bundle.mjs",
123
116
  join("hooks", "pretooluse.mjs"),
@@ -127,6 +120,94 @@ const REQUIRED_RUNTIME_SIBLINGS = Object.freeze([
127
120
  join("hooks", "userpromptsubmit.mjs"),
128
121
  ]);
129
122
 
123
+ /**
124
+ * SOFT_FALLBACK_BUNDLES — bundles that already implement
125
+ * bundle-first / build-fallback resolution (via session-loaders.mjs or
126
+ * session-helpers.mjs). Their absence on a published install is
127
+ * gracefully recoverable, so they MUST NOT join the fail-fast boot
128
+ * gate — the gate would refuse to start a working install.
129
+ *
130
+ * The security bundle is intentionally NOT here: its absence creates a
131
+ * silent fail-OPEN regression (#558), so it IS boot-critical.
132
+ */
133
+ const SOFT_FALLBACK_BUNDLES = new Set([
134
+ "hooks/session-extract.bundle.mjs",
135
+ "hooks/session-snapshot.bundle.mjs",
136
+ "hooks/session-db.bundle.mjs",
137
+ "hooks/session-attribution.bundle.mjs",
138
+ ]);
139
+
140
+ /**
141
+ * Algorithmically extract every esbuild output path from
142
+ * `package.json scripts.bundle`. The bundle script is the SINGLE
143
+ * SOURCE OF TRUTH for "what bundles this build produces" — parsing
144
+ * its `--outfile=…` arguments avoids the parallel-list trap that
145
+ * bit Algo-D4 v1.0.126 (the hardcoded REQUIRED list lagged the
146
+ * actual bundle output).
147
+ *
148
+ * Returns POSIX-style relative paths (forward slashes) for stable
149
+ * comparison with SOFT_FALLBACK_BUNDLES. Caller normalizes to
150
+ * `path.join` shape before pluginRoot-relative resolution.
151
+ */
152
+ function extractBundleOutfiles(pkg) {
153
+ const script = pkg?.scripts?.bundle;
154
+ if (typeof script !== "string") return [];
155
+ const out = new Set();
156
+ // Match every `--outfile=<path>` token (path is whitespace-delimited
157
+ // because the script chains commands with `&&`).
158
+ const re = /--outfile=(\S+)/g;
159
+ let m;
160
+ while ((m = re.exec(script)) !== null) {
161
+ out.add(m[1]);
162
+ }
163
+ return [...out];
164
+ }
165
+
166
+ /**
167
+ * Algorithmic — derive the boot-critical sibling set as the union of:
168
+ * 1. LEGACY_FALLBACK (the v1.0.126 contract, preserved verbatim).
169
+ * 2. Every esbuild output path from `package.json scripts.bundle`
170
+ * that is NOT in SOFT_FALLBACK_BUNDLES.
171
+ *
172
+ * Why algorithmic instead of hardcoded:
173
+ *
174
+ * v1.0.126 shipped Algo-D4 with a hardcoded REQUIRED_RUNTIME_SIBLINGS
175
+ * array that omitted `hooks/security.bundle.mjs` (the bundle didn't
176
+ * ship until v1.0.127). The hardcoded list would need manual
177
+ * extension every time a runtime bundle is added — the same trap
178
+ * would re-bite the next bundle. Deriving from `scripts.bundle`
179
+ * closes the trap: any new bundle output is auto-gated unless it
180
+ * joins the soft-fallback whitelist (which is itself an explicit
181
+ * architectural decision, not a maintenance burden). (#558)
182
+ *
183
+ * Returns OS-native-separator relative paths (suitable for
184
+ * `path.join(pluginRoot, …)`).
185
+ *
186
+ * If `package.json` is unreadable, returns LEGACY_FALLBACK as a
187
+ * safety net so the boot gate never goes silent due to a parse
188
+ * error in the publish manifest.
189
+ */
190
+ export function getRequiredRuntimeSiblings(pluginRoot) {
191
+ let pkg;
192
+ try {
193
+ pkg = JSON.parse(readFileSync(join(pluginRoot, "package.json"), "utf-8"));
194
+ } catch {
195
+ return [...LEGACY_FALLBACK];
196
+ }
197
+ const required = new Set(LEGACY_FALLBACK);
198
+ for (const outfile of extractBundleOutfiles(pkg)) {
199
+ // Normalize to POSIX for soft-fallback membership check —
200
+ // scripts.bundle is hand-authored with forward slashes already,
201
+ // but be defensive in case a Windows-authored package.json ever
202
+ // reaches us.
203
+ const posix = outfile.split(sep).join("/");
204
+ if (SOFT_FALLBACK_BUNDLES.has(posix)) continue;
205
+ // Convert back to OS-native sep for downstream filesystem ops.
206
+ required.add(posix.split("/").join(sep));
207
+ }
208
+ return [...required];
209
+ }
210
+
130
211
  /**
131
212
  * Verify boot-critical siblings exist at pluginRoot.
132
213
  *
@@ -134,15 +215,14 @@ const REQUIRED_RUNTIME_SIBLINGS = Object.freeze([
134
215
  * stderr. The caller (start.mjs at boot, src/cli.ts at doctor) decides
135
216
  * the failure surface (fail-fast exit 2 vs. doctor diagnostic).
136
217
  *
137
- * Uses package.json (read from pluginRoot) only as a source-of-truth
138
- * cross-check; the actual REQUIRED list is hardcoded above to keep the
139
- * runtime contract independent of package.json being readable. If
140
- * package.json IS readable AND files[] omits something we require, the
141
- * check fails — that's the "drift between contract and tarball" trap.
218
+ * Required-set is computed by `getRequiredRuntimeSiblings()`
219
+ * algorithmically derived from `package.json files[]` filtered to the
220
+ * RUNTIME_CRITICAL_PATTERN. Drift between publish manifest and runtime
221
+ * contract becomes architecturally impossible (#558).
142
222
  */
143
223
  export function assertPluginCacheIntegrity({ pluginRoot }) {
144
224
  const missing = [];
145
- for (const rel of REQUIRED_RUNTIME_SIBLINGS) {
225
+ for (const rel of getRequiredRuntimeSiblings(pluginRoot)) {
146
226
  const abs = join(pluginRoot, rel);
147
227
  if (!existsSync(abs)) missing.push(abs);
148
228
  }