@theronap/cortex-mcp 0.9.139 โ†’ 0.9.141

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.
package/lib/capture.mjs CHANGED
@@ -396,6 +396,10 @@ async function captureWork(stdinRaw) {
396
396
  people: extracted.people,
397
397
  entities: extracted.namedEntities,
398
398
  ...(extracted.pages?.length ? { pages: extracted.pages } : {}),
399
+ // ADR-0059. Obligation candidates the extraction found AND verified verbatim against the
400
+ // transcript it was shown โ€” the server cannot re-check the quote (it never receives the
401
+ // transcript), so these arrive already filtered. Omitted when empty, same as `pages`.
402
+ ...(extracted.obligations?.length ? { obligations: extracted.obligations } : {}),
399
403
  }
400
404
  : { ...common, transcript }
401
405
 
@@ -12,9 +12,31 @@ import { homedir } from 'os'
12
12
  // timeout + grace, or whose PID is dead) is reclaimed.
13
13
  const LOCK_PATH = join(homedir(), '.cortex', 'summarize.lock')
14
14
 
15
+ // ๐Ÿ”ด THE TIMEOUT MUST COVER THE TAIL OF A WIDE DISTRIBUTION, NOT ITS MIDDLE. Every full extraction
16
+ // timed 2026-09-10 on two real transcripts: 56, 68, 77, 86, 93, 102s โ€” and one run that exceeded 150s
17
+ // and was killed. The old 45s default was below nearly all of them; a first raise to 150s still sat
18
+ // inside the tail. A killed call returns null, which skips EVERYTHING downstream: the summary, people,
19
+ // entities, ADR-0055's `pages`, and ADR-0059's `obligations`. The fallback then ships the transcript
20
+ // tail and the server writes a summary, so the record lands and nothing looks broken โ€” a green outcome
21
+ // over a dead mechanism, which is this file's own recorded failure family (see edgeSkip's header).
22
+ //
23
+ // โš  LATENCY TRACKS OUTPUT, NOT INPUT โ€” measured, and it is why length is a poor predictor. A plain
24
+ // one-key summary of the full 50k-char CSG transcript returns in ~12s. The five-key extraction of the
25
+ // same text takes 93s+. And ENT 425, three times LONGER than CSG, ran FASTER (77s) โ€” because CSG names
26
+ // ~35 people in its team roster and each person is a six-field object. The cost is generating the JSON.
27
+ // So a record dense with names is slow regardless of its length, and no length-scaled timeout would
28
+ // have predicted it.
29
+ //
30
+ // โš  RAISING IT COSTS THE USER NOTHING. capture.mjs re-invokes itself as a fully DETACHED child
31
+ // (`detached: true`, `child.unref()`), so nobody waits on this call. The one real cost is the
32
+ // single-flight lock held longer, during which a concurrent session ships its tail instead โ€” the
33
+ // existing graceful fallback. Too short loses the whole extraction; too long only degrades a neighbour
34
+ // to the fallback it already has. The stale-lock reclaim scales off this same value.
35
+ //
36
+ // โš  Its effect on capture_route (1 attachment in its first two days) is a candidate, NOT proven.
15
37
  function summaryTimeoutMs() {
16
38
  const n = Number(process.env.CORTEX_SUMMARY_TIMEOUT_MS)
17
- return Number.isFinite(n) && n > 0 ? n : 45_000
39
+ return Number.isFinite(n) && n > 0 ? n : 300_000
18
40
  }
19
41
 
20
42
  // Try to acquire the lock. Returns true if acquired (caller must call releaseSummaryLock in a finally),
@@ -134,6 +156,32 @@ const PAGES_FRAGMENT =
134
156
  'page. Most sessions belong on one to three pages. Empty array is correct for a session with no ' +
135
157
  'durable subject, and is much better than a loose guess.)'
136
158
 
