@theronap/cortex-mcp 0.9.98 → 0.9.100

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
@@ -232,7 +232,7 @@ export async function runCapture() {
232
232
  // Recursion guard: edge extraction shells out to `claude --print` with CORTEX_SUMMARIZING=1. That
233
233
  // headless session fires its own Stop hook → this same capture command. Without this guard it would
234
234
  // recurse (and re-ingest the summarizer's prompt as a phantom session). Bail immediately.
235
- if (process.env.CORTEX_SUMMARIZING) { process.stderr.write('cortex: summarizer subprocess, skipping\n'); return }
235
+ if (process.env.CORTEX_SUMMARIZING) { process.stderr.write(`cortex: ${new Date().toISOString()} (summarizer subprocess) skipping — recursion guard\n`); return }
236
236
 
237
237
  const stdinRaw = readStdin()
238
238
 
@@ -291,14 +291,29 @@ function spawnDetachedWorker(stdinRaw) {
291
291
  }
292
292
 
293
293
  async function captureWork(stdinRaw) {
294
+ // Parse the hook payload FIRST so every diagnostic below can name its session.
295
+ //
296
+ // This used to sit after the token check, which meant the `no CORTEX_TOKEN` line — the one that
297
+ // fires on a whole misconfigured machine — was the only failure that could not say which session
298
+ // it lost. Parsing stdin depends on nothing, so the old order bought nothing.
299
+ let hook = {}
300
+ try { hook = JSON.parse(stdinRaw) } catch { /* no/invalid stdin */ }
301
+
302
+ // Every line in ~/.cortex/capture.log carries an ISO timestamp and the session id, so a silent
303
+ // stretch can be reconstructed afterwards and matched against `records` and
304
+ // `page_revisions.session_key`. A line without a session id is a line you cannot act on: it tells
305
+ // you something was lost and not what. That was the state of all five early returns below until
306
+ // now — visible since the log landed, but unattributable, which is the same shape as the defect
307
+ // this whole file has been chasing.
308
+ const stamp = new Date().toISOString()
309
+ const sid = hook.session_id ?? '(no session_id)'
310
+
294
311
  // Env first (explicit override / legacy inlined hooks), else the token wired into this machine's
295
312
  // MCP config — so hook commands carry no secret (token-hygiene, 2026-07-02).
296
313
  const token = resolveTokenSource().token
297
- if (!token) { process.stderr.write('cortex: no CORTEX_TOKEN in env or wired config, skipping\n'); return }
314
+ if (!token) { process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — no CORTEX_TOKEN in env or wired config\n`); return }
298
315
  const base = resolveBase(process.env.CORTEX_URL)
299
316
 
300
- let hook = {}
301
- try { hook = JSON.parse(stdinRaw) } catch { /* no/invalid stdin */ }
302
317
  const repo = projectFrom(hook.cwd)
303
318
  // Redact credential-shaped strings BEFORE anything leaves the machine — this `transcript`
304
319
  // feeds local edge/typed extraction AND the fallback POST to the org. A secret in a
@@ -322,11 +337,11 @@ async function captureWork(stdinRaw) {
322
337
  // unreadable for a moment is picked up by the next turn, so a transient miss self-heals; only a
323
338
  // genuinely persistence-disabled session is dropped, and that session has no content to capture.
324
339
  if (hook.transcript_path && !transcriptReadable(hook.transcript_path)) {
325
- process.stderr.write('cortex: transcript_path unreadable (session persistence disabled?), skipping\n')
340
+ process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — transcript_path unreadable (session persistence disabled?)\n`)
326
341
  return
327
342
  }
328
343
 
329
- if (!transcript && !hook.session_id) { process.stderr.write('cortex: empty session, skipping\n'); return }
344
+ if (!transcript && !hook.session_id) { process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — empty session\n`); return }
330
345
 
331
346
  // T11: digest node refs surfaced to this session (stashed by the MCP server's my_context, keyed by
332
347
  // cwd). Forwarded as hydratedFrom so the materializer excludes this session from the digests it
@@ -370,7 +385,7 @@ async function captureWork(stdinRaw) {
370
385
  ...(hydratedFrom.length ? { hydratedFrom } : {}),
371
386
  }
372
387
  const extracted = transcript ? extractSession(transcript) : null
373
- if (extracted && /^NOOP\b/i.test(extracted.summary)) { process.stderr.write('cortex: no-op session, skipping\n'); return }
388
+ if (extracted && /^NOOP\b/i.test(extracted.summary)) { process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — summarizer classified it a no-op session\n`); return }
374
389
  const ingestBody = extracted
