projectstore-claude 0.30.4 → 0.30.6
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/node_modules/projectstore/.claude-plugin/marketplace.json +2 -2
- package/node_modules/projectstore/.claude-plugin/plugin.json +6 -2
- package/node_modules/projectstore/.mcp.json +1 -2
- package/node_modules/projectstore/README.md +65 -16
- package/node_modules/projectstore/agents/archaeologist.md +1 -1
- package/node_modules/projectstore/agents/librarian.md +1 -1
- package/node_modules/projectstore/agents/planner.md +1 -1
- package/node_modules/projectstore/commands/doctor.md +14 -11
- package/node_modules/projectstore/commands/reconcile.md +1 -1
- package/node_modules/projectstore/commands/review.md +2 -2
- package/node_modules/projectstore/commands/story.md +1 -1
- package/node_modules/projectstore/docs/getting-started.md +3 -4
- package/node_modules/projectstore/docs/harnesses.md +1 -1
- package/node_modules/projectstore/docs/how-it-works.md +7 -0
- package/node_modules/projectstore/docs/images/icon.png +0 -0
- package/node_modules/projectstore/harnesses/claude-code.json +61 -11
- package/node_modules/projectstore/harnesses/codex.json +45 -15
- package/node_modules/projectstore/hooks/band.tsx +19 -8
- package/node_modules/projectstore/hooks/hooks.json +6 -6
- package/node_modules/projectstore/hooks/session-start.mjs +55 -24
- package/node_modules/projectstore/hooks/session-stop.mjs +43 -20
- package/node_modules/projectstore/package.json +11 -12
- package/node_modules/projectstore/scripts/band-shared.mjs +17 -2
- package/node_modules/projectstore/scripts/band.mjs +20 -2
- package/node_modules/projectstore/scripts/binding.mjs +1 -1
- package/node_modules/projectstore/scripts/build-adapters.mjs +45 -4
- package/node_modules/projectstore/scripts/cli.mjs +74 -1373
- package/node_modules/projectstore/scripts/convergence.mjs +59 -16
- package/node_modules/projectstore/scripts/doctor.mjs +199 -36
- package/node_modules/projectstore/scripts/harness.mjs +88 -6
- package/node_modules/projectstore/scripts/install-handlers.mjs +925 -0
- package/node_modules/projectstore/scripts/install-harness.mjs +129 -2081
- package/node_modules/projectstore/scripts/install-layout.mjs +453 -0
- package/node_modules/projectstore/scripts/install-preview.mjs +747 -0
- package/node_modules/projectstore/scripts/lib.mjs +387 -107
- package/node_modules/projectstore/scripts/mcp-server.mjs +33 -0
- package/node_modules/projectstore/scripts/mcp.mjs +60 -60
- package/node_modules/projectstore/scripts/model-spec.mjs +24 -0
- package/node_modules/projectstore/scripts/models.mjs +6 -10
- package/node_modules/projectstore/scripts/query.mjs +7 -6
- package/node_modules/projectstore/scripts/read-verbs.mjs +277 -0
- package/node_modules/projectstore/scripts/setup.mjs +62 -12
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -3
- package/node_modules/projectstore/scripts/surfaces.mjs +18 -5
- package/node_modules/projectstore/scripts/touch-session.mjs +30 -15
- package/node_modules/projectstore/scripts/verbs.mjs +1202 -0
- package/node_modules/projectstore/scripts/version-guard.mjs +57 -7
- package/package.json +3 -3
- package/node_modules/projectstore/docs/images/loop-light.svg +0 -94
- package/node_modules/projectstore/docs/images/team-light.svg +0 -79
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
// projectstore — install-handlers.mjs: the installer's planners for the
|
|
2
|
+
// surfaces it writes itself, setup's items, and the plan helpers they share.
|
|
3
|
+
//
|
|
4
|
+
// Moved out of install-harness.mjs as it was (the story "The installer is
|
|
5
|
+
// split into modules under 200 KiB each, with every export and every test
|
|
6
|
+
// unchanged"). The installer's HANDLERS table keys these by the manifest's
|
|
7
|
+
// surfaces.<kind>.format, never by a harness id: planAgentsBlock (the agents
|
|
8
|
+
// block, planned once per run), planJsonEntry (our entry in a shared JSON
|
|
9
|
+
// file, such as the status-line slot) and planStampedFile (an exclusive file
|
|
10
|
+
// carrying the provenance line). Each answers (ctx, key, s) → PlanItem[] from
|
|
11
|
+
// the state surfaces.mjs derived, and writes nothing.
|
|
12
|
+
//
|
|
13
|
+
// Setup's items (the setup story, rule 10): planVault, planStateDir and the
|
|
14
|
+
// `binding` items — planStatuslineFlag, STAMP, stamped and bindingItem, which
|
|
15
|
+
// the installer's keep record, mark and binding apply build on. And the helpers
|
|
16
|
+
// plan(), apply() and the preview share: rel and inside, isWrite, the
|
|
17
|
+
// consequence note, surfaceExcluded, oneAddPerFile and the host-managed
|
|
18
|
+
// report (contract 14).
|
|
19
|
+
//
|
|
20
|
+
// A leaf: it imports neither the installer nor any module that reads the
|
|
21
|
+
// registry, fetches a shell or runs a host command. The registration's
|
|
22
|
+
// planners, which do, stay in install-harness.mjs.
|
|
23
|
+
|
|
24
|
+
import { existsSync } from "node:fs";
|
|
25
|
+
import { join, resolve, relative, isAbsolute } from "node:path";
|
|
26
|
+
import { loadHarnesses, packageCommand, agentsBlockRow } from "./harness.mjs";
|
|
27
|
+
import { FOREIGN_TEXT } from "./provenance.mjs";
|
|
28
|
+
import { analyseBlock, analyseJsonEntry, analyseStampedFile, readText } from "./surfaces.mjs";
|
|
29
|
+
import { bindPlanSteps } from "./binding.mjs";
|
|
30
|
+
import {
|
|
31
|
+
removeAgentsBlock,
|
|
32
|
+
replaceAgentsBlock,
|
|
33
|
+
isPluginCacheRoot,
|
|
34
|
+
isHostInstallRoot,
|
|
35
|
+
isEphemeralRoot,
|
|
36
|
+
importsLine,
|
|
37
|
+
layoutPaths,
|
|
38
|
+
newerBlockWords,
|
|
39
|
+
} from "./lib.mjs";
|
|
40
|
+
|
|
41
|
+
export function rel(projectDir, p) {
|
|
42
|
+
const r = relative(projectDir, p);
|
|
43
|
+
return r && !r.startsWith("..") && !isAbsolute(r) ? r : p;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function inside(dir, parent) {
|
|
47
|
+
const r = relative(parent, dir);
|
|
48
|
+
return r !== "" && !r.startsWith("..") && !isAbsolute(r);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Planned once per RUN by plan(), never once per harness (the story "One run
|
|
52
|
+
// plans the agents block once, and a bare uninstall selects every harness the
|
|
53
|
+
// project uses", rule 1). `s` carries the run's common file list as `files`;
|
|
54
|
+
// `ctx.run` is every harness of the run that has a block, `ctx.harness` the
|
|
55
|
+
// first of them, `ctx.readers` set when the run names more than one, and
|
|
56
|
+
// `ctx.layout` the roster's layout name (rule 3).
|
|
57
|
+
export function planAgentsBlock(ctx, key, s) {
|
|
58
|
+
const { projectDir, mode, root } = ctx;
|
|
59
|
+
const run = ctx.run || [ctx.harness];
|
|
60
|
+
const a = analyseBlock(projectDir, s, { root, layout: ctx.layout || null, readers: ctx.readers || null });
|
|
61
|
+
const { withBlock, preferred, importLine, FALLBACK } = a;
|
|
62
|
+
const items = [];
|
|
63
|
+
if (a.refusal)
|
|
64
|
+
return [
|
|
65
|
+
{
|
|
66
|
+
surface: key,
|
|
67
|
+
kind: "shared",
|
|
68
|
+
path: a.files[0].path,
|
|
69
|
+
entry: "projectstore:agents",
|
|
70
|
+
state: "refused",
|
|
71
|
+
action: "refuse",
|
|
72
|
+
reason: a.refusal,
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const hasImport = (text) => importsLine(text, importLine);
|
|
77
|
+
// A CLAUDE.md that is nothing but the import registration added is ours to
|
|
78
|
+
// delete when the block goes (ADR-002 decision 4); anything else stays.
|
|
79
|
+
const onlyImport = (text) =>
|
|
80
|
+
String(text ?? "")
|
|
81
|
+
.split("\n")
|
|
82
|
+
.every((l) => !l.trim() || l.trim() === importLine);
|
|
83
|
+
// A file the removal leaves empty is deleted when it held only the block
|
|
84
|
+
// (rule 4) — and the fallback file, as before. Keyed on the fallback alone,
|
|
85
|
+
// a run's common list (whose last entry is AGENTS.md) and Claude Code alone
|
|
86
|
+
// both left a 0-byte AGENTS.md (measured 2026-10-08).
|
|
87
|
+
const removal = (e, extra = {}) => {
|
|
88
|
+
const after = removeAgentsBlock(e.text);
|
|
89
|
+
return {
|
|
90
|
+
surface: key,
|
|
91
|
+
kind: "shared",
|
|
92
|
+
path: e.path,
|
|
93
|
+
entry: `projectstore:agents v${e.block.v}`,
|
|
94
|
+
state: "ours-current",
|
|
95
|
+
action: "remove",
|
|
96
|
+
reason: null,
|
|
97
|
+
before: e.text,
|
|
98
|
+
after,
|
|
99
|
+
deleteIfEmpty: e.file === FALLBACK || !after.trim(),
|
|
100
|
+
...extra,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
if (mode === "uninstall") {
|
|
105
|
+
if (!withBlock.length)
|
|
106
|
+
return [
|
|
107
|
+
{
|
|
108
|
+
surface: key,
|
|
109
|
+
kind: "shared",
|
|
110
|
+
path: preferred.path,
|
|
111
|
+
entry: "projectstore:agents",
|
|
112
|
+
state: "ours-absent",
|
|
113
|
+
action: "skip",
|
|
114
|
+
reason: "no block to remove",
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
// The agents block is the one surface the PROJECT owns rather than a
|
|
118
|
+
// harness: several harnesses read the same file. So disowning one harness
|
|
119
|
+
// may not remove it, and measurably did — `uninstall --harness <other>`
|
|
120
|
+
// deleted AGENTS.md while the source harness was still reading it through
|
|
121
|
+
// an @AGENTS.md import, and the reverse stripped the block Codex reads
|
|
122
|
+
// (both measured 2026-09-09). Either way, disowning one harness silently
|
|
123
|
+
// disabled the other.
|
|
124
|
+
//
|
|
125
|
+
// The test is static manifest data, not detection: is the file the block
|
|
126
|
+
// lives in one that ANOTHER manifest also names? No project state is read,
|
|
127
|
+
// so there is no circularity — and the single-harness case is untouched,
|
|
128
|
+
// because a file only one manifest names is that harness's to clear.
|
|
129
|
+
// Over-conservative on purpose: a block left behind is a marked,
|
|
130
|
+
// self-describing region a user can delete, where a deleted file another
|
|
131
|
+
// harness reads is silent breakage. `--surface agents_block` still removes
|
|
132
|
+
// it, which is the confirmation the gate asks for everywhere else.
|
|
133
|
+
//
|
|
134
|
+
// "Another" means a manifest OUTSIDE this run (rule 4): a run that names
|
|
135
|
+
// every harness reading the file is the last one, and removes the block
|
|
136
|
+
// once. Excluding only the planning harness kept the block for a harness
|
|
137
|
+
// that was in the same run.
|
|
138
|
+
const inRun = new Set(run.map((m) => m?.id));
|
|
139
|
+
const outside = [...loadHarnesses().values()].filter((m) => !inRun.has(m.id));
|
|
140
|
+
const alsoRead = (file) => outside.some((m) => (agentsBlockRow(m)?.[1].files || []).includes(file));
|
|
141
|
+
for (const e of withBlock) {
|
|
142
|
+
// Naming the surface removes it regardless: `--surface agents_block`
|
|
143
|
+
// is the confirmation without a terminal, and a terminal is asked
|
|
144
|
+
// (contract 9 as amended 2026-10-04).
|
|
145
|
+
if (!(ctx.surfaces || []).includes(key) && alsoRead(e.file)) {
|
|
146
|
+
items.push({
|
|
147
|
+
surface: key,
|
|
148
|
+
kind: "shared",
|
|
149
|
+
path: e.path,
|
|
150
|
+
entry: `projectstore:agents v${e.block.v}`,
|
|
151
|
+
state: "ours-current",
|
|
152
|
+
action: "skip",
|
|
153
|
+
reason: `${e.file} is read by another harness too — a per-harness uninstall leaves the project's block alone. Remove it with --surface ${key}`,
|
|
154
|
+
});
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
items.push(removal(e));
|
|
158
|
+
}
|
|
159
|
+
// An import that points at a file whose block has just gone is a pointer to
|
|
160
|
+
// nothing. Look across every file any manifest names, not just this one's:
|
|
161
|
+
// with a single-entry list PREFERRED and FALLBACK are the same file, which
|
|
162
|
+
// made the old condition — block in PREFERRED and not in FALLBACK —
|
|
163
|
+
// impossible to satisfy, so the import was never cleaned up at all.
|
|
164
|
+
// An import is dangling only when the file it names will be GONE — not
|
|
165
|
+
// merely when the block left it. A user's own AGENTS.md keeps its prose and
|
|
166
|
+
// survives, and importing it stays meaningful (install contract 13 /
|
|
167
|
+
// ADR-002 decision 4). The condition is therefore apply's own: marked for
|
|
168
|
+
// deletion and left holding nothing (apply's `deleteIfEmpty` branch).
|
|
169
|
+
const vanishing = new Set(
|
|
170
|
+
items
|
|
171
|
+
.filter((i) => i.action === "remove" && i.deleteIfEmpty && typeof i.after === "string" && !i.after.trim())
|
|
172
|
+
.map((i) => rel(projectDir, i.path)),
|
|
173
|
+
);
|
|
174
|
+
// Two independent reasons to take an import out, and the first version of
|
|
175
|
+
// this replaced one with the other:
|
|
176
|
+
// (1) it is OURS and nothing else is there — the registration we added to
|
|
177
|
+
// a file that held nothing else goes with the block (ADR-002
|
|
178
|
+
// decision 4), whether or not its target survives;
|
|
179
|
+
// (2) it DANGLES — the file it names will be gone, so it points at
|
|
180
|
+
// nothing even though the reader's own prose keeps the file alive.
|
|
181
|
+
// A user's AGENTS.md that keeps its prose satisfies neither, and its import
|
|
182
|
+
// stays: that is install contract 13, and it is what caught the mistake.
|
|
183
|
+
//
|
|
184
|
+
// Over the UNION of every manifest's list, not this one's: the file holding
|
|
185
|
+
// the import need not be one this harness can read. Codex's list is a
|
|
186
|
+
// single entry, which also made the old condition — block in PREFERRED and
|
|
187
|
+
// not in FALLBACK — impossible to satisfy, so nothing was ever cleaned up.
|
|
188
|
+
//
|
|
189
|
+
// Except a BRIDGE (rule 4: a narrowed uninstall "leaves the block and the
|
|
190
|
+
// import in place"): the import in the file a harness outside this run
|
|
191
|
+
// reads by itself, pointing at a block this run keeps for that harness.
|
|
192
|
+
// It is the only way that harness sees the block; taking it out because it
|
|
193
|
+
// is ours kept the block and blinded its reader. The source harness's
|
|
194
|
+
// own uninstall still removes its own file's import, as before.
|
|
195
|
+
const blockGone = new Set(
|
|
196
|
+
items.filter((i) => i.action === "remove" && i.surface === key).map((i) => rel(projectDir, i.path)),
|
|
197
|
+
);
|
|
198
|
+
const blockKept = new Set(
|
|
199
|
+
items.filter((i) => i.action === "skip" && i.surface === key).map((i) => rel(projectDir, i.path)),
|
|
200
|
+
);
|
|
201
|
+
const outsideNatives = new Set(outside.map((m) => agentsBlockRow(m)?.[1].reads_natively).filter(Boolean));
|
|
202
|
+
const union = [...new Set([...loadHarnesses().values()].flatMap((m) => m.surfaces?.agents_block?.files || []))];
|
|
203
|
+
for (const file of union) {
|
|
204
|
+
if (blockGone.has(file)) continue;
|
|
205
|
+
const e = readText(join(projectDir, file));
|
|
206
|
+
if (!e.present || e.text === null || !hasImport(e.text)) continue;
|
|
207
|
+
const ours = onlyImport(e.text);
|
|
208
|
+
const dangles = vanishing.has(importLine.slice(1));
|
|
209
|
+
const bridge = blockKept.has(importLine.slice(1)) && outsideNatives.has(file);
|
|
210
|
+
if ((!ours && !dangles) || bridge) continue;
|
|
211
|
+
const after = e.text
|
|
212
|
+
.split("\n")
|
|
213
|
+
.filter((l) => l.trim() !== importLine)
|
|
214
|
+
.join("\n")
|
|
215
|
+
.replace(/^\n+/, "");
|
|
216
|
+
items.push({
|
|
217
|
+
surface: `${key}_import`,
|
|
218
|
+
kind: "shared",
|
|
219
|
+
path: join(projectDir, file),
|
|
220
|
+
entry: importLine,
|
|
221
|
+
state: "ours-current",
|
|
222
|
+
action: "remove",
|
|
223
|
+
reason: ours
|
|
224
|
+
? `${file} holds only the import registration added`
|
|
225
|
+
: `${importLine} points at a file this uninstall removes`,
|
|
226
|
+
before: e.text,
|
|
227
|
+
after,
|
|
228
|
+
deleteIfEmpty: ours,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return items;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const current = a.current;
|
|
235
|
+
// A block marked above this copy's template is never rendered down (the
|
|
236
|
+
// install spec, contracts 4, 6 and 9, as amended 2026-10-14): a move, a
|
|
237
|
+
// migration or a dedupe carries its own bytes, stamped with its own marker.
|
|
238
|
+
const newer = a.newer || null;
|
|
239
|
+
const words = newer ? newerBlockWords(newer.block.v, a.version) : null;
|
|
240
|
+
const body = newer ? newer.block.block : a.desired;
|
|
241
|
+
const entry = `projectstore:agents v${newer ? newer.block.v : a.version}`;
|
|
242
|
+
// Two files, one block each: resolve in favour of the preferred file —
|
|
243
|
+
// register migrates, never duplicates (ADR-002 decision 3).
|
|
244
|
+
for (const e of a.duplicates || [])
|
|
245
|
+
items.push(
|
|
246
|
+
removal(e, {
|
|
247
|
+
state: "ours-stale",
|
|
248
|
+
reason:
|
|
249
|
+
newer === e
|
|
250
|
+
? `carries v${e.block.v}, newer than this copy's v${a.version}; its text replaces the block in ${current.file}`
|
|
251
|
+
: `duplicate of the block in ${current.file}`,
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
let newerInPlace = null;
|
|
255
|
+
|
|
256
|
+
if (!current) {
|
|
257
|
+
const target = preferred;
|
|
258
|
+
items.push({
|
|
259
|
+
surface: key,
|
|
260
|
+
kind: "shared",
|
|
261
|
+
path: target.path,
|
|
262
|
+
entry,
|
|
263
|
+
state: "ours-absent",
|
|
264
|
+
action: target.present ? "add" : "create",
|
|
265
|
+
reason: null,
|
|
266
|
+
before: target.present ? target.text : null,
|
|
267
|
+
after: replaceAgentsBlock(target.present ? target.text : "", a.desired),
|
|
268
|
+
});
|
|
269
|
+
} else if (!current.own || (current.file !== preferred.file && preferred.present)) {
|
|
270
|
+
// Two reasons to move, and they are not the same reason:
|
|
271
|
+
// - the block sits in a file this harness cannot read (`!own`). It must
|
|
272
|
+
// move, and its target is CREATED if it does not exist — this is a
|
|
273
|
+
// second harness arriving in a project that had one.
|
|
274
|
+
// - it sits in a readable but non-preferred file while the preferred one
|
|
275
|
+
// already exists. A preference, satisfied because the file is there.
|
|
276
|
+
// The distinction is what keeps a Claude-Code-only project from acquiring
|
|
277
|
+
// an AGENTS.md it never asked for, while still moving the substance the
|
|
278
|
+
// moment a harness that can only read AGENTS.md is installed.
|
|
279
|
+
// A run of several harnesses says which of them cannot read the file the
|
|
280
|
+
// block leaves (rule 1); one harness keeps its text, byte for byte.
|
|
281
|
+
items.push(removal(current, { state: "ours-stale", reason: `migrating to ${preferred.file}` }));
|
|
282
|
+
items.push({
|
|
283
|
+
surface: key,
|
|
284
|
+
kind: "shared",
|
|
285
|
+
path: preferred.path,
|
|
286
|
+
entry,
|
|
287
|
+
state: "ours-absent",
|
|
288
|
+
action: preferred.present ? "add" : "create",
|
|
289
|
+
reason:
|
|
290
|
+
ctx.readers && !current.own
|
|
291
|
+
? a.reason
|
|
292
|
+
: `migrated from ${current.file}${newer ? ` with its own text (${words})` : ""}`,
|
|
293
|
+
before: preferred.present ? preferred.text : null,
|
|
294
|
+
after: replaceAgentsBlock(preferred.present ? preferred.text : "", body),
|
|
295
|
+
});
|
|
296
|
+
} else if (newer && newer !== current) {
|
|
297
|
+
// The duplicate carries the higher marker: its text replaces the stayer's.
|
|
298
|
+
items.push({
|
|
299
|
+
surface: key,
|
|
300
|
+
kind: "shared",
|
|
301
|
+
path: current.path,
|
|
302
|
+
entry,
|
|
303
|
+
state: "ours-stale",
|
|
304
|
+
action: "replace-entry",
|
|
305
|
+
reason: `its text is replaced by ${newer.file}'s, which carries v${newer.block.v}, newer than this copy's v${a.version}`,
|
|
306
|
+
before: current.text,
|
|
307
|
+
after: replaceAgentsBlock(current.text, body),
|
|
308
|
+
});
|
|
309
|
+
} else if (newer) {
|
|
310
|
+
// It stays as it is; a named run with nothing else to place overwrites it,
|
|
311
|
+
// decided once the imports are known (below).
|
|
312
|
+
newerInPlace = {
|
|
313
|
+
surface: key,
|
|
314
|
+
kind: "shared",
|
|
315
|
+
path: current.path,
|
|
316
|
+
entry,
|
|
317
|
+
state: "ours-newer",
|
|
318
|
+
action: "skip",
|
|
319
|
+
reason: `${words}: left as it is — ${
|
|
320
|
+
ctx.takesSurface === false
|
|
321
|
+
? `to write this copy's v${a.version} over it: ${packageCommand(ctx.harness, "install", { args: `--surface ${key}` })}`
|
|
322
|
+
: `name --surface ${key} to write this copy's v${a.version} over it`
|
|
323
|
+
}`,
|
|
324
|
+
};
|
|
325
|
+
items.push(newerInPlace);
|
|
326
|
+
} else if (current.block.v === a.version && current.block.block === a.desired) {
|
|
327
|
+
items.push({
|
|
328
|
+
surface: key,
|
|
329
|
+
kind: "shared",
|
|
330
|
+
path: current.path,
|
|
331
|
+
entry,
|
|
332
|
+
state: "ours-current",
|
|
333
|
+
action: "skip",
|
|
334
|
+
reason: null,
|
|
335
|
+
});
|
|
336
|
+
} else {
|
|
337
|
+
items.push({
|
|
338
|
+
surface: key,
|
|
339
|
+
kind: "shared",
|
|
340
|
+
path: current.path,
|
|
341
|
+
entry,
|
|
342
|
+
state: "ours-stale",
|
|
343
|
+
action: "replace-entry",
|
|
344
|
+
reason:
|
|
345
|
+
current.block.v !== a.version
|
|
346
|
+
? `v${current.block.v} → v${a.version}`
|
|
347
|
+
: "content differs from the current source",
|
|
348
|
+
before: current.text,
|
|
349
|
+
after: replaceAgentsBlock(current.text, a.desired),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// The @AGENTS.md import in CLAUDE.md (ADR-002 decision 3), only when the
|
|
354
|
+
// block lives in AGENTS.md and CLAUDE.md exists without it. A removal
|
|
355
|
+
// already rewriting CLAUDE.md in this plan takes the import onto its own
|
|
356
|
+
// text, or the two items would race.
|
|
357
|
+
const written = items.find((i) => ["add", "create", "replace-entry", "skip"].includes(i.action) && i.surface === key);
|
|
358
|
+
const blockFile = written ? rel(projectDir, written.path) : null;
|
|
359
|
+
// Every OTHER file any manifest names, and only if it already exists: a
|
|
360
|
+
// reader whose file no longer holds the substance is pointed at the one that
|
|
361
|
+
// does. Never a file we would have to create — a project with no CLAUDE.md
|
|
362
|
+
// does not acquire one because Codex was installed.
|
|
363
|
+
//
|
|
364
|
+
// It used to key on `a.files.length > 1`, which is a property of the
|
|
365
|
+
// INSTALLING harness's list: Codex's has one entry, so installing Codex left
|
|
366
|
+
// a CLAUDE.md that still exists, still reads as authoritative, and no longer
|
|
367
|
+
// holds anything. The block's file is what decides, not the list's length.
|
|
368
|
+
// Installing FOR a harness means the file that harness reads by itself ends
|
|
369
|
+
// up present. It holds the block when the block lands there; it holds the
|
|
370
|
+
// import when the block lands elsewhere. Without this, a project that already
|
|
371
|
+
// had an AGENTS.md took the block into it and created no bridge — so Claude
|
|
372
|
+
// Code, the harness that ran the install, could not see what it had just
|
|
373
|
+
// installed. `reads_natively` is the manifest's, so this is a file list, not
|
|
374
|
+
// a harness name — and it is every RUN harness's (rule 1): one run for two
|
|
375
|
+
// harnesses leaves both native files present, each holding the block or the
|
|
376
|
+
// import.
|
|
377
|
+
const natives = new Set(run.map((m) => agentsBlockRow(m)?.[1].reads_natively).filter(Boolean));
|
|
378
|
+
// The import item sits under the run's first harness, which need not be the
|
|
379
|
+
// one that reads the file: in a run of several, the reason names the reader.
|
|
380
|
+
// One harness is its own reader and keeps its text, byte for byte.
|
|
381
|
+
const reader = (file) =>
|
|
382
|
+
(ctx.readers && run.find((m) => agentsBlockRow(m)?.[1].reads_natively === file)?.display_name) || "this harness";
|
|
383
|
+
for (const e of a.files) {
|
|
384
|
+
const mustExist = natives.has(e.file);
|
|
385
|
+
if (!blockFile || e.file === blockFile || (!e.present && !mustExist)) continue;
|
|
386
|
+
const line = `@${blockFile}`;
|
|
387
|
+
const rewrite = items.find((i) => i.action === "remove" && i.path === e.path);
|
|
388
|
+
// An absent native file is empty text, not a reason to skip: it is created.
|
|
389
|
+
const text = rewrite ? rewrite.after : e.present ? e.text : "";
|
|
390
|
+
if (typeof text !== "string" || importsLine(text, line)) continue;
|
|
391
|
+
const after = line + "\n" + (text.startsWith("\n") || !text.trim() ? "" : "\n") + text;
|
|
392
|
+
if (rewrite) {
|
|
393
|
+
rewrite.after = after;
|
|
394
|
+
rewrite.deleteIfEmpty = false;
|
|
395
|
+
rewrite.reason += `; ${line} import added`;
|
|
396
|
+
} else
|
|
397
|
+
items.push({
|
|
398
|
+
surface: `${key}_import`,
|
|
399
|
+
kind: "shared",
|
|
400
|
+
path: e.path,
|
|
401
|
+
entry: line,
|
|
402
|
+
state: "ours-absent",
|
|
403
|
+
action: e.present ? "add" : "create",
|
|
404
|
+
reason: e.present
|
|
405
|
+
? `${blockFile} carries the block; ${e.file} must import it`
|
|
406
|
+
: `${e.file} is what ${reader(e.file)} reads by itself; it is created to import ${blockFile}`,
|
|
407
|
+
before: e.present ? text : null,
|
|
408
|
+
after,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
// Naming the surface confirms the overwrite only when the run has nothing
|
|
412
|
+
// else to do for the block: it is the only block and every import it needs
|
|
413
|
+
// is in place. Doctor's placement remedies name the surface too, and they
|
|
414
|
+
// must never downgrade (contract 9 as amended 2026-10-14).
|
|
415
|
+
if (
|
|
416
|
+
newerInPlace &&
|
|
417
|
+
(ctx.surfaces || []).includes(key) &&
|
|
418
|
+
!(a.duplicates || []).length &&
|
|
419
|
+
!items.some((i) => i.surface === `${key}_import`)
|
|
420
|
+
)
|
|
421
|
+
Object.assign(newerInPlace, {
|
|
422
|
+
entry: `projectstore:agents v${a.version}`,
|
|
423
|
+
action: "replace-entry",
|
|
424
|
+
reason: `v${current.block.v} → v${a.version}: ${words}, and --surface ${key} names it`,
|
|
425
|
+
before: current.text,
|
|
426
|
+
after: replaceAgentsBlock(current.text, a.desired),
|
|
427
|
+
});
|
|
428
|
+
return items;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// A row's consequence (presentation spec contract 7) carried as a `note` step:
|
|
432
|
+
// the envelope reads `{ kind: "note", why }`, as any note; PLAN draws it first
|
|
433
|
+
// beneath its row, in the explanation role, by the mark kept off the envelope.
|
|
434
|
+
export function consequenceStep(why) {
|
|
435
|
+
const st = { kind: "note", why };
|
|
436
|
+
Object.defineProperty(st, "consequence", { value: true, enumerable: false });
|
|
437
|
+
return st;
|
|
438
|
+
}
|
|
439
|
+
export const isConsequence = (st) => st?.kind === "note" && st.consequence === true;
|
|
440
|
+
|
|
441
|
+
// The status line's flag (the setup story, rule 9; the configure spec,
|
|
442
|
+
// contract 6): `statusline.enabled`, from setup's answer or from a run that
|
|
443
|
+
// names the surface — install records on, uninstall off. This item is the
|
|
444
|
+
// flag's one writer; every other key of a `statusline` object is kept.
|
|
445
|
+
// Planned for the run's harness that has the status-line surface, when the
|
|
446
|
+
// binding the run leaves does not already say the same; only when there is a
|
|
447
|
+
// binding to record it in.
|
|
448
|
+
const STATUSLINE_FLAG = "statusline.enabled";
|
|
449
|
+
export function planStatuslineFlag(ctx, value, mode = "install") {
|
|
450
|
+
const b = ctx.binding;
|
|
451
|
+
if (!b || !b.bound || typeof value !== "boolean" || b.statusline === value) return null;
|
|
452
|
+
const off =
|
|
453
|
+
mode === "uninstall"
|
|
454
|
+
? "the status line is removed in this run, and the SessionStart hook keeps it off"
|
|
455
|
+
: "the status line stays off, and setup does not ask again";
|
|
456
|
+
return bindingItem({
|
|
457
|
+
harness: ctx.harness.id,
|
|
458
|
+
surface: "statusline_flag",
|
|
459
|
+
entry: STATUSLINE_FLAG,
|
|
460
|
+
path: b.path,
|
|
461
|
+
bound: true,
|
|
462
|
+
set: { [STATUSLINE_FLAG]: value },
|
|
463
|
+
why: {
|
|
464
|
+
[STATUSLINE_FLAG]: value
|
|
465
|
+
? "the status line is wired in this run, and the SessionStart hook keeps it current"
|
|
466
|
+
: off,
|
|
467
|
+
},
|
|
468
|
+
said: {
|
|
469
|
+
unbound: "the project is not bound; the status line's flag is recorded once it is",
|
|
470
|
+
done: "the status line's flag is already recorded",
|
|
471
|
+
object: "the status line's flag is not recorded",
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Setup's vault item (the story's rule 10; the front-door ADR's decision 7):
|
|
477
|
+
// one item of kind `vault`, first in the plan and under no harness — PLAN's
|
|
478
|
+
// VAULT group. Its steps are the binding plan's own (binding.mjs
|
|
479
|
+
// bindPlanSteps): the mkdir, the binding, `git init`, the scaffold. `create`
|
|
480
|
+
// for a vault this run makes (missing, or there and empty); `add` for a
|
|
481
|
+
// binding of one that exists, scaffolded or not, and for a worktree's
|
|
482
|
+
// inherited binding. The binding plan rides along off the envelope, for
|
|
483
|
+
// apply; with nothing to write there is no item.
|
|
484
|
+
export function planVault(bp) {
|
|
485
|
+
const steps = bindPlanSteps(bp);
|
|
486
|
+
if (!steps.length) return null;
|
|
487
|
+
const reason = bp.inherit
|
|
488
|
+
? `the vault of ${bp.inherit.from}, with its settings`
|
|
489
|
+
: bp.buildsVault || !bp.vaultExists
|
|
490
|
+
? null
|
|
491
|
+
: bp.scaffoldsExisting
|
|
492
|
+
? "it already exists; it is bound as it is, and the layout's missing folders and indexes are added"
|
|
493
|
+
: "it already exists; it is bound as it is";
|
|
494
|
+
const item = {
|
|
495
|
+
harness: null,
|
|
496
|
+
surface: "vault",
|
|
497
|
+
kind: "vault",
|
|
498
|
+
path: bp.vault,
|
|
499
|
+
entry: null,
|
|
500
|
+
state: !bp.vaultExists ? "absent" : bp.buildsVault ? "empty" : "present",
|
|
501
|
+
action: bp.buildsVault ? "create" : "add",
|
|
502
|
+
reason,
|
|
503
|
+
steps,
|
|
504
|
+
};
|
|
505
|
+
Object.defineProperty(item, "bind", { value: bp, enumerable: false });
|
|
506
|
+
return item;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Setup's state directory (the story's rule 10): an empty
|
|
510
|
+
// .projectstore/state/<id>/, the signal detection reads (detectHarnesses'
|
|
511
|
+
// `state`), so a harness setup installed with no directory of its own is used
|
|
512
|
+
// here before its first session. Never the welcome marker: that file means
|
|
513
|
+
// the welcome was shown, and the first session writes it into this
|
|
514
|
+
// directory. The first such item names state/ and its .gitignore when they
|
|
515
|
+
// are still to be made.
|
|
516
|
+
export function planStateDir(projectDir, harness, { first = false } = {}) {
|
|
517
|
+
const P = layoutPaths(projectDir);
|
|
518
|
+
const path = P.harnessState(harness.id);
|
|
519
|
+
if (existsSync(path)) return null;
|
|
520
|
+
const steps =
|
|
521
|
+
first && !existsSync(P.stateGitignore)
|
|
522
|
+
? [
|
|
523
|
+
{
|
|
524
|
+
kind: "ensure",
|
|
525
|
+
path: P.state,
|
|
526
|
+
why: "state/ with its .gitignore, which keeps everything under it out of git",
|
|
527
|
+
},
|
|
528
|
+
]
|
|
529
|
+
: [];
|
|
530
|
+
return {
|
|
531
|
+
harness: harness.id,
|
|
532
|
+
surface: "harness_state",
|
|
533
|
+
kind: "state",
|
|
534
|
+
path,
|
|
535
|
+
entry: null,
|
|
536
|
+
state: "absent",
|
|
537
|
+
action: "create",
|
|
538
|
+
reason: null,
|
|
539
|
+
...(steps.length ? { steps } : {}),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// A key a `binding` item stamps at apply: `{ by: "setup", at: <the time of
|
|
544
|
+
// apply> }`, the mark's shape and the keep's.
|
|
545
|
+
export const STAMP = Object.freeze({ stamp: "setup" });
|
|
546
|
+
export const stamped = (v) =>
|
|
547
|
+
Boolean(v) && typeof v === "object" && !Array.isArray(v) && typeof v.by === "string" && typeof v.at === "string";
|
|
548
|
+
|
|
549
|
+
// A `binding` item (the converge story's kind, made generic by the setup
|
|
550
|
+
// story's rule 10): keys merged into the binding at apply time, after the
|
|
551
|
+
// layout item, every other key kept. `set` maps a key — dotted for a nested
|
|
552
|
+
// one — to its value, or to STAMP; `unset` lists the keys it removes. Both
|
|
553
|
+
// are apply's data, kept off the envelope, as `said` is: the item's own
|
|
554
|
+
// words for a binding that is not there, not an object, or already says it.
|
|
555
|
+
// The steps are what PLAN shows and the envelope carries, in that order:
|
|
556
|
+
// `mark` for a stamped key, `set` for a value, `unmark` for a removal. An
|
|
557
|
+
// item that only removes a key is an `update` of a binding whose key is
|
|
558
|
+
// `present`.
|
|
559
|
+
export function bindingItem({
|
|
560
|
+
harness,
|
|
561
|
+
surface,
|
|
562
|
+
entry,
|
|
563
|
+
path,
|
|
564
|
+
bound,
|
|
565
|
+
reason = null,
|
|
566
|
+
set = {},
|
|
567
|
+
unset = [],
|
|
568
|
+
why = {},
|
|
569
|
+
said = {},
|
|
570
|
+
action = "add",
|
|
571
|
+
state = "absent",
|
|
572
|
+
}) {
|
|
573
|
+
const step = (fields, key) => (why[key] ? { ...fields, why: why[key] } : fields);
|
|
574
|
+
const steps = [
|
|
575
|
+
...Object.entries(set).map(([key, value]) =>
|
|
576
|
+
step(value === STAMP ? { kind: "mark", path, key } : { kind: "set", path, key, value }, key),
|
|
577
|
+
),
|
|
578
|
+
...unset.map((key) => step({ kind: "unmark", path, key }, key)),
|
|
579
|
+
];
|
|
580
|
+
const item = {
|
|
581
|
+
harness,
|
|
582
|
+
surface,
|
|
583
|
+
kind: "binding",
|
|
584
|
+
path,
|
|
585
|
+
entry,
|
|
586
|
+
state: bound ? state : "unbound",
|
|
587
|
+
action,
|
|
588
|
+
reason,
|
|
589
|
+
steps,
|
|
590
|
+
};
|
|
591
|
+
for (const [k, v] of Object.entries({ set, unset, said }))
|
|
592
|
+
Object.defineProperty(item, k, { value: v, enumerable: false });
|
|
593
|
+
return item;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export function planJsonEntry(ctx, key, s) {
|
|
597
|
+
const { projectDir, mode, root, home } = ctx;
|
|
598
|
+
if (s.supported === false) {
|
|
599
|
+
return [
|
|
600
|
+
{
|
|
601
|
+
surface: key,
|
|
602
|
+
kind: "shared",
|
|
603
|
+
path: join(projectDir, s.file),
|
|
604
|
+
entry: s.marker?.pointer || null,
|
|
605
|
+
state: "unsupported",
|
|
606
|
+
action: "skip",
|
|
607
|
+
reason: s.why_unsupported || "not supported for this harness yet",
|
|
608
|
+
},
|
|
609
|
+
];
|
|
610
|
+
}
|
|
611
|
+
const path = join(projectDir, s.file);
|
|
612
|
+
const entryKey = (s.marker?.pointer || "statusLine.command").split(".")[0];
|
|
613
|
+
// The status line is opt-in (projectstore.json → statusline.enabled), as
|
|
614
|
+
// the SessionStart refresh already honours; naming the surface explicitly
|
|
615
|
+
// is the other way to opt in. A verb with no --surface (setup, its rule 9)
|
|
616
|
+
// names install's command, which takes it, in the core form.
|
|
617
|
+
if (mode === "install" && !ctx.optIn.has(key)) {
|
|
618
|
+
const anyway =
|
|
619
|
+
ctx.takesSurface === false
|
|
620
|
+
? `to wire it anyway: ${packageCommand(ctx.harness, "install", { args: `--surface ${key}` })}`
|
|
621
|
+
: "name --surface statusline to wire it anyway";
|
|
622
|
+
return [
|
|
623
|
+
{
|
|
624
|
+
surface: key,
|
|
625
|
+
kind: "shared",
|
|
626
|
+
path,
|
|
627
|
+
entry: entryKey,
|
|
628
|
+
state: "opt-out",
|
|
629
|
+
action: "skip",
|
|
630
|
+
reason: `statusline.enabled is not true in projectstore.json — ${anyway}`,
|
|
631
|
+
},
|
|
632
|
+
];
|
|
633
|
+
}
|
|
634
|
+
const renderRoot = ctx.renderRoot || root;
|
|
635
|
+
// A root the package manager will collect (npx's cache, a node_modules) is
|
|
636
|
+
// never wired directly: the entry would name a path that disappears. It is
|
|
637
|
+
// wired against a registration's install path — or not at all (contract 4′).
|
|
638
|
+
if (mode === "install" && !isPluginCacheRoot(renderRoot, home) && isEphemeralRoot(renderRoot)) {
|
|
639
|
+
return [
|
|
640
|
+
{
|
|
641
|
+
surface: key,
|
|
642
|
+
kind: "shared",
|
|
643
|
+
path,
|
|
644
|
+
entry: entryKey,
|
|
645
|
+
state: "absent-or-present",
|
|
646
|
+
action: "skip",
|
|
647
|
+
reason:
|
|
648
|
+
"this package root is a package-manager cache; the status line is wired only against a registered install (see the registration above)",
|
|
649
|
+
},
|
|
650
|
+
];
|
|
651
|
+
}
|
|
652
|
+
const a = analyseJsonEntry(projectDir, s, { root, home, renderRoot, harness: ctx.harness });
|
|
653
|
+
if (a.state === "unparseable")
|
|
654
|
+
return [
|
|
655
|
+
{ surface: key, kind: "shared", path, entry: entryKey, state: "unparseable", action: "refuse", reason: a.reason },
|
|
656
|
+
];
|
|
657
|
+
|
|
658
|
+
if (mode === "uninstall") {
|
|
659
|
+
if (!a.curEntry)
|
|
660
|
+
return [
|
|
661
|
+
{ surface: key, kind: "shared", path, entry: entryKey, state: "ours-absent", action: "skip", reason: null },
|
|
662
|
+
];
|
|
663
|
+
if (!a.ours)
|
|
664
|
+
return [
|
|
665
|
+
{
|
|
666
|
+
surface: key,
|
|
667
|
+
kind: "shared",
|
|
668
|
+
path,
|
|
669
|
+
entry: entryKey,
|
|
670
|
+
state: "theirs",
|
|
671
|
+
action: "skip",
|
|
672
|
+
reason: "the entry is not ours — left in place",
|
|
673
|
+
},
|
|
674
|
+
];
|
|
675
|
+
const after = { ...a.settings };
|
|
676
|
+
delete after[entryKey];
|
|
677
|
+
return [
|
|
678
|
+
{
|
|
679
|
+
surface: key,
|
|
680
|
+
kind: "shared",
|
|
681
|
+
path,
|
|
682
|
+
entry: entryKey,
|
|
683
|
+
state: "ours-current",
|
|
684
|
+
action: "remove",
|
|
685
|
+
reason: null,
|
|
686
|
+
before: a.settings,
|
|
687
|
+
after,
|
|
688
|
+
},
|
|
689
|
+
];
|
|
690
|
+
}
|
|
691
|
+
if (a.state === "theirs") {
|
|
692
|
+
ctx.slotForeign.add(key);
|
|
693
|
+
return [
|
|
694
|
+
{
|
|
695
|
+
surface: key,
|
|
696
|
+
kind: "shared",
|
|
697
|
+
path,
|
|
698
|
+
entry: entryKey,
|
|
699
|
+
state: "theirs",
|
|
700
|
+
action: "skip",
|
|
701
|
+
reason: "a status line we did not write owns the slot — left to its owner, and nothing else is wired for it",
|
|
702
|
+
},
|
|
703
|
+
];
|
|
704
|
+
}
|
|
705
|
+
if (a.state === "ours-current")
|
|
706
|
+
return [
|
|
707
|
+
{ surface: key, kind: "shared", path, entry: entryKey, state: "ours-current", action: "skip", reason: null },
|
|
708
|
+
];
|
|
709
|
+
const after = {
|
|
710
|
+
...a.settings,
|
|
711
|
+
[entryKey]: {
|
|
712
|
+
...(a.curEntry && typeof a.curEntry === "object" ? a.curEntry : {}),
|
|
713
|
+
type: "command",
|
|
714
|
+
command: a.desired,
|
|
715
|
+
},
|
|
716
|
+
};
|
|
717
|
+
return [
|
|
718
|
+
{
|
|
719
|
+
surface: key,
|
|
720
|
+
kind: "shared",
|
|
721
|
+
path,
|
|
722
|
+
entry: entryKey,
|
|
723
|
+
state: a.state,
|
|
724
|
+
action: a.curEntry ? "replace-entry" : a.cur.present ? "add" : "create",
|
|
725
|
+
reason: a.curEntry ? a.reason : null,
|
|
726
|
+
before: a.cur.present ? a.settings : null,
|
|
727
|
+
after,
|
|
728
|
+
},
|
|
729
|
+
];
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export function planStampedFile(ctx, key, s) {
|
|
733
|
+
const { projectDir, mode, root, home, harness } = ctx;
|
|
734
|
+
const renderRoot = ctx.renderRoot || root;
|
|
735
|
+
const path = join(projectDir, s.file);
|
|
736
|
+
const produced = !(s.condition === "plugin_cache_install" && !isHostInstallRoot(renderRoot, home));
|
|
737
|
+
// The policy early-outs come before the render-and-hash, which they make
|
|
738
|
+
// unnecessary.
|
|
739
|
+
if (produced && mode === "install" && !ctx.optIn.has(s.condition ? "statusline" : key) && !ctx.optIn.has(key)) {
|
|
740
|
+
return [
|
|
741
|
+
{
|
|
742
|
+
surface: key,
|
|
743
|
+
kind: "exclusive",
|
|
744
|
+
path,
|
|
745
|
+
entry: null,
|
|
746
|
+
state: "opt-out",
|
|
747
|
+
action: "skip",
|
|
748
|
+
reason: "statusline.enabled is not true in projectstore.json",
|
|
749
|
+
},
|
|
750
|
+
];
|
|
751
|
+
}
|
|
752
|
+
if (produced && mode === "install" && ctx.slotForeign.size) {
|
|
753
|
+
return [
|
|
754
|
+
{
|
|
755
|
+
surface: key,
|
|
756
|
+
kind: "exclusive",
|
|
757
|
+
path,
|
|
758
|
+
entry: null,
|
|
759
|
+
state: "absent-or-present",
|
|
760
|
+
action: "skip",
|
|
761
|
+
reason: "the status line slot is foreign; a launcher nothing points at is not written",
|
|
762
|
+
},
|
|
763
|
+
];
|
|
764
|
+
}
|
|
765
|
+
const a = analyseStampedFile(projectDir, s, { root, home, harness, renderRoot });
|
|
766
|
+
// Not produced for this installation (a dev checkout is wired directly):
|
|
767
|
+
// a root that cannot produce a file has, by construction, never written it,
|
|
768
|
+
// so install and upgrade REPORT it and leave it (contract 13's wording;
|
|
769
|
+
// contract 7 as amended 2026-09-05 — a dev checkout's plan used to prune a
|
|
770
|
+
// cache install's launcher, the maintainer's habitual loop). Only uninstall
|
|
771
|
+
// removes it: the user asked to disown, and the file is recognisably ours.
|
|
772
|
+
// `prune` stays an action for the day a surface leaves the roster.
|
|
773
|
+
// A file found at its legacy path (the layout ADR): classified from there,
|
|
774
|
+
// removed from there, created at the new path — the legacy copy is then
|
|
775
|
+
// the layout cleanup's to delete once nothing names it.
|
|
776
|
+
const at = a.legacyPath || path;
|
|
777
|
+
if (!a.produced) {
|
|
778
|
+
if (!a.file.present) return [];
|
|
779
|
+
if (!a.ours)
|
|
780
|
+
return [
|
|
781
|
+
{
|
|
782
|
+
surface: key,
|
|
783
|
+
kind: "exclusive",
|
|
784
|
+
path: at,
|
|
785
|
+
entry: null,
|
|
786
|
+
state: "foreign",
|
|
787
|
+
action: "skip",
|
|
788
|
+
reason: "not produced for a dev checkout, and not ours — left in place",
|
|
789
|
+
},
|
|
790
|
+
];
|
|
791
|
+
return [
|
|
792
|
+
{
|
|
793
|
+
surface: key,
|
|
794
|
+
kind: "exclusive",
|
|
795
|
+
path: at,
|
|
796
|
+
entry: null,
|
|
797
|
+
state: "stale",
|
|
798
|
+
action: mode === "uninstall" ? "remove" : "skip",
|
|
799
|
+
reason: a.reason,
|
|
800
|
+
},
|
|
801
|
+
];
|
|
802
|
+
}
|
|
803
|
+
if (a.refusal)
|
|
804
|
+
return [{ surface: key, kind: "exclusive", path: at, state: "refused", action: "refuse", reason: a.refusal }];
|
|
805
|
+
const base = {
|
|
806
|
+
surface: key,
|
|
807
|
+
kind: "exclusive",
|
|
808
|
+
path: at,
|
|
809
|
+
entry: null,
|
|
810
|
+
state: a.state,
|
|
811
|
+
reason: a.reason,
|
|
812
|
+
writtenBy: a.writtenBy,
|
|
813
|
+
sameProject: a.sameProject,
|
|
814
|
+
};
|
|
815
|
+
if (mode === "uninstall") {
|
|
816
|
+
if (a.state === "absent") return [{ ...base, action: "skip" }];
|
|
817
|
+
if (a.state === "foreign") return [{ ...base, action: "refuse", reason: FOREIGN_TEXT }];
|
|
818
|
+
return [{ ...base, action: "remove" }];
|
|
819
|
+
}
|
|
820
|
+
if (a.state === "foreign") return [{ ...base, action: "refuse", reason: FOREIGN_TEXT }];
|
|
821
|
+
// Current, but written for another project (a copied or moved checkout):
|
|
822
|
+
// the file lives inside THIS project and its render names its project since
|
|
823
|
+
// 2026-09-06, so it is re-rendered here — doctor still reports who wrote it
|
|
824
|
+
// (contract 12); only the installer acts on it.
|
|
825
|
+
if (a.state === "current" && !a.legacyPath && a.writtenBy && !a.sameProject)
|
|
826
|
+
return [
|
|
827
|
+
{
|
|
828
|
+
...base,
|
|
829
|
+
path,
|
|
830
|
+
action: "update",
|
|
831
|
+
reason: `current, written for ${a.writtenBy} — re-rendered for this project`,
|
|
832
|
+
after: a.stamped.text,
|
|
833
|
+
},
|
|
834
|
+
];
|
|
835
|
+
if (a.state === "current" && !a.legacyPath) return [{ ...base, action: "skip" }];
|
|
836
|
+
// Written at the NEW path; a legacy file's state is why (stale, or current-but-moving).
|
|
837
|
+
return [
|
|
838
|
+
{
|
|
839
|
+
...base,
|
|
840
|
+
path,
|
|
841
|
+
action: a.state === "absent" || a.legacyPath ? "create" : "update",
|
|
842
|
+
reason: a.legacyPath
|
|
843
|
+
? `${a.reason ? a.reason + "; " : ""}moving from ${rel(projectDir, a.legacyPath)} (the layout ADR)`
|
|
844
|
+
: a.reason,
|
|
845
|
+
after: a.stamped.text,
|
|
846
|
+
legacyPath: a.legacyPath,
|
|
847
|
+
},
|
|
848
|
+
];
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// The surface rows a run leaves out: a --surface that does not name the key
|
|
852
|
+
// (by prefix, so `statusline` covers the launcher), or --no-register's
|
|
853
|
+
// registration. One predicate for plan() and the shell fetch's decision.
|
|
854
|
+
export function surfaceExcluded(key, s, { surfaces = null, register = true } = {}) {
|
|
855
|
+
return (
|
|
856
|
+
Boolean(surfaces && !surfaces.some((x) => key === x || key.startsWith(x + "_"))) ||
|
|
857
|
+
(register === false && s.kind === "registration")
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// One add per file (the presentation spec, contract 7 as amended 2026-10-14):
|
|
862
|
+
// within one plan, a file an earlier item creates is added once. A later
|
|
863
|
+
// `create` or `add` on that path changes the file the run has just made, so it
|
|
864
|
+
// reads `update`; its state, steps and detail stay. Each item is planned
|
|
865
|
+
// against the tree before the run, so the binding the vault item writes was
|
|
866
|
+
// "absent" to the status-line flag and the channel mark, and all three read
|
|
867
|
+
// "add". A creator is an earlier `create` or `add` item that is neither a
|
|
868
|
+
// registration (its host-owned files are the host CLI's) nor a binding row (it
|
|
869
|
+
// never makes the binding: apply skips it on an unbound project, and the vault
|
|
870
|
+
// item or the layout move makes the file), counted for the paths absent at
|
|
871
|
+
// plan time: its own and each step's `path` — never a move step's `to`, so a
|
|
872
|
+
// migrating project's binding rows keep `add`. An entry row on a file that was
|
|
873
|
+
// there keeps `add`.
|
|
874
|
+
export function oneAddPerFile(items) {
|
|
875
|
+
const created = new Set();
|
|
876
|
+
const writes = (i) => i.action === "create" || i.action === "add";
|
|
877
|
+
for (const item of items) {
|
|
878
|
+
if (!writes(item) || item.kind === "registration") continue;
|
|
879
|
+
if (item.path && created.has(resolve(item.path))) {
|
|
880
|
+
item.action = "update";
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (item.kind === "binding") continue;
|
|
884
|
+
for (const p of [item.path, ...(item.steps || []).map((s) => s.path)])
|
|
885
|
+
if (p && !existsSync(p)) created.add(resolve(p));
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Contract 14: the host installs and updates these itself; say how, from the
|
|
890
|
+
// manifest, and write nothing. (PR #13's installElsewhere, as a plan line.)
|
|
891
|
+
export function hostManagedReport(m, rows, registration = null) {
|
|
892
|
+
const inst = m.install || {};
|
|
893
|
+
const lines = [
|
|
894
|
+
`${m.display_name}: ${rows.join(", ")} are installed by ${inst.mechanism || "the host"} — nothing to write.`,
|
|
895
|
+
];
|
|
896
|
+
if (registration && registration.entry) {
|
|
897
|
+
const how =
|
|
898
|
+
registration.action === "skip" && registration.state === "current"
|
|
899
|
+
? "registered here"
|
|
900
|
+
: registration.action === "refuse"
|
|
901
|
+
? "refused, see below"
|
|
902
|
+
: registration.action === "skip"
|
|
903
|
+
? "not registered on this run, see below"
|
|
904
|
+
: `registered by this run (${registration.action})`;
|
|
905
|
+
// A host plugin registration is a marketplace added from a local path, and
|
|
906
|
+
// the host loads its plugin in place, from the plugin's root inside that
|
|
907
|
+
// directory (`payload`, set by plan()); the install path is the registry's
|
|
908
|
+
// copy, which the launcher renders (the story "The SessionStart hook and
|
|
909
|
+
// install agree on the status line…", measured on host 2.1.293 and
|
|
910
|
+
// 2.1.296). A portable registration's host loads the copy.
|
|
911
|
+
const where = !registration.root
|
|
912
|
+
? ""
|
|
913
|
+
: registration.payload
|
|
914
|
+
? ` — install path ${registration.root}; the host loads them in place from ${registration.payload}`
|
|
915
|
+
: ` — the host loads them from ${registration.root}`;
|
|
916
|
+
lines.push(` They come from the registration ${registration.entry}, ${how}${where}.`);
|
|
917
|
+
}
|
|
918
|
+
if (inst.why_not_scripted) lines.push(` ${inst.why_not_scripted}`);
|
|
919
|
+
for (const s of inst.steps || []) lines.push(` ${s}`);
|
|
920
|
+
for (const n of inst.notes || []) lines.push(` ${n}`);
|
|
921
|
+
if (inst.docs) lines.push(` ${inst.docs}`);
|
|
922
|
+
return lines.join("\n");
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
export const isWrite = (i) => !["skip", "refuse"].includes(i.action);
|