projectstore-codex 0.0.1 → 0.28.2
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/.codex-plugin/plugin.json +48 -0
- package/README.md +15 -7
- package/bin/projectstore-codex.mjs +88 -0
- package/hooks/hooks.json +59 -0
- package/node_modules/projectstore/.claude-plugin/marketplace.json +40 -0
- package/node_modules/projectstore/.claude-plugin/plugin.json +23 -0
- package/node_modules/projectstore/.mcp.json +14 -0
- package/node_modules/projectstore/AGENTS.md +26 -0
- package/node_modules/projectstore/LICENSE +21 -0
- package/node_modules/projectstore/README.md +284 -0
- package/node_modules/projectstore/agents/archaeologist.md +76 -0
- package/node_modules/projectstore/agents/clerk.md +93 -0
- package/node_modules/projectstore/agents/critic.md +94 -0
- package/node_modules/projectstore/agents/librarian.md +81 -0
- package/node_modules/projectstore/agents/planner.md +80 -0
- package/node_modules/projectstore/agents/reviewer.md +98 -0
- package/node_modules/projectstore/bin/projectstore.mjs +7 -0
- package/node_modules/projectstore/commands/adr.md +57 -0
- package/node_modules/projectstore/commands/agents.md +180 -0
- package/node_modules/projectstore/commands/bind.md +128 -0
- package/node_modules/projectstore/commands/codemap.md +50 -0
- package/node_modules/projectstore/commands/concept.md +17 -0
- package/node_modules/projectstore/commands/doctor.md +166 -0
- package/node_modules/projectstore/commands/epic.md +40 -0
- package/node_modules/projectstore/commands/graph.md +56 -0
- package/node_modules/projectstore/commands/kanban.md +40 -0
- package/node_modules/projectstore/commands/meeting.md +17 -0
- package/node_modules/projectstore/commands/reconcile.md +73 -0
- package/node_modules/projectstore/commands/research.md +17 -0
- package/node_modules/projectstore/commands/review.md +89 -0
- package/node_modules/projectstore/commands/runbook.md +17 -0
- package/node_modules/projectstore/commands/scaffold.md +23 -0
- package/node_modules/projectstore/commands/search.md +22 -0
- package/node_modules/projectstore/commands/spec.md +91 -0
- package/node_modules/projectstore/commands/status.md +27 -0
- package/node_modules/projectstore/commands/statusline.md +46 -0
- package/node_modules/projectstore/commands/story.md +113 -0
- package/node_modules/projectstore/docs/extending.md +172 -0
- package/node_modules/projectstore/docs/getting-started.md +133 -0
- package/node_modules/projectstore/docs/harnesses.md +176 -0
- package/node_modules/projectstore/docs/how-it-works.md +263 -0
- package/node_modules/projectstore/docs/images/loop-light.svg +94 -0
- package/node_modules/projectstore/docs/images/loop.svg +93 -0
- package/node_modules/projectstore/docs/images/statusline-hud.png +0 -0
- package/node_modules/projectstore/docs/images/team-light.svg +79 -0
- package/node_modules/projectstore/docs/images/team.svg +79 -0
- package/node_modules/projectstore/harnesses/claude-code.json +483 -0
- package/node_modules/projectstore/harnesses/codex.json +332 -0
- package/node_modules/projectstore/hooks/hooks.json +59 -0
- package/node_modules/projectstore/hooks/pre-compact.mjs +121 -0
- package/node_modules/projectstore/hooks/session-rules.mjs +63 -0
- package/node_modules/projectstore/hooks/session-start.mjs +301 -0
- package/node_modules/projectstore/hooks/session-stop.mjs +84 -0
- package/node_modules/projectstore/package.json +70 -0
- package/node_modules/projectstore/scaffold/checklists.json +88 -0
- package/node_modules/projectstore/scaffold/headings.json +171 -0
- package/node_modules/projectstore/scaffold/layouts/engineering.json +85 -0
- package/node_modules/projectstore/scripts/binding.mjs +165 -0
- package/node_modules/projectstore/scripts/build-adapters.mjs +264 -0
- package/node_modules/projectstore/scripts/cli.mjs +595 -0
- package/node_modules/projectstore/scripts/codemap.mjs +99 -0
- package/node_modules/projectstore/scripts/diff-refs.mjs +127 -0
- package/node_modules/projectstore/scripts/doctor.mjs +2127 -0
- package/node_modules/projectstore/scripts/draft.mjs +261 -0
- package/node_modules/projectstore/scripts/graph.mjs +219 -0
- package/node_modules/projectstore/scripts/harness.mjs +608 -0
- package/node_modules/projectstore/scripts/install-harness.mjs +1387 -0
- package/node_modules/projectstore/scripts/kanban.mjs +174 -0
- package/node_modules/projectstore/scripts/lib.mjs +3085 -0
- package/node_modules/projectstore/scripts/mcp.mjs +391 -0
- package/node_modules/projectstore/scripts/portable-registration.mjs +198 -0
- package/node_modules/projectstore/scripts/provenance.mjs +375 -0
- package/node_modules/projectstore/scripts/query.mjs +490 -0
- package/node_modules/projectstore/scripts/reconcile.mjs +422 -0
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +141 -0
- package/node_modules/projectstore/scripts/statusline.mjs +253 -0
- package/node_modules/projectstore/scripts/story-section.mjs +209 -0
- package/node_modules/projectstore/scripts/surfaces.mjs +421 -0
- package/node_modules/projectstore/scripts/tokens.mjs +449 -0
- package/node_modules/projectstore/scripts/touch-session.mjs +336 -0
- package/node_modules/projectstore/scripts/version-guard.mjs +261 -0
- package/node_modules/projectstore/scripts/worktree.mjs +109 -0
- package/node_modules/projectstore/skills/projectstore-decision-detector/SKILL.md +40 -0
- package/node_modules/projectstore/skills/projectstore-peer-reviewer/SKILL.md +38 -0
- package/node_modules/projectstore/skills/projectstore-story-completion/SKILL.md +50 -0
- package/node_modules/projectstore/skills/projectstore-vault-communication/SKILL.md +96 -0
- package/node_modules/projectstore/templates/claude-md-block.md.tmpl +26 -0
- package/node_modules/projectstore/templates/de/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/de/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/de/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/de/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/de/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/de/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/de/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/de/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/de/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/de/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/de/strings.json +6 -0
- package/node_modules/projectstore/templates/en/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/en/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/en/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/en/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/en/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/en/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/en/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/en/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/en/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/en/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/en/strings.json +6 -0
- package/node_modules/projectstore/templates/es/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/es/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/es/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/es/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/es/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/es/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/es/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/es/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/es/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/es/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/es/strings.json +6 -0
- package/node_modules/projectstore/templates/fr/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/fr/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/fr/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/fr/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/fr/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/fr/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/fr/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/fr/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/fr/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/fr/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/fr/strings.json +6 -0
- package/node_modules/projectstore/templates/ru/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/ru/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/ru/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/ru/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/ru/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/ru/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/ru/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/ru/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/ru/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/ru/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/ru/strings.json +6 -0
- package/node_modules/projectstore/templates/zh/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/zh/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/zh/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/zh/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/zh/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/zh/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/zh/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/zh/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/zh/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/zh/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/zh/strings.json +6 -0
- package/package.json +35 -14
- package/skills/projectstore-adr/SKILL.md +76 -0
- package/skills/projectstore-agents/SKILL.md +50 -0
- package/skills/projectstore-archaeologist/SKILL.md +109 -0
- package/skills/projectstore-bind/SKILL.md +44 -0
- package/skills/projectstore-clerk/SKILL.md +126 -0
- package/skills/projectstore-codemap/SKILL.md +69 -0
- package/skills/projectstore-concept/SKILL.md +36 -0
- package/skills/projectstore-critic/SKILL.md +127 -0
- package/skills/projectstore-decision-detector/SKILL.md +59 -0
- package/skills/projectstore-doctor/SKILL.md +33 -0
- package/skills/projectstore-epic/SKILL.md +59 -0
- package/skills/projectstore-graph/SKILL.md +75 -0
- package/skills/projectstore-kanban/SKILL.md +60 -0
- package/skills/projectstore-librarian/SKILL.md +114 -0
- package/skills/projectstore-meeting/SKILL.md +36 -0
- package/skills/projectstore-peer-reviewer/SKILL.md +57 -0
- package/skills/projectstore-planner/SKILL.md +113 -0
- package/skills/projectstore-reconcile/SKILL.md +92 -0
- package/skills/projectstore-research/SKILL.md +36 -0
- package/skills/projectstore-review/SKILL.md +108 -0
- package/skills/projectstore-reviewer/SKILL.md +131 -0
- package/skills/projectstore-runbook/SKILL.md +36 -0
- package/skills/projectstore-scaffold/SKILL.md +42 -0
- package/skills/projectstore-search/SKILL.md +41 -0
- package/skills/projectstore-spec/SKILL.md +110 -0
- package/skills/projectstore-status/SKILL.md +47 -0
- package/skills/projectstore-statusline/SKILL.md +29 -0
- package/skills/projectstore-story/SKILL.md +132 -0
- package/skills/projectstore-story-completion/SKILL.md +69 -0
- package/skills/projectstore-vault-communication/SKILL.md +115 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// projectstore — mcp.mjs
|
|
2
|
+
//
|
|
3
|
+
// The MCP read surface: a hand-rolled JSON-RPC server over stdio, one tool
|
|
4
|
+
// per CLI verb (MCP ADR decisions 2, 3, 4, 6). A front-end over the
|
|
5
|
+
// front-end: every tool call runs the CLI's own `run()` in-process with
|
|
6
|
+
// captured streams and returns the envelope it wrote, so a tool result equals
|
|
7
|
+
// `projectstore <verb> --json` by construction, `doctor` stays spawned, and
|
|
8
|
+
// the exit code maps onto `isError`. Nothing here reads an artifact's
|
|
9
|
+
// frontmatter, walks the vault or names a branded environment variable —
|
|
10
|
+
// cli.mjs, query.mjs and harness.mjs do, once each; the resources read the
|
|
11
|
+
// three derived views and one artifact file whole, which is what a resource is.
|
|
12
|
+
//
|
|
13
|
+
// stdout is the protocol channel. This module writes to it only through
|
|
14
|
+
// serve()'s frame writer; importing it prints nothing (a test asserts it),
|
|
15
|
+
// and every module it reaches guards its main() with lib.isMain.
|
|
16
|
+
//
|
|
17
|
+
// Protocol era: the LEGACY `initialize` handshake — revisions 2025-11-25
|
|
18
|
+
// and 2025-06-18 accepted — which is what Claude Code 2.1.261
|
|
19
|
+
// speaks (measured 2026-09-05: `initialize` with protocolVersion 2025-11-25
|
|
20
|
+
// and request id 0; `tools/list` and `resources/list` at connect;
|
|
21
|
+
// `resources/templates/list` on demand; shutdown by SIGINT). The 2026-07-28
|
|
22
|
+
// revision's `server/discover` is answered with -32601 ON PURPOSE: that is
|
|
23
|
+
// the documented signal that makes a dual-era client fall back to
|
|
24
|
+
// `initialize`. Moving eras is an ADR amendment, not a refactor.
|
|
25
|
+
//
|
|
26
|
+
// Binding (ADR decision 6): the project comes from --project,
|
|
27
|
+
// PROJECTSTORE_PROJECT_DIR or the harness-declared project directory —
|
|
28
|
+
// never the ambient cwd. Started with none of them, or with a placeholder the
|
|
29
|
+
// host did not expand (`${…}` arrives literally when unset), the server
|
|
30
|
+
// exits non-zero naming what it needed. An UNBOUND project is different: the
|
|
31
|
+
// server starts, `status` says bound:false, and every other tool answers
|
|
32
|
+
// isError naming /projectstore:bind — refusing to start would put a red
|
|
33
|
+
// entry in the host's plugin errors for every new user.
|
|
34
|
+
//
|
|
35
|
+
// Normative: the MCP ADR (accepted 2026-09-04, amended 2026-09-05); the
|
|
36
|
+
// PS-CORE story "MCP read surface: hand-rolled stdio, one tool per CLI verb".
|
|
37
|
+
|
|
38
|
+
import { existsSync, readFileSync, statSync, realpathSync } from "node:fs";
|
|
39
|
+
import { join, resolve, dirname } from "node:path";
|
|
40
|
+
import { fileURLToPath } from "node:url";
|
|
41
|
+
import { createInterface } from "node:readline";
|
|
42
|
+
import { spawn } from "node:child_process";
|
|
43
|
+
import { run, envelope, packageVersion, VERBS } from "./cli.mjs";
|
|
44
|
+
|
|
45
|
+
const BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "projectstore.mjs");
|
|
46
|
+
|
|
47
|
+
// Exit codes → isError. 0 is ok; 1 is "findings or a refusal" — a doctor that
|
|
48
|
+
// found issues has SUCCEEDED, and the envelope carries ok:false already; 2
|
|
49
|
+
// (usage) and 3 (not bound) are the tool failing to answer at all.
|
|
50
|
+
export const isErrorExit = (code) => code !== 0 && code !== 1;
|
|
51
|
+
|
|
52
|
+
// doctor is the one verb the CLI spawns synchronously, which would hold this
|
|
53
|
+
// server's loop for up to a minute — no ping answered, no other call served.
|
|
54
|
+
// It runs as an asynchronous spawn of this same bin instead; stdout is still
|
|
55
|
+
// the CLI's envelope, so parity holds.
|
|
56
|
+
function spawnBin(argv, { env, cwd, timeoutMs = DOCTOR_TIMEOUT_MS, onChild = null }) {
|
|
57
|
+
return new Promise((done) => {
|
|
58
|
+
const child = spawn(process.execPath, [BIN, ...argv], { env, cwd: existsSync(cwd) ? cwd : undefined, stdio: ["ignore", "pipe", "pipe"] });
|
|
59
|
+
if (onChild) onChild(child);
|
|
60
|
+
let out = "", err = "";
|
|
61
|
+
child.stdout.on("data", (d) => { out += d; });
|
|
62
|
+
child.stderr.on("data", (d) => { err += d; });
|
|
63
|
+
const timer = setTimeout(() => { child.kill("SIGKILL"); err += `timed out after ${timeoutMs} ms`; }, timeoutMs);
|
|
64
|
+
child.on("error", (e) => { clearTimeout(timer); done({ code: 2, out, err: err + e.message }); });
|
|
65
|
+
child.on("close", (code) => { clearTimeout(timer); done({ code: code === null ? 2 : code, out, err }); });
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
import { readConfigAt, isInsideVault } from "./lib.mjs";
|
|
69
|
+
|
|
70
|
+
// Two revisions, not four: 2025-03-26 has JSON-RPC batching (removed again in
|
|
71
|
+
// 2025-06-18), which this server answers with -32600 — advertising that
|
|
72
|
+
// revision would be a lie the conformance test could not see.
|
|
73
|
+
export const PROTOCOL_VERSIONS = Object.freeze(["2025-11-25", "2025-06-18"]);
|
|
74
|
+
export const DOCTOR_TIMEOUT_MS = 60000;
|
|
75
|
+
export const SERVER_NAME = "projectstore";
|
|
76
|
+
export const TOOL_CEILING = 8;
|
|
77
|
+
export const TOOL_LIST_TOKEN_CEILING = 3000; // ADR decision 7, chars/4
|
|
78
|
+
export const RESOURCE_SCHEME = "projectstore://";
|
|
79
|
+
|
|
80
|
+
// JSON-RPC error codes.
|
|
81
|
+
export const E_PARSE = -32700;
|
|
82
|
+
export const E_INVALID_REQUEST = -32600;
|
|
83
|
+
export const E_METHOD_NOT_FOUND = -32601;
|
|
84
|
+
export const E_INVALID_PARAMS = -32602;
|
|
85
|
+
export const E_INTERNAL = -32603;
|
|
86
|
+
|
|
87
|
+
// ─── The tool table ────────────────────────────────────────────────────
|
|
88
|
+
//
|
|
89
|
+
// One row per MCP tool: the verb it is the JSON form of, how its arguments
|
|
90
|
+
// become argv, and the input schema a client sees. cli.mjs's VERBS rows name
|
|
91
|
+
// these tools in their `mcp` field; a drift test pins the two tables to each
|
|
92
|
+
// other in both directions.
|
|
93
|
+
|
|
94
|
+
const str = (description) => ({ type: "string", description });
|
|
95
|
+
const bool = (description) => ({ type: "boolean", description });
|
|
96
|
+
const int = (description, minimum = 0) => ({ type: "integer", minimum, description });
|
|
97
|
+
const strs = (description) => ({ type: "array", items: { type: "string" }, description });
|
|
98
|
+
const schema = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
|
|
99
|
+
const flag = (name, v) => (v ? [`--${name}`] : []);
|
|
100
|
+
const opt = (name, v) => (v === undefined || v === null ? [] : [`--${name}`, String(v)]);
|
|
101
|
+
const many = (name, vs) => (Array.isArray(vs) ? vs.flatMap((v) => [`--${name}`, String(v)]) : []);
|
|
102
|
+
// argv() returns {opts, positionals}: the CLI receives
|
|
103
|
+
// [verb, ...opts, --json, --project, <dir>, --, ...positionals], so a value
|
|
104
|
+
// beginning with "-" (a search for "--kind", a path named "-x") is a
|
|
105
|
+
// positional, never an option the parser would act on.
|
|
106
|
+
const READ_ONLY = Object.freeze({ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false });
|
|
107
|
+
|
|
108
|
+
export const TOOLS = Object.freeze({
|
|
109
|
+
status: {
|
|
110
|
+
verb: "status",
|
|
111
|
+
description: "The project's binding, the stories in progress, and whether the derived views are fresh.",
|
|
112
|
+
inputSchema: schema({}),
|
|
113
|
+
argv: () => ({ opts: [], positionals: [] }),
|
|
114
|
+
},
|
|
115
|
+
orientation: {
|
|
116
|
+
verb: "orientation",
|
|
117
|
+
description: "The vault's navigation skeleton (the SessionStart orientation) and the facts behind it.",
|
|
118
|
+
inputSchema: schema({}),
|
|
119
|
+
argv: () => ({ opts: [], positionals: [] }),
|
|
120
|
+
},
|
|
121
|
+
search: {
|
|
122
|
+
verb: "search",
|
|
123
|
+
description: "Find a phrase in the vault's artifacts: literal, case-insensitive by default, bounded; matches carry path, line, snippet, type, title, status.",
|
|
124
|
+
inputSchema: schema({
|
|
125
|
+
query: str("the phrase to find (literal substring)"),
|
|
126
|
+
kind: strs("only artifacts of these kinds (adr, spec, epic, story, research, …)"),
|
|
127
|
+
status: str("only artifacts in this status"),
|
|
128
|
+
limit: int("at most this many matches (default 20, hard cap 100)", 1),
|
|
129
|
+
include_derived: bool("search the derived views (kanban, code-map, graph) too"),
|
|
130
|
+
case_sensitive: bool("match case"),
|
|
131
|
+
}, ["query"]),
|
|
132
|
+
argv: (a) => ({ opts: [...many("kind", a.kind), ...opt("status", a.status), ...opt("limit", a.limit), ...flag("include-derived", a.include_derived), ...flag("case-sensitive", a.case_sensitive)], positionals: [a.query] }),
|
|
133
|
+
},
|
|
134
|
+
get_artifact: {
|
|
135
|
+
verb: "show",
|
|
136
|
+
description: "One artifact by vault-relative path: its frontmatter, and its body or one section on request.",
|
|
137
|
+
inputSchema: schema({
|
|
138
|
+
path: str("vault-relative path of the artifact"),
|
|
139
|
+
body: bool("include the body"),
|
|
140
|
+
section: str("one section by its registry id (description, acceptance, …)"),
|
|
141
|
+
}, ["path"]),
|
|
142
|
+
argv: (a) => ({ opts: [...flag("body", a.body), ...opt("section", a.section)], positionals: [a.path] }),
|
|
143
|
+
},
|
|
144
|
+
neighbors: {
|
|
145
|
+
verb: "graph",
|
|
146
|
+
description: "The typed links of one artifact in both directions, from the live link graph (the same edges graph.md holds).",
|
|
147
|
+
inputSchema: schema({
|
|
148
|
+
path: str("vault-relative path of the artifact"),
|
|
149
|
+
kind: strs("only edges of these kinds (wikilink, dead, supersedes, spec-covers, spec-implements-adr, epic-contains, …)"),
|
|
150
|
+
direction: { type: "string", enum: ["in", "out", "both"], description: "which edges (default both)" },
|
|
151
|
+
limit: int("cap per direction (default and maximum 100)", 1),
|
|
152
|
+
}, ["path"]),
|
|
153
|
+
argv: (a) => ({ opts: [...many("kind", a.kind), ...opt("direction", a.direction), ...opt("limit", a.limit)], positionals: ["neighbors", a.path] }),
|
|
154
|
+
},
|
|
155
|
+
lineage: {
|
|
156
|
+
verb: "graph",
|
|
157
|
+
description: "The typed ancestry and descent of one artifact: supersedes, spec-covers, spec-implements-adr, epic-contains, breadth-first to a depth (default 3).",
|
|
158
|
+
inputSchema: schema({
|
|
159
|
+
path: str("vault-relative path of the artifact"),
|
|
160
|
+
kind: strs("only these lineage kinds (a subset of the four)"),
|
|
161
|
+
depth: int("how far to walk (default 3, maximum 10)"),
|
|
162
|
+
}, ["path"]),
|
|
163
|
+
argv: (a) => ({ opts: [...many("kind", a.kind), ...opt("depth", a.depth)], positionals: ["lineage", a.path] }),
|
|
164
|
+
},
|
|
165
|
+
code_refs: {
|
|
166
|
+
verb: "codemap",
|
|
167
|
+
description: "Which code an epic or artifact maps to (its code_refs), or which artifacts map to a repository path; the result says which reading was taken.",
|
|
168
|
+
inputSchema: schema({
|
|
169
|
+
selector: str("an epic id, an artifact identity or vault path, or a repository-relative path"),
|
|
170
|
+
reverse: bool("read the selector as a path even if it names an artifact"),
|
|
171
|
+
}, ["selector"]),
|
|
172
|
+
argv: (a) => ({ opts: [...opt("for", a.selector), ...flag("reverse", a.reverse)], positionals: [] }),
|
|
173
|
+
},
|
|
174
|
+
doctor: {
|
|
175
|
+
verb: "doctor",
|
|
176
|
+
description: "Check the install wiring and the vault's consistency: findings with severity, id and remedy. The slow tool (it spawns the doctor script).",
|
|
177
|
+
inputSchema: schema({
|
|
178
|
+
install: bool("only the install section"),
|
|
179
|
+
vault: bool("only the vault section"),
|
|
180
|
+
}),
|
|
181
|
+
argv: (a) => ({ opts: [...flag("install", a.install), ...flag("vault", a.vault)], positionals: [] }),
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
export function toolList() {
|
|
186
|
+
return Object.entries(TOOLS).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema, annotations: { ...READ_ONLY } }));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── Resources ─────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
export const RESOURCES = Object.freeze([
|
|
192
|
+
{ uri: `${RESOURCE_SCHEME}orientation`, name: "orientation", description: "The vault's navigation skeleton, as SessionStart renders it.", mimeType: "text/markdown" },
|
|
193
|
+
{ uri: `${RESOURCE_SCHEME}kanban`, name: "kanban", description: "The derived board (kanban.md).", mimeType: "text/markdown", file: "kanban.md" },
|
|
194
|
+
{ uri: `${RESOURCE_SCHEME}graph`, name: "graph", description: "The derived link graph (graph.md) — large; grep it by vault-relative path.", mimeType: "text/markdown", file: "graph.md" },
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
export const RESOURCE_TEMPLATES = Object.freeze([
|
|
198
|
+
{ uriTemplate: `${RESOURCE_SCHEME}artifact/{path}`, name: "artifact", description: "One artifact's markdown by vault-relative path (percent-encoded).", mimeType: "text/markdown" },
|
|
199
|
+
]);
|
|
200
|
+
|
|
201
|
+
// ─── The server ────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
class Sink {
|
|
204
|
+
constructor() { this.text = ""; }
|
|
205
|
+
write(s) { this.text += String(s); return true; }
|
|
206
|
+
get isTTY() { return false; }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const rpcError = (id, code, message, data) => ({ jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } });
|
|
210
|
+
const rpcResult = (id, result) => ({ jsonrpc: "2.0", id, result });
|
|
211
|
+
const hasId = (m) => m && typeof m === "object" && "id" in m && m.id !== null; // 0 is a valid id — never `if (!msg.id)`
|
|
212
|
+
|
|
213
|
+
// A project directory the host handed over verbatim rather than expanded.
|
|
214
|
+
export const UNEXPANDED = /\$\{[A-Za-z_]/;
|
|
215
|
+
|
|
216
|
+
// Why the server may not start, or null.
|
|
217
|
+
export function startRefusal(project) {
|
|
218
|
+
if (!project) return "projectstore mcp: no project directory — pass --project <dir> or set PROJECTSTORE_PROJECT_DIR (the host's own project variable is honoured when present).";
|
|
219
|
+
if (UNEXPANDED.test(project)) return `projectstore mcp: the project directory "${project}" still contains a placeholder the host did not expand — pass --project <dir> or set PROJECTSTORE_PROJECT_DIR.`;
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function createServer({ project, env = process.env, cwd = project, version = packageVersion(), runVerb = run } = {}) {
|
|
224
|
+
let initialized = false;
|
|
225
|
+
const cfgOf = () => readConfigAt(project);
|
|
226
|
+
const vaultOf = () => { const c = cfgOf(); return c && c.vault_path ? String(c.vault_path).replace(/\/+$/, "") : null; };
|
|
227
|
+
|
|
228
|
+
// In-flight doctor children by request id, so notifications/cancelled can
|
|
229
|
+
// kill the one cancellable operation instead of leaking a process per call.
|
|
230
|
+
const inFlight = new Map();
|
|
231
|
+
|
|
232
|
+
async function callTool(name, args, requestId = null) {
|
|
233
|
+
const t = TOOLS[name];
|
|
234
|
+
if (!t) return { error: rpcError(null, E_INVALID_PARAMS, `unknown tool: ${name}`).error };
|
|
235
|
+
const a = args && typeof args === "object" ? args : {};
|
|
236
|
+
for (const r of t.inputSchema.required) if (a[r] === undefined || a[r] === null || a[r] === "") return { error: rpcError(null, E_INVALID_PARAMS, `${name}: "${r}" is required`).error };
|
|
237
|
+
for (const [k, v] of Object.entries(a)) {
|
|
238
|
+
if (!Object.hasOwn(t.inputSchema.properties, k)) return { error: rpcError(null, E_INVALID_PARAMS, `${name}: unknown argument "${k}"`).error };
|
|
239
|
+
const want = t.inputSchema.properties[k].type;
|
|
240
|
+
const okType = want === "array" ? Array.isArray(v) && v.every((x) => typeof x === "string") : want === "integer" ? Number.isInteger(v) : typeof v === want;
|
|
241
|
+
if (!okType) return { error: rpcError(null, E_INVALID_PARAMS, `${name}: "${k}" must be ${want === "array" ? "an array of strings" : want === "integer" ? "an integer" : "a " + want}`).error };
|
|
242
|
+
// An option value that starts with "-" would read as a flag to the CLI's parser; a positional is safe behind "--".
|
|
243
|
+
if (typeof v === "string" && v.startsWith("-") && ["section", "status", "direction", "selector"].includes(k)) return { error: rpcError(null, E_INVALID_PARAMS, `${name}: "${k}" may not start with "-"`).error };
|
|
244
|
+
}
|
|
245
|
+
// The verb table decides who runs unbound: status and doctor do (doctor
|
|
246
|
+
// is the tool one calls to learn why a project is unbound); the rest
|
|
247
|
+
// answer the bind instruction inside the envelope.
|
|
248
|
+
const rowRequiresBinding = (VERBS.find((v) => v.verb === t.verb) || {}).requiresBinding !== false;
|
|
249
|
+
if (rowRequiresBinding && !cfgOf()) {
|
|
250
|
+
return { content: [{ type: "text", text: JSON.stringify(envelope(t.verb, project, false, { error: `${project} is not bound to a vault — run /projectstore:bind <vault> in a session, or projectstore bind <vault> --project ${project}` })) }], isError: true };
|
|
251
|
+
}
|
|
252
|
+
const { opts, positionals } = t.argv(a);
|
|
253
|
+
const argv = [t.verb, ...opts, "--json", "--project", project, ...(positionals.length ? ["--", ...positionals] : [])];
|
|
254
|
+
let code, outText, errText;
|
|
255
|
+
if (t.verb === "doctor") {
|
|
256
|
+
({ code, out: outText, err: errText } = await spawnBin(argv, { env, cwd, onChild: (child) => { if (requestId !== null) inFlight.set(requestId, child); } }));
|
|
257
|
+
if (requestId !== null) inFlight.delete(requestId);
|
|
258
|
+
} else {
|
|
259
|
+
const out = new Sink(), err = new Sink();
|
|
260
|
+
try {
|
|
261
|
+
code = await runVerb(argv, { env, cwd: existsSync(cwd) ? cwd : undefined, stdin: null, stdout: out, stderr: err, ask: async () => "no" });
|
|
262
|
+
} catch (e) {
|
|
263
|
+
return { content: [{ type: "text", text: JSON.stringify(envelope(t.verb, project, false, { error: e.message })) }], isError: true };
|
|
264
|
+
}
|
|
265
|
+
outText = out.text; errText = err.text;
|
|
266
|
+
}
|
|
267
|
+
const text = outText.trim() || JSON.stringify(envelope(t.verb, project, false, { error: errText.trim() || `exit ${code}`, exit: code }));
|
|
268
|
+
return { content: [{ type: "text", text }], isError: isErrorExit(code) };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function resourceList() {
|
|
272
|
+
const vault = vaultOf();
|
|
273
|
+
return RESOURCES.map((r) => {
|
|
274
|
+
const row = { uri: r.uri, name: r.name, description: r.description, mimeType: r.mimeType };
|
|
275
|
+
if (r.file && vault) { try { row.size = statSync(join(vault, r.file)).size; } catch {} }
|
|
276
|
+
return row;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function readResource(uri) {
|
|
281
|
+
if (typeof uri !== "string" || !uri.startsWith(RESOURCE_SCHEME)) return { error: { code: E_INVALID_PARAMS, message: `unknown resource: ${uri}` } };
|
|
282
|
+
const vault = vaultOf();
|
|
283
|
+
const rest = uri.slice(RESOURCE_SCHEME.length);
|
|
284
|
+
if (rest === "orientation") {
|
|
285
|
+
const out = new Sink(), err = new Sink();
|
|
286
|
+
const code = await runVerb(["orientation", "--project", project], { env, cwd: existsSync(cwd) ? cwd : undefined, stdin: null, stdout: out, stderr: err, ask: async () => "no" });
|
|
287
|
+
if (code !== 0) return { error: { code: code === 3 ? E_INVALID_PARAMS : E_INTERNAL, message: err.text.trim() || `orientation exited ${code}` } };
|
|
288
|
+
return { contents: [{ uri, mimeType: "text/markdown", text: out.text }] };
|
|
289
|
+
}
|
|
290
|
+
const fixed = RESOURCES.find((r) => r.file && r.name === rest);
|
|
291
|
+
if (fixed) {
|
|
292
|
+
if (!vault) return { error: { code: E_INVALID_PARAMS, message: `${project} is not bound to a vault` } };
|
|
293
|
+
const p = join(vault, fixed.file);
|
|
294
|
+
if (!existsSync(p)) return { error: { code: E_INVALID_PARAMS, message: `${fixed.file} does not exist yet — run reconcile` } };
|
|
295
|
+
// A read can fail on an existing file (an iCloud-evicted, dataless one): an error, not a crash.
|
|
296
|
+
try { return { contents: [{ uri, mimeType: "text/markdown", text: readFileSync(p, "utf8") }] }; } catch (e) { return { error: { code: E_INTERNAL, message: `${fixed.file}: ${e.message}` } }; }
|
|
297
|
+
}
|
|
298
|
+
if (rest.startsWith("artifact/")) {
|
|
299
|
+
if (!vault) return { error: { code: E_INVALID_PARAMS, message: `${project} is not bound to a vault` } };
|
|
300
|
+
let rel;
|
|
301
|
+
try { rel = decodeURIComponent(rest.slice("artifact/".length)); } catch { return { error: { code: E_INVALID_PARAMS, message: "artifact path is not valid percent-encoding" } }; }
|
|
302
|
+
// Containment: resolved (collapses `..`), then the vault prefix test,
|
|
303
|
+
// then the realpath so a symlink out of the vault is refused too.
|
|
304
|
+
const abs = resolve(vault, rel);
|
|
305
|
+
if (!isInsideVault(abs, vault)) return { error: { code: E_INVALID_PARAMS, message: `${rel} is outside the vault` } };
|
|
306
|
+
let real;
|
|
307
|
+
try { real = statSync(abs).isFile() ? realpathSync(abs) : null; } catch { real = null; }
|
|
308
|
+
if (!real || !isInsideVault(real, realpathSync(vault))) return { error: { code: E_INVALID_PARAMS, message: `${rel} is not an artifact of the vault` } };
|
|
309
|
+
try { return { contents: [{ uri: `${RESOURCE_SCHEME}artifact/${encodeURIComponent(rel).replace(/%2F/g, "/")}`, mimeType: "text/markdown", text: readFileSync(real, "utf8") }] }; } catch (e) { return { error: { code: E_INTERNAL, message: `${rel}: ${e.message}` } }; }
|
|
310
|
+
}
|
|
311
|
+
return { error: { code: E_INVALID_PARAMS, message: `unknown resource: ${uri}` } };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// One message in, one response (or null for a notification) out. Pure of
|
|
315
|
+
// I/O: the loop owns the streams.
|
|
316
|
+
async function handle(msg) {
|
|
317
|
+
if (Array.isArray(msg)) return rpcError(null, E_INVALID_REQUEST, "batches are not supported (removed in protocol revision 2025-06-18)");
|
|
318
|
+
if (!msg || typeof msg !== "object" || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") return rpcError(hasId(msg) ? msg.id : null, E_INVALID_REQUEST, "not a JSON-RPC 2.0 request");
|
|
319
|
+
const id = hasId(msg) ? msg.id : null;
|
|
320
|
+
const params = msg.params && typeof msg.params === "object" ? msg.params : {};
|
|
321
|
+
const isNotification = !hasId(msg);
|
|
322
|
+
switch (msg.method) {
|
|
323
|
+
case "initialize": {
|
|
324
|
+
const asked = String(params.protocolVersion || "");
|
|
325
|
+
const negotiated = PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0];
|
|
326
|
+
return rpcResult(id, { protocolVersion: negotiated, capabilities: { tools: {}, resources: {} }, serverInfo: { name: SERVER_NAME, version }, instructions: `Read-only tools over the projectstore vault bound to ${project}. Every result is the CLI's --json envelope; grep-sized answers, never the vault.` });
|
|
327
|
+
}
|
|
328
|
+
case "notifications/initialized": initialized = true; return null;
|
|
329
|
+
case "notifications/cancelled": {
|
|
330
|
+
const child = inFlight.get(params.requestId);
|
|
331
|
+
if (child) { inFlight.delete(params.requestId); try { child.kill("SIGTERM"); } catch {} }
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
case "ping": return isNotification ? null : rpcResult(id, {});
|
|
335
|
+
case "server/discover":
|
|
336
|
+
// The 2026-07-28 era's probe. -32601 here is the documented answer a
|
|
337
|
+
// legacy server gives so a dual-era client falls back to initialize.
|
|
338
|
+
return rpcError(id, E_METHOD_NOT_FOUND, "server/discover is not implemented — this server speaks the initialize era");
|
|
339
|
+
}
|
|
340
|
+
if (msg.method.startsWith("notifications/")) return null;
|
|
341
|
+
// JSON-RPC: a request without an id is a notification and is never
|
|
342
|
+
// answered, whatever its method.
|
|
343
|
+
if (isNotification) return null;
|
|
344
|
+
if (!initialized) return rpcError(id, E_INVALID_REQUEST, "server not initialized — send initialize, then notifications/initialized");
|
|
345
|
+
switch (msg.method) {
|
|
346
|
+
case "tools/list": return rpcResult(id, { tools: toolList() });
|
|
347
|
+
case "tools/call": {
|
|
348
|
+
const r = await callTool(params.name, params.arguments, id);
|
|
349
|
+
return r.error ? rpcError(id, r.error.code, r.error.message) : rpcResult(id, r);
|
|
350
|
+
}
|
|
351
|
+
case "resources/list": return rpcResult(id, { resources: resourceList() });
|
|
352
|
+
case "resources/templates/list": return rpcResult(id, { resourceTemplates: RESOURCE_TEMPLATES.map((t) => ({ ...t })) });
|
|
353
|
+
case "resources/read": {
|
|
354
|
+
const r = await readResource(params.uri);
|
|
355
|
+
return r.error ? rpcError(id, r.error.code, r.error.message) : rpcResult(id, r);
|
|
356
|
+
}
|
|
357
|
+
default: return rpcError(id, E_METHOD_NOT_FOUND, `method not found: ${msg.method}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return { handle, callTool, readResource, resourceList, get initialized() { return initialized; } };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// The loop: newline-delimited JSON frames on stdin, one response per line on
|
|
365
|
+
// stdout, nothing else ever on stdout. Resolves with the exit code when
|
|
366
|
+
// stdin closes or a signal arrives; never calls process.exit (the bin sets
|
|
367
|
+
// process.exitCode and lets the streams drain).
|
|
368
|
+
export function serve({ project, env = process.env, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, signals = process } = {}) {
|
|
369
|
+
const refusal = startRefusal(project);
|
|
370
|
+
if (refusal) { stderr.write(refusal + "\n"); return Promise.resolve(2); }
|
|
371
|
+
const server = createServer({ project, env });
|
|
372
|
+
const send = (obj) => { if (obj) stdout.write(JSON.stringify(obj) + "\n"); };
|
|
373
|
+
return new Promise((done) => {
|
|
374
|
+
let pending = 0, closing = false;
|
|
375
|
+
const finish = () => { if (closing && pending === 0) done(0); };
|
|
376
|
+
const rl = createInterface({ input: stdin, crlfDelay: Infinity, terminal: false });
|
|
377
|
+
rl.on("line", async (line) => {
|
|
378
|
+
if (!line.trim()) return;
|
|
379
|
+
let msg;
|
|
380
|
+
try { msg = JSON.parse(line); } catch { send(rpcError(null, E_PARSE, "parse error")); return; }
|
|
381
|
+
pending++;
|
|
382
|
+
try { send(await server.handle(msg)); }
|
|
383
|
+
catch (e) { send(rpcError(hasId(msg) ? msg.id : null, E_INTERNAL, e && e.message ? e.message : String(e))); }
|
|
384
|
+
finally { pending--; finish(); }
|
|
385
|
+
});
|
|
386
|
+
const close = () => { if (closing) return; closing = true; rl.close(); finish(); };
|
|
387
|
+
rl.on("close", close);
|
|
388
|
+
stdin.on("end", close);
|
|
389
|
+
for (const sig of ["SIGINT", "SIGTERM"]) { try { signals.once(sig, close); } catch {} }
|
|
390
|
+
});
|
|
391
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Portable Agent Plugin registration state. This module is selected by a
|
|
2
|
+
// surface format in the harness manifest; it contains no harness-id branch and
|
|
3
|
+
// performs no host mutation.
|
|
4
|
+
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { whichOnPath } from "./lib.mjs";
|
|
10
|
+
|
|
11
|
+
const readJson = (path) => { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; } };
|
|
12
|
+
const samePath = (a, b) => {
|
|
13
|
+
const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
|
|
14
|
+
return Boolean(a && b) && real(a) === real(b);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function parseValue(raw) {
|
|
18
|
+
const s = raw.trim();
|
|
19
|
+
if (s === "true") return true;
|
|
20
|
+
if (s === "false") return false;
|
|
21
|
+
if (s.startsWith('"') && s.endsWith('"')) {
|
|
22
|
+
try { return JSON.parse(s); } catch { return s.slice(1, -1); }
|
|
23
|
+
}
|
|
24
|
+
return s;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readTomlSections(path) {
|
|
28
|
+
let text;
|
|
29
|
+
try { text = readFileSync(path, "utf8"); } catch { return new Map(); }
|
|
30
|
+
const sections = new Map();
|
|
31
|
+
let current = null;
|
|
32
|
+
for (const line of text.split(/\r?\n/)) {
|
|
33
|
+
const header = line.match(/^\s*\[([^\]]+)]\s*(?:#.*)?$/);
|
|
34
|
+
if (header) { current = header[1]; if (!sections.has(current)) sections.set(current, {}); continue; }
|
|
35
|
+
if (!current || /^\s*(?:#|$)/.test(line)) continue;
|
|
36
|
+
const kv = line.match(/^\s*([A-Za-z0-9_-]+)\s*=\s*(.*?)\s*(?:#.*)?$/);
|
|
37
|
+
if (kv) sections.get(current)[kv[1]] = parseValue(kv[2]);
|
|
38
|
+
}
|
|
39
|
+
return sections;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function payloadFiles(root) {
|
|
43
|
+
const out = [];
|
|
44
|
+
const walk = (at, rel = "") => {
|
|
45
|
+
let entries;
|
|
46
|
+
try { entries = readdirSync(at, { withFileTypes: true }); } catch { return; }
|
|
47
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
48
|
+
if (e.name.startsWith(".DS_Store")) continue;
|
|
49
|
+
const abs = join(at, e.name), next = rel ? `${rel}/${e.name}` : e.name;
|
|
50
|
+
// npm creates command shims as symlinks here. The shell invokes the
|
|
51
|
+
// bundled core by its real path, so these convenience links are neither
|
|
52
|
+
// runtime input nor portable plugin payload.
|
|
53
|
+
if (next === "node_modules/.bin" || next.startsWith("node_modules/.bin/")) continue;
|
|
54
|
+
const st = lstatSync(abs);
|
|
55
|
+
if (st.isSymbolicLink()) throw new Error(`plugin payload contains a symlink: ${next}`);
|
|
56
|
+
if (st.isDirectory()) walk(abs, next); else if (st.isFile()) out.push(next);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
walk(root);
|
|
60
|
+
return out.sort();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function payloadDigest(root) {
|
|
64
|
+
const files = payloadFiles(root);
|
|
65
|
+
const h = createHash("sha256");
|
|
66
|
+
for (const rel of files) {
|
|
67
|
+
h.update(rel + "\n");
|
|
68
|
+
h.update(createHash("sha256").update(readFileSync(join(root, rel))).digest("hex") + "\n");
|
|
69
|
+
}
|
|
70
|
+
return { count: files.length, sha256: h.digest("hex") };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function portableRegistrationPaths(s, { home = homedir(), projectDir, harness, env = process.env } = {}) {
|
|
74
|
+
const r = harness.runtime || {};
|
|
75
|
+
const harnessHome = (r.home_env && env[r.home_env]) || join(home, r.home_default);
|
|
76
|
+
const dir = join(harnessHome, ...s.dir);
|
|
77
|
+
return {
|
|
78
|
+
home: harnessHome,
|
|
79
|
+
dir,
|
|
80
|
+
lock: join(harnessHome, "projectstore", `${s.marketplace_name}.lock`),
|
|
81
|
+
journal: join(harnessHome, "projectstore", `${s.marketplace_name}.journal.json`),
|
|
82
|
+
catalog: join(dir, s.manifest),
|
|
83
|
+
ownership: join(dir, s.ownership_manifest),
|
|
84
|
+
payload: join(dir, ...s.plugin_subdir.split("/")),
|
|
85
|
+
globalConfig: join(harnessHome, ...(s.registry.global_config || ["config.toml"])),
|
|
86
|
+
projectConfig: join(projectDir, r.harness_dir, ...(s.registry.project_config || ["config.toml"])),
|
|
87
|
+
cacheDir: join(harnessHome, ...(s.registry.cache_dir || ["plugins", "cache"])),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function renderPortableCatalog(s) {
|
|
92
|
+
return {
|
|
93
|
+
name: s.marketplace_name,
|
|
94
|
+
interface: { displayName: "ProjectStore (npm)" },
|
|
95
|
+
plugins: [{
|
|
96
|
+
name: s.plugin_name,
|
|
97
|
+
source: { source: "local", path: `./${s.plugin_subdir}` },
|
|
98
|
+
policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" },
|
|
99
|
+
category: "Developer Tools",
|
|
100
|
+
}],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function pluginTable(id) { return `plugins.${JSON.stringify(id)}`; }
|
|
105
|
+
function marketplaceTable(name) { return `marketplaces.${name}`; }
|
|
106
|
+
|
|
107
|
+
function pluginIds(sections) {
|
|
108
|
+
const ids = [];
|
|
109
|
+
for (const key of sections.keys()) {
|
|
110
|
+
const m = key.match(/^plugins\.(.+)$/);
|
|
111
|
+
if (!m) continue;
|
|
112
|
+
try { ids.push(m[1].startsWith('"') ? JSON.parse(m[1]) : m[1]); } catch {}
|
|
113
|
+
}
|
|
114
|
+
return ids;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function cacheEntries(paths, s) {
|
|
118
|
+
const base = join(paths.cacheDir, s.marketplace_name, s.plugin_name);
|
|
119
|
+
let names;
|
|
120
|
+
try { names = readdirSync(base); } catch { return []; }
|
|
121
|
+
return names.map((name) => {
|
|
122
|
+
const path = join(base, name);
|
|
123
|
+
const manifest = readJson(join(path, "plugin.json")) || readJson(join(path, ".codex-plugin", "plugin.json"));
|
|
124
|
+
let at = 0; try { at = statSync(path).mtimeMs; } catch {}
|
|
125
|
+
if (!manifest || typeof manifest.version !== "string") return null;
|
|
126
|
+
let digest = null;
|
|
127
|
+
try { digest = payloadDigest(path); } catch {}
|
|
128
|
+
return { path, version: manifest.version, at, digest };
|
|
129
|
+
}).filter(Boolean).sort((a, b) => b.at - a.at || b.version.localeCompare(a.version));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Where the payload to register lives. A surface conditioned on a distribution
|
|
133
|
+
// root has one only when a distribution shell named itself
|
|
134
|
+
// (PROJECTSTORE_DISTRIBUTION_ROOT) AND that root is a portable plugin root: the
|
|
135
|
+
// Claude Code shell sets the same variable, and the core alone is never a
|
|
136
|
+
// payload. Null means nothing is registered from this run — callers defer and
|
|
137
|
+
// never fall back to the core. That fallback is how doctor came to report
|
|
138
|
+
// "<core> is not a portable plugin root" in any project that merely contained
|
|
139
|
+
// the harness's directory (S2 of the 2026-10-03 review in "The Codex shell's
|
|
140
|
+
// plugin root: .codex-plugin, the rendered surfaces, and the first Codex
|
|
141
|
+
// install").
|
|
142
|
+
export function portablePayloadRoot(s, { root, env = process.env } = {}) {
|
|
143
|
+
const named = env.PROJECTSTORE_DISTRIBUTION_ROOT || null;
|
|
144
|
+
if (s.condition !== "distribution_root") return named || root;
|
|
145
|
+
if (!named) return null;
|
|
146
|
+
return existsSync(join(named, "plugin.json")) || existsSync(join(named, ".codex-plugin", "plugin.json")) ? named : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function analysePortableRegistration(projectDir, s, { root, payloadRoot: explicitPayloadRoot = null, home = homedir(), harness, env = process.env, ignoreJournal = false } = {}) {
|
|
150
|
+
const paths = portableRegistrationPaths(s, { home, projectDir, harness, env });
|
|
151
|
+
const payloadRoot = explicitPayloadRoot || portablePayloadRoot(s, { root, env });
|
|
152
|
+
const desiredManifest = payloadRoot ? (readJson(join(payloadRoot, "plugin.json")) || readJson(join(payloadRoot, ".codex-plugin", "plugin.json"))) : null;
|
|
153
|
+
const desiredVersion = desiredManifest?.version || null;
|
|
154
|
+
const id = `${s.plugin_name}@${s.marketplace_name}`;
|
|
155
|
+
const bin = whichOnPath(s.cli.bin, env);
|
|
156
|
+
const ownership = readJson(paths.ownership)?.[s.provenance_key] || null;
|
|
157
|
+
const journal = readJson(paths.journal);
|
|
158
|
+
const catalog = readJson(paths.catalog);
|
|
159
|
+
const global = readTomlSections(paths.globalConfig);
|
|
160
|
+
const project = readTomlSections(paths.projectConfig);
|
|
161
|
+
const market = global.get(marketplaceTable(s.marketplace_name)) || null;
|
|
162
|
+
const globalPlugin = global.get(pluginTable(id)) || null;
|
|
163
|
+
const projectPlugin = project.get(pluginTable(id)) || null;
|
|
164
|
+
const enabled = typeof projectPlugin?.enabled === "boolean" ? projectPlugin.enabled : globalPlugin?.enabled === true;
|
|
165
|
+
const others = [...new Set([...pluginIds(global), ...pluginIds(project)])]
|
|
166
|
+
.filter((other) => other !== id && other.split("@")[0] === s.plugin_name)
|
|
167
|
+
.map((other) => {
|
|
168
|
+
const g = global.get(pluginTable(other)) || null, p = project.get(pluginTable(other)) || null;
|
|
169
|
+
return { id: other, enabled: typeof p?.enabled === "boolean" ? p.enabled : g?.enabled === true };
|
|
170
|
+
});
|
|
171
|
+
const caches = cacheEntries(paths, s);
|
|
172
|
+
const installed = caches.find((e) => e.version === desiredVersion) || caches[0] || null;
|
|
173
|
+
const out = { id, paths, path: paths.dir, payloadRoot, desiredVersion, bin, ownership, journal, catalog, market, globalPlugin, projectPlugin, enabled, others, caches, installed, installPath: installed?.path || null, installedVersion: installed?.version || null, state: "absent", reason: null, produced: Boolean(desiredManifest), contentDiffers: null };
|
|
174
|
+
const enabledOther = others.find((other) => other.enabled);
|
|
175
|
+
const nothing = !existsSync(paths.dir) && !market && !globalPlugin && !installed && !enabledOther;
|
|
176
|
+
if (!desiredManifest) return { ...out, state: "unavailable", reason: payloadRoot ? `${payloadRoot} is not a portable plugin root` : `nothing to register from this run: ${harness?.display_name || "this harness"}'s plugin is registered from its distribution shell, not from the core` };
|
|
177
|
+
if (!bin && nothing) return { ...out, state: "unavailable", reason: `\`${s.cli.bin}\` is not on PATH — the registration needs the host CLI` };
|
|
178
|
+
let desiredDigest = null;
|
|
179
|
+
try { desiredDigest = payloadDigest(payloadRoot); } catch (e) { return { ...out, state: "unavailable", reason: e.message }; }
|
|
180
|
+
out.desiredDigest = desiredDigest;
|
|
181
|
+
if (enabledOther) return { ...out, state: "conflict", refusal: `${enabledOther.id} is already enabled; two ProjectStore plugins would load the same skills and hooks. Disable or remove that registration explicitly, then retry` };
|
|
182
|
+
if (journal && !ignoreJournal) return { ...out, state: "stale", reason: "an interrupted registration has a recovery journal; the next confirmed run recovers it before applying this plan" };
|
|
183
|
+
if (existsSync(paths.dir) && !ownership) return { ...out, state: "foreign", refusal: `${paths.dir} exists without ${s.ownership_manifest}; a source this installer does not own holds the stable marketplace path` };
|
|
184
|
+
if (market?.source && !samePath(market.source, paths.dir)) return { ...out, state: "foreign", refusal: `${paths.globalConfig} points marketplace ${s.marketplace_name} at ${market.source}, not ${paths.dir}` };
|
|
185
|
+
if (nothing) return out;
|
|
186
|
+
let sourceDigest = null;
|
|
187
|
+
try { if (existsSync(paths.payload)) sourceDigest = payloadDigest(paths.payload); } catch {}
|
|
188
|
+
out.sourceDigest = sourceDigest;
|
|
189
|
+
out.contentDiffers = Boolean(ownership?.digest) && (ownership.digest.sha256 !== desiredDigest.sha256 || ownership.digest.count !== desiredDigest.count);
|
|
190
|
+
if (!ownership || !catalog) return { ...out, state: "stale", reason: "the owned marketplace source is incomplete" };
|
|
191
|
+
if (ownership.version === desiredVersion && out.contentDiffers) return { ...out, state: "conflict", refusal: `release ${desiredVersion} already owns this marketplace with a different payload digest` };
|
|
192
|
+
if (!sourceDigest || sourceDigest.sha256 !== ownership.digest?.sha256 || sourceDigest.count !== ownership.digest?.count) return { ...out, state: "stale", reason: "the stable source does not match its recorded digest" };
|
|
193
|
+
if (!market) return { ...out, state: "stale", reason: "the host does not know the marketplace" };
|
|
194
|
+
if (!installed || installed.version !== ownership.version) return { ...out, state: "stale", reason: "the host cache does not contain the registered payload version" };
|
|
195
|
+
if (!installed.digest || installed.digest.sha256 !== ownership.digest?.sha256 || installed.digest.count !== ownership.digest?.count) return { ...out, state: "stale", reason: "the materialised host cache does not match the registered payload digest" };
|
|
196
|
+
if (ownership.version !== desiredVersion) return { ...out, state: "stale", reason: `installed ${ownership.version}; package offers ${desiredVersion}` };
|
|
197
|
+
return { ...out, state: "current", reason: enabled ? null : "installed globally but disabled by effective configuration" };
|
|
198
|
+
}
|