@theronap/cortex-mcp 0.9.56 → 0.9.58
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 +45 -1
- package/lib/server.mjs +128 -2
- package/package.json +1 -1
- package/skills/author-docs/SKILL.md +27 -6
package/lib/capture.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'fs'
|
|
2
|
-
import { spawn } from 'child_process'
|
|
2
|
+
import { spawn, execFileSync } from 'child_process'
|
|
3
3
|
import { homedir } from 'os'
|
|
4
4
|
import { resolve, dirname, join } from 'path'
|
|
5
5
|
import { fileURLToPath } from 'url'
|
|
@@ -37,6 +37,45 @@ export function projectFrom(cwd) {
|
|
|
37
37
|
|
|
38
38
|
// Claude Code Stop hook → POSTs a session digest to Cortex cloud, which
|
|
39
39
|
// summarizes server-side and upserts ONE record per session. Node-native
|
|
40
|
+
// SHR-01/T6 — parse a git remote URL into GitHub 'owner/name', or null.
|
|
41
|
+
//
|
|
42
|
+
// Handles the four remote forms git emits: scp-like ssh (git@github.com:o/n.git), ssh://, https://,
|
|
43
|
+
// git://. The HOST CHECK IS LOAD-BEARING, not cosmetic: this string becomes a clearance key, and
|
|
44
|
+
// 'owner/name' on gitlab.com or a self-hosted forge would collide in the identifier namespace with an
|
|
45
|
+
// unrelated GitHub repo of the same name — granting its members read access to each other's sessions.
|
|
46
|
+
export function githubFullName(url) {
|
|
47
|
+
if (!url) return null
|
|
48
|
+
const m = String(url).trim()
|
|
49
|
+
.match(/^(?:git\+)?(?:https?:\/\/|ssh:\/\/|git:\/\/)?(?:[^@/]+@)?github\.com[:/]+([^/]+)\/(.+?)(?:\.git)?\/?$/i)
|
|
50
|
+
if (!m) return null
|
|
51
|
+
const owner = m[1].toLowerCase()
|
|
52
|
+
const name = m[2].toLowerCase()
|
|
53
|
+
if (!owner || !name || name.includes('/')) return null
|
|
54
|
+
return `${owner}/${name}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// SHR-01/T6 — the repo this session is working in, as 'owner/name', or null.
|
|
58
|
+
//
|
|
59
|
+
// D2, FAIL CLOSED: anything that is not unambiguously a GitHub worktree — no repo, no origin remote,
|
|
60
|
+
// a non-GitHub host, git not installed — returns null, and the session is then stamped with NO
|
|
61
|
+
// identifier and stays private. There is deliberately no fallback: a brain-level or hostname-level
|
|
62
|
+
// identifier would be held by every member of the org, so overlap would ALWAYS succeed. That is an
|
|
63
|
+
// accidental org-wide grant, i.e. the default-tier flip that was explicitly declined.
|
|
64
|
+
//
|
|
65
|
+
// `git config --get` is local and does no network I/O. Bounded and swallowed regardless: capture must
|
|
66
|
+
// never break a session, and a missing identifier is a private session, not a broken one.
|
|
67
|
+
export function repoFullNameFrom(cwd) {
|
|
68
|
+
if (!cwd) return null
|
|
69
|
+
try {
|
|
70
|
+
const url = execFileSync('git', ['-C', String(cwd), 'config', '--get', 'remote.origin.url'], {
|
|
71
|
+
encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
72
|
+
})
|
|
73
|
+
return githubFullName(url)
|
|
74
|
+
} catch {
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
40
79
|
// (no bun, no repo clone). Always exits 0 — capture must never break a session.
|
|
41
80
|
|
|
42
81
|
function readStdin() {
|
|
@@ -159,6 +198,10 @@ async function captureWork(stdinRaw) {
|
|
|
159
198
|
// summarizer (which has been the silent point of failure). Fall back to shipping the transcript
|
|
160
199
|
// tail only if local extraction is unavailable (e.g. `claude` not on PATH) so we never drop a
|
|
161
200
|
// session. The server re-validates people/entities — the edge is not trusted.
|
|
201
|
+
// SHR-01/T6: the repo identifier is what lets a PEER read this session (see 0096). Omitted entirely
|
|
202
|
+
// when the cwd is not a GitHub worktree — the record still lands, it just stays private (D2).
|
|
203
|
+
const repoFullName = repoFullNameFrom(hook.cwd)
|
|
204
|
+
|
|
162
205
|
const common = {
|
|
163
206
|
source: 'claude-code',
|
|
164
207
|
project: repo,
|
|
@@ -166,6 +209,7 @@ async function captureWork(stdinRaw) {
|
|
|
166
209
|
title: `Worked in ${repo}`,
|
|
167
210
|
payload: { session_id: hook.session_id, cwd: hook.cwd },
|
|
168
211
|
captureSource: 'hook', // T8: fallback writer — never clobbers a cortex-log ('skill') record
|
|
212
|
+
...(repoFullName ? { repoFullName } : {}),
|
|
169
213
|
...(hydratedFrom.length ? { hydratedFrom } : {}),
|
|
170
214
|
}
|
|
171
215
|
const extracted = transcript ? extractSession(transcript) : null
|
package/lib/server.mjs
CHANGED
|
@@ -670,6 +670,121 @@ export async function runServer(version) {
|
|
|
670
670
|
},
|
|
671
671
|
)
|
|
672
672
|
|
|
673
|
+
server.registerTool(
|
|
674
|
+
'rename_section',
|
|
675
|
+
{
|
|
676
|
+
title: 'Rename one section heading on a wiki page',
|
|
677
|
+
description: 'Rename ONE section\'s heading on a wiki page, in place. Use this instead of re-authoring the page: `author` cannot express a rename, because a section\'s IDENTITY is its heading string — sending a new title reads as "dropped the old section, added a new one" and is rejected 409 would_drop_sections. This route changes the heading and NOTHING else: the section keeps its body, its position, and crucially its as-of date, so renaming does not reset the currency stamp on a claim nobody re-verified. It is also the ONLY way to fix a heading longer than the 80-char limit, which cannot be re-authored at all. Requires base_version (the `version` read_page prints) — that is also how it finds the right brain, so it can never rename on the wrong one. Refuses, rather than guessing, when the page exists at several tiers, when the heading repeats, or when the new heading is already taken.',
|
|
678
|
+
inputSchema: {
|
|
679
|
+
name: z.string().describe('the exact page name, as read_page shows it'),
|
|
680
|
+
from: z.string().describe('the CURRENT heading, exactly as stored (match is case- and whitespace-insensitive)'),
|
|
681
|
+
to: z.string().describe('the new heading. Max 80 chars. Must not already be used by another section on this page.'),
|
|
682
|
+
base_version: z.string().describe('the `version` read_page prints for this page (64-hex). REQUIRED — it is the concurrency check AND how the right brain is resolved. A per-SECTION hash is not valid here.'),
|
|
683
|
+
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('only when the page exists at MORE THAN ONE tier — which one to rename in. A rename never moves content between tiers.'),
|
|
684
|
+
ordinal: z.number().optional().describe('only when the same heading appears more than once on the page — which occurrence to rename (the error lists the ordinals).'),
|
|
685
|
+
reason: z.string().optional().describe('why you are renaming it — recorded in page_history like any other edit'),
|
|
686
|
+
},
|
|
687
|
+
},
|
|
688
|
+
async ({ name, from, to, base_version, tier, ordinal, reason }) => {
|
|
689
|
+
let res
|
|
690
|
+
try {
|
|
691
|
+
res = await fetchCortex(`${BASE}/api/brain/rename-section`, {
|
|
692
|
+
method: 'POST',
|
|
693
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
694
|
+
body: JSON.stringify({
|
|
695
|
+
name, from, to, base_version,
|
|
696
|
+
...(tier ? { tier } : {}), ...(ordinal !== undefined ? { ordinal } : {}),
|
|
697
|
+
...(reason ? { reason } : {}),
|
|
698
|
+
}),
|
|
699
|
+
})
|
|
700
|
+
} catch (e) {
|
|
701
|
+
return { content: [{ type: 'text', text: `Could not rename the section: ${e.message}` }] }
|
|
702
|
+
}
|
|
703
|
+
const out = await res.json().catch(() => null)
|
|
704
|
+
if (!res.ok) {
|
|
705
|
+
// Surface the server's hint AND the disambiguators it named, so a 409 is directly actionable
|
|
706
|
+
// rather than something to retry blindly.
|
|
707
|
+
const extra = [
|
|
708
|
+
out?.detail ? `existing: ${out.detail}` : '',
|
|
709
|
+
Array.isArray(out?.tiers) ? `tiers: ${out.tiers.join(', ')}` : '',
|
|
710
|
+
Array.isArray(out?.ordinals) ? `ordinals: ${out.ordinals.join(', ')}` : '',
|
|
711
|
+
out?.currentVersion ? `current version: ${out.currentVersion}` : '',
|
|
712
|
+
].filter(Boolean).join(' · ')
|
|
713
|
+
const hint = out?.hint ? `\n${out.hint}` : ''
|
|
714
|
+
return { content: [{ type: 'text', text: `Could not rename "${from}" on "${name}": ${out?.error ?? res.status}${extra ? `\n${extra}` : ''}${hint}` }] }
|
|
715
|
+
}
|
|
716
|
+
return { content: [{ type: 'text', text: `Renamed on "${name}" (${out.brain} · ${out.tier} tier): "${out.from}" -> "${out.to}". The section kept its body, position and as-of date. New version: ${out.version}` }] }
|
|
717
|
+
},
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
server.registerTool(
|
|
721
|
+
'edit_page',
|
|
722
|
+
{
|
|
723
|
+
title: 'Change one passage on a wiki page (use this, not author, to fix something)',
|
|
724
|
+
description:
|
|
725
|
+
'THE DEFAULT WAY TO CORRECT A PAGE. Replaces ONE passage inside ONE section, by quoting the exact text to replace — like editing a file, not rewriting it. Use this whenever you are fixing, updating or correcting something on an existing page; use `author` only when you are genuinely rewriting a page wholesale or creating one. WHY IT MATTERS: `author` takes the WHOLE page, so every section you did not mean to touch gets retyped by you on the way through, and drifts. Measured 2026-07-30: an edit meant for one section silently deleted a sentence from another, along with a [[link]] — a lost graph edge. Text you never send cannot be damaged. Anchors match the STORED body, so quote from what read_page shows as the section body; if a match fails on whitespace the error tells you so. An anchor matching twice is REFUSED, never guessed — quote more surrounding text. On a stale-version conflict you get the target section back so you can re-anchor without re-reading the page.',
|
|
726
|
+
inputSchema: {
|
|
727
|
+
name: z.string().describe('the exact page name, as read_page shows it'),
|
|
728
|
+
heading: z.string().describe('the heading of the section containing the text you are changing'),
|
|
729
|
+
old_string: z.string().describe('the exact text to replace. Must appear EXACTLY ONCE within that section — if it repeats, quote more surrounding text to make it unique.'),
|
|
730
|
+
new_string: z.string().describe('what to replace it with. May be empty, which deletes the anchored text.'),
|
|
731
|
+
base_version: z.string().describe('the `version` read_page prints for this page (64-hex). REQUIRED — it is the concurrency check AND how the right brain is resolved.'),
|
|
732
|
+
reason: z.string().describe('WHY you are making this change, in one short phrase — recorded in page_history exactly like an author edit.'),
|
|
733
|
+
tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('only when the page exists at MORE THAN ONE tier — which one to edit. An edit never moves content between tiers.'),
|
|
734
|
+
ordinal: z.number().optional().describe('only when the same heading appears more than once on the page — which occurrence (the error lists the ordinals).'),
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
async ({ name, heading, old_string, new_string, base_version, reason, tier, ordinal }) => {
|
|
738
|
+
let res
|
|
739
|
+
try {
|
|
740
|
+
res = await fetchCortex(`${BASE}/api/brain/edit-page`, {
|
|
741
|
+
method: 'POST',
|
|
742
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
743
|
+
body: JSON.stringify({
|
|
744
|
+
name, heading, old_string, new_string, base_version, reason,
|
|
745
|
+
...(tier ? { tier } : {}), ...(ordinal !== undefined ? { ordinal } : {}),
|
|
746
|
+
}),
|
|
747
|
+
})
|
|
748
|
+
} catch (e) {
|
|
749
|
+
return { content: [{ type: 'text', text: `Could not edit "${name}": ${e.message}` }] }
|
|
750
|
+
}
|
|
751
|
+
const out = await res.json().catch(() => null)
|
|
752
|
+
if (!res.ok) {
|
|
753
|
+
// Every failure here is meant to be directly actionable — the caller should be able to fix and
|
|
754
|
+
// retry from this text alone, without re-reading the page.
|
|
755
|
+
const bits = [
|
|
756
|
+
Array.isArray(out?.available) ? `sections on this page: ${out.available.join(' | ')}` : '',
|
|
757
|
+
Array.isArray(out?.tiers) ? `tiers: ${out.tiers.join(', ')}` : '',
|
|
758
|
+
Array.isArray(out?.ordinals) ? `ordinals: ${out.ordinals.join(', ')}` : '',
|
|
759
|
+
out?.count ? `matches: ${out.count}` : '',
|
|
760
|
+
out?.whitespaceNear === true ? 'YOUR TEXT IS PRESENT but the whitespace differs — re-copy it from the stored body' : '',
|
|
761
|
+
out?.currentVersion ? `current version: ${out.currentVersion}` : '',
|
|
762
|
+
].filter(Boolean).join('\n')
|
|
763
|
+
const cur = out?.currentSectionBody
|
|
764
|
+
? `\n\n--- the section as it stands now (re-anchor against this) ---\n${out.currentSectionBody}`
|
|
765
|
+
: ''
|
|
766
|
+
// B5: what ANOTHER session changed elsewhere on this page while you were working. Rendered
|
|
767
|
+
// INLINE, not as a pointer to page_diff — a pointer is something a busy agent skips, and the
|
|
768
|
+
// whole point is that you cannot be ignorant of it.
|
|
769
|
+
const cc = Array.isArray(out?.concurrentChanges) && out.concurrentChanges.length
|
|
770
|
+
? '\n\n--- changed elsewhere on this page since you read it (READ THIS before retrying) ---\n' +
|
|
771
|
+
out.concurrentChanges.map((c) =>
|
|
772
|
+
`§ ${c.heading}\n${(c.lines ?? []).map((l) => `${l.kind === 'add' ? '+' : '-'} ${l.line}`).join('\n')}`,
|
|
773
|
+
).join('\n\n')
|
|
774
|
+
: ''
|
|
775
|
+
const ccMeta = [
|
|
776
|
+
Array.isArray(out?.concurrentAdded) && out.concurrentAdded.length ? `sections ADDED elsewhere: ${out.concurrentAdded.join(', ')}` : '',
|
|
777
|
+
Array.isArray(out?.concurrentRemoved) && out.concurrentRemoved.length ? `sections REMOVED elsewhere: ${out.concurrentRemoved.join(', ')}` : '',
|
|
778
|
+
Array.isArray(out?.concurrentTruncated) && out.concurrentTruncated.length ? `(truncated, see page_diff for all of: ${out.concurrentTruncated.join(', ')})` : '',
|
|
779
|
+
].filter(Boolean).join('\n')
|
|
780
|
+
return { content: [{ type: 'text', text: `Could not edit "${name}": ${out?.error ?? res.status}${out?.hint ? `\n${out.hint}` : ''}${bits ? `\n${bits}` : ''}${ccMeta ? `\n${ccMeta}` : ''}${cc}${cur}` }] }
|
|
781
|
+
}
|
|
782
|
+
const red = Array.isArray(out.redLinks) && out.redLinks.length
|
|
783
|
+
? `\nRed-links now on this page: ${out.redLinks.map((r) => `[[${r}]]`).join(', ')}` : ''
|
|
784
|
+
return { content: [{ type: 'text', text: `Edited "${name}" (${out.brain} · ${out.tier} tier) § ${out.heading}. Only that passage changed; every other section is byte-identical. New version: ${out.version}${red}` }] }
|
|
785
|
+
},
|
|
786
|
+
)
|
|
787
|
+
|
|
673
788
|
server.registerTool(
|
|
674
789
|
'writing_style',
|
|
675
790
|
{
|
|
@@ -1462,9 +1577,20 @@ export async function runServer(version) {
|
|
|
1462
1577
|
const redList = Array.isArray(out?.redLinks) && out.redLinks.length ? `\nRed-links (wanted nodes): ${out.redLinks.map((r) => `[[${r}]]`).join(', ')}` : ''
|
|
1463
1578
|
const retiredList = Array.isArray(out?.retiredLinks) && out.retiredLinks.length ? `\nRetired links (not current or wanted): ${out.retiredLinks.map((r) => `[[${r}]]`).join(', ')}` : ''
|
|
1464
1579
|
const stamps = Array.isArray(out?.identifiers) && out.identifiers.length ? `\nIdentifier stamps (join keys): ${out.identifiers.map((i) => `[[${i}]]`).join(', ')}` : ''
|
|
1580
|
+
// TIER RETARGET — say it out loud. The server has always computed these (AuthorResult.tierCorrections)
|
|
1581
|
+
// and its own comment says corrections are "surfaced (not hidden)", but nothing ever printed them, so
|
|
1582
|
+
// the intent died at the client. That silence is not cosmetic: on 2026-07-29 a correction to
|
|
1583
|
+
// cortex-cross-editor-hub landed on the SCOPED tier while the accessible tier kept serving a claim
|
|
1584
|
+
// known to be false, and the response said only "Authored (1 tier)". Whoever reads this line is the
|
|
1585
|
+
// last chance to notice a page was fixed somewhere nobody reads.
|
|
1586
|
+
const corrections = Array.isArray(out?.tierCorrections) && out.tierCorrections.length
|
|
1587
|
+
? `\n⚠ TIER: this wrote to ${out.tierCorrections.map((c) => `${c.actualTier} (you asked for ${c.requestedTier})`).join('; ')}.` +
|
|
1588
|
+
` If that is not the tier you meant, read_page and check which copy you just changed —` +
|
|
1589
|
+
` a page can exist at several tiers and they drift apart independently.`
|
|
1590
|
+
: ''
|
|
1465
1591
|
const verb = out?.created ? 'Created + authored' : 'Authored'
|
|
1466
|
-
const note = out?.built ? `${verb} "${name}" (${out.built} tier${out.built === 1 ? '' : 's'}). Links: ${blue} resolved, ${retired} retired, ${red} red.${retiredList}${redList}${stamps}`
|
|
1467
|
-
: `No change to "${name}"${out?.skipped?.length ? ` (${out.skipped.join(', ')})` : ''}
|
|
1592
|
+
const note = out?.built ? `${verb} "${name}" (${out.built} tier${out.built === 1 ? '' : 's'}). Links: ${blue} resolved, ${retired} retired, ${red} red.${corrections}${retiredList}${redList}${stamps}`
|
|
1593
|
+
: `No change to "${name}"${out?.skipped?.length ? ` (${out.skipped.join(', ')})` : ''}.${corrections}`
|
|
1468
1594
|
return { content: [{ type: 'text', text: note }] }
|
|
1469
1595
|
},
|
|
1470
1596
|
)
|
package/package.json
CHANGED
|
@@ -24,8 +24,25 @@ the doc and author a synthesis. Never dump raw markdown into a page.
|
|
|
24
24
|
1. **Detect:** run `npx -y @theronap/cortex-mcp docs-scan --json`. If `pending` is empty, stop —
|
|
25
25
|
report nothing. (If no roots are registered and you just wrote docs somewhere, suggest
|
|
26
26
|
`docs-scan --add-root <dir>` to the user once; don't nag.)
|
|
27
|
-
2. **
|
|
28
|
-
|
|
27
|
+
2. **Triage each pending doc into exactly ONE of three dispositions.** Every pending doc gets one —
|
|
28
|
+
there is no fourth "deal with it later" state:
|
|
29
|
+
- **AUTHOR** it (below), then mark it.
|
|
30
|
+
- **ABSORBED** — the doc is substantive, but an existing page *already covers it as well or
|
|
31
|
+
better*. Common for build-notes and handoffs: the page kept getting updated while the doc
|
|
32
|
+
stayed frozen at its writing date. Authoring it again would duplicate, or worse, overwrite a
|
|
33
|
+
current page with a stale snapshot. **Mark it anyway** (`--mark`), and say which page absorbed
|
|
34
|
+
it. Do NOT leave it unmarked: it is genuinely handled, and leaving it pending makes every
|
|
35
|
+
future sweep re-read and re-litigate it. If the doc has one or two durable details the page
|
|
36
|
+
lacks, add just those to the page, then mark.
|
|
37
|
+
- **NON-SUBSTANTIVE** — scratch notes, generated output, throwaway logs. Leave unmarked and say
|
|
38
|
+
so, so a human can decide whether it should be registered at all.
|
|
39
|
+
|
|
40
|
+
⚠ **A doc records the state at its writing date, never the state now.** Before writing any status
|
|
41
|
+
claim into a page, verify it against the code — a build-notes doc saying "NOT built yet" is
|
|
42
|
+
evidence about the past, not the present. Copying its status forward is the single most common way
|
|
43
|
+
this skill injects a false claim into the wiki.
|
|
44
|
+
|
|
45
|
+
To author:
|
|
29
46
|
- Read the file. Decide the target node: a substantial standalone doc becomes its own
|
|
30
47
|
project-kind node named by the doc's H1 title; a small note folds into its parent project's
|
|
31
48
|
page as a section. Check the namespace first (`authoring_context`) — enrich an existing node
|
|
@@ -38,10 +55,14 @@ the doc and author a synthesis. Never dump raw markdown into a page.
|
|
|
38
55
|
page just to add a link back to a doc. Fan-in is queryable (`grep "[[hub]]"` = backlinks;
|
|
39
56
|
`read_page history:true` = the node's event ledger) — hub pages stay curated prose, and a doc
|
|
40
57
|
belongs on the hub only when a human-judged synthesis mentions it.
|
|
41
|
-
3. **Mark:**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
58
|
+
3. **Mark:** run `npx -y @theronap/cortex-mcp docs-scan --mark <path>` for every doc you AUTHORED or
|
|
59
|
+
judged ABSORBED. Never mark a doc whose `author` failed — it must stay pending for the next sweep.
|
|
60
|
+
4. **Verify before reporting.** For each doc you claim you authored, confirm the write actually landed
|
|
61
|
+
(`read_page` or `page_history`) — an `author` that returns "no change" when you intended an update
|
|
62
|
+
did NOT land. Never report a page you did not confirm.
|
|
63
|
+
5. **Report:** one short block — each doc → the page it became, the page that absorbed it, or why it
|
|
64
|
+
was left unmarked. The count of pending docs should be zero afterwards except for the
|
|
65
|
+
non-substantive ones you deliberately left.
|
|
45
66
|
|
|
46
67
|
## Safety rules
|
|
47
68
|
|