159
+ // โ”€โ”€ ADR-0059 โ€” obligations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
160
+ //
161
+ // A DIFFERENT QUESTION FROM `pages`, AND DELIBERATELY NOT AS CONSERVATIVE. The pages prompt says an
162
+ // empty array is "much better than a loose guess", which is right for attachment โ€” a missed page
163
+ // costs retrieval quality โ€” and measured: capture_route produced 1 attachment across 10 sessions on
164
+ // a capable client (2026-09-09). A missed DEADLINE is a different cost, in the other direction, so
165
+ // this fragment asks for the same care about applicability without importing the same reluctance.
166
+ //
167
+ // โš  `evidence` IS VERIFIED HERE, NOT SERVER-SIDE (ADR-0059 ยง4.1). The cloud receives only the derived
168
+ // digest and never the transcript, so there is nothing server-side to compare a quote against. The
169
+ // check is `verbatimIn()` below, run against the SAME slice the model was shown.
170
+ const OBLIGATIONS_FRAGMENT =
171
+ '"obligations" (array of things someone must DO by a date, stated in this session. Each an object ' +
172
+ 'with "subject" (what must be done, one short phrase), ' +
173
+ '"due_at" (ISO 8601 date or datetime if one is stated, otherwise null โ€” do NOT invent or infer a ' +
174
+ 'date that was not said), ' +
175
+ '"evidence" (the sentence from the session that says so, copied EXACTLY and VERBATIM โ€” it is ' +
176
+ 'checked against the transcript and the whole item is discarded if it does not match), ' +
177
+ '"obligated_party" (exactly one of: "self" if the person whose session this is must do it; ' +
178
+ '"other" if someone else must; "none" if nobody must). ' +
179
+ 'The hard part is "none", so read it carefully: a real date about a real person is still "none" ' +
180
+ 'when nobody owes anything โ€” "so-and-so\'s birthday is today" has every surface feature of a ' +
181
+ 'deadline and is not one. Historical and course-content dates are "none" too. ' +
182
+ 'Include an item when the session genuinely states an obligation, and do not stretch to find one; ' +
183
+ 'an empty array is correct for a session that contains no deadlines.)'
184
+
137
185
  const ENTITY_FRAGMENT =
138
186
  '"namedEntities" (array of important NON-PERSON things this content is about โ€” concrete, named ' +
139
187
  'projects, processes, systems, products, documents, teams, tools, events, places, or topics. ' +
@@ -145,6 +193,108 @@ const ENTITY_FRAGMENT =
145
193
 
146
194
  // PURE. The scheduled task this transcript is an unattended run of, or null for a human session.
147
195
  // Claude Code wraps a scheduled run's instructions in <scheduled-task name="..." file="...">.
