projectstore-claude 0.29.2 → 0.29.3
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/README.md +1 -1
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
- package/node_modules/projectstore/README.md +27 -7
- package/node_modules/projectstore/commands/bar.md +30 -0
- package/node_modules/projectstore/commands/bind.md +14 -7
- package/node_modules/projectstore/commands/scaffold.md +24 -16
- package/node_modules/projectstore/commands/status.md +1 -1
- package/node_modules/projectstore/commands/statusline.md +1 -1
- package/node_modules/projectstore/docs/alongside.md +167 -0
- package/node_modules/projectstore/docs/extending.md +17 -3
- package/node_modules/projectstore/docs/getting-started.md +18 -9
- package/node_modules/projectstore/docs/harnesses.md +1 -1
- package/node_modules/projectstore/docs/how-it-works.md +7 -0
- package/node_modules/projectstore/harnesses/claude-code.json +18 -3
- package/node_modules/projectstore/harnesses/codex.json +12 -1
- package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
- package/node_modules/projectstore/hooks/band.tsx +594 -0
- package/node_modules/projectstore/hooks/hooks.json +1 -0
- package/node_modules/projectstore/hooks/session-start.mjs +6 -2
- package/node_modules/projectstore/package.json +2 -1
- package/node_modules/projectstore/scaffold/headings.json +8 -0
- package/node_modules/projectstore/scripts/band-shared.mjs +329 -0
- package/node_modules/projectstore/scripts/band.mjs +156 -0
- package/node_modules/projectstore/scripts/binding.mjs +157 -17
- package/node_modules/projectstore/scripts/build-adapters.mjs +26 -7
- package/node_modules/projectstore/scripts/cli.mjs +545 -179
- package/node_modules/projectstore/scripts/convergence.mjs +468 -0
- package/node_modules/projectstore/scripts/doctor-report.mjs +185 -0
- package/node_modules/projectstore/scripts/doctor.mjs +80 -62
- package/node_modules/projectstore/scripts/fetch-shell.mjs +283 -0
- package/node_modules/projectstore/scripts/harness.mjs +178 -7
- package/node_modules/projectstore/scripts/install-harness.mjs +1488 -257
- package/node_modules/projectstore/scripts/kanban.mjs +4 -20
- package/node_modules/projectstore/scripts/lib.mjs +257 -58
- package/node_modules/projectstore/scripts/portable-registration.mjs +62 -15
- package/node_modules/projectstore/scripts/query.mjs +83 -12
- package/node_modules/projectstore/scripts/run-host.mjs +94 -0
- package/node_modules/projectstore/scripts/scaffold.mjs +183 -0
- package/node_modules/projectstore/scripts/stages.mjs +80 -0
- package/node_modules/projectstore/scripts/statusline.mjs +25 -2
- package/node_modules/projectstore/scripts/surfaces.mjs +78 -13
- package/node_modules/projectstore/scripts/term.mjs +729 -39
- package/node_modules/projectstore/scripts/version-guard.mjs +5 -0
- package/node_modules/projectstore/templates/de/strings.json +63 -1
- package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/en/strings.json +63 -1
- package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/es/strings.json +63 -1
- package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/fr/strings.json +63 -1
- package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/ru/strings.json +63 -1
- package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/zh/strings.json +63 -1
- package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
- package/package.json +2 -2
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// projectstore — doctor-report.mjs: doctor's text report, drawn from its
|
|
2
|
+
// findings (the presentation spec, *Terminal presentation of the projectstore
|
|
3
|
+
// CLI: layout, glyphs, colour roles, questions and live lines*, contract 11).
|
|
4
|
+
//
|
|
5
|
+
// A renderer, not a second doctor: it imports no check and is pure — it
|
|
6
|
+
// returns text and writes nothing. That is why doctor.mjs itself stays
|
|
7
|
+
// spawned, never imported (the MCP ADR's decision 2 table), while this module
|
|
8
|
+
// is imported by both readers of the findings: the bin, which renders the
|
|
9
|
+
// findings of its one `--json` spawn in its own process (so a terminal gets
|
|
10
|
+
// colour), and doctor.mjs's own text mode, through a dynamic import. The
|
|
11
|
+
// dynamic import keeps the SessionStart hook's static graph — which reaches
|
|
12
|
+
// doctor.mjs — as it was; this module imports only term.mjs and lib.mjs.
|
|
13
|
+
//
|
|
14
|
+
// report(findings, groups, { caps, verbose, version, project, env }) → text
|
|
15
|
+
// CHECKS → the check table
|
|
16
|
+
//
|
|
17
|
+
// The layout: a badge with the version and the project's folder name; per
|
|
18
|
+
// section (INSTALL, VAULT) a heading with its counts and a rule; issues, then
|
|
19
|
+
// warnings, then notes; the Summary line last, verbatim — the write ceremony
|
|
20
|
+
// reads it (commands/story.md step 5a, agents/clerk.md). That line is
|
|
21
|
+
// lib.mjs's doctorSummaryLine, so a caller's fallback ends with it too.
|
|
22
|
+
//
|
|
23
|
+
// Two readers, one layout:
|
|
24
|
+
// - plain (a pipe, an agent's Bash tool) prints every finding, its message
|
|
25
|
+
// whole on one line with its path, and every note. Agents read it, and the
|
|
26
|
+
// vocabulary suite's about-filter cuts a finding out by its exact message;
|
|
27
|
+
// - a terminal gets the same report compacted: a cause with many instances
|
|
28
|
+
// lists each by its capture and path, at most eight, and the notes of the
|
|
29
|
+
// folding rows fold into one line. `--verbose` turns the compaction off.
|
|
30
|
+
// Every entry keeps its `[check]` id: the `--fix` walk of commands/doctor.md
|
|
31
|
+
// keys on it.
|
|
32
|
+
|
|
33
|
+
import { PLAIN, painter, icon, badge, heading, rule, rowLines, wrap, commandBlock } from "./term.mjs";
|
|
34
|
+
import { commandForm, doctorSummaryLine } from "./lib.mjs";
|
|
35
|
+
|
|
36
|
+
// ─── The check table ─────────────────────────────────────────────────────
|
|
37
|
+
//
|
|
38
|
+
// One row per check id that is grouped, fixed or folded; a check with no row
|
|
39
|
+
// prints each finding as it is. A row is { instance, title, fix, folds }:
|
|
40
|
+
// - instance: a pattern over the message, with zero or one capture. Two or
|
|
41
|
+
// more findings of one check and one level that match it form one entry:
|
|
42
|
+
// the title once, then each finding's capture and path. A finding that
|
|
43
|
+
// does not match — and a finding about another harness (`about`), whose
|
|
44
|
+
// exact message the vocabulary suite cuts out — prints on its own.
|
|
45
|
+
// - fix: what to do, listed once under the check's entries at each level,
|
|
46
|
+
// only for a check whose messages carry no command (contract 11): a
|
|
47
|
+
// sentence, or { say, command } where the command is composed through
|
|
48
|
+
// commandForm and stands on its own line (contract 6). Each is worded to
|
|
49
|
+
// be true for every message its check can carry.
|
|
50
|
+
// - folds: notes of this check fold on a terminal — true for all of them,
|
|
51
|
+
// or a pattern for the ones it names. The offer checks (doctor.mjs's
|
|
52
|
+
// OFFER_CHECKS: upgrade, layout-legacy) never fold, so they have no row.
|
|
53
|
+
const row = ({ instance = null, title = null, fix = null, folds = false }) => Object.freeze({ instance, title, fix, folds });
|
|
54
|
+
|
|
55
|
+
export const CHECKS = Object.freeze({
|
|
56
|
+
// Fixes: the checks whose messages name no command.
|
|
57
|
+
"code-refs": row({
|
|
58
|
+
instance: /^code_refs path "([^"]+)" does not resolve/,
|
|
59
|
+
title: "code_refs path does not resolve inside the project",
|
|
60
|
+
fix: "make each code_refs path exist in the project, and keep a story's paths inside its epic's",
|
|
61
|
+
}),
|
|
62
|
+
"wikilink": row({
|
|
63
|
+
instance: /^Dead wiki-link \[\[(.+)\]\]\.$/,
|
|
64
|
+
title: "Dead wiki-link",
|
|
65
|
+
fix: "make each [[link]] name exactly one existing artifact (correct the name, or qualify it with its folder); if the check was skipped, fix the error it names",
|
|
66
|
+
}),
|
|
67
|
+
"rel-link": row({
|
|
68
|
+
instance: /^Dead relative link \((.+)\)\.$/,
|
|
69
|
+
title: "Dead relative link",
|
|
70
|
+
fix: "point each relative link at a file that exists, or remove it",
|
|
71
|
+
}),
|
|
72
|
+
"acceptance": row({ fix: "tick the criteria the work met, or move the story out of done" }),
|
|
73
|
+
"review-status": row({ fix: "set reviewed_at to the review's date, or clear review_status" }),
|
|
74
|
+
"epic-status": row({ fix: "close the open stories, or move the epic out of done" }),
|
|
75
|
+
"spec-links": row({ fix: "make each spec's stories and each story's specs name existing artifacts that list each other back, written inline as specs: [\"id\"]" }),
|
|
76
|
+
"index": row({
|
|
77
|
+
instance: / is not listed in .+\/README\.md's index\.$/,
|
|
78
|
+
title: "Artifact is not listed in its folder's README index",
|
|
79
|
+
fix: { say: "rebuild the folder indexes from frontmatter", command: (env) => commandForm("reconcile", { env }) },
|
|
80
|
+
}),
|
|
81
|
+
"index-header": row({ fix: "rewrite the index header in a registered form, so reconcile can rebuild the index" }),
|
|
82
|
+
// Notes that fold on a terminal while their section has something to fix.
|
|
83
|
+
"surface": row({ folds: /— current, last written by / }),
|
|
84
|
+
"mcp": row({ folds: true }),
|
|
85
|
+
"statusline": row({ folds: /^Base HUD present/ }),
|
|
86
|
+
"harness": row({ folds: true }),
|
|
87
|
+
"plugin-registration": row({ folds: / registered from the npm package / }),
|
|
88
|
+
"spec-policy": row({ folds: true }),
|
|
89
|
+
"identity": row({ folds: true }),
|
|
90
|
+
"artifact-name": row({ folds: true }),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// The instance list on a terminal: at most this many, then `+N more`.
|
|
94
|
+
export const INSTANCE_CAP = 8;
|
|
95
|
+
|
|
96
|
+
const NONE = row({});
|
|
97
|
+
const LEVELS = Object.freeze([
|
|
98
|
+
// [level, glyph name, role, count word]
|
|
99
|
+
["issue", "issue", "error", "issue"],
|
|
100
|
+
["warn", "warning", "attention", "warning"],
|
|
101
|
+
// A note is not a warning: its own glyph, so the two differ without colour.
|
|
102
|
+
["info", "info", "explanation", "note"],
|
|
103
|
+
]);
|
|
104
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
105
|
+
// Joins the dash to the path after it, so wrapping never leaves a bare dash
|
|
106
|
+
// at a line's end with the path alone below; a space again once wrapped.
|
|
107
|
+
const GLUE = "";
|
|
108
|
+
|
|
109
|
+
const folderName = (p) => (p ? String(p).replace(/[\\/]+$/, "").split(/[\\/]/).pop() || null : null);
|
|
110
|
+
const folds = (r, f) => f.level === "info" && Boolean(r.folds) && (r.folds === true || r.folds.test(f.message));
|
|
111
|
+
const capture = (r, f) => { const m = r.instance.exec(f.message); return m && m[1] !== undefined ? m[1] : null; };
|
|
112
|
+
|
|
113
|
+
export function report(findings, groups, { caps: c = PLAIN, verbose = false, version = null, project = null, env = process.env } = {}) {
|
|
114
|
+
const paint = painter(c);
|
|
115
|
+
// The terminal's compaction (contract 11, "on a TTY only"): the cap, the
|
|
116
|
+
// folds and the short instance lines. Plain output prints everything, and
|
|
117
|
+
// --verbose asks a terminal for the same.
|
|
118
|
+
const compact = c.live && !verbose;
|
|
119
|
+
const width = c.live ? c.width : 0;
|
|
120
|
+
const dash = icon(c, "dash");
|
|
121
|
+
// Prose at an indent: wrapped on a live terminal, every line after the
|
|
122
|
+
// first indented two further; one line anywhere else.
|
|
123
|
+
const prose = (n, text) => {
|
|
124
|
+
const pad = " ".repeat(n);
|
|
125
|
+
return wrap(text, width, pad + " ").split("\n").map((l, k) => (k ? l : pad + l).split(GLUE).join(" "));
|
|
126
|
+
};
|
|
127
|
+
const withPath = (text, f) => (f.file ? `${text} ${paint("explanation", dash)}${GLUE}${paint("explanation", f.file)}` : text);
|
|
128
|
+
|
|
129
|
+
function entries(level, check, r, these) {
|
|
130
|
+
const [, glyph, role] = LEVELS.find(([l]) => l === level);
|
|
131
|
+
const lead = `${paint(role, icon(c, glyph))} [${check}]`;
|
|
132
|
+
const matched = r.instance ? these.filter((f) => !f.about && r.instance.test(f.message)) : [];
|
|
133
|
+
const grouped = matched.length >= 2 ? matched : [];
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const f of these) {
|
|
136
|
+
if (!grouped.includes(f)) { out.push(...prose(2, withPath(`${lead} ${f.message}`, f))); continue; }
|
|
137
|
+
if (f !== grouped[0]) continue;
|
|
138
|
+
out.push(...prose(2, `${lead} ${paint("emphasis", r.title)} (${grouped.length})`));
|
|
139
|
+
if (!compact) { for (const g of grouped) out.push(...prose(6, withPath(g.message, g))); continue; }
|
|
140
|
+
// Each instance a row with its own glyph: its capture, then its path.
|
|
141
|
+
// A path moved beneath its capture (contract 5) reads `— <path>`, as
|
|
142
|
+
// plain's `message — file` does, so neither colour nor the indent is
|
|
143
|
+
// all that tells it from the next capture.
|
|
144
|
+
const shown = grouped.slice(0, INSTANCE_CAP);
|
|
145
|
+
const laid = rowLines(c, shown.map((g) => { const cap = capture(r, g); return { glyph: "dot", role: "explanation", ...(cap === null ? { target: g.file || g.message } : { target: cap, note: g.file || "" }) }; }), { indent: 6 });
|
|
146
|
+
for (const [first, ...moved] of laid) out.push(first, ...moved.map((l, k) => (k ? l : l.replace(/^ */, (pad) => `${pad}${paint("explanation", dash)} `))));
|
|
147
|
+
if (grouped.length > shown.length) out.push(` ${paint("explanation", `+${grouped.length - shown.length} more`)}`);
|
|
148
|
+
}
|
|
149
|
+
if (r.fix) {
|
|
150
|
+
const fix = typeof r.fix === "string" ? { say: r.fix, command: null } : r.fix;
|
|
151
|
+
out.push(...prose(4, `${paint("action", "fix:")} ${fix.say}${fix.command ? ":" : "."}`));
|
|
152
|
+
if (fix.command) out.push(...commandBlock(c, [fix.command(env)], { indent: 6 }), "");
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function section(group) {
|
|
158
|
+
const fs = findings.filter((f) => f.group === group);
|
|
159
|
+
const n = Object.fromEntries(LEVELS.map(([l]) => [l, fs.filter((f) => f.level === l).length]));
|
|
160
|
+
// Each count in its role; a zero issue or warning count is the done role.
|
|
161
|
+
const counts = LEVELS.map(([l, , role, word]) => [plural(n[l], word), l === "info" ? role : n[l] ? role : "done"]);
|
|
162
|
+
const out = [heading(c, group.toUpperCase(), counts), rule(c)];
|
|
163
|
+
if (!fs.length) return [...out, ` ${paint("done", icon(c, "ok"))} clean`];
|
|
164
|
+
// A note folds only while its section has something to fix.
|
|
165
|
+
const folding = compact && n.issue + n.warn > 0;
|
|
166
|
+
let hidden = 0;
|
|
167
|
+
for (const [level] of LEVELS) {
|
|
168
|
+
const atLevel = fs.filter((f) => f.level === level);
|
|
169
|
+
for (const check of [...new Set(atLevel.map((f) => f.check))]) {
|
|
170
|
+
const r = Object.hasOwn(CHECKS, check) ? CHECKS[check] : NONE;
|
|
171
|
+
let these = atLevel.filter((f) => f.check === check);
|
|
172
|
+
if (folding) { const kept = these.filter((f) => !folds(r, f)); hidden += these.length - kept.length; these = kept; }
|
|
173
|
+
if (these.length) out.push(...entries(level, check, r, these));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (hidden) out.push(` ${paint("explanation", `${plural(hidden, "note")} hidden ${icon(c, "dot")} --verbose shows them`)}`);
|
|
177
|
+
while (out.at(-1) === "") out.pop();
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const lines = [badge(c, "projectstore doctor", [version, folderName(project)])];
|
|
182
|
+
for (const g of groups) lines.push("", ...section(g));
|
|
183
|
+
lines.push("", doctorSummaryLine(findings, { env }));
|
|
184
|
+
return lines.join("\n") + "\n";
|
|
185
|
+
}
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
//
|
|
15
15
|
// CLI: node doctor.mjs [--install] [--vault] [--startup] [--json]
|
|
16
16
|
// default = --install --vault. Exit code is always 0 (reporting tool).
|
|
17
|
+
// Without --json the findings are drawn by doctor-report.mjs, the
|
|
18
|
+
// renderer the bin uses, in its plain layout.
|
|
17
19
|
|
|
18
20
|
import {
|
|
19
21
|
existsSync,
|
|
@@ -66,7 +68,8 @@ import {
|
|
|
66
68
|
findAgentsBlock,
|
|
67
69
|
statusLineLauncherPath,
|
|
68
70
|
LAUNCHER_HEADER,
|
|
69
|
-
installedPluginEntries, isMain,
|
|
71
|
+
installedPluginEntries, selectCopy, takesNoRegister, copyUpdateAdvice, isMain,
|
|
72
|
+
channelMark,
|
|
70
73
|
pickExisting,
|
|
71
74
|
legacyStatusLineLauncherPath,
|
|
72
75
|
stateDir,
|
|
@@ -85,6 +88,7 @@ import {
|
|
|
85
88
|
speakingHarness,
|
|
86
89
|
inheritForm,
|
|
87
90
|
bindInherits,
|
|
91
|
+
doctorFallbackText,
|
|
88
92
|
} from "./lib.mjs";
|
|
89
93
|
import { agentOverrides, childEnv, sourceHarness, runtimeEnvNames, loadHarness, detectHarnesses, identifiedHarnessId, configPath as harnessConfigPath, packageCommand, invocation } from "./harness.mjs";
|
|
90
94
|
|
|
@@ -101,6 +105,7 @@ import { agentOverrides, childEnv, sourceHarness, runtimeEnvNames, loadHarness,
|
|
|
101
105
|
// brand, and --harness still carries the target.
|
|
102
106
|
import { uncommittedProjectFiles, lastCommitMs } from "./diff-refs.mjs";
|
|
103
107
|
import { resolveBinding } from "./worktree.mjs";
|
|
108
|
+
import { missingScaffoldRequirements } from "./scaffold.mjs";
|
|
104
109
|
|
|
105
110
|
const AGENT_BLOCK_MARKER = new RegExp(AGENTS_BLOCK_OPEN_SRC, "g");
|
|
106
111
|
// The provenance grammar's prefix, duplicated here on purpose: the startup
|
|
@@ -257,6 +262,15 @@ export function checkLayoutTemplates(cfg) {
|
|
|
257
262
|
out.push(finding("install", "issue", "templates", `Missing template for language "${lang}": ${k}.md.tmpl`));
|
|
258
263
|
}
|
|
259
264
|
}
|
|
265
|
+
// What `scaffold` (and `init`) refuse on, through the same check the plan
|
|
266
|
+
// makes: the vault README's template, and a name and a description for every
|
|
267
|
+
// folder kind the layout declares — `diagram` included. A warn, not an issue:
|
|
268
|
+
// a vault already scaffolded is not broken by a hand-added kind; only its
|
|
269
|
+
// next scaffold is refused. The folder README is the issue above.
|
|
270
|
+
for (const m of missingScaffoldRequirements(layout, lang, pluginRoot())) {
|
|
271
|
+
if (m.what === "template" && m.name === "folder-readme") continue;
|
|
272
|
+
out.push(finding("install", "warn", "templates", `${m.message} — scaffold and init refuse until it is there.`));
|
|
273
|
+
}
|
|
260
274
|
if (!existsSync(join(pluginRoot(), "scaffold", "headings.json"))) {
|
|
261
275
|
out.push(finding("install", "issue", "templates",
|
|
262
276
|
"scaffold/headings.json is missing — heading-registry checks (index headers, acceptance, spec gates) cannot run. Stale/corrupt plugin install?"));
|
|
@@ -492,14 +506,17 @@ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot()
|
|
|
492
506
|
// contract 6 as amended after the rc.3 tag): one well-formed block, seen by
|
|
493
507
|
// every harness the project uses. rc.1 and rc.2 left a block in an
|
|
494
508
|
// AGENTS.md-only project with no CLAUDE.md, so Claude Code saw nothing, and
|
|
495
|
-
// nothing said so. Used means detected by directory
|
|
496
|
-
// environment — never the source harness
|
|
497
|
-
// Codex-only project hears nothing about
|
|
509
|
+
// nothing said so. Used means detected by directory or by its state
|
|
510
|
+
// directory, or identified from the environment — never the source harness
|
|
511
|
+
// a terminal run falls back to, so a Codex-only project hears nothing about
|
|
512
|
+
// CLAUDE.md (contract 16). The state directory is what a bare upgrade and a
|
|
513
|
+
// bare uninstall select by, so doctor warns about exactly the block they
|
|
514
|
+
// move (the story "One run plans the agents block once…", rule 5). An issue
|
|
498
515
|
// for the harness that identified itself, a warning for one only detected.
|
|
499
516
|
const blockFile = blocks === 1 && !refuses ? Object.keys(perFile)[0] : null;
|
|
500
517
|
if (blockFile) {
|
|
501
518
|
const identified = identifiedHarnessId(env);
|
|
502
|
-
const used = new Set([...detectHarnesses(proj).map((d) => d.id), ...(identified ? [identified] : [])]);
|
|
519
|
+
const used = new Set([...detectHarnesses(proj, { state: true }).map((d) => d.id), ...(identified ? [identified] : [])]);
|
|
503
520
|
for (const id of used) {
|
|
504
521
|
const m = loadHarness(id);
|
|
505
522
|
if (!m || blockVisibleTo(m, blockFile, texts)) continue;
|
|
@@ -616,9 +633,12 @@ export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root
|
|
|
616
633
|
push(finding("install", "warn", "surface", `${where} [projectstore:agents] — ${s.reason}. Run ${invocation(sh, "agents", { args: "register" })}.`, where));
|
|
617
634
|
}
|
|
618
635
|
}
|
|
619
|
-
// The statusline entry's states are checkStatusline's, under its own id
|
|
620
|
-
//
|
|
621
|
-
// self-healing: syncStatusLine
|
|
636
|
+
// The statusline entry's states are checkStatusline's, under its own id.
|
|
637
|
+
// Its ours-stale (a command this installation would not write) is not
|
|
638
|
+
// always self-healing: syncStatusLine re-points a direct entry at the
|
|
639
|
+
// running root's script and never touches an entry naming a launcher on
|
|
640
|
+
// disk, so install is what re-points an entry at the launcher
|
|
641
|
+
// (contract 0 as amended 2026-10-10).
|
|
622
642
|
}
|
|
623
643
|
return out;
|
|
624
644
|
}
|
|
@@ -655,8 +675,9 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
|
|
|
655
675
|
// Only when another harness in this project HAS an overlay. A project with
|
|
656
676
|
// none at all is the ordinary fresh state and needs no advice; the asymmetry
|
|
657
677
|
// is what is actionable, because it is almost always the second harness that
|
|
658
|
-
// was forgotten rather than the first that was deliberate.
|
|
659
|
-
|
|
678
|
+
// was forgotten rather than the first that was deliberate. Used as a bare
|
|
679
|
+
// upgrade or uninstall selects: by directory or by state directory.
|
|
680
|
+
const used = detectHarnesses(proj, { state: true }).map((d) => d.id);
|
|
660
681
|
if (used.length > 1) {
|
|
661
682
|
const overlays = used.map((id) => ({ id, o: readOverlayAt(proj, loadHarness(id)?.runtime?.overlay || id) }));
|
|
662
683
|
const configured = overlays.filter(({ o }) => o.present);
|
|
@@ -691,8 +712,13 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
|
|
|
691
712
|
// itself); a competing copy enabled beside ours → an issue (two enabled copies
|
|
692
713
|
// of one plugin); a competitor alone → an info naming the npm path; foreign →
|
|
693
714
|
// never repairable; the host CLI missing → an info.
|
|
694
|
-
export function checkPluginRegistration(proj, states = [], { home = homedir(), speaker = speakingHarness() } = {}) {
|
|
715
|
+
export function checkPluginRegistration(proj, states = [], { home = homedir(), speaker = speakingHarness(), cfg = null } = {}) {
|
|
695
716
|
const out = [];
|
|
717
|
+
// A checkout whose binding records a channel switch (the converge story's
|
|
718
|
+
// rule 3) chose the npm install: the info that ends "If moving to npm was
|
|
719
|
+
// meant, ignore this" is the one thing it no longer needs. Doctor never
|
|
720
|
+
// reads who made the mark.
|
|
721
|
+
const marked = Boolean(channelMark(cfg));
|
|
696
722
|
for (const s of states.filter((x) => x.kind === "registration")) {
|
|
697
723
|
// The shell form when the manifest names a shell (contract 12): the
|
|
698
724
|
// command a user can paste, built in one place.
|
|
@@ -719,6 +745,7 @@ export function checkPluginRegistration(proj, states = [], { home = homedir(), s
|
|
|
719
745
|
push(finding("install", "info", "plugin-registration", `${key} is held off in this checkout's local settings, where the npm registration turned it off — and that copy is no longer installed, so the entry is stale.`, s.path));
|
|
720
746
|
continue;
|
|
721
747
|
}
|
|
748
|
+
if (marked) continue;
|
|
722
749
|
// The release line, not the build: a 0.28 release candidate reads a
|
|
723
750
|
// moved project; 0.27.x reads it as unbound. No version reads as old.
|
|
724
751
|
const old = !row.version || cmpVersion(row.version, "0.28.0") < 0;
|
|
@@ -737,8 +764,16 @@ export function checkPluginRegistration(proj, states = [], { home = homedir(), s
|
|
|
737
764
|
} else if (s.state === "stale") {
|
|
738
765
|
push(finding("install", "issue", "plugin-registration", `${s.entry} — stale: ${s.reason}. Refresh it: ${refresh}`, s.path));
|
|
739
766
|
} else if (s.state === "current") {
|
|
767
|
+
// The host loads a host plugin registration in place, from the
|
|
768
|
+
// plugin's root inside our marketplace directory (the payload — what
|
|
769
|
+
// its plugin-root variable names); the install path is the registry's
|
|
770
|
+
// copy, the one the launcher renders (measured on host 2.1.293 and
|
|
771
|
+
// 2.1.296). A portable registration's host loads the copy.
|
|
772
|
+
const loaded = h.surfaces?.[s.surface]?.format === "host-plugin-registration" && s.payload
|
|
773
|
+
? `install path ${s.installPath}; loaded in place from ${s.payload}`
|
|
774
|
+
: `loaded from ${s.installPath}`;
|
|
740
775
|
if (others) push(finding("install", "issue", "plugin-registration", `${s.entry} is current, and ${others} is enabled for this project too — two enabled copies of one plugin load twice. install silences the other for this project: ${packageCommand(h, "install", { args: `--surface ${s.surface} --project "${proj}"` })} (or the host's own disable at the scope the manifest names — never the committed project scope).`, s.path));
|
|
741
|
-
else push(finding("install", "info", "plugin-registration", `${s.entry} ${s.installedVersion} registered from the npm package for this project (
|
|
776
|
+
else push(finding("install", "info", "plugin-registration", `${s.entry} ${s.installedVersion} registered from the npm package for this project (${loaded}); refresh with ${refresh}.`));
|
|
742
777
|
}
|
|
743
778
|
}
|
|
744
779
|
return out;
|
|
@@ -913,20 +948,13 @@ export function checkLayout(proj, harness = sourceHarness(), { level = "warn", r
|
|
|
913
948
|
// the move never needs the registration, so a misread channel can cost a
|
|
914
949
|
// launcher stamp but never a channel switch. A copy that predates the move
|
|
915
950
|
// (no bin/projectstore.mjs — 0.27.x) cannot run it: update that copy first.
|
|
916
|
-
// Nor can one whose CLI predates --no-register (takesNoRegister).
|
|
951
|
+
// Nor can one whose CLI predates --no-register (lib's takesNoRegister).
|
|
917
952
|
//
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
|
|
923
|
-
// the flag, while a copy labelled rc.2 taken from main at 418448e has it. A
|
|
924
|
-
// file read, because this runs in the SessionStart subset: never an import()
|
|
925
|
-
// of the copy, never a spawn. Add files to the read, never drop cli.mjs:
|
|
926
|
-
// released copies keep their parse there.
|
|
927
|
-
export function takesNoRegister(copy) {
|
|
928
|
-
try { return readFileSync(join(copy, "scripts", "cli.mjs"), "utf8").includes('"no-register"'); } catch { return false; }
|
|
929
|
-
}
|
|
953
|
+
// The selection, the capability read and the copy advice are lib's
|
|
954
|
+
// (selectCopy, takesNoRegister, copyUpdateAdvice), shared with the
|
|
955
|
+
// installer's keep (the converge story's rule 5). takesNoRegister is
|
|
956
|
+
// re-exported here, where its callers already import it.
|
|
957
|
+
export { takesNoRegister };
|
|
930
958
|
|
|
931
959
|
export function layoutRemedy(proj, { root = pluginRoot(), home = homedir(), harness = sourceHarness(), env = process.env } = {}) {
|
|
932
960
|
// A session under a relocated host home hands it on: a terminal without it
|
|
@@ -939,29 +967,23 @@ export function layoutRemedy(proj, { root = pluginRoot(), home = homedir(), harn
|
|
|
939
967
|
const channel = installChannel(root, { home, harness });
|
|
940
968
|
if (channel === "registration") return shell(pluginVersion(root));
|
|
941
969
|
if (channel === "marketplace") return own(root);
|
|
942
|
-
//
|
|
943
|
-
//
|
|
944
|
-
const
|
|
945
|
-
const here = real(proj);
|
|
946
|
-
const mine = (e) => Boolean(e.projectPath) && real(e.projectPath) === here;
|
|
947
|
-
const copy = installedPluginEntries(home, proj)
|
|
948
|
-
.filter((e) => e.present && e.enabled && (!e.projectPath || mine(e)))
|
|
949
|
-
.sort((a, b) => (Number(mine(b)) - Number(mine(a))) || (b.at - a.at))[0];
|
|
970
|
+
// The selection is lib's selectCopy: this checkout's row first, then
|
|
971
|
+
// user-scope rows by recency, never another checkout's.
|
|
972
|
+
const copy = selectCopy(home, proj, { harness });
|
|
950
973
|
if (copy) {
|
|
951
974
|
const v = copy.version ? ` (${copy.version})` : "";
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
}
|
|
975
|
+
const then = "and the startup line names the command";
|
|
976
|
+
if (copy.missing === "bin") return { advice: copyUpdateAdvice(copy, { cannot: "predates the move and cannot run it", then }) };
|
|
955
977
|
// Chosen first, gated after: the copy is what the project runs, so an
|
|
956
978
|
// incapable one gets advice even beside a capable user-scope copy.
|
|
957
|
-
const viaRegistration =
|
|
958
|
-
if (
|
|
979
|
+
const viaRegistration = copy.channel === "registration";
|
|
980
|
+
if (copy.missing === "no-register") {
|
|
959
981
|
return { advice: viaRegistration
|
|
960
982
|
// Its channel's ordinary refresh, at the running version: the plain
|
|
961
983
|
// upgrade re-registers from npm — not a switch for an npm project — and
|
|
962
984
|
// moves the project in the same run.
|
|
963
985
|
? `This project's projectstore plugin${v} is the npm registration's copy and predates --no-register, so it cannot run the move as named: refresh that registration, which moves the project too, from a terminal outside the session: ${prefix}${packageCommand(harness, "upgrade", { version: pluginVersion(root) || "latest", args: `--project "${proj}"` })}`
|
|
964
|
-
:
|
|
986
|
+
: copyUpdateAdvice(copy, { cannot: "predates --no-register and cannot run the move as named", then }) };
|
|
965
987
|
}
|
|
966
988
|
return viaRegistration ? shell(copy.version) : own(copy.path);
|
|
967
989
|
}
|
|
@@ -2032,7 +2054,7 @@ export async function runInstallChecks(cfg, proj, opts = {}) {
|
|
|
2032
2054
|
let read = null;
|
|
2033
2055
|
try { read = await readSurfaceStates(proj, opts); } catch {} // reported as a warn by checkHarnessSurfaces
|
|
2034
2056
|
out.push(...await checkHarnessSurfaces(cfg, proj, { ...opts, read }));
|
|
2035
|
-
if (read) out.push(...checkPluginRegistration(proj, read.result.states, { home: opts.home }));
|
|
2057
|
+
if (read) out.push(...checkPluginRegistration(proj, read.result.states, { home: opts.home, cfg }));
|
|
2036
2058
|
if (read) out.push(...checkVersionDrift(opts.home, read.result.states, proj));
|
|
2037
2059
|
return foldIntoMove(out);
|
|
2038
2060
|
}
|
|
@@ -2122,27 +2144,6 @@ export function runStartupChecks(cfg, proj, budgetMs = 150) {
|
|
|
2122
2144
|
|
|
2123
2145
|
// ─── CLI ───────────────────────────────────────────────────────────────
|
|
2124
2146
|
|
|
2125
|
-
function icon(level) {
|
|
2126
|
-
return level === "issue" ? "✖" : level === "warn" ? "⚠" : "ℹ";
|
|
2127
|
-
}
|
|
2128
|
-
|
|
2129
|
-
function report(findings, groups) {
|
|
2130
|
-
const ver = pluginVersion();
|
|
2131
|
-
const lines = [`projectstore doctor — plugin v${ver || "?"}, ${new Date().toISOString().slice(0, 10)}`];
|
|
2132
|
-
for (const g of groups) {
|
|
2133
|
-
const fs = findings.filter((f) => f.group === g);
|
|
2134
|
-
lines.push("", `## ${g} (${fs.filter((f) => f.level === "issue").length} issue(s), ${fs.filter((f) => f.level === "warn").length} warning(s))`);
|
|
2135
|
-
if (!fs.length) lines.push(" ✓ clean");
|
|
2136
|
-
for (const f of fs) {
|
|
2137
|
-
lines.push(` ${icon(f.level)} [${f.check}] ${f.message}${f.file ? ` — ${f.file}` : ""}`);
|
|
2138
|
-
}
|
|
2139
|
-
}
|
|
2140
|
-
const issues = findings.filter((f) => f.level === "issue").length;
|
|
2141
|
-
const warns = findings.filter((f) => f.level === "warn").length;
|
|
2142
|
-
lines.push("", `Summary: ${issues} issue(s), ${warns} warning(s). ${issues ? `Repairs: ${commandForm("doctor", { args: "--fix" })} (install), ${commandForm("kanban")} / reconcile (vault).` : "Vault and wiring look healthy."}`);
|
|
2143
|
-
return lines.join("\n");
|
|
2144
|
-
}
|
|
2145
|
-
|
|
2146
2147
|
async function main() {
|
|
2147
2148
|
const args = process.argv.slice(2);
|
|
2148
2149
|
const wantJson = args.includes("--json");
|
|
@@ -2171,7 +2172,24 @@ async function main() {
|
|
|
2171
2172
|
findings.push(finding("vault", "info", "vault", "Vault checks skipped — no usable vault (see install issues)."));
|
|
2172
2173
|
}
|
|
2173
2174
|
|
|
2174
|
-
process.stdout.write(
|
|
2175
|
+
if (wantJson) { process.stdout.write(JSON.stringify(findings, null, 2) + "\n"); return; }
|
|
2176
|
+
process.stdout.write(await textReport(findings, groups, { version: pluginVersion(), project: proj }));
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
// The text is the bin's renderer's, in its plain layout (the presentation
|
|
2180
|
+
// spec, contract 11) — one report, whoever prints it. Imported at run time,
|
|
2181
|
+
// and nowhere else in this file: the SessionStart hook imports this module
|
|
2182
|
+
// statically, and the renderer's term.mjs is not on its path. A renderer
|
|
2183
|
+
// that cannot load, or throws, costs the reader nothing: the findings one
|
|
2184
|
+
// per line and the Summary line, still last (lib.mjs). `load` is a test's.
|
|
2185
|
+
export async function textReport(findings, groups, { version = null, project = null, env = process.env, stderr = process.stderr } = {}, { load = () => import("./doctor-report.mjs") } = {}) {
|
|
2186
|
+
try {
|
|
2187
|
+
const { report } = await load();
|
|
2188
|
+
return report(findings, groups, { version, project, env });
|
|
2189
|
+
} catch (e) {
|
|
2190
|
+
if (stderr) stderr.write(`doctor: the report could not be drawn (${e && e.message ? e.message : e}); its findings follow, one per line.\n`);
|
|
2191
|
+
return doctorFallbackText(findings, { env });
|
|
2192
|
+
}
|
|
2175
2193
|
}
|
|
2176
2194
|
|
|
2177
2195
|
if (isMain(import.meta.url)) {
|