projectstore-claude 0.29.2 → 0.29.4
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/bin/projectstore-claude.mjs +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 +44 -7
- package/node_modules/projectstore/commands/bar.md +30 -0
- package/node_modules/projectstore/commands/bind.md +16 -20
- package/node_modules/projectstore/commands/doctor.md +12 -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 +2 -2
- package/node_modules/projectstore/commands/story.md +15 -2
- 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 +12 -3
- package/node_modules/projectstore/harnesses/claude-code.json +23 -8
- package/node_modules/projectstore/harnesses/codex.json +16 -6
- package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
- package/node_modules/projectstore/hooks/band.tsx +622 -0
- package/node_modules/projectstore/hooks/hooks.json +1 -0
- package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
- package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
- package/node_modules/projectstore/hooks/session-start.mjs +56 -20
- package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
- package/node_modules/projectstore/package.json +12 -2
- package/node_modules/projectstore/scaffold/headings.json +8 -0
- package/node_modules/projectstore/scripts/band-shared.mjs +446 -0
- package/node_modules/projectstore/scripts/band.mjs +207 -0
- package/node_modules/projectstore/scripts/binding.mjs +733 -62
- package/node_modules/projectstore/scripts/build-adapters.mjs +98 -35
- package/node_modules/projectstore/scripts/cli.mjs +1463 -267
- package/node_modules/projectstore/scripts/codemap.mjs +22 -7
- package/node_modules/projectstore/scripts/convergence.mjs +638 -0
- package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
- package/node_modules/projectstore/scripts/doctor-report.mjs +253 -0
- package/node_modules/projectstore/scripts/doctor.mjs +1384 -420
- package/node_modules/projectstore/scripts/draft.mjs +5 -2
- package/node_modules/projectstore/scripts/fetch-shell.mjs +369 -0
- package/node_modules/projectstore/scripts/graph.mjs +19 -13
- package/node_modules/projectstore/scripts/harness.mjs +283 -37
- package/node_modules/projectstore/scripts/install-harness.mjs +4701 -575
- package/node_modules/projectstore/scripts/kanban.mjs +6 -21
- package/node_modules/projectstore/scripts/lib.mjs +933 -199
- package/node_modules/projectstore/scripts/mcp.mjs +386 -105
- package/node_modules/projectstore/scripts/portable-registration.mjs +276 -56
- package/node_modules/projectstore/scripts/provenance.mjs +26 -10
- package/node_modules/projectstore/scripts/query.mjs +441 -72
- package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
- package/node_modules/projectstore/scripts/run-host.mjs +139 -0
- package/node_modules/projectstore/scripts/scaffold.mjs +244 -0
- package/node_modules/projectstore/scripts/setup.mjs +987 -0
- package/node_modules/projectstore/scripts/stages.mjs +84 -0
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
- package/node_modules/projectstore/scripts/statusline.mjs +55 -14
- package/node_modules/projectstore/scripts/story-section.mjs +39 -17
- package/node_modules/projectstore/scripts/surfaces.mjs +485 -99
- package/node_modules/projectstore/scripts/term.mjs +1027 -52
- package/node_modules/projectstore/scripts/tokens.mjs +106 -28
- package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
- package/node_modules/projectstore/scripts/version-guard.mjs +79 -27
- package/node_modules/projectstore/scripts/worktree.mjs +16 -5
- 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
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
import { readFileSync, existsSync, realpathSync, readdirSync } from "node:fs";
|
|
28
28
|
import { join, resolve } from "node:path";
|
|
29
29
|
import { homedir } from "node:os";
|
|
30
|
-
import { loadHarnesses,
|
|
30
|
+
import { loadHarnesses, harnessIds, detectHarnesses, sourceHarness, MANIFEST_DIR } from "./harness.mjs";
|
|
31
31
|
import { stamp, deriveState, parseProvenance, sourceHash, STALE, STALE_TEXT, FOREIGN_TEXT } from "./provenance.mjs";
|
|
32
32
|
import {
|
|
33
33
|
pluginRoot,
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
claudeHome,
|
|
45
45
|
whichOnPath,
|
|
46
46
|
installedPluginEntries,
|
|
47
|
+
projectCopiesByKey,
|
|
47
48
|
pluginEnabled,
|
|
48
49
|
treeDigest,
|
|
49
50
|
packageDigest,
|
|
@@ -52,11 +53,17 @@ import {
|
|
|
52
53
|
RUNTIME_GITIGNORE_HEADER,
|
|
53
54
|
LAUNCHER_HEADER,
|
|
54
55
|
commandForm,
|
|
56
|
+
registrationProvenance,
|
|
55
57
|
} from "./lib.mjs";
|
|
56
|
-
import {
|
|
58
|
+
import {
|
|
59
|
+
analysePortableRegistration,
|
|
60
|
+
portableRegistrationPaths,
|
|
61
|
+
portablePayloadRoot,
|
|
62
|
+
} from "./portable-registration.mjs";
|
|
57
63
|
|
|
58
64
|
export const GENERATOR = "scripts/install-harness.mjs";
|
|
59
|
-
export const INSTALLED_REMEDY =
|
|
65
|
+
export const INSTALLED_REMEDY =
|
|
66
|
+
"projectstore doctor reports this file when it is stale; run install again to refresh it.";
|
|
60
67
|
// The line every launcher has carried since the template was written; the
|
|
61
68
|
// recogniser for a pre-provenance launcher (contract 4, rung 1″). A substring
|
|
62
69
|
// of the template's own header, so a reworded comment cannot turn every
|
|
@@ -89,39 +96,90 @@ export function isOurFile(text) {
|
|
|
89
96
|
|
|
90
97
|
// ─── markdown-block: the ADR-002 block ─────────────────────────────────
|
|
91
98
|
|
|
92
|
-
|
|
99
|
+
// `layout` names the roster the run leaves (a layout name; the binding's when
|
|
100
|
+
// null). `readers` is a run's harnesses as [{ display, files }], passed when a
|
|
101
|
+
// run names more than one: the migration reason then names the one whose
|
|
102
|
+
// files lack the block's file, rather than the harness whose surface `s` is
|
|
103
|
+
// (the story "One run plans the agents block once…", rules 1 and 3).
|
|
104
|
+
export function analyseBlock(
|
|
105
|
+
projectDir,
|
|
106
|
+
s,
|
|
107
|
+
{ root = pluginRoot(), manifestDir = MANIFEST_DIR, layout = null, readers = null } = {},
|
|
108
|
+
) {
|
|
93
109
|
const files = s.files || ["AGENTS.md", "CLAUDE.md"];
|
|
94
|
-
const PREFERRED = files[0],
|
|
110
|
+
const PREFERRED = files[0],
|
|
111
|
+
FALLBACK = files[files.length - 1];
|
|
95
112
|
// LOOK across every file any manifest names; WRITE to one this harness can
|
|
96
113
|
// read. The two are different questions and conflating them is what let the
|
|
97
114
|
// block be created twice: a harness that scanned only its own list could not
|
|
98
115
|
// see the copy another harness had already written, so it created a second.
|
|
99
116
|
// Scanning is free and static — the union is manifest data, not project
|
|
100
117
|
// state — while the write target stays this manifest's own preference.
|
|
101
|
-
const scan = [
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
118
|
+
const scan = [
|
|
119
|
+
...files,
|
|
120
|
+
...[...loadHarnesses(manifestDir).values()]
|
|
121
|
+
.flatMap((m) => m.surfaces?.agents_block?.files || [])
|
|
122
|
+
.filter((f) => !files.includes(f)),
|
|
123
|
+
];
|
|
124
|
+
const found = scan
|
|
125
|
+
.map((f) => ({ file: f, path: join(projectDir, f), ...readText(join(projectDir, f)) }))
|
|
105
126
|
.map((e) => ({ ...e, block: e.text !== null ? findAgentsBlock(e.text) : null, own: files.includes(e.file) }));
|
|
106
127
|
const withBlock = found.filter((e) => e.block);
|
|
107
128
|
const preferred = found.find((e) => e.file === PREFERRED && e.present) || found.find((e) => e.file === FALLBACK);
|
|
108
129
|
const claude = found.find((e) => e.file === FALLBACK);
|
|
109
130
|
const importLine = `@${PREFERRED}`;
|
|
110
|
-
const a = {
|
|
131
|
+
const a = {
|
|
132
|
+
files: found,
|
|
133
|
+
own: files,
|
|
134
|
+
withBlock,
|
|
135
|
+
preferred,
|
|
136
|
+
claude,
|
|
137
|
+
importLine,
|
|
138
|
+
PREFERRED,
|
|
139
|
+
FALLBACK,
|
|
140
|
+
entryKey: "projectstore:agents",
|
|
141
|
+
version: null,
|
|
142
|
+
desired: null,
|
|
143
|
+
current: null,
|
|
144
|
+
state: "ours-absent",
|
|
145
|
+
reason: null,
|
|
146
|
+
refusal: null,
|
|
147
|
+
};
|
|
111
148
|
|
|
112
149
|
const unclosed = withBlock.find((e) => e.block.unclosed);
|
|
113
|
-
if (unclosed && unclosed.block.wrapped)
|
|
114
|
-
|
|
150
|
+
if (unclosed && unclosed.block.wrapped)
|
|
151
|
+
return {
|
|
152
|
+
...a,
|
|
153
|
+
state: "unparseable",
|
|
154
|
+
refusal: `${unclosed.file}:${unclosed.block.line}: the projectstore:agents open marker does not close on its own line — the parser reads one line. Put \`-->\` back on the marker's line, then run ${commandForm("agents", { args: "register" })}`,
|
|
155
|
+
};
|
|
156
|
+
if (unclosed)
|
|
157
|
+
return {
|
|
158
|
+
...a,
|
|
159
|
+
state: "unparseable",
|
|
160
|
+
refusal: `${unclosed.file}: the block opens and never closes — restore the closing marker or remove the block by hand`,
|
|
161
|
+
};
|
|
115
162
|
const twice = withBlock.find((e) => e.block.count > 1);
|
|
116
|
-
if (twice)
|
|
163
|
+
if (twice)
|
|
164
|
+
return { ...a, state: "unparseable", refusal: `${twice.file} carries the block more than once — keep exactly one` };
|
|
117
165
|
|
|
118
166
|
const tmpl = readText(agentsBlockTemplatePath(root));
|
|
119
|
-
if (!tmpl.present || tmpl.text === null)
|
|
167
|
+
if (!tmpl.present || tmpl.text === null)
|
|
168
|
+
return {
|
|
169
|
+
...a,
|
|
170
|
+
state: "unparseable",
|
|
171
|
+
refusal: `the block's source is missing from the plugin at ${agentsBlockTemplatePath(root)}`,
|
|
172
|
+
};
|
|
120
173
|
a.version = agentsBlockVersion(tmpl.text);
|
|
121
174
|
const cfg = readConfigAt(projectDir);
|
|
122
175
|
let roster = null;
|
|
123
|
-
|
|
124
|
-
|
|
176
|
+
const name = layout ?? cfg?.layout;
|
|
177
|
+
if (typeof name === "string") {
|
|
178
|
+
try {
|
|
179
|
+
roster = loadLayout(name, root).agents || null;
|
|
180
|
+
} catch {
|
|
181
|
+
roster = null;
|
|
182
|
+
}
|
|
125
183
|
}
|
|
126
184
|
a.desired = renderAgentsBlock(tmpl.text, roster);
|
|
127
185
|
a.current = withBlock.find((e) => e.file === preferred.file) || withBlock[0] || null;
|
|
@@ -134,11 +192,31 @@ export function analyseBlock(projectDir, s, { root = pluginRoot(), manifestDir =
|
|
|
134
192
|
// never acquires an AGENTS.md it did not ask for, while installing Codex into
|
|
135
193
|
// that same project moves the substance to the file Codex can read and leaves
|
|
136
194
|
// CLAUDE.md importing it.
|
|
137
|
-
if (!a.current.own)
|
|
138
|
-
|
|
139
|
-
|
|
195
|
+
if (!a.current.own) {
|
|
196
|
+
const reader = readers?.find((r) => !(r.files || []).includes(a.current.file))?.display;
|
|
197
|
+
return {
|
|
198
|
+
...a,
|
|
199
|
+
state: "ours-stale",
|
|
200
|
+
reason: `in ${a.current.file}, which ${reader || s.harness_display || "this harness"} does not read; install moves it to ${preferred.file}`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (a.current.file !== preferred.file && preferred.present)
|
|
204
|
+
return { ...a, state: "ours-stale", reason: `in ${a.current.file}; install migrates it to ${preferred.file}` };
|
|
205
|
+
if (a.duplicates.length)
|
|
206
|
+
return {
|
|
207
|
+
...a,
|
|
208
|
+
state: "ours-stale",
|
|
209
|
+
reason: `also in ${a.duplicates.map((e) => e.file).join(", ")}; install keeps the one in ${a.current.file}`,
|
|
210
|
+
};
|
|
140
211
|
if (a.current.block.v === a.version && a.current.block.block === a.desired) return { ...a, state: "ours-current" };
|
|
141
|
-
return {
|
|
212
|
+
return {
|
|
213
|
+
...a,
|
|
214
|
+
state: "ours-stale",
|
|
215
|
+
reason:
|
|
216
|
+
a.current.block.v !== a.version
|
|
217
|
+
? `v${a.current.block.v} → v${a.version}`
|
|
218
|
+
: "content differs from the current source",
|
|
219
|
+
};
|
|
142
220
|
}
|
|
143
221
|
|
|
144
222
|
// ─── json-entry: one entry in a JSON file the user co-owns ─────────────
|
|
@@ -151,18 +229,40 @@ export function analyseJsonEntry(projectDir, s, { root = pluginRoot(), home = ho
|
|
|
151
229
|
const pointer = s.marker?.pointer || "statusLine.command";
|
|
152
230
|
const entryKey = pointer.split(".")[0];
|
|
153
231
|
const cur = readText(path);
|
|
154
|
-
const a = {
|
|
232
|
+
const a = {
|
|
233
|
+
path,
|
|
234
|
+
entryKey,
|
|
235
|
+
cur,
|
|
236
|
+
settings: {},
|
|
237
|
+
curEntry: null,
|
|
238
|
+
curCmd: null,
|
|
239
|
+
ours: false,
|
|
240
|
+
desired: null,
|
|
241
|
+
state: "ours-absent",
|
|
242
|
+
reason: null,
|
|
243
|
+
};
|
|
155
244
|
if (cur.present) {
|
|
156
245
|
if (cur.text === null) return { ...a, state: "unparseable", reason: "the file cannot be read" };
|
|
157
|
-
try {
|
|
158
|
-
|
|
246
|
+
try {
|
|
247
|
+
a.settings = JSON.parse(cur.text);
|
|
248
|
+
} catch {
|
|
249
|
+
return {
|
|
250
|
+
...a,
|
|
251
|
+
state: "unparseable",
|
|
252
|
+
reason: "the file is not valid JSON — nothing outside a marked entry is touched, so nothing is written",
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
if (!a.settings || typeof a.settings !== "object" || Array.isArray(a.settings))
|
|
256
|
+
return { ...a, state: "unparseable", reason: "the file is not a JSON object" };
|
|
159
257
|
}
|
|
160
258
|
a.curEntry = a.settings[entryKey] ?? null;
|
|
161
259
|
a.curCmd = a.curEntry && typeof a.curEntry.command === "string" ? a.curEntry.command : null;
|
|
162
260
|
// "Did we write this?" is asked of the EXISTING entry, written by whatever
|
|
163
261
|
// root was current then: a dev checkout's direct wiring stays ours when the
|
|
164
262
|
// plan now renders against a registration's install path (reviewer, 2026-09-05).
|
|
165
|
-
a.ours =
|
|
263
|
+
a.ours =
|
|
264
|
+
statusLineIsOurWiring(a.curCmd, projectDir, home, root) ||
|
|
265
|
+
statusLineIsOurWiring(a.curCmd, projectDir, home, renderRoot);
|
|
166
266
|
a.desired = desiredStatusLineCommand(projectDir, renderRoot, home).command;
|
|
167
267
|
if (!a.curEntry) return { ...a, state: "ours-absent" };
|
|
168
268
|
if (!a.ours) return { ...a, state: "theirs", reason: "a status line we did not write owns the slot" };
|
|
@@ -172,7 +272,11 @@ export function analyseJsonEntry(projectDir, s, { root = pluginRoot(), home = ho
|
|
|
172
272
|
|
|
173
273
|
// ─── mjs: an exclusive, provenance-stamped file ────────────────────────
|
|
174
274
|
|
|
175
|
-
export function analyseStampedFile(
|
|
275
|
+
export function analyseStampedFile(
|
|
276
|
+
projectDir,
|
|
277
|
+
s,
|
|
278
|
+
{ root = pluginRoot(), home = homedir(), harness = null, renderRoot = root } = {},
|
|
279
|
+
) {
|
|
176
280
|
const path = join(projectDir, s.file);
|
|
177
281
|
let file = readText(path);
|
|
178
282
|
// The manifest may name where an earlier release wrote this file
|
|
@@ -181,28 +285,74 @@ export function analyseStampedFile(projectDir, s, { root = pluginRoot(), home =
|
|
|
181
285
|
const legacyPath = s.legacy_file ? join(projectDir, s.legacy_file) : null;
|
|
182
286
|
const atLegacy = !file.present && legacyPath && existsSync(legacyPath);
|
|
183
287
|
if (atLegacy) file = readText(legacyPath);
|
|
184
|
-
const a = {
|
|
288
|
+
const a = {
|
|
289
|
+
path,
|
|
290
|
+
file,
|
|
291
|
+
produced: true,
|
|
292
|
+
ours: null,
|
|
293
|
+
refusal: null,
|
|
294
|
+
stamped: null,
|
|
295
|
+
state: "absent",
|
|
296
|
+
reason: null,
|
|
297
|
+
writtenBy: null,
|
|
298
|
+
sameProject: false,
|
|
299
|
+
legacy: false,
|
|
300
|
+
renderRoot,
|
|
301
|
+
legacyPath: atLegacy ? legacyPath : null,
|
|
302
|
+
};
|
|
185
303
|
if (s.condition === "plugin_cache_install" && !isPluginCacheRoot(renderRoot, home)) {
|
|
186
304
|
// Not produced for this installation (a dev checkout is wired directly).
|
|
187
305
|
a.produced = false;
|
|
188
306
|
if (!file.present) return { ...a, state: "absent" };
|
|
189
307
|
a.ours = isOurFile(file.text);
|
|
190
|
-
return {
|
|
308
|
+
return {
|
|
309
|
+
...a,
|
|
310
|
+
state: a.ours ? "stale" : "foreign",
|
|
311
|
+
reason: a.ours
|
|
312
|
+
? "not produced for this installation (a dev checkout is wired directly) — left in place; a prune of a launcher this root did not write needs the root that wrote it, or uninstall"
|
|
313
|
+
: null,
|
|
314
|
+
};
|
|
191
315
|
}
|
|
192
316
|
const src = join(root, s.source);
|
|
193
317
|
const tpl = readText(src);
|
|
194
|
-
if (!tpl.present || tpl.text === null)
|
|
318
|
+
if (!tpl.present || tpl.text === null)
|
|
319
|
+
return { ...a, state: "unparseable", refusal: `the source ${s.source} is missing from the plugin at ${root}` };
|
|
195
320
|
const rendered = renderStatusLineLauncher(tpl.text, renderRoot, projectDir);
|
|
196
|
-
if (rendered === null)
|
|
321
|
+
if (rendered === null)
|
|
322
|
+
return { ...a, state: "unparseable", refusal: "the launcher template is not one this installer knows how to fill" };
|
|
197
323
|
const pkg = pluginVersionAt(root, harness) || "0.0.0";
|
|
198
324
|
a.pkg = pkg;
|
|
199
|
-
a.stamped = stamp(rendered, {
|
|
200
|
-
|
|
325
|
+
a.stamped = stamp(rendered, {
|
|
326
|
+
format: s.format,
|
|
327
|
+
src: s.source,
|
|
328
|
+
srcHash: sourceHash(tpl.text),
|
|
329
|
+
pkg,
|
|
330
|
+
project: projectDir,
|
|
331
|
+
harness: harness?.id || sourceHarness()?.id || "harness",
|
|
332
|
+
generator: GENERATOR,
|
|
333
|
+
remedy: INSTALLED_REMEDY,
|
|
334
|
+
});
|
|
335
|
+
let st = deriveState({
|
|
336
|
+
file,
|
|
337
|
+
sourceHash: sourceHash(tpl.text),
|
|
338
|
+
pkg,
|
|
339
|
+
renderNowHash: a.stamped.render,
|
|
340
|
+
project: projectDir,
|
|
341
|
+
});
|
|
201
342
|
// Contract 4, rung 1″: a launcher written before provenance existed carries
|
|
202
343
|
// no line but the template's own header. It is ours, stale, replaceable.
|
|
203
|
-
if (st.state === "foreign" && typeof file.text === "string" && file.text.includes(LAUNCHER_HEADER)) {
|
|
344
|
+
if (st.state === "foreign" && typeof file.text === "string" && file.text.includes(LAUNCHER_HEADER)) {
|
|
345
|
+
st = { ...st, state: "stale", reason: STALE.PLUGIN };
|
|
346
|
+
a.legacy = true;
|
|
347
|
+
}
|
|
204
348
|
a.installedPkg = st.provenance?.pkg || null;
|
|
205
|
-
return {
|
|
349
|
+
return {
|
|
350
|
+
...a,
|
|
351
|
+
state: st.state,
|
|
352
|
+
reason: st.reason ? STALE_TEXT[st.reason] + (a.legacy ? " (pre-provenance file)" : "") : null,
|
|
353
|
+
writtenBy: st.writtenBy,
|
|
354
|
+
sameProject: st.sameProject,
|
|
355
|
+
};
|
|
206
356
|
}
|
|
207
357
|
|
|
208
358
|
// ─── host-plugin-registration: our marketplace directory + the host's registry ──
|
|
@@ -214,12 +364,22 @@ export function analyseStampedFile(projectDir, s, { root = pluginRoot(), home =
|
|
|
214
364
|
// located, never run.
|
|
215
365
|
|
|
216
366
|
function samePath(a, b) {
|
|
217
|
-
const real = (p) => {
|
|
367
|
+
const real = (p) => {
|
|
368
|
+
try {
|
|
369
|
+
return realpathSync(p);
|
|
370
|
+
} catch {
|
|
371
|
+
return resolve(p);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
218
374
|
return Boolean(a && b) && real(a) === real(b);
|
|
219
375
|
}
|
|
220
376
|
|
|
221
377
|
function readJson(p) {
|
|
222
|
-
try {
|
|
378
|
+
try {
|
|
379
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
380
|
+
} catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
223
383
|
}
|
|
224
384
|
|
|
225
385
|
export function registrationPaths(s, { home = homedir(), projectDir = null, harness = null } = {}) {
|
|
@@ -239,35 +399,68 @@ export function registrationPaths(s, { home = homedir(), projectDir = null, harn
|
|
|
239
399
|
};
|
|
240
400
|
}
|
|
241
401
|
|
|
242
|
-
export function analyseRegistration(
|
|
402
|
+
export function analyseRegistration(
|
|
403
|
+
projectDir,
|
|
404
|
+
s,
|
|
405
|
+
{ root = pluginRoot(), home = homedir(), harness = null, env = process.env } = {},
|
|
406
|
+
) {
|
|
243
407
|
const id = `${s.plugin_name}@${s.marketplace_name}`;
|
|
244
408
|
const paths = registrationPaths(s, { home, projectDir, harness });
|
|
245
409
|
const pkg = pluginVersionAt(root, harness) || "0.0.0";
|
|
246
|
-
const a = {
|
|
247
|
-
|
|
248
|
-
|
|
410
|
+
const a = {
|
|
411
|
+
id,
|
|
412
|
+
paths,
|
|
413
|
+
path: paths.dir,
|
|
414
|
+
entryKey: id,
|
|
415
|
+
pkg,
|
|
416
|
+
bin: whichOnPath(s.cli?.bin || "claude", env),
|
|
417
|
+
produced: !(s.condition === "npm_package_root" && isPluginCacheRoot(root, home)),
|
|
418
|
+
dir: { present: existsSync(paths.dir), manifest: null, prov: null, pkg: null, disabled: [], digestOk: null },
|
|
419
|
+
known: null,
|
|
420
|
+
registeredHere: null,
|
|
421
|
+
installed: null,
|
|
422
|
+
installedVersion: null,
|
|
423
|
+
installPath: null,
|
|
424
|
+
enabled: null,
|
|
425
|
+
others: [],
|
|
426
|
+
otherProjects: 0,
|
|
427
|
+
writtenBy: null,
|
|
428
|
+
newer: false,
|
|
429
|
+
state: "absent",
|
|
430
|
+
reason: null,
|
|
431
|
+
refusal: null,
|
|
432
|
+
};
|
|
249
433
|
|
|
250
434
|
// Machine-global facts: our directory, the host's marketplace registry, every row of ours.
|
|
251
435
|
if (a.dir.present) {
|
|
252
436
|
a.dir.manifest = readJson(paths.manifest);
|
|
253
|
-
const prov = a.dir.manifest
|
|
254
|
-
if (prov
|
|
255
|
-
a.dir.prov = prov;
|
|
437
|
+
const prov = registrationProvenance(a.dir.manifest, s);
|
|
438
|
+
if (prov) {
|
|
439
|
+
a.dir.prov = prov;
|
|
440
|
+
a.dir.pkg = prov.pkg;
|
|
256
441
|
a.dir.disabled = Array.isArray(prov.disabled) ? prov.disabled.filter((x) => typeof x === "string") : [];
|
|
257
442
|
if (typeof prov.project === "string" && !samePath(prov.project, projectDir)) a.writtenBy = prov.project;
|
|
258
443
|
// The payload digest: a copy that did not finish, or a hand edit, is not current.
|
|
259
444
|
if (prov.digest && typeof prov.digest.sha256 === "string") {
|
|
260
|
-
try {
|
|
445
|
+
try {
|
|
446
|
+
const d = treeDigest(paths.payload);
|
|
447
|
+
a.dir.digestOk = d.count === prov.digest.count && d.sha256 === prov.digest.sha256;
|
|
448
|
+
} catch {
|
|
449
|
+
a.dir.digestOk = false;
|
|
450
|
+
}
|
|
261
451
|
} else a.dir.digestOk = null; // no digest recorded: nothing to compare
|
|
262
452
|
}
|
|
263
453
|
}
|
|
264
454
|
const known = readJson(paths.marketplaces);
|
|
265
455
|
const mp = known && known[s.marketplace_name];
|
|
266
|
-
a.known = mp ? { path:
|
|
456
|
+
a.known = mp ? { path: mp.installLocation || mp.source?.path || null, source: mp.source?.source || null } : null;
|
|
267
457
|
const all = installedPluginEntries(home, projectDir).filter((e) => e.key === id);
|
|
268
458
|
// Per-project facts: the checkout's own settings file and the registry row carrying its path.
|
|
269
459
|
const local = readJson(paths.projectSettings);
|
|
270
|
-
const here =
|
|
460
|
+
const here =
|
|
461
|
+
local &&
|
|
462
|
+
local[s.registry?.known_pointer || "extraKnownMarketplaces"] &&
|
|
463
|
+
local[s.registry?.known_pointer || "extraKnownMarketplaces"][s.marketplace_name];
|
|
271
464
|
a.registeredHere = here ? { path: here.source?.path || null } : null;
|
|
272
465
|
const enabledHere = (local && local[s.registry?.enabled_pointer || "enabledPlugins"]) || {};
|
|
273
466
|
a.disabledHere = Object.keys(enabledHere).filter((k) => enabledHere[k] === false);
|
|
@@ -278,19 +471,25 @@ export function analyseRegistration(projectDir, s, { root = pluginRoot(), home =
|
|
|
278
471
|
a.installPath = row ? row.path : null;
|
|
279
472
|
a.installedVersion = row ? row.version : null;
|
|
280
473
|
a.enabled = pluginEnabled(id, home, projectDir);
|
|
281
|
-
// Competing registrations of the same plugin, enabled for this project
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
474
|
+
// Competing registrations of the same plugin, enabled for this project:
|
|
475
|
+
// contract 12's selection, one row per key — the row the host loads here,
|
|
476
|
+
// this checkout's own before a user-scope one. The convergence story's rule
|
|
477
|
+
// 6 reads the same rows (lib's projectCopiesByKey), so the deferral of a
|
|
478
|
+
// newer copy and the stop never disagree about its version.
|
|
479
|
+
for (const e of projectCopiesByKey(home, projectDir)) {
|
|
480
|
+
if (e.key !== id) a.others.push({ key: e.key, path: e.path, version: e.version });
|
|
287
481
|
}
|
|
288
482
|
a.newer = Boolean(a.dir.pkg) && cmpPrecedence(a.dir.pkg, pkg) > 0;
|
|
289
483
|
// Same version, different payload: the directory's recorded digest against
|
|
290
484
|
// this package's — contract 4's rung 3 (source changed) for a directory.
|
|
291
485
|
a.contentDiffers = null;
|
|
292
486
|
if (a.dir.prov && a.dir.pkg === pkg && a.dir.prov.digest && typeof a.dir.prov.digest.sha256 === "string") {
|
|
293
|
-
try {
|
|
487
|
+
try {
|
|
488
|
+
const d = packageDigest(root);
|
|
489
|
+
a.contentDiffers = d.sha256 !== a.dir.prov.digest.sha256 || d.count !== a.dir.prov.digest.count;
|
|
490
|
+
} catch {
|
|
491
|
+
a.contentDiffers = null;
|
|
492
|
+
}
|
|
294
493
|
}
|
|
295
494
|
// The version the host would install from the directory as it will stand after this run.
|
|
296
495
|
a.targetPkg = a.newer ? a.dir.pkg : pkg;
|
|
@@ -300,22 +499,100 @@ export function analyseRegistration(projectDir, s, { root = pluginRoot(), home =
|
|
|
300
499
|
const bin = s.cli?.bin || "claude";
|
|
301
500
|
|
|
302
501
|
// The ladder (contract 4′).
|
|
303
|
-
if (!a.bin && nothingOfOurs)
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
502
|
+
if (!a.bin && nothingOfOurs)
|
|
503
|
+
return {
|
|
504
|
+
...a,
|
|
505
|
+
state: "unavailable",
|
|
506
|
+
reason: `\`${bin}\` is not on PATH — the registration needs the host's CLI; the other surfaces still install`,
|
|
507
|
+
};
|
|
508
|
+
if (a.dir.present && !a.dir.prov)
|
|
509
|
+
return {
|
|
510
|
+
...a,
|
|
511
|
+
state: "foreign",
|
|
512
|
+
refusal: `${paths.dir} exists and its ${s.manifest} carries no \`${s.provenance_key}\` field of ours — a directory we did not write sits at our path; move it if it is yours, or delete it to let install take the name`,
|
|
513
|
+
};
|
|
514
|
+
if (a.known && a.known.path && !samePath(a.known.path, paths.dir))
|
|
515
|
+
return {
|
|
516
|
+
...a,
|
|
517
|
+
state: "foreign",
|
|
518
|
+
refusal: `the host's registry names marketplace \`${s.marketplace_name}\` at ${a.known.path}, not at ${paths.dir} — a registration we did not make holds our name; \`${bin} plugin marketplace remove ${s.marketplace_name}\` lets install take it`,
|
|
519
|
+
};
|
|
520
|
+
if (a.registeredHere && a.registeredHere.path && !samePath(a.registeredHere.path, paths.dir))
|
|
521
|
+
return {
|
|
522
|
+
...a,
|
|
523
|
+
state: "foreign",
|
|
524
|
+
refusal: `${paths.projectSettings} declares marketplace \`${s.marketplace_name}\` at ${a.registeredHere.path}, not at ${paths.dir} — remove that entry to let install take the name`,
|
|
525
|
+
};
|
|
307
526
|
if (nothingOfOurs) return { ...a, state: "absent" };
|
|
308
527
|
// The directory is shared by every checkout on the machine; a project that never registered is absent, whatever the directory holds.
|
|
309
|
-
if (nothingOfOursHere)
|
|
528
|
+
if (nothingOfOursHere)
|
|
529
|
+
return {
|
|
530
|
+
...a,
|
|
531
|
+
state: "absent",
|
|
532
|
+
reason: a.dir.present
|
|
533
|
+
? `the marketplace directory is present${a.writtenBy ? ` (written from ${a.writtenBy})` : ""}; this checkout is not registered`
|
|
534
|
+
: null,
|
|
535
|
+
};
|
|
310
536
|
if (!a.dir.present) return { ...a, state: "stale", reason: STALE_TEXT[STALE.PLUGIN] + " (the directory is gone)" };
|
|
311
|
-
if (cmpPrecedence(a.dir.pkg, pkg) < 0)
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
537
|
+
if (cmpPrecedence(a.dir.pkg, pkg) < 0)
|
|
538
|
+
return {
|
|
539
|
+
...a,
|
|
540
|
+
state: "stale",
|
|
541
|
+
reason: STALE_TEXT[STALE.PLUGIN] + ` (directory at ${a.dir.pkg}, package at ${pkg})`,
|
|
542
|
+
};
|
|
543
|
+
if (a.dir.digestOk === false)
|
|
544
|
+
return {
|
|
545
|
+
...a,
|
|
546
|
+
state: "stale",
|
|
547
|
+
reason: "edited by hand or half-written (the payload does not match the digest its manifest carries)",
|
|
548
|
+
};
|
|
549
|
+
if (a.contentDiffers === true)
|
|
550
|
+
return {
|
|
551
|
+
...a,
|
|
552
|
+
state: "stale",
|
|
553
|
+
reason:
|
|
554
|
+
STALE_TEXT[STALE.PLUGIN] +
|
|
555
|
+
` (same version ${pkg}, different content — the directory's digest is not this package's)`,
|
|
556
|
+
};
|
|
557
|
+
if (!a.known || !a.registeredHere)
|
|
558
|
+
return {
|
|
559
|
+
...a,
|
|
560
|
+
state: "stale",
|
|
561
|
+
reason:
|
|
562
|
+
STALE_TEXT[STALE.CONFIG] +
|
|
563
|
+
(a.known ? " (this checkout does not declare the marketplace)" : " (the host does not know the marketplace)"),
|
|
564
|
+
};
|
|
565
|
+
if (!a.installed || !a.installed.present)
|
|
566
|
+
return {
|
|
567
|
+
...a,
|
|
568
|
+
state: "stale",
|
|
569
|
+
reason:
|
|
570
|
+
"not installed" +
|
|
571
|
+
(a.installed ? " (the host's install path is gone)" : " (registered, not installed for this checkout)"),
|
|
572
|
+
};
|
|
573
|
+
if (a.installedVersion !== a.dir.pkg)
|
|
574
|
+
return {
|
|
575
|
+
...a,
|
|
576
|
+
state: "stale",
|
|
577
|
+
reason: STALE_TEXT[STALE.PLUGIN] + ` (installed ${a.installedVersion}, directory ${a.dir.pkg})`,
|
|
578
|
+
};
|
|
317
579
|
if (!a.enabled) return { ...a, state: "stale", reason: "disabled for this checkout" };
|
|
318
|
-
return {
|
|
580
|
+
return {
|
|
581
|
+
...a,
|
|
582
|
+
state: "current",
|
|
583
|
+
reason: a.newer
|
|
584
|
+
? `the directory is at ${a.dir.pkg}, newer than this package (${pkg})${a.writtenBy ? `, written from ${a.writtenBy}` : ""} — not downgraded`
|
|
585
|
+
: null,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// The root a registration renders the project's dependent surfaces against,
|
|
590
|
+
// read-only (contract 4′, two phases): the host's install path of this
|
|
591
|
+
// checkout's row, when that copy is on disk and enabled; else null. One rule
|
|
592
|
+
// for plan() (a run that leaves the registration out) and surfaceStates
|
|
593
|
+
// (doctor), so the two cannot disagree about which root a launcher is for.
|
|
594
|
+
export function registrationRenderRoot(a) {
|
|
595
|
+
return a && a.installed && (a.installed.present ?? true) && a.enabled ? a.installPath : null;
|
|
319
596
|
}
|
|
320
597
|
|
|
321
598
|
// ─── the project-level layout: legacy / new / both (the layout ADR) ─────
|
|
@@ -329,13 +606,27 @@ export function analyseLayout(projectDir, { harness = null } = {}) {
|
|
|
329
606
|
const has = (f) => existsSync(f);
|
|
330
607
|
const legacy = {
|
|
331
608
|
dir: p.legacy.dir,
|
|
332
|
-
binding: has(p.legacy.binding),
|
|
333
|
-
|
|
334
|
-
|
|
609
|
+
binding: has(p.legacy.binding),
|
|
610
|
+
runtime: has(p.legacy.runtime),
|
|
611
|
+
state: has(p.legacy.state),
|
|
612
|
+
launcher: has(p.legacy.launcher),
|
|
613
|
+
entryLog: has(p.legacy.entryLog),
|
|
614
|
+
welcomed: has(p.legacy.welcomed),
|
|
615
|
+
sessionId: has(p.legacy.sessionId),
|
|
616
|
+
runtimeOurs: false,
|
|
617
|
+
stateFiles: [],
|
|
335
618
|
};
|
|
336
619
|
if (legacy.runtime) {
|
|
337
|
-
try {
|
|
338
|
-
|
|
620
|
+
try {
|
|
621
|
+
legacy.runtimeOurs = readFileSync(p.legacy.gitignore, "utf8").includes(RUNTIME_GITIGNORE_HEADER);
|
|
622
|
+
} catch {
|
|
623
|
+
legacy.runtimeOurs = false;
|
|
624
|
+
}
|
|
625
|
+
if (legacy.state) {
|
|
626
|
+
try {
|
|
627
|
+
legacy.stateFiles = readdirSync(p.legacy.state).filter((n) => n !== ".gitignore");
|
|
628
|
+
} catch {}
|
|
629
|
+
}
|
|
339
630
|
}
|
|
340
631
|
const current = { root: has(p.root), binding: has(p.binding) };
|
|
341
632
|
const any = legacy.binding || legacy.runtime || legacy.welcomed || legacy.sessionId;
|
|
@@ -344,28 +635,77 @@ export function analyseLayout(projectDir, { harness = null } = {}) {
|
|
|
344
635
|
let resumable = false;
|
|
345
636
|
if (legacy.binding && current.binding) {
|
|
346
637
|
try {
|
|
347
|
-
const { agents, ...oldRest } = JSON.parse(readFileSync(p.legacy.binding, "utf8"));
|
|
638
|
+
const { agents: _agents, ...oldRest } = JSON.parse(readFileSync(p.legacy.binding, "utf8"));
|
|
348
639
|
const cur = JSON.parse(readFileSync(p.binding, "utf8"));
|
|
349
640
|
resumable = JSON.stringify(oldRest) === JSON.stringify(cur);
|
|
350
|
-
} catch {
|
|
641
|
+
} catch {
|
|
642
|
+
resumable = false;
|
|
643
|
+
}
|
|
351
644
|
}
|
|
352
645
|
return {
|
|
353
|
-
paths: p,
|
|
646
|
+
paths: p,
|
|
647
|
+
legacy,
|
|
648
|
+
current,
|
|
649
|
+
resumable,
|
|
354
650
|
twoConfigs: legacy.binding && current.binding,
|
|
355
651
|
pending: any,
|
|
356
|
-
state: !any ? "current" :
|
|
652
|
+
state: !any ? "current" : legacy.binding && current.binding ? "both" : legacy.binding ? "legacy" : "partial",
|
|
357
653
|
};
|
|
358
654
|
}
|
|
359
655
|
|
|
656
|
+
// ─── a harness's state directory: the welcome marker (contract 13) ──────
|
|
657
|
+
//
|
|
658
|
+
// What a project uninstall may take from .projectstore/state/<id>/: the
|
|
659
|
+
// welcome marker, and the directory once it is empty. Without that the
|
|
660
|
+
// directory outlives the uninstall and the next bare upgrade selects the
|
|
661
|
+
// removed harness again (layout spec contract 5). The marker carries no
|
|
662
|
+
// provenance line, so it is recognised three ways at once — the third
|
|
663
|
+
// recogniser contract 13 accepts beside the provenance line and rung 1″: its
|
|
664
|
+
// path, layoutPaths(p).welcomed(id); the state directory's .gitignore carrying
|
|
665
|
+
// RUNTIME_GITIGNORE_HEADER; and its content, one ISO timestamp line, exactly
|
|
666
|
+
// as the SessionStart hook writes it. Anything else in the directory — a
|
|
667
|
+
// launcher, a file of the user's — is named in `others` and never ours to
|
|
668
|
+
// take here: a launcher is its surface item's. Read-only, like everything in
|
|
669
|
+
// this module.
|
|
670
|
+
const WELCOME_STAMP = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z\n?$/;
|
|
671
|
+
export function analyseHarnessState(projectDir, id) {
|
|
672
|
+
const p = layoutPaths(projectDir);
|
|
673
|
+
const dir = p.harnessState(id);
|
|
674
|
+
let names;
|
|
675
|
+
try {
|
|
676
|
+
names = readdirSync(dir).sort();
|
|
677
|
+
} catch {
|
|
678
|
+
return { dir, present: false, names: [], headed: false, marker: null, others: [] };
|
|
679
|
+
}
|
|
680
|
+
let headed = false;
|
|
681
|
+
try {
|
|
682
|
+
headed = readFileSync(p.stateGitignore, "utf8").includes(RUNTIME_GITIGNORE_HEADER);
|
|
683
|
+
} catch {}
|
|
684
|
+
const markerPath = p.welcomed(id);
|
|
685
|
+
const markerName = markerPath.slice(dir.length + 1);
|
|
686
|
+
const t = names.includes(markerName) ? readText(markerPath) : { present: false, text: null };
|
|
687
|
+
const marker = headed && t.present && typeof t.text === "string" && WELCOME_STAMP.test(t.text) ? markerPath : null;
|
|
688
|
+
return { dir, present: true, names, headed, marker, others: names.filter((n) => !(marker && n === markerName)) };
|
|
689
|
+
}
|
|
690
|
+
|
|
360
691
|
// ─── every surface, for doctor ─────────────────────────────────────────
|
|
361
692
|
|
|
362
693
|
export { analysePortableRegistration, portableRegistrationPaths };
|
|
363
|
-
const ANALYSERS = {
|
|
694
|
+
const ANALYSERS = {
|
|
695
|
+
"markdown-block": analyseBlock,
|
|
696
|
+
"json-entry": analyseJsonEntry,
|
|
697
|
+
mjs: analyseStampedFile,
|
|
698
|
+
"host-plugin-registration": analyseRegistration,
|
|
699
|
+
"portable-plugin-registration": analysePortableRegistration,
|
|
700
|
+
};
|
|
364
701
|
|
|
365
702
|
// The states of every non-host, supported surface of every harness this
|
|
366
703
|
// project uses — detected by directory, or carrying a file of ours (contract
|
|
367
704
|
// 16). Host-managed rows have nothing to derive (contract 14).
|
|
368
|
-
export function surfaceStates(
|
|
705
|
+
export function surfaceStates(
|
|
706
|
+
projectDir,
|
|
707
|
+
{ home = homedir(), root = pluginRoot(), manifestDir = MANIFEST_DIR, harnesses = null, env = process.env } = {},
|
|
708
|
+
) {
|
|
369
709
|
projectDir = resolve(projectDir);
|
|
370
710
|
const detected = detectHarnesses(projectDir, { dir: manifestDir }).map((d) => d.id);
|
|
371
711
|
const out = { projectDir, detected, used: [], installable: harnessIds(manifestDir), states: [] };
|
|
@@ -376,37 +716,83 @@ export function surfaceStates(projectDir, { home = homedir(), root = pluginRoot(
|
|
|
376
716
|
// and so does this (S2, the 2026-10-03 review): reporting it from the
|
|
377
717
|
// core printed "<core> is not a portable plugin root" into every project
|
|
378
718
|
// that merely contained the harness's directory.
|
|
379
|
-
const rows = Object.entries(m.surfaces || {}).filter(
|
|
380
|
-
|
|
719
|
+
const rows = Object.entries(m.surfaces || {}).filter(
|
|
720
|
+
([k, s]) =>
|
|
721
|
+
!k.startsWith("_") &&
|
|
722
|
+
s.kind !== "host" &&
|
|
723
|
+
s.supported !== false &&
|
|
724
|
+
ANALYSERS[s.format] &&
|
|
725
|
+
!(s.condition === "distribution_root" && !portablePayloadRoot(s, { root, env })),
|
|
726
|
+
);
|
|
727
|
+
// The render root, as plan() takes it (contract 0 as amended 2026-10-10):
|
|
728
|
+
// an enabled, produced registration's install path. The launcher and the
|
|
729
|
+
// status-line entry are analysed against it — from the marketplace
|
|
730
|
+
// directory the host loads an npm registration from, or from a package
|
|
731
|
+
// root, the running root alone read the launcher as "not produced here".
|
|
732
|
+
// Read once, before the rows (the registration sorts last in the
|
|
733
|
+
// manifest); its own row reuses the result, since the analysis digests
|
|
734
|
+
// the payload.
|
|
735
|
+
const reg = rows.find(([, s]) => s.kind === "registration");
|
|
736
|
+
const regA = reg ? ANALYSERS[reg[1].format](projectDir, reg[1], { root, home, harness: m, env }) : null;
|
|
737
|
+
const renderRoot = (regA && regA.produced !== false && registrationRenderRoot(regA)) || root;
|
|
381
738
|
const states = [];
|
|
382
739
|
for (const [key, s] of rows) {
|
|
383
|
-
const
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
740
|
+
const dependent = renderRoot !== root && ["json-entry", "mjs"].includes(s.format);
|
|
741
|
+
const a =
|
|
742
|
+
reg && s === reg[1]
|
|
743
|
+
? regA
|
|
744
|
+
: ANALYSERS[s.format](projectDir, s, { root, home, harness: m, env, ...(dependent ? { renderRoot } : {}) });
|
|
745
|
+
const entry = s.kind === "shared" ? a.entryKey || s.marker?.pointer || null : null;
|
|
746
|
+
const path =
|
|
747
|
+
a.legacyPath ||
|
|
748
|
+
a.path ||
|
|
749
|
+
(a.current ? a.current.path : a.preferred ? a.preferred.path : join(projectDir, s.file || ""));
|
|
750
|
+
const row = {
|
|
751
|
+
harness: m.id,
|
|
752
|
+
surface: key,
|
|
753
|
+
kind: s.kind,
|
|
754
|
+
path,
|
|
755
|
+
entry,
|
|
756
|
+
state: a.state,
|
|
757
|
+
reason: a.reason || a.refusal || null,
|
|
398
758
|
writtenBy: a.writtenBy || null,
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
759
|
+
sameProject: Boolean(a.sameProject),
|
|
760
|
+
produced: a.produced !== false,
|
|
761
|
+
legacy: Boolean(a.legacy),
|
|
762
|
+
installedPkg: a.installedPkg || null,
|
|
763
|
+
present: a.file ? a.file.present : a.current ? true : a.curEntry ? true : false,
|
|
764
|
+
};
|
|
765
|
+
if (s.kind === "registration")
|
|
766
|
+
Object.assign(row, {
|
|
767
|
+
entry: a.id,
|
|
768
|
+
present: Boolean(a.dir?.present || a.ownership || a.known || a.market || a.installed),
|
|
769
|
+
produced: a.produced,
|
|
770
|
+
pkg: a.pkg || a.desiredVersion || null,
|
|
771
|
+
dirPkg: a.dir?.pkg || a.ownership?.version || null,
|
|
772
|
+
installedVersion: a.installedVersion,
|
|
773
|
+
installPath: a.installPath,
|
|
774
|
+
// The plugin's root inside our directory (registrationPaths' payload):
|
|
775
|
+
// what a host that loads the registration in place runs from.
|
|
776
|
+
payload: a.paths?.payload || null,
|
|
777
|
+
enabled: a.enabled,
|
|
778
|
+
others: a.others || [],
|
|
779
|
+
otherProjects: a.otherProjects || 0,
|
|
780
|
+
writtenBy: a.writtenBy || null,
|
|
781
|
+
newer: Boolean(a.newer),
|
|
782
|
+
bin: a.bin,
|
|
783
|
+
// What this registration silenced for the checkout and the checkout
|
|
784
|
+
// still holds off: exactly the set uninstall re-enables (contract 13).
|
|
785
|
+
silenced: (a.dir?.disabled || []).filter((k) => (a.disabledHere || []).includes(k)),
|
|
786
|
+
});
|
|
405
787
|
states.push(row);
|
|
406
788
|
}
|
|
407
789
|
// A registration is the host's to load and never counts as a file of ours
|
|
408
790
|
// (contract 16): it decides nothing about whether the harness is in use.
|
|
409
|
-
const hasOurs = states.some(
|
|
791
|
+
const hasOurs = states.some(
|
|
792
|
+
(x) =>
|
|
793
|
+
(x.kind === "exclusive" && ["current", "stale"].includes(x.state)) ||
|
|
794
|
+
(x.kind === "shared" && ["ours-current", "ours-stale"].includes(x.state)),
|
|
795
|
+
);
|
|
410
796
|
if (detected.includes(m.id) || hasOurs) {
|
|
411
797
|
out.used.push(m.id);
|
|
412
798
|
// The agents block is deliberately shared across harnesses. Its presence
|