375
390
  ? { ...common, summary: extracted.summary, people: extracted.people, entities: extracted.namedEntities }
376
391
  : { ...common, transcript }
@@ -388,12 +403,6 @@ async function captureWork(stdinRaw) {
388
403
  } catch { /* best-effort — never block capture */ }
389
404
  }
390
405
 
391
- // Defined before the fetch so the throw path below can stamp its line the same way. Every line in
392
- // capture.log carries an ISO timestamp and the session id, so a silent day can be reconstructed
393
- // afterwards and matched against `records` / `page_revisions.session_key`.
394
- const stamp = new Date().toISOString()
395
- const sid = hook.session_id ?? '(no session_id)'
396
-
397
406
  let res
398
407
  try {
399
408
  // Best-effort background ingest: bound it tight and retry once so a hanging/504-ing server
@@ -105,9 +105,67 @@ const ENTITY_FRAGMENT =
105
105
  '"importance" ("high" if central to the work, "low" if incidental). ' +
106
106
  'Do NOT include people or companies (those go in "people"). Omit vague/generic mentions. Empty array is fine.)'
107
107
 
108
+ // PURE. The scheduled task this transcript is an unattended run of, or null for a human session.
109
+ // Claude Code wraps a scheduled run's instructions in <scheduled-task name="..." file="...">.
110
+ export function scheduledTaskName(transcript) {
111
+ const m = String(transcript ?? '').match(/<scheduled-task\s+[^>]*name="([^"]+)"/)
112
+ return m ? m[1] : null
113
+ }
114
+
115
+ // PURE. Drop the <scheduled-task>…</scheduled-task> blocks — they are the task's OWN INSTRUCTIONS,
116
+ // not anything the run observed, and Claude Code emits them TWICE per transcript. Measured
117
+ // 2026-08-19 on real units: ~2.1KB of instructions repeated, so ~4.2KB of the summarizer's 12KB
118
+ // window went to text describing what the agent was told to do before a single line of what it
119
+ // found. Stripping them spends the budget on the run's actual output.
120
+ export function stripScheduledTaskBlocks(transcript) {
121
+ return String(transcript ?? '').replace(/<scheduled-task\b[\s\S]*?<\/scheduled-task>/g, '').trim()
122
+ }
123
+
124
+ // The NOOP bar for an unattended run. The generic bar ("greetings, no tasks") does not fit these and
125
+ // was never meant to: a scheduled run DID do something — it read a file, evaluated a condition,
126
+ // maybe sent a notification — so a summarizer following the generic instruction correctly writes
127
+ // "Automated scripture study check ran; no confirmation for 2026-08-14; reminder sent." That is a
128
+ // faithful summary of a heartbeat, and it is why 36% of session records since 2026-08-01 are
129
+ // automation reporting its own execution. The summarizer was not malfunctioning; it was answering
130
+ // the question it was asked.
131
+ //
132
+ // The distinction that matters is not attended-vs-unattended, it is CHANGED-vs-UNCHANGED. A routine
133
+ // run that found the expected state is a heartbeat. The SAME task finding the domain finally
134
+ // reactivated, or the backlog crossing a threshold, is exactly the thing the knowledge base exists
135
+ // to hold — so this deliberately does not blanket-skip scheduled tasks, which would throw away the
136
+ // one run in a hundred that matters. There are 12 tasks configured and one of them alone fires up to
137
+ // 11 times a day.
138
+ // ⚠ THE WORDING HERE IS LOAD-BEARING AND WAS WRONG ON THE FIRST ATTEMPT. Verified 2026-08-19 against
139
+ // two real transcripts: a first draft ended "when it is genuinely borderline, prefer NOOP" and
140
+ // correctly suppressed the heartbeat — but ALSO suppressed a run of the same task that had flagged a
141
+ // live read path into the frozen Robin archive, a genuine finding. Every unit test still passed,
142
+ // because they cover the stripping, not the judgement. The prompt was killing signal with the noise.
143
+ //
144
+ // Two fixes: the borderline tilt is gone (it resolved every mixed case toward silence), and an
145
+ // incidental observation now counts explicitly — these findings arrive as an aside at the END of an
146
+ // otherwise routine report, which is exactly where a "was this routine?" reading drops them.
147
+ const scheduledTaskFragment = (name) =>
148
+ '\n\nIMPORTANT — this session is an UNATTENDED run of the scheduled task "' + name + '". ' +
149
+ 'Its instructions have been removed; what remains is only what the run reported. ' +
150
+ 'Judge what it FOUND and what it CHANGED — never merely that it executed. ' +
151
+ 'If the run only confirmed the state it expected and changed nothing, set summary to exactly ' +
152
+ '"NOOP": a routine run that found nothing new is a heartbeat, not knowledge, and these fire many ' +
153
+ 'times a day. ' +
154
+ 'BUT summarize whenever the run found something changed, hit an error, took an action with a ' +
155
+ 'lasting effect, OR raised a problem, risk or observation the operator would not already know — ' +
156
+ 'INCLUDING one mentioned only in passing at the very end of an otherwise routine report. ' +
157
+ 'One genuine finding outweighs an otherwise unremarkable run; summarize the FINDING, not the run.'
158
+
108
159
  export function extractSession(transcript) {
109
- const text = (transcript ?? '').trim()
110
- if (!text || process.env.CORTEX_SUMMARIZE_DISABLED) return null
160
+ const raw = (transcript ?? '').trim()
161
+ if (!raw || process.env.CORTEX_SUMMARIZE_DISABLED) return null
162
+ const task = scheduledTaskName(raw)
163
+ // Strip instructions only for scheduled runs; a human transcript has no such blocks and is passed
164
+ // through untouched, so this cannot change how ordinary sessions are summarized.
165
+ const text = task ? stripScheduledTaskBlocks(raw) : raw
166
+ // A run whose entire transcript WAS the instruction block leaves nothing to judge. That is a
167
+ // heartbeat by definition — skip without spending a `claude -p` call on it.
168
+ if (task && !text) return { summary: 'NOOP', people: [], namedEntities: [] }
111
169
  const prompt =
112
170
  'You are processing a Claude Code work session for a knowledge base. Return ONLY minified JSON ' +
113
171
  '(no prose, no markdown fences) with EXACTLY these three keys:\n' +
@@ -115,6 +173,7 @@ export function extractSession(transcript) {
115
173
  'had no real work — greetings, no tasks — set summary to exactly "NOOP"),\n' +
116
174
  PEOPLE_FRAGMENT + ',\n' +
117
175
  ENTITY_FRAGMENT +
176
+ (task ? scheduledTaskFragment(task) : '') +
118
177
  '\n\n--- SESSION ---\n' + text.slice(0, 12000) + '\n--- END ---'
119
178
  // Single-flight: if another session already has a summarizer running, skip (caller ships the tail;
120
179
  // server summarizes). Prevents the concurrent-`claude -p` stampede that deadlocks the OAuth refresh.
package/lib/skills.mjs CHANGED
@@ -131,6 +131,52 @@ function installInto(skillsRoot, skills, { removeNames = [] } = {}) {
131
131
  * @param {{ quiet?: boolean }} opts quiet → only emit on actual change (for the SessionStart hook)
132
132
  * @returns {{ installed: string[], repaired: string[], unchanged: string[], targets: string[] }}
133
133
  */
134
+ // Skills renamed in C3 (ADR-0033). The old name keeps working for one release via a forwarding
135
+ // stub, then both the map and the stubs are deleted.
136
+ //
137
+ // The stub is a REAL skill that delegates, not a message that the command moved. A stub which only
138
+ // announces the rename breaks every caller that invokes the old name from automation — and this repo
139
+ // has a rule about surfaces that report something other than what happened. The model reads the stub,
140
+ // follows the pointer, and does the actual work, so `/cortex-log` behaves exactly as it always did.
141
+ export const RENAMED_SKILLS = {
142
+ 'cortex-log': 'agnoclast-log',
143
+ 'cortex-context': 'agnoclast-context',
144
+ 'cortex-author-docs': 'agnoclast-author-docs',
145
+ 'cortex-walkthrough': 'agnoclast-walkthrough',
146
+ }
147
+
148
+ /** PURE. The forwarding stub's SKILL.md body. */
149
+ export function forwardingStub(oldName, newName) {
150
+ return `---
151
+ name: ${oldName}
152
+ description: Renamed to /${newName}. This forwarding stub keeps the old command working and will be removed in a later release.
153
+ ---
154
+
155
+ This skill is now **${newName}**.
156
+
157
+ Read \`~/.claude/skills/${newName}/SKILL.md\` and follow it in full. Everything it says applies
158
+ here unchanged — this file exists only so \`/${oldName}\` keeps working for automation and habit
159
+ that still names the old command. Do the work the real skill describes; do not stop at this notice.
160
+ `
161
+ }
162
+
163
+ /** Write a forwarding stub for every renamed skill whose new form is present. Best-effort: a stub
164
+ * that cannot be written is a lost alias, never a failed install. */
165
+ function installForwardingStubs(skillsRoot, log) {
166
+ for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
167
+ try {
168
+ if (!existsSync(join(skillsRoot, newName, 'SKILL.md'))) continue
169
+ const dir = join(skillsRoot, oldName)
170
+ const file = join(dir, 'SKILL.md')
171
+ const body = forwardingStub(oldName, newName)
172
+ if (existsSync(file) && readFileSync(file, 'utf8') === body) continue
173
+ ensureDir(file)
174
+ writeFileSync(file, body)
175
+ log(` ✓ ${oldName} → forwards to ${newName}`)
176
+ } catch { /* an alias is a convenience, never a reason to fail the install */ }
177
+ }
178
+ }
179
+
134
180
  export function installSkills(opts = {}) {
135
181
  const quiet = !!opts.quiet
136
182
  const log = (m) => { if (!quiet) process.stdout.write(m + '\n') }
@@ -151,6 +197,7 @@ export function installSkills(opts = {}) {
151
197
  summary.unchanged.push(...r.unchanged)
152
198
  const changed = [...r.installed, ...r.repaired]
153
199
  if (changed.length) log(` ✓ ${cli.id}: ${changed.join(', ')} → ${skillsRoot}`)
200
+ installForwardingStubs(skillsRoot, log)
154
201
  }
155
202
 
156
203
  if (quiet && (summary.installed.length || summary.repaired.length)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.98",
3
+ "version": "0.9.100",
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": {
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: cortex-author-docs
2
+ name: agnoclast-author-docs
3
3
  description: Push new/changed documentation (specs, plans, design docs) from disk into Agnoclast as authored wiki pages. Run after writing a spec/plan/design doc, when the user asks to sync docs to Agnoclast, or as part of session close-out.
4
4
  ---
5
5
 
@@ -17,7 +17,7 @@ the doc and author a synthesis. Never dump raw markdown into a page.
17
17
 
18
18
  - Right after you write or substantially update a spec/plan/design/runbook doc on disk.
19
19
  - When the user asks to push/sync docs to Agnoclast.
20
- - During session close-out (`/cortex-log` runs this as a sweep step).
20
+ - During session close-out (`/agnoclast-log` runs this as a sweep step).
21
21
 
22
22
  ## Steps
23
23
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: cortex-context
2
+ name: agnoclast-context
3
3
  description: Automatically hydrate Agnoclast context at the start of a substantive session. Use when Agnoclast MCP is available and the user has made a real request, so the first answer is grounded in query-centered org context instead of the static baseline alone.
4
4
  ---
5
5
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: cortex-log
2
+ name: agnoclast-log
3
3
  description: Close out a work session into Agnoclast — summarize what happened, confirm it reached the org, and surface anything teammates should know. Run at or near the end of any working session.
4
4
  ---
5
5
 
@@ -62,7 +62,7 @@ No arguments. Read the conversation context.
62
62
  already authored a node mid-session and nothing changed since, `author` will report "no change" —
63
63
  that's fine.
64
64
  6. **Sweep pending documentation** — run `npx -y @theronap/cortex-mcp docs-scan --json`; if any
65
- docs are pending, follow the `cortex-author-docs` skill (author each into its page, then
65
+ docs are pending, follow the `agnoclast-author-docs` skill (author each into its page, then
66
66
  `docs-scan --mark`). Specs/plans written to disk this session must not die on disk — a spec IS
67
67
  a page. If no roots are registered or nothing is pending, skip silently.
68
68
  7. **Reconcile the sweep (don't trust it).** Step 5 relies on your in-the-moment judgment of "what
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: cortex-walkthrough
2
+ name: agnoclast-walkthrough
3
3
  description: Run the guided Agnoclast walkthrough for someone new. Use when the person asks for the walkthrough, a tutorial, or getting started — "give me the walkthrough", "walk me through this", "how do I use this", "show me around", "what can this do", "remind me how this works" — or when a brand-new user needs orienting for the first time.
4
4
  ---
5
5