196
+ // โ”€โ”€ THE SUMMARIZER'S WINDOW โ€” the whole record, unless it is enormous โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
197
+ //
198
+ // ๐Ÿ”ด NO FIXED WINDOW WORKS, AND TWO REAL TRANSCRIPTS PROVED IT FROM OPPOSITE DIRECTIONS (2026-09-10).
199
+ //
200
+ // ENT 425 lecture (146,890 chars): the one assignment sits at char 147,121 โ€” the LAST line. A head
201
+ // window cannot see it. A tail window can.
202
+ //
203
+ // CSG kickoff (50,241 chars): all three dated deadlines sit at chars 8,027โ€“11,390 โ€” the EARLY
204
+ // MIDDLE, in the "upcoming schedule" segment. A head window sees all three. A head+tail window,
205
+ // which is what this function did for about twenty minutes, sees NONE of them.
206
+ //
207
+ // Each placement fixed one transcript by breaking the other. The head-only slice this replaced was
208
+ // wrong; the head+tail "fix" was a second guess about where content lives, validated against n=1 and
209
+ // refuted by n=2. Where obligations appear depends on how a particular meeting was run, so any rule
210
+ // about which part to keep is a bet on the next transcript's structure.
211
+ //
212
+ // โœ… THE ACTUAL CONSTRAINT WAS NEVER THE MODEL. Measured: full text of the 146,890-char transcript ran
213
+ // in 77.0s, faster than the 12k-window runs of the same file (56โ€“86s). Latency barely tracks length
214
+ // at this scale. And on CSG, full text found 2 of the 3 dated deadlines on BOTH runs, with correct ISO
215
+ // dates and zero false positives, where the head+tail window found 0 of 3.
216
+ //
217
+ // So the default is effectively "send everything". The head+tail fallback survives only for records
218
+ // so long they approach the model's context, where SOME window is unavoidable. At that size the
219
+ // placement bet returns, and it should be treated as a known-lossy last resort, not a design.
220
+ //
221
+ // โš  THIS REQUIRES THE PROMPT TO TRAVEL ON STDIN (see extractSession). Full text exceeds Linux's
222
+ // per-argument limit and Windows' command-line limit; as argv it would fail everywhere but macOS.
223
+ //
224
+ // โš  Env override CORTEX_SUMMARY_WINDOW is for MEASUREMENT. The default is what ships.
225
+ const SUMMARY_WINDOW = Number(process.env.CORTEX_SUMMARY_WINDOW) > 0 ? Number(process.env.CORTEX_SUMMARY_WINDOW) : 400_000
226
+ const ELISION = '\n\n[โ€ฆ middle of the session elided for length โ€ฆ]\n\n'
227
+
228
+ /**
229
+ * PURE, exported for tests. The text the summarizer actually sees โ€” the whole record, unless it
230
+ * exceeds the budget, in which case both ends are kept around a MARKED elision.
231
+ *
232
+ * โš  WHATEVER THIS RETURNS IS ALSO THE CORPUS THE VERBATIM GATE CHECKS AGAINST (see `extractSession`).
233
+ * That coupling is required, not incidental: checking a quote against text the model was never shown
234
+ * would reject valid quotes, and checking against MORE than it was shown would accept a fabrication
235
+ * that happens to appear in the discarded middle. A quote spanning the elision fails closed, which is
236
+ * the correct outcome โ€” those two passages were never adjacent.
237
+ */
238
+ export function summaryWindow(text, budget = SUMMARY_WINDOW) {
239
+ const s = String(text ?? '')
240
+ if (s.length <= budget) return s
241
+ const headBudget = Math.max(0, Math.floor((budget - ELISION.length) * 0.4))
242
+ const tailBudget = Math.max(0, budget - ELISION.length - headBudget)
243
+ return s.slice(0, headBudget) + ELISION + s.slice(s.length - tailBudget)
244
+ }
245
+
246
+ // โ”€โ”€ ADR-0059 ยง4.1 โ€” the verbatim gate โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
247
+
248
+ const OBLIGATION_PARTIES = new Set(['self', 'other', 'none'])
249
+
250
+ /** A guard against a runaway answer, not a target โ€” the same framing as MAX_CAPTURE_PAGES. */
251
+ const MAX_OBLIGATIONS = 5
252
+
253
+ /**
254
+ * Is `needle` actually present in `haystack`, allowing only whitespace to differ?
255
+ *
256
+ * โš  WHITESPACE IS NORMALISED AND NOTHING ELSE IS. A transcript wraps lines; a model reproducing a
257
+ * sentence will not reproduce the wrapping. Collapsing runs of whitespace is the difference between
258
+ * a check that passes on real quotes and one that rejects nearly all of them. Case, punctuation and
259
+ * wording are NOT normalised โ€” loosening those turns "is this sentence in the source" into "is this
260
+ * roughly like something in the source", which is the question this gate exists to refuse.
261
+ *
262
+ * ๐Ÿ”ด AND THE MINIMUM LENGTH IS LOAD-BEARING, NOT TIDINESS. Without it the gate is defeated by a
263
+ * one-word quote: `evidence: "Friday"` appears verbatim in almost any transcript that mentions a
264
+ * day, so a fabricated obligation would pass the check that exists to catch fabrication. A quote
265
+ * short enough to be accidentally present proves nothing about the claim attached to it.
266
+ */
267
+ export function verbatimIn(needle, haystack) {
268
+ const norm = (s) => String(s ?? '').replace(/\s+/g, ' ').trim()
269
+ const n = norm(needle)
270
+ if (n.length < 16) return false
271
+ return norm(haystack).includes(n)
272
+ }
273
+
274
+ /**
275
+ * Drop every obligation candidate that cannot be verified against the text the model was shown.
276
+ *
277
+ * Shape first, then the quote. A candidate missing a subject, missing evidence, or carrying an
278
+ * `obligated_party` outside the preset is malformed and goes โ€” the server's CHECK constraint would
279
+ * reject it anyway, and failing here means the record still lands with its other keys intact.
280
+ */
281
+ export function keepVerifiableObligations(items, shown) {
282
+ if (!Array.isArray(items)) return []
283
+ const out = []
284
+ for (const it of items) {
285
+ if (!it || typeof it !== 'object') continue
286
+ const subject = typeof it.subject === 'string' ? it.subject.trim() : ''
287
+ const evidence = typeof it.evidence === 'string' ? it.evidence.trim() : ''
288
+ const party = typeof it.obligated_party === 'string' ? it.obligated_party.trim() : ''
289
+ if (!subject || !evidence || !OBLIGATION_PARTIES.has(party)) continue
290
+ if (!verbatimIn(evidence, shown)) continue
291
+ const due = typeof it.due_at === 'string' && it.due_at.trim() ? it.due_at.trim() : null
292
+ out.push({ subject: subject.slice(0, 200), due_at: due, evidence: evidence.slice(0, 500), obligated_party: party })
293
+ if (out.length >= MAX_OBLIGATIONS) break
294
+ }
295
+ return out
296
+ }
297
+
148
298
  export function scheduledTaskName(transcript) {
149
299
  const m = String(transcript ?? '').match(/<scheduled-task\s+[^>]*name="([^"]+)"/)
150
300
  return m ? m[1] : null
@@ -203,31 +353,57 @@ export function extractSession(transcript) {
203
353
  const text = task ? stripScheduledTaskBlocks(raw) : raw
204
354
  // A run whose entire transcript WAS the instruction block leaves nothing to judge. That is a
205
355
  // heartbeat by definition โ€” skip without spending a `claude -p` call on it.
206
- if (task && !text) return { summary: 'NOOP', people: [], namedEntities: [], pages: [] }
356
+ if (task && !text) return { summary: 'NOOP', people: [], namedEntities: [], pages: [], obligations: [] }
357
+ // ONE window, named, used by both the prompt and the verbatim check โ€” see `summaryWindow`.
358
+ const shown = summaryWindow(text)
359
+ // โš  THE OPENING FRAMING WAS "a Claude Code work session", AND IT CLASSIFIED A LECTURE AS NOOP.
360
+ // Measured 2026-09-10: the tail of the ENT 425 transcript came back `summary: "NOOP"` because the
361
+ // old no-op test โ€” "no real work โ€” greetings, no tasks" โ€” reads "no tasks" as "no code was
362
+ // written". A NOOP here does not merely lose the summary: capture.mjs returns before ingest, so the
363
+ // record is never created at all.
364
+ //
365
+ // The new bar is "no DURABLE content", which still catches the case the rule exists for โ€” a
366
+ // greeting, an interrupted session, a run that did nothing โ€” and stops equating "nothing was built"
367
+ // with "nothing happened". Unattended scheduled runs are unaffected: `scheduledTaskFragment` is
368
+ // appended below and carries its own, stricter changed-vs-unchanged bar, which is where the
369
+ // heartbeat suppression that matters actually lives. โš  That fragment's own header records a NOOP
370
+ // wording change that killed a real finding on its first attempt โ€” so this one is asserted by a
371
+ // negative control (a bare greeting must still NOOP), not just by the case it was written for.
207
372
  const prompt =
208
- 'You are processing a Claude Code work session for a knowledge base. Return ONLY minified JSON ' +
209
- '(no prose, no markdown fences) with EXACTLY these four keys:\n' +
210
- '"summary" (ONE concrete sentence under 20 words: what was worked on or decided. If the session ' +
211
- 'had no real work โ€” greetings, no tasks โ€” set summary to exactly "NOOP"),\n' +
373
+ 'You are processing a captured record โ€” a work session, a meeting, or a lecture transcript โ€” for a ' +
374
+ 'knowledge base. Return ONLY minified JSON (no prose, no markdown fences) with EXACTLY these five keys:\n' +
375
+ '"summary" (ONE concrete sentence under 20 words: what was worked on, decided, discussed or taught. ' +
376
+ 'If the record holds NO durable content at all โ€” only greetings, small talk, or a run that did ' +
377
+ 'nothing โ€” set summary to exactly "NOOP". A lecture, meeting or discussion with real content is ' +
378
+ 'NOT a no-op merely because nothing was built),\n' +
212
379
  PEOPLE_FRAGMENT + ',\n' +
213
380
  ENTITY_FRAGMENT + ',\n' +
214
- PAGES_FRAGMENT +
381
+ PAGES_FRAGMENT + ',\n' +
382
+ OBLIGATIONS_FRAGMENT +
215
383
  (task ? scheduledTaskFragment(task) : '') +
216
- '\n\n--- SESSION ---\n' + text.slice(0, 12000) + '\n--- END ---'
384
+ '\n\n--- SESSION ---\n' + shown + '\n--- END ---'
217
385
  // Single-flight: if another session already has a summarizer running, skip (caller ships the tail;
218
386
  // server summarizes). Prevents the concurrent-`claude -p` stampede that deadlocks the OAuth refresh.
219
387
  if (!acquireSummaryLock()) return edgeSkip('busy', 'another session is summarizing; the tail ships instead')
220
388
  try {
389
+ // โš  THE PROMPT GOES ON STDIN, NOT ARGV โ€” AND THE WINDOW BELOW DEPENDS ON IT. A command-line
390
+ // argument has an OS size limit: Linux caps a SINGLE argument at 131,072 bytes (MAX_ARG_STRLEN)
391
+ // and Windows caps the WHOLE command line at 32,767 characters. The full-text window can exceed
392
+ // both โ€” ENT 425 alone is 146,890 characters. As argv it would work on macOS (ARG_MAX 1MB, the
393
+ // only machine that tests this) and fail with E2BIG everywhere else, green on the dev box and
394
+ // broken for the user. `claude --print` reads its prompt from stdin; verified live 2026-09-10.
221
395
  const r = spawnSync(
222
396
  'claude',
223
- ['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5', prompt],
224
- { env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: summaryTimeoutMs(), maxBuffer: 4 * 1024 * 1024 },
397
+ ['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5'],
398
+ { input: prompt, env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: summaryTimeoutMs(), maxBuffer: 4 * 1024 * 1024 },
225
399
  )
226
400
  const why = classifyEdgeFailure(r)
227
401
  if (why) return edgeSkip(why.reason, why.detail)
228
402
  const parsed = parseEdgeJson(r.stdout.trim())
229
403
  if (!parsed) return edgeSkip('unparseable', 'the model returned output that is not the expected JSON')
230
- return parsed
404
+ // ADR-0059 ยง4.1 โ€” the verbatim gate. This is the ONLY place it can run: the server never receives
405
+ // the transcript, so a quote it cannot see is a quote it cannot check.
406
+ return { ...parsed, obligations: keepVerifiableObligations(parsed.obligations, shown) }
231
407
  } catch (e) {
232
408
  return edgeSkip('threw', e instanceof Error ? e.message : String(e))
233
409
  } finally {
@@ -290,6 +466,18 @@ export function classifyEdgeFailure(r) {
290
466
  // The other shape of the same event: spawnSync killed the child, so status is null and signal set.
291
467
  if (r.signal) return { reason: 'timeout', detail: `killed by ${r.signal} after ${summaryTimeoutMs()}ms` }
292
468
 
469
+ // ๐Ÿ”ด EVERYTHING BELOW APPLIES ONLY TO A FAILED CALL. Exit 0 means the model answered; its answer
470
+ // is CONTENT, not a status report, and must never be scanned for failure keywords.
471
+ //
472
+ // This ordering was wrong on the first version and produced a false `auth-expired` on a call that
473
+ // had returned status 0 โ€” because the session being summarized was itself about fixing an OAuth
474
+ // bug, so the model's own summary contained the word. A working extraction was thrown away for
475
+ // discussing the wrong subject. Any session about auth, 401s or logins would have hit it.
476
+ //
477
+ // The general form of the mistake: matching a symptom string across a channel that carries both
478
+ // diagnostics AND payload. The exit status is the only trustworthy separator, so it goes first.
479
+ if (r.status === 0) return r.stdout ? null : { reason: 'no-output', detail: 'claude exited 0 with empty stdout' }
480
+
293
481
  const out = `${r.stdout ?? ''}\n${r.stderr ?? ''}`
294
482
  // โš  THE AUTH MESSAGE ARRIVES ON **STDOUT**, WITH EXIT 1 โ€” not on stderr, which is where a reader
295
483
  // would look for it. Verified against the live failure 2026-09-08:
@@ -305,9 +493,7 @@ export function classifyEdgeFailure(r) {
305
493
  + 'and no page proposals are made.',
306
494
  }
307
495
  }
308
- if (r.status !== 0) return { reason: 'exit', detail: `claude exited ${r.status}: ${firstLine(out)}` }
309
- if (!r.stdout) return { reason: 'no-output', detail: 'claude exited 0 with empty stdout' }
310
- return null
496
+ return { reason: 'exit', detail: `claude exited ${r.status}: ${firstLine(out)}` }
311
497
  }
312
498
 
313
499
  function firstLine(s) {
@@ -333,6 +519,9 @@ export function parseEdgeJson(out) {
333
519
  // model returned and no matter what the prompt asked for. Adding a prompt fragment without
334
520
  // adding it here yields a feature that ships, runs, and does nothing.
335
521
  pages: Array.isArray(o.pages) ? o.pages : [],
522
+ // ADR-0059. Unverified at this point โ€” parseEdgeJson has no transcript to check a quote
523
+ // against. `extractSession` filters these through `verbatimIn()` before returning.
524
+ obligations: Array.isArray(o.obligations) ? o.obligations : [],
336
525
  }
337
526
  } catch {
338
527
  return null
package/lib/setup.mjs CHANGED
@@ -168,6 +168,33 @@ export async function runSetup(argv, version) {
168
168
  log(` โš  skills install skipped: ${e.message} (will retry on next session)`)
169
169
  }
170
170
 
171
+ // โ”€โ”€ 3b. Stamp what installed this machine โ€” ADR-0057 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
172
+ // Nothing recorded which CLI last ran setup, and the desktop app needs exactly that to decide
173
+ // whether a seat's on-disk artifacts are stale.
174
+ //
175
+ // โš  WHY A STAMP AND NOT THE WIRED SPEC. The config holds a moving dist-tag (see pickSpec above:
176
+ // "never a version"), so it says nothing about WHEN this machine was installed or by what. The
177
+ // desktop's autoHealMcp() used to read a version out of it and, once this file started writing
178
+ // `@stable`, silently returned on every launch for three weeks โ€” the skills/hooks refresh simply
179
+ // stopped. This file is the signal that replaces it.
180
+ //
181
+ // Written AFTER the artifacts it describes (config, hooks, skills) and BEFORE the token check on
182
+ // purpose: a bad token means the connection is wrong, not that the files failed to install.
183
+ // Claiming otherwise would leave a correctly-installed machine looking permanently stale.
184
+ //
185
+ // Fail-soft: a stamp we cannot write must never break an otherwise successful setup. A missing
186
+ // stamp reads as stale downstream, so the cost of failing here is one redundant heal, not a break.
187
+ try {
188
+ const cortexDir = join(homedir(), '.cortex')
189
+ if (!existsSync(cortexDir)) mkdirSync(cortexDir, { recursive: true })
190
+ writeFileSync(
191
+ join(cortexDir, 'setup.json'),
192
+ JSON.stringify({ version, spec, at: new Date().toISOString() }, null, 2),
193
+ )
194
+ } catch (e) {
195
+ log(` โš  could not record the setup stamp: ${e.message} (harmless; the app may re-run setup once)`)
196
+ }
197
+
171
198
  // โ”€โ”€ 4. Self-verify โ€” writing config proves "files written", NOT "connection works".
172
199
  // Actually call the API so a bad/expired token is caught HERE, not 40 minutes into debugging.
173
200
  log('')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.139",
3
+ "version": "0.9.141",
4
4
  "description": "Connect your AI assistant to Cortex โ€” your org's projects, activity, gaps, and directives, scoped to you.",
5
5
  "type": "module",
6
6
  "bin": {