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
|
@@ -79,6 +79,12 @@
|
|
|
79
79
|
// status-line flag, the keep's record and the mark — each a set of keys
|
|
80
80
|
// merged into the binding at apply time, after the layout item.
|
|
81
81
|
//
|
|
82
|
+
// What never reads the registry, fetches a shell or runs a host command is in
|
|
83
|
+
// three leaf modules, moved as it was: install-handlers.mjs (the format-keyed
|
|
84
|
+
// planners, setup's items, the plan helpers), install-layout.mjs (the layout
|
|
85
|
+
// migration) and install-preview.mjs (PLAN and the APPLY lines). None imports
|
|
86
|
+
// this file, and every name this file exported, it still exports.
|
|
87
|
+
//
|
|
82
88
|
// Direction: installer → surfaces ← doctor; installer → provenance ← doctor.
|
|
83
89
|
// Doctor never imports this file.
|
|
84
90
|
//
|
|
@@ -97,10 +103,9 @@ import {
|
|
|
97
103
|
readFileSync,
|
|
98
104
|
openSync,
|
|
99
105
|
closeSync,
|
|
100
|
-
statSync,
|
|
101
106
|
realpathSync,
|
|
102
107
|
} from "node:fs";
|
|
103
|
-
import { join, resolve, dirname
|
|
108
|
+
import { join, resolve, dirname } from "node:path";
|
|
104
109
|
import { homedir } from "node:os";
|
|
105
110
|
import { randomUUID } from "node:crypto";
|
|
106
111
|
import { fileURLToPath } from "node:url";
|
|
@@ -110,14 +115,9 @@ import {
|
|
|
110
115
|
painter,
|
|
111
116
|
icon as termIcon,
|
|
112
117
|
duration,
|
|
113
|
-
stepReporter,
|
|
114
118
|
liveLine,
|
|
115
119
|
wrap,
|
|
116
120
|
askApply,
|
|
117
|
-
plain,
|
|
118
|
-
badge as badgeText,
|
|
119
|
-
heading,
|
|
120
|
-
rowLines,
|
|
121
121
|
kv,
|
|
122
122
|
commandBlock,
|
|
123
123
|
PLAIN,
|
|
@@ -139,17 +139,10 @@ import {
|
|
|
139
139
|
configPath,
|
|
140
140
|
childEnv,
|
|
141
141
|
} from "./harness.mjs";
|
|
142
|
-
import {
|
|
142
|
+
import { GRAMMAR_VERSION } from "./provenance.mjs";
|
|
143
143
|
import {
|
|
144
|
-
analyseBlock,
|
|
145
|
-
analyseJsonEntry,
|
|
146
|
-
analyseStampedFile,
|
|
147
144
|
analyseRegistration,
|
|
148
145
|
analysePortableRegistration,
|
|
149
|
-
analyseLayout,
|
|
150
|
-
analyseHarnessState,
|
|
151
|
-
isOurFile,
|
|
152
|
-
readText,
|
|
153
146
|
registrationRenderRoot,
|
|
154
147
|
registrationPaths,
|
|
155
148
|
} from "./surfaces.mjs";
|
|
@@ -184,6 +177,8 @@ import {
|
|
|
184
177
|
updateCheckOff,
|
|
185
178
|
channelChoices,
|
|
186
179
|
switchConsequence,
|
|
180
|
+
directoryConsequence,
|
|
181
|
+
copyOrigin,
|
|
187
182
|
switchReason,
|
|
188
183
|
noCopyToKeep,
|
|
189
184
|
keepArgv,
|
|
@@ -201,7 +196,7 @@ import {
|
|
|
201
196
|
READ_TIMEOUT_MS,
|
|
202
197
|
MIN_READ_MS,
|
|
203
198
|
} from "./convergence.mjs";
|
|
204
|
-
import { applyBind,
|
|
199
|
+
import { applyBind, recheckBind, bindResult } from "./binding.mjs";
|
|
205
200
|
import {
|
|
206
201
|
pluginRoot,
|
|
207
202
|
channelMark,
|
|
@@ -212,25 +207,15 @@ import {
|
|
|
212
207
|
writeExclusiveMetadata,
|
|
213
208
|
ensureStateDir,
|
|
214
209
|
ensureStateRoot,
|
|
215
|
-
ensureRuntimeDir,
|
|
216
|
-
removeAgentsBlock,
|
|
217
|
-
replaceAgentsBlock,
|
|
218
210
|
readConfigAt,
|
|
219
|
-
|
|
211
|
+
isHostInstallRoot,
|
|
220
212
|
isEphemeralRoot,
|
|
221
|
-
statusLineIsOurWiring,
|
|
222
213
|
claudeHome,
|
|
223
214
|
packageDigest,
|
|
224
215
|
writeOwnTree,
|
|
225
216
|
removeOwnTree,
|
|
226
217
|
cmpPrecedence,
|
|
227
|
-
importsLine,
|
|
228
218
|
whichOnPath as whichOnPathFromLib,
|
|
229
|
-
moveStateDir,
|
|
230
|
-
mergeEntryLog,
|
|
231
|
-
movePath,
|
|
232
|
-
removeInside,
|
|
233
|
-
statusLineScriptPath,
|
|
234
219
|
layoutPaths,
|
|
235
220
|
stagePortableMarketplace,
|
|
236
221
|
finishPortableMarketplace,
|
|
@@ -238,30 +223,52 @@ import {
|
|
|
238
223
|
removeTreeUnder,
|
|
239
224
|
projectDirRefusal,
|
|
240
225
|
fromTerminalIn,
|
|
241
|
-
newerBlockWords,
|
|
242
226
|
registrationProvenance,
|
|
243
227
|
envFlag,
|
|
244
228
|
selectCopy,
|
|
245
229
|
copyUpdateAdvice,
|
|
246
230
|
takesVerbose,
|
|
247
231
|
} from "./lib.mjs";
|
|
232
|
+
import {
|
|
233
|
+
rel,
|
|
234
|
+
inside,
|
|
235
|
+
planAgentsBlock,
|
|
236
|
+
consequenceStep,
|
|
237
|
+
planStatuslineFlag,
|
|
238
|
+
planVault,
|
|
239
|
+
planStateDir,
|
|
240
|
+
STAMP,
|
|
241
|
+
stamped,
|
|
242
|
+
bindingItem,
|
|
243
|
+
planJsonEntry,
|
|
244
|
+
planStampedFile,
|
|
245
|
+
surfaceExcluded,
|
|
246
|
+
oneAddPerFile,
|
|
247
|
+
hostManagedReport,
|
|
248
|
+
isWrite,
|
|
249
|
+
} from "./install-handlers.mjs";
|
|
250
|
+
import { planLayout, planHarnessState, applyLayout, stepResult } from "./install-layout.mjs";
|
|
251
|
+
import {
|
|
252
|
+
surfaceWords,
|
|
253
|
+
rowTarget,
|
|
254
|
+
VAULT_GROUP,
|
|
255
|
+
displayName,
|
|
256
|
+
badgeLine,
|
|
257
|
+
noticeText,
|
|
258
|
+
renderPreview,
|
|
259
|
+
applyReporter,
|
|
260
|
+
} from "./install-preview.mjs";
|
|
248
261
|
|
|
249
262
|
import { GENERATOR } from "./surfaces.mjs";
|
|
250
263
|
export { GENERATOR };
|
|
251
264
|
|
|
252
|
-
function rel(projectDir, p) {
|
|
253
|
-
const r = relative(projectDir, p);
|
|
254
|
-
return r && !r.startsWith("..") && !isAbsolute(r) ? r : p;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
265
|
// isEphemeralRoot lives in lib.mjs beside the channel classifier that uses it;
|
|
258
266
|
// re-exported here under the name the installer's callers already import.
|
|
259
267
|
export { isEphemeralRoot };
|
|
260
268
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}
|
|
269
|
+
// PLAN, the badge, the notice and the APPLY lines live in install-preview.mjs;
|
|
270
|
+
// re-exported here under the names the installer's callers already import.
|
|
271
|
+
export { renderPreview, badgeLine, noticeText, applyReporter };
|
|
265
272
|
|
|
266
273
|
// ─── Surface handlers, keyed by format ─────────────────────────────────
|
|
267
274
|
//
|
|
@@ -282,386 +289,6 @@ const HANDLERS = {
|
|
|
282
289
|
// line slot) decides whether the exclusive launcher is written at all.
|
|
283
290
|
const KIND_ORDER = { registration: 0, shared: 1, exclusive: 2 };
|
|
284
291
|
|
|
285
|
-
// Planned once per RUN by plan(), never once per harness (the story "One run
|
|
286
|
-
// plans the agents block once, and a bare uninstall selects every harness the
|
|
287
|
-
// project uses", rule 1). `s` carries the run's common file list as `files`;
|
|
288
|
-
// `ctx.run` is every harness of the run that has a block, `ctx.harness` the
|
|
289
|
-
// first of them, `ctx.readers` set when the run names more than one, and
|
|
290
|
-
// `ctx.layout` the roster's layout name (rule 3).
|
|
291
|
-
function planAgentsBlock(ctx, key, s) {
|
|
292
|
-
const { projectDir, mode, root } = ctx;
|
|
293
|
-
const run = ctx.run || [ctx.harness];
|
|
294
|
-
const a = analyseBlock(projectDir, s, { root, layout: ctx.layout || null, readers: ctx.readers || null });
|
|
295
|
-
const { withBlock, preferred, importLine, FALLBACK } = a;
|
|
296
|
-
const items = [];
|
|
297
|
-
if (a.refusal)
|
|
298
|
-
return [
|
|
299
|
-
{
|
|
300
|
-
surface: key,
|
|
301
|
-
kind: "shared",
|
|
302
|
-
path: a.files[0].path,
|
|
303
|
-
entry: "projectstore:agents",
|
|
304
|
-
state: "refused",
|
|
305
|
-
action: "refuse",
|
|
306
|
-
reason: a.refusal,
|
|
307
|
-
},
|
|
308
|
-
];
|
|
309
|
-
|
|
310
|
-
const hasImport = (text) => importsLine(text, importLine);
|
|
311
|
-
// A CLAUDE.md that is nothing but the import registration added is ours to
|
|
312
|
-
// delete when the block goes (ADR-002 decision 4); anything else stays.
|
|
313
|
-
const onlyImport = (text) =>
|
|
314
|
-
String(text ?? "")
|
|
315
|
-
.split("\n")
|
|
316
|
-
.every((l) => !l.trim() || l.trim() === importLine);
|
|
317
|
-
// A file the removal leaves empty is deleted when it held only the block
|
|
318
|
-
// (rule 4) — and the fallback file, as before. Keyed on the fallback alone,
|
|
319
|
-
// a run's common list (whose last entry is AGENTS.md) and Claude Code alone
|
|
320
|
-
// both left a 0-byte AGENTS.md (measured 2026-10-08).
|
|
321
|
-
const removal = (e, extra = {}) => {
|
|
322
|
-
const after = removeAgentsBlock(e.text);
|
|
323
|
-
return {
|
|
324
|
-
surface: key,
|
|
325
|
-
kind: "shared",
|
|
326
|
-
path: e.path,
|
|
327
|
-
entry: `projectstore:agents v${e.block.v}`,
|
|
328
|
-
state: "ours-current",
|
|
329
|
-
action: "remove",
|
|
330
|
-
reason: null,
|
|
331
|
-
before: e.text,
|
|
332
|
-
after,
|
|
333
|
-
deleteIfEmpty: e.file === FALLBACK || !after.trim(),
|
|
334
|
-
...extra,
|
|
335
|
-
};
|
|
336
|
-
};
|
|
337
|
-
|
|
338
|
-
if (mode === "uninstall") {
|
|
339
|
-
if (!withBlock.length)
|
|
340
|
-
return [
|
|
341
|
-
{
|
|
342
|
-
surface: key,
|
|
343
|
-
kind: "shared",
|
|
344
|
-
path: preferred.path,
|
|
345
|
-
entry: "projectstore:agents",
|
|
346
|
-
state: "ours-absent",
|
|
347
|
-
action: "skip",
|
|
348
|
-
reason: "no block to remove",
|
|
349
|
-
},
|
|
350
|
-
];
|
|
351
|
-
// The agents block is the one surface the PROJECT owns rather than a
|
|
352
|
-
// harness: several harnesses read the same file. So disowning one harness
|
|
353
|
-
// may not remove it, and measurably did — `uninstall --harness <other>`
|
|
354
|
-
// deleted AGENTS.md while the source harness was still reading it through
|
|
355
|
-
// an @AGENTS.md import, and the reverse stripped the block Codex reads
|
|
356
|
-
// (both measured 2026-09-09). Either way, disowning one harness silently
|
|
357
|
-
// disabled the other.
|
|
358
|
-
//
|
|
359
|
-
// The test is static manifest data, not detection: is the file the block
|
|
360
|
-
// lives in one that ANOTHER manifest also names? No project state is read,
|
|
361
|
-
// so there is no circularity — and the single-harness case is untouched,
|
|
362
|
-
// because a file only one manifest names is that harness's to clear.
|
|
363
|
-
// Over-conservative on purpose: a block left behind is a marked,
|
|
364
|
-
// self-describing region a user can delete, where a deleted file another
|
|
365
|
-
// harness reads is silent breakage. `--surface agents_block` still removes
|
|
366
|
-
// it, which is the confirmation the gate asks for everywhere else.
|
|
367
|
-
//
|
|
368
|
-
// "Another" means a manifest OUTSIDE this run (rule 4): a run that names
|
|
369
|
-
// every harness reading the file is the last one, and removes the block
|
|
370
|
-
// once. Excluding only the planning harness kept the block for a harness
|
|
371
|
-
// that was in the same run.
|
|
372
|
-
const inRun = new Set(run.map((m) => m?.id));
|
|
373
|
-
const outside = [...loadHarnesses().values()].filter((m) => !inRun.has(m.id));
|
|
374
|
-
const alsoRead = (file) => outside.some((m) => (agentsBlockRow(m)?.[1].files || []).includes(file));
|
|
375
|
-
for (const e of withBlock) {
|
|
376
|
-
// Naming the surface removes it regardless: `--surface agents_block`
|
|
377
|
-
// is the confirmation without a terminal, and a terminal is asked
|
|
378
|
-
// (contract 9 as amended 2026-10-04).
|
|
379
|
-
if (!(ctx.surfaces || []).includes(key) && alsoRead(e.file)) {
|
|
380
|
-
items.push({
|
|
381
|
-
surface: key,
|
|
382
|
-
kind: "shared",
|
|
383
|
-
path: e.path,
|
|
384
|
-
entry: `projectstore:agents v${e.block.v}`,
|
|
385
|
-
state: "ours-current",
|
|
386
|
-
action: "skip",
|
|
387
|
-
reason: `${e.file} is read by another harness too — a per-harness uninstall leaves the project's block alone. Remove it with --surface ${key}`,
|
|
388
|
-
});
|
|
389
|
-
continue;
|
|
390
|
-
}
|
|
391
|
-
items.push(removal(e));
|
|
392
|
-
}
|
|
393
|
-
// An import that points at a file whose block has just gone is a pointer to
|
|
394
|
-
// nothing. Look across every file any manifest names, not just this one's:
|
|
395
|
-
// with a single-entry list PREFERRED and FALLBACK are the same file, which
|
|
396
|
-
// made the old condition — block in PREFERRED and not in FALLBACK —
|
|
397
|
-
// impossible to satisfy, so the import was never cleaned up at all.
|
|
398
|
-
// An import is dangling only when the file it names will be GONE — not
|
|
399
|
-
// merely when the block left it. A user's own AGENTS.md keeps its prose and
|
|
400
|
-
// survives, and importing it stays meaningful (install contract 13 /
|
|
401
|
-
// ADR-002 decision 4). The condition is therefore apply's own: marked for
|
|
402
|
-
// deletion and left holding nothing (`install-harness.mjs:760`).
|
|
403
|
-
const vanishing = new Set(
|
|
404
|
-
items
|
|
405
|
-
.filter((i) => i.action === "remove" && i.deleteIfEmpty && typeof i.after === "string" && !i.after.trim())
|
|
406
|
-
.map((i) => rel(projectDir, i.path)),
|
|
407
|
-
);
|
|
408
|
-
// Two independent reasons to take an import out, and the first version of
|
|
409
|
-
// this replaced one with the other:
|
|
410
|
-
// (1) it is OURS and nothing else is there — the registration we added to
|
|
411
|
-
// a file that held nothing else goes with the block (ADR-002
|
|
412
|
-
// decision 4), whether or not its target survives;
|
|
413
|
-
// (2) it DANGLES — the file it names will be gone, so it points at
|
|
414
|
-
// nothing even though the reader's own prose keeps the file alive.
|
|
415
|
-
// A user's AGENTS.md that keeps its prose satisfies neither, and its import
|
|
416
|
-
// stays: that is install contract 13, and it is what caught the mistake.
|
|
417
|
-
//
|
|
418
|
-
// Over the UNION of every manifest's list, not this one's: the file holding
|
|
419
|
-
// the import need not be one this harness can read. Codex's list is a
|
|
420
|
-
// single entry, which also made the old condition — block in PREFERRED and
|
|
421
|
-
// not in FALLBACK — impossible to satisfy, so nothing was ever cleaned up.
|
|
422
|
-
//
|
|
423
|
-
// Except a BRIDGE (rule 4: a narrowed uninstall "leaves the block and the
|
|
424
|
-
// import in place"): the import in the file a harness outside this run
|
|
425
|
-
// reads by itself, pointing at a block this run keeps for that harness.
|
|
426
|
-
// It is the only way that harness sees the block; taking it out because it
|
|
427
|
-
// is ours kept the block and blinded its reader. The source harness's
|
|
428
|
-
// own uninstall still removes its own file's import, as before.
|
|
429
|
-
const blockGone = new Set(
|
|
430
|
-
items.filter((i) => i.action === "remove" && i.surface === key).map((i) => rel(projectDir, i.path)),
|
|
431
|
-
);
|
|
432
|
-
const blockKept = new Set(
|
|
433
|
-
items.filter((i) => i.action === "skip" && i.surface === key).map((i) => rel(projectDir, i.path)),
|
|
434
|
-
);
|
|
435
|
-
const outsideNatives = new Set(outside.map((m) => agentsBlockRow(m)?.[1].reads_natively).filter(Boolean));
|
|
436
|
-
const union = [...new Set([...loadHarnesses().values()].flatMap((m) => m.surfaces?.agents_block?.files || []))];
|
|
437
|
-
for (const file of union) {
|
|
438
|
-
if (blockGone.has(file)) continue;
|
|
439
|
-
const e = readText(join(projectDir, file));
|
|
440
|
-
if (!e.present || e.text === null || !hasImport(e.text)) continue;
|
|
441
|
-
const ours = onlyImport(e.text);
|
|
442
|
-
const dangles = vanishing.has(importLine.slice(1));
|
|
443
|
-
const bridge = blockKept.has(importLine.slice(1)) && outsideNatives.has(file);
|
|
444
|
-
if ((!ours && !dangles) || bridge) continue;
|
|
445
|
-
const after = e.text
|
|
446
|
-
.split("\n")
|
|
447
|
-
.filter((l) => l.trim() !== importLine)
|
|
448
|
-
.join("\n")
|
|
449
|
-
.replace(/^\n+/, "");
|
|
450
|
-
items.push({
|
|
451
|
-
surface: `${key}_import`,
|
|
452
|
-
kind: "shared",
|
|
453
|
-
path: join(projectDir, file),
|
|
454
|
-
entry: importLine,
|
|
455
|
-
state: "ours-current",
|
|
456
|
-
action: "remove",
|
|
457
|
-
reason: ours
|
|
458
|
-
? `${file} holds only the import registration added`
|
|
459
|
-
: `${importLine} points at a file this uninstall removes`,
|
|
460
|
-
before: e.text,
|
|
461
|
-
after,
|
|
462
|
-
deleteIfEmpty: ours,
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
return items;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
const current = a.current;
|
|
469
|
-
// A block marked above this copy's template is never rendered down (the
|
|
470
|
-
// install spec, contracts 4, 6 and 9, as amended 2026-10-14): a move, a
|
|
471
|
-
// migration or a dedupe carries its own bytes, stamped with its own marker.
|
|
472
|
-
const newer = a.newer || null;
|
|
473
|
-
const words = newer ? newerBlockWords(newer.block.v, a.version) : null;
|
|
474
|
-
const body = newer ? newer.block.block : a.desired;
|
|
475
|
-
const entry = `projectstore:agents v${newer ? newer.block.v : a.version}`;
|
|
476
|
-
// Two files, one block each: resolve in favour of the preferred file —
|
|
477
|
-
// register migrates, never duplicates (ADR-002 decision 3).
|
|
478
|
-
for (const e of a.duplicates || [])
|
|
479
|
-
items.push(
|
|
480
|
-
removal(e, {
|
|
481
|
-
state: "ours-stale",
|
|
482
|
-
reason:
|
|
483
|
-
newer === e
|
|
484
|
-
? `carries v${e.block.v}, newer than this copy's v${a.version}; its text replaces the block in ${current.file}`
|
|
485
|
-
: `duplicate of the block in ${current.file}`,
|
|
486
|
-
}),
|
|
487
|
-
);
|
|
488
|
-
let newerInPlace = null;
|
|
489
|
-
|
|
490
|
-
if (!current) {
|
|
491
|
-
const target = preferred;
|
|
492
|
-
items.push({
|
|
493
|
-
surface: key,
|
|
494
|
-
kind: "shared",
|
|
495
|
-
path: target.path,
|
|
496
|
-
entry,
|
|
497
|
-
state: "ours-absent",
|
|
498
|
-
action: target.present ? "add" : "create",
|
|
499
|
-
reason: null,
|
|
500
|
-
before: target.present ? target.text : null,
|
|
501
|
-
after: replaceAgentsBlock(target.present ? target.text : "", a.desired),
|
|
502
|
-
});
|
|
503
|
-
} else if (!current.own || (current.file !== preferred.file && preferred.present)) {
|
|
504
|
-
// Two reasons to move, and they are not the same reason:
|
|
505
|
-
// - the block sits in a file this harness cannot read (`!own`). It must
|
|
506
|
-
// move, and its target is CREATED if it does not exist — this is a
|
|
507
|
-
// second harness arriving in a project that had one.
|
|
508
|
-
// - it sits in a readable but non-preferred file while the preferred one
|
|
509
|
-
// already exists. A preference, satisfied because the file is there.
|
|
510
|
-
// The distinction is what keeps a Claude-Code-only project from acquiring
|
|
511
|
-
// an AGENTS.md it never asked for, while still moving the substance the
|
|
512
|
-
// moment a harness that can only read AGENTS.md is installed.
|
|
513
|
-
// A run of several harnesses says which of them cannot read the file the
|
|
514
|
-
// block leaves (rule 1); one harness keeps its text, byte for byte.
|
|
515
|
-
items.push(removal(current, { state: "ours-stale", reason: `migrating to ${preferred.file}` }));
|
|
516
|
-
items.push({
|
|
517
|
-
surface: key,
|
|
518
|
-
kind: "shared",
|
|
519
|
-
path: preferred.path,
|
|
520
|
-
entry,
|
|
521
|
-
state: "ours-absent",
|
|
522
|
-
action: preferred.present ? "add" : "create",
|
|
523
|
-
reason:
|
|
524
|
-
ctx.readers && !current.own
|
|
525
|
-
? a.reason
|
|
526
|
-
: `migrated from ${current.file}${newer ? ` with its own text (${words})` : ""}`,
|
|
527
|
-
before: preferred.present ? preferred.text : null,
|
|
528
|
-
after: replaceAgentsBlock(preferred.present ? preferred.text : "", body),
|
|
529
|
-
});
|
|
530
|
-
} else if (newer && newer !== current) {
|
|
531
|
-
// The duplicate carries the higher marker: its text replaces the stayer's.
|
|
532
|
-
items.push({
|
|
533
|
-
surface: key,
|
|
534
|
-
kind: "shared",
|
|
535
|
-
path: current.path,
|
|
536
|
-
entry,
|
|
537
|
-
state: "ours-stale",
|
|
538
|
-
action: "replace-entry",
|
|
539
|
-
reason: `its text is replaced by ${newer.file}'s, which carries v${newer.block.v}, newer than this copy's v${a.version}`,
|
|
540
|
-
before: current.text,
|
|
541
|
-
after: replaceAgentsBlock(current.text, body),
|
|
542
|
-
});
|
|
543
|
-
} else if (newer) {
|
|
544
|
-
// It stays as it is; a named run with nothing else to place overwrites it,
|
|
545
|
-
// decided once the imports are known (below).
|
|
546
|
-
newerInPlace = {
|
|
547
|
-
surface: key,
|
|
548
|
-
kind: "shared",
|
|
549
|
-
path: current.path,
|
|
550
|
-
entry,
|
|
551
|
-
state: "ours-newer",
|
|
552
|
-
action: "skip",
|
|
553
|
-
reason: `${words}: left as it is — ${
|
|
554
|
-
ctx.takesSurface === false
|
|
555
|
-
? `to write this copy's v${a.version} over it: ${coreCommand("install", { args: `--harness ${ctx.harness.id} --surface ${key}` })}`
|
|
556
|
-
: `name --surface ${key} to write this copy's v${a.version} over it`
|
|
557
|
-
}`,
|
|
558
|
-
};
|
|
559
|
-
items.push(newerInPlace);
|
|
560
|
-
} else if (current.block.v === a.version && current.block.block === a.desired) {
|
|
561
|
-
items.push({
|
|
562
|
-
surface: key,
|
|
563
|
-
kind: "shared",
|
|
564
|
-
path: current.path,
|
|
565
|
-
entry,
|
|
566
|
-
state: "ours-current",
|
|
567
|
-
action: "skip",
|
|
568
|
-
reason: null,
|
|
569
|
-
});
|
|
570
|
-
} else {
|
|
571
|
-
items.push({
|
|
572
|
-
surface: key,
|
|
573
|
-
kind: "shared",
|
|
574
|
-
path: current.path,
|
|
575
|
-
entry,
|
|
576
|
-
state: "ours-stale",
|
|
577
|
-
action: "replace-entry",
|
|
578
|
-
reason:
|
|
579
|
-
current.block.v !== a.version
|
|
580
|
-
? `v${current.block.v} → v${a.version}`
|
|
581
|
-
: "content differs from the current source",
|
|
582
|
-
before: current.text,
|
|
583
|
-
after: replaceAgentsBlock(current.text, a.desired),
|
|
584
|
-
});
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// The @AGENTS.md import in CLAUDE.md (ADR-002 decision 3), only when the
|
|
588
|
-
// block lives in AGENTS.md and CLAUDE.md exists without it. A removal
|
|
589
|
-
// already rewriting CLAUDE.md in this plan takes the import onto its own
|
|
590
|
-
// text, or the two items would race.
|
|
591
|
-
const written = items.find((i) => ["add", "create", "replace-entry", "skip"].includes(i.action) && i.surface === key);
|
|
592
|
-
const blockFile = written ? rel(projectDir, written.path) : null;
|
|
593
|
-
// Every OTHER file any manifest names, and only if it already exists: a
|
|
594
|
-
// reader whose file no longer holds the substance is pointed at the one that
|
|
595
|
-
// does. Never a file we would have to create — a project with no CLAUDE.md
|
|
596
|
-
// does not acquire one because Codex was installed.
|
|
597
|
-
//
|
|
598
|
-
// It used to key on `a.files.length > 1`, which is a property of the
|
|
599
|
-
// INSTALLING harness's list: Codex's has one entry, so installing Codex left
|
|
600
|
-
// a CLAUDE.md that still exists, still reads as authoritative, and no longer
|
|
601
|
-
// holds anything. The block's file is what decides, not the list's length.
|
|
602
|
-
// Installing FOR a harness means the file that harness reads by itself ends
|
|
603
|
-
// up present. It holds the block when the block lands there; it holds the
|
|
604
|
-
// import when the block lands elsewhere. Without this, a project that already
|
|
605
|
-
// had an AGENTS.md took the block into it and created no bridge — so Claude
|
|
606
|
-
// Code, the harness that ran the install, could not see what it had just
|
|
607
|
-
// installed. `reads_natively` is the manifest's, so this is a file list, not
|
|
608
|
-
// a harness name — and it is every RUN harness's (rule 1): one run for two
|
|
609
|
-
// harnesses leaves both native files present, each holding the block or the
|
|
610
|
-
// import.
|
|
611
|
-
const natives = new Set(run.map((m) => agentsBlockRow(m)?.[1].reads_natively).filter(Boolean));
|
|
612
|
-
// The import item sits under the run's first harness, which need not be the
|
|
613
|
-
// one that reads the file: in a run of several, the reason names the reader.
|
|
614
|
-
// One harness is its own reader and keeps its text, byte for byte.
|
|
615
|
-
const reader = (file) =>
|
|
616
|
-
(ctx.readers && run.find((m) => agentsBlockRow(m)?.[1].reads_natively === file)?.display_name) || "this harness";
|
|
617
|
-
for (const e of a.files) {
|
|
618
|
-
const mustExist = natives.has(e.file);
|
|
619
|
-
if (!blockFile || e.file === blockFile || (!e.present && !mustExist)) continue;
|
|
620
|
-
const line = `@${blockFile}`;
|
|
621
|
-
const rewrite = items.find((i) => i.action === "remove" && i.path === e.path);
|
|
622
|
-
// An absent native file is empty text, not a reason to skip: it is created.
|
|
623
|
-
const text = rewrite ? rewrite.after : e.present ? e.text : "";
|
|
624
|
-
if (typeof text !== "string" || importsLine(text, line)) continue;
|
|
625
|
-
const after = line + "\n" + (text.startsWith("\n") || !text.trim() ? "" : "\n") + text;
|
|
626
|
-
if (rewrite) {
|
|
627
|
-
rewrite.after = after;
|
|
628
|
-
rewrite.deleteIfEmpty = false;
|
|
629
|
-
rewrite.reason += `; ${line} import added`;
|
|
630
|
-
} else
|
|
631
|
-
items.push({
|
|
632
|
-
surface: `${key}_import`,
|
|
633
|
-
kind: "shared",
|
|
634
|
-
path: e.path,
|
|
635
|
-
entry: line,
|
|
636
|
-
state: "ours-absent",
|
|
637
|
-
action: e.present ? "add" : "create",
|
|
638
|
-
reason: e.present
|
|
639
|
-
? `${blockFile} carries the block; ${e.file} must import it`
|
|
640
|
-
: `${e.file} is what ${reader(e.file)} reads by itself; it is created to import ${blockFile}`,
|
|
641
|
-
before: e.present ? text : null,
|
|
642
|
-
after,
|
|
643
|
-
});
|
|
644
|
-
}
|
|
645
|
-
// Naming the surface confirms the overwrite only when the run has nothing
|
|
646
|
-
// else to do for the block: it is the only block and every import it needs
|
|
647
|
-
// is in place. Doctor's placement remedies name the surface too, and they
|
|
648
|
-
// must never downgrade (contract 9 as amended 2026-10-14).
|
|
649
|
-
if (
|
|
650
|
-
newerInPlace &&
|
|
651
|
-
(ctx.surfaces || []).includes(key) &&
|
|
652
|
-
!(a.duplicates || []).length &&
|
|
653
|
-
!items.some((i) => i.surface === `${key}_import`)
|
|
654
|
-
)
|
|
655
|
-
Object.assign(newerInPlace, {
|
|
656
|
-
entry: `projectstore:agents v${a.version}`,
|
|
657
|
-
action: "replace-entry",
|
|
658
|
-
reason: `v${current.block.v} → v${a.version}: ${words}, and --surface ${key} names it`,
|
|
659
|
-
before: current.text,
|
|
660
|
-
after: replaceAgentsBlock(current.text, a.desired),
|
|
661
|
-
});
|
|
662
|
-
return items;
|
|
663
|
-
}
|
|
664
|
-
|
|
665
292
|
// ─── host-plugin-registration ──────────────────────────────────────────
|
|
666
293
|
|
|
667
294
|
// One host command as a plan step: the verbatim argv (the manifest's
|
|
@@ -1134,16 +761,29 @@ function planRegistration(ctx, key, s) {
|
|
|
1134
761
|
path: a.paths.dir,
|
|
1135
762
|
why: "our marketplace directory, removed whole (its manifest carries our provenance field); no other checkout is installed from it",
|
|
1136
763
|
});
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
764
|
+
const removed = {
|
|
765
|
+
...base,
|
|
766
|
+
action: "remove",
|
|
767
|
+
steps,
|
|
768
|
+
reason: a.otherProjects
|
|
769
|
+
? `${a.otherProjects} other checkout(s) are installed from the shared directory; it and the host's marketplace entry stay`
|
|
770
|
+
: a.reason,
|
|
771
|
+
};
|
|
772
|
+
// The way back from the npm answer over a directory copy (PS-DIRECTORY,
|
|
773
|
+
// decision 5): when this checkout's npm copy was the one loading and no
|
|
774
|
+
// other copy is left on, the synced copy is the one the host loads again.
|
|
775
|
+
// DONE says so (off the envelope).
|
|
776
|
+
const reenabled = a.dir.disabled.some((k) => a.disabledHere.includes(k));
|
|
777
|
+
if (a.synced && a.installed?.present && a.enabled && !a.others.length && !reenabled)
|
|
778
|
+
Object.defineProperty(removed, "next", {
|
|
779
|
+
value: [
|
|
780
|
+
{
|
|
781
|
+
lead: `${a.synced.key}, from ${copyOrigin(a.synced)}, loads in this project again at the next start of ${harness.display_name}.`,
|
|
782
|
+
},
|
|
783
|
+
],
|
|
784
|
+
enumerable: false,
|
|
785
|
+
});
|
|
786
|
+
return [removed];
|
|
1147
787
|
}
|
|
1148
788
|
|
|
1149
789
|
// install / upgrade
|
|
@@ -1238,7 +878,8 @@ function planRegistration(ctx, key, s) {
|
|
|
1238
878
|
// Newer than what the project would run with the copy off — the shared
|
|
1239
879
|
// directory's version when another checkout wrote a newer one — not merely
|
|
1240
880
|
// newer than this package: under that, turning it off is no downgrade.
|
|
1241
|
-
|
|
881
|
+
// A directory copy this registration would load over counts too (PS-DIRECTORY).
|
|
882
|
+
const newerOther = [...a.others, ...(a.directory ? [a.directory] : [])]
|
|
1242
883
|
.filter((o) => typeof o.version === "string" && cmpPrecedence(o.version, a.targetPkg || a.pkg) > 0)
|
|
1243
884
|
.sort((x, y) => cmpPrecedence(y.version, x.version))[0];
|
|
1244
885
|
if (newerOther && (ctx.channel ?? null) === null) {
|
|
@@ -1264,8 +905,11 @@ function planRegistration(ctx, key, s) {
|
|
|
1264
905
|
// switches this run plans, and contract 13's silenced set — the copies this
|
|
1265
906
|
// registration turned off that this checkout still holds off — for a
|
|
1266
907
|
// registration this checkout holds.
|
|
908
|
+
// A directory copy the registration loads over is that switch too, never
|
|
909
|
+
// turned off (PS-DIRECTORY, decision 4): a skip row of its own, below.
|
|
910
|
+
const shadows = a.directory && a.state !== "current" ? a.directory : null;
|
|
1267
911
|
ctx.channelFacts = {
|
|
1268
|
-
switches: a.others.map((o) => o.key),
|
|
912
|
+
switches: [...a.others.map((o) => o.key), ...(shadows ? [shadows.key] : [])],
|
|
1269
913
|
silenced: ["current", "stale"].includes(a.state) ? a.dir.disabled.filter((k) => a.disabledHere.includes(k)) : [],
|
|
1270
914
|
};
|
|
1271
915
|
|
|
@@ -1435,27 +1079,44 @@ function planRegistration(ctx, key, s) {
|
|
|
1435
1079
|
: null;
|
|
1436
1080
|
items.push({ ...other(o), action: "disable", ...(reason ? { reason } : {}), steps });
|
|
1437
1081
|
}
|
|
1082
|
+
// The directory copy (PS-DIRECTORY, "Setup and install keep a directory
|
|
1083
|
+
// copy…", decision 4): the registration above loads over it by Claude
|
|
1084
|
+
// Code's own precedence, so nothing is turned off — no `disable`, no ⇄
|
|
1085
|
+
// switch, no entry in our manifest's disabled list. A skip whose
|
|
1086
|
+
// consequence line says what changes; asked like a switch (the gate).
|
|
1087
|
+
if (shadows) {
|
|
1088
|
+
const extra =
|
|
1089
|
+
ctx.channel === "npm"
|
|
1090
|
+
? switchReason({
|
|
1091
|
+
copy: shadows,
|
|
1092
|
+
running: a.pkg,
|
|
1093
|
+
runs: a.targetPkg,
|
|
1094
|
+
decision: ctx.convergence,
|
|
1095
|
+
unbound: !ctx.binding?.bound,
|
|
1096
|
+
})
|
|
1097
|
+
: null;
|
|
1098
|
+
items.push({
|
|
1099
|
+
...other(shadows),
|
|
1100
|
+
state: "shadowed",
|
|
1101
|
+
action: "skip",
|
|
1102
|
+
reason: `${shadows.key} (${shadows.version || "no version recorded"}), from ${copyOrigin(shadows)}, stays enabled: nothing turns it off${extra ? `; ${extra}` : ""}`,
|
|
1103
|
+
steps: [consequenceStep(directoryConsequence(harness.display_name))],
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1438
1106
|
return items;
|
|
1439
1107
|
}
|
|
1440
|
-
|
|
1441
|
-
// A row's consequence (presentation spec contract 7) carried as a `note` step:
|
|
1442
|
-
// the envelope reads `{ kind: "note", why }`, as any note; PLAN draws it first
|
|
1443
|
-
// beneath its row, in the explanation role, by the mark kept off the envelope.
|
|
1444
|
-
function consequenceStep(why) {
|
|
1445
|
-
const st = { kind: "note", why };
|
|
1446
|
-
Object.defineProperty(st, "consequence", { value: true, enumerable: false });
|
|
1447
|
-
return st;
|
|
1448
|
-
}
|
|
1449
|
-
const isConsequence = (st) => st?.kind === "note" && st.consequence === true;
|
|
1450
1108
|
// A channel switch: a copy from another marketplace turned off for this checkout.
|
|
1451
1109
|
const isSwitch = (i) => String(i?.surface || "").endsWith("_others") && i.action === "disable";
|
|
1110
|
+
// …and a directory copy an npm registration loads over, never turned off.
|
|
1111
|
+
const isShadow = (i) => String(i?.surface || "").endsWith("_others") && i.state === "shadowed" && i.action === "skip";
|
|
1452
1112
|
|
|
1453
1113
|
// The channel "git" (the converge story's rules 1 and 5), for the first
|
|
1454
1114
|
// harness of the run with a host plugin registration — the channel concerns
|
|
1455
1115
|
// that registration alone; Codex has none. The copy kept is contract 12's
|
|
1456
|
-
// selection (lib's selectCopy), limited to copies from another
|
|
1457
|
-
//
|
|
1458
|
-
//
|
|
1116
|
+
// selection (lib's selectCopy), limited to copies from another channel — the
|
|
1117
|
+
// git marketplace, a fork's, or Anthropic's directory (after every registry
|
|
1118
|
+
// row, so an enabled git copy wins) — so the npm registration's copy is never
|
|
1119
|
+
// the kept one. None enabled for this project is a refusal: there is nothing to
|
|
1459
1120
|
// keep. A copy of ours this checkout's local settings hold off — a hand
|
|
1460
1121
|
// removal of the npm row leaves the git copy off — is named with the way to
|
|
1461
1122
|
// turn it back on. Read-only; plan() and the prepare step both call it, so the
|
|
@@ -1471,7 +1132,7 @@ function keepTarget(projectDir, ids, { root, home, env }) {
|
|
|
1471
1132
|
);
|
|
1472
1133
|
if (!row) continue;
|
|
1473
1134
|
const [key, s] = row;
|
|
1474
|
-
const copy = selectCopy(home, projectDir, { harness, only: "marketplace" });
|
|
1135
|
+
const copy = selectCopy(home, projectDir, { harness, only: ["marketplace", "synced"] });
|
|
1475
1136
|
if (copy) return { harness, key, s, copy };
|
|
1476
1137
|
const a = analyseRegistration(projectDir, s, { root, home, harness, env });
|
|
1477
1138
|
const off = (a.disabledHere || []).filter((k) => k !== a.id && k.split("@")[0] === s.plugin_name);
|
|
@@ -1537,7 +1198,7 @@ function planKeep(ctx, target, read) {
|
|
|
1537
1198
|
const { projectDir, env, home, root, harness, surfaces } = ctx;
|
|
1538
1199
|
const { key, s, copy } = target;
|
|
1539
1200
|
const a = analyseRegistration(projectDir, s, { root, home, harness, env });
|
|
1540
|
-
const beside = a.installed?.present && a.enabled ? npmRowBeside({ harness, id: a.id, surface: key }) : null;
|
|
1201
|
+
const beside = a.installed?.present && a.enabled ? npmRowBeside({ harness, id: a.id, surface: key, copy }) : null;
|
|
1541
1202
|
const besides = beside ? [beside] : [];
|
|
1542
1203
|
const named = `${copy.key} ${copy.version || "(no version recorded)"}`;
|
|
1543
1204
|
// Built whole, never spread into place: `next` (what DONE says) and `kept`
|
|
@@ -1568,7 +1229,7 @@ function planKeep(ctx, target, read) {
|
|
|
1568
1229
|
});
|
|
1569
1230
|
return it;
|
|
1570
1231
|
};
|
|
1571
|
-
const kept = `this project keeps ${named} from
|
|
1232
|
+
const kept = `this project keeps ${named} from ${copyOrigin(copy)}, and that copy writes its own ${harness.display_name} surfaces here — no registration changes`;
|
|
1572
1233
|
if (surfaces && surfaces.length) {
|
|
1573
1234
|
return [
|
|
1574
1235
|
item({
|
|
@@ -1768,7 +1429,7 @@ function planMark(ctx, facts) {
|
|
|
1768
1429
|
// answer wins, as for the mark: a `channel_switch` this checkout recorded
|
|
1769
1430
|
// earlier, of any shape, goes in the same item — alone, when the keep is
|
|
1770
1431
|
// already recorded.
|
|
1771
|
-
function planKeepRecord(ctx) {
|
|
1432
|
+
function planKeepRecord(ctx, copy = null) {
|
|
1772
1433
|
const b = ctx.binding;
|
|
1773
1434
|
if (!b || !b.bound) return null;
|
|
1774
1435
|
const record = !b.hasKeepRecord;
|
|
@@ -1789,8 +1450,7 @@ function planKeepRecord(ctx) {
|
|
|
1789
1450
|
set: record ? { [CHANNEL_KEEP_KEY]: STAMP } : {},
|
|
1790
1451
|
unset: b.hasKey ? [CHANNEL_SWITCH_KEY] : [],
|
|
1791
1452
|
why: {
|
|
1792
|
-
[CHANNEL_KEEP_KEY]:
|
|
1793
|
-
"this checkout chose to keep the git marketplace: setup's channel question defaults to keep from now on, and a run without a terminal reads it as --channel git",
|
|
1453
|
+
[CHANNEL_KEEP_KEY]: `this checkout chose to keep ${copy?.synced ? `the copy from ${copyOrigin(copy)}` : "the git marketplace"}: setup's channel question defaults to keep from now on, and a run without a terminal reads it as --channel git`,
|
|
1794
1454
|
[CHANNEL_SWITCH_KEY]:
|
|
1795
1455
|
"the switch this checkout recorded earlier goes: the keep is its later answer, and doctor names a copy held off here again",
|
|
1796
1456
|
},
|
|
@@ -1802,428 +1462,8 @@ function planKeepRecord(ctx) {
|
|
|
1802
1462
|
});
|
|
1803
1463
|
}
|
|
1804
1464
|
|
|
1805
|
-
// The status line's flag (the setup story, rule 9; the configure spec,
|
|
1806
|
-
// contract 6): `statusline.enabled`, from setup's answer or from a run that
|
|
1807
|
-
// names the surface — install records on, uninstall off. This item is the
|
|
1808
|
-
// flag's one writer; every other key of a `statusline` object is kept.
|
|
1809
|
-
// Planned for the run's harness that has the status-line surface, when the
|
|
1810
|
-
// binding the run leaves does not already say the same; only when there is a
|
|
1811
|
-
// binding to record it in.
|
|
1812
|
-
const STATUSLINE_FLAG = "statusline.enabled";
|
|
1813
|
-
function planStatuslineFlag(ctx, value, mode = "install") {
|
|
1814
|
-
const b = ctx.binding;
|
|
1815
|
-
if (!b || !b.bound || typeof value !== "boolean" || b.statusline === value) return null;
|
|
1816
|
-
const off =
|
|
1817
|
-
mode === "uninstall"
|
|
1818
|
-
? "the status line is removed in this run, and the SessionStart hook keeps it off"
|
|
1819
|
-
: "the status line stays off, and setup does not ask again";
|
|
1820
|
-
return bindingItem({
|
|
1821
|
-
harness: ctx.harness.id,
|
|
1822
|
-
surface: "statusline_flag",
|
|
1823
|
-
entry: STATUSLINE_FLAG,
|
|
1824
|
-
path: b.path,
|
|
1825
|
-
bound: true,
|
|
1826
|
-
set: { [STATUSLINE_FLAG]: value },
|
|
1827
|
-
why: {
|
|
1828
|
-
[STATUSLINE_FLAG]: value
|
|
1829
|
-
? "the status line is wired in this run, and the SessionStart hook keeps it current"
|
|
1830
|
-
: off,
|
|
1831
|
-
},
|
|
1832
|
-
said: {
|
|
1833
|
-
unbound: "the project is not bound; the status line's flag is recorded once it is",
|
|
1834
|
-
done: "the status line's flag is already recorded",
|
|
1835
|
-
object: "the status line's flag is not recorded",
|
|
1836
|
-
},
|
|
1837
|
-
});
|
|
1838
|
-
}
|
|
1839
|
-
|
|
1840
|
-
// Setup's vault item (the story's rule 10; the front-door ADR's decision 7):
|
|
1841
|
-
// one item of kind `vault`, first in the plan and under no harness — PLAN's
|
|
1842
|
-
// VAULT group. Its steps are the binding plan's own (binding.mjs
|
|
1843
|
-
// bindPlanSteps): the mkdir, the binding, `git init`, the scaffold. `create`
|
|
1844
|
-
// for a vault this run makes (missing, or there and empty); `add` for a
|
|
1845
|
-
// binding of one that exists, scaffolded or not, and for a worktree's
|
|
1846
|
-
// inherited binding. The binding plan rides along off the envelope, for
|
|
1847
|
-
// apply; with nothing to write there is no item.
|
|
1848
|
-
function planVault(bp) {
|
|
1849
|
-
const steps = bindPlanSteps(bp);
|
|
1850
|
-
if (!steps.length) return null;
|
|
1851
|
-
const reason = bp.inherit
|
|
1852
|
-
? `the vault of ${bp.inherit.from}, with its settings`
|
|
1853
|
-
: bp.buildsVault || !bp.vaultExists
|
|
1854
|
-
? null
|
|
1855
|
-
: bp.scaffoldsExisting
|
|
1856
|
-
? "it already exists; it is bound as it is, and the layout's missing folders and indexes are added"
|
|
1857
|
-
: "it already exists; it is bound as it is";
|
|
1858
|
-
const item = {
|
|
1859
|
-
harness: null,
|
|
1860
|
-
surface: "vault",
|
|
1861
|
-
kind: "vault",
|
|
1862
|
-
path: bp.vault,
|
|
1863
|
-
entry: null,
|
|
1864
|
-
state: !bp.vaultExists ? "absent" : bp.buildsVault ? "empty" : "present",
|
|
1865
|
-
action: bp.buildsVault ? "create" : "add",
|
|
1866
|
-
reason,
|
|
1867
|
-
steps,
|
|
1868
|
-
};
|
|
1869
|
-
Object.defineProperty(item, "bind", { value: bp, enumerable: false });
|
|
1870
|
-
return item;
|
|
1871
|
-
}
|
|
1872
|
-
|
|
1873
|
-
// Setup's state directory (the story's rule 10): an empty
|
|
1874
|
-
// .projectstore/state/<id>/, the signal detection reads (detectHarnesses'
|
|
1875
|
-
// `state`), so a harness setup installed with no directory of its own is used
|
|
1876
|
-
// here before its first session. Never the welcome marker: that file means
|
|
1877
|
-
// the welcome was shown, and the first session writes it into this
|
|
1878
|
-
// directory. The first such item names state/ and its .gitignore when they
|
|
1879
|
-
// are still to be made.
|
|
1880
|
-
function planStateDir(projectDir, harness, { first = false } = {}) {
|
|
1881
|
-
const P = layoutPaths(projectDir);
|
|
1882
|
-
const path = P.harnessState(harness.id);
|
|
1883
|
-
if (existsSync(path)) return null;
|
|
1884
|
-
const steps =
|
|
1885
|
-
first && !existsSync(P.stateGitignore)
|
|
1886
|
-
? [
|
|
1887
|
-
{
|
|
1888
|
-
kind: "ensure",
|
|
1889
|
-
path: P.state,
|
|
1890
|
-
why: "state/ with its .gitignore, which keeps everything under it out of git",
|
|
1891
|
-
},
|
|
1892
|
-
]
|
|
1893
|
-
: [];
|
|
1894
|
-
return {
|
|
1895
|
-
harness: harness.id,
|
|
1896
|
-
surface: "harness_state",
|
|
1897
|
-
kind: "state",
|
|
1898
|
-
path,
|
|
1899
|
-
entry: null,
|
|
1900
|
-
state: "absent",
|
|
1901
|
-
action: "create",
|
|
1902
|
-
reason: null,
|
|
1903
|
-
...(steps.length ? { steps } : {}),
|
|
1904
|
-
};
|
|
1905
|
-
}
|
|
1906
|
-
|
|
1907
|
-
// A key a `binding` item stamps at apply: `{ by: "setup", at: <the time of
|
|
1908
|
-
// apply> }`, the mark's shape and the keep's.
|
|
1909
|
-
const STAMP = Object.freeze({ stamp: "setup" });
|
|
1910
|
-
const stamped = (v) =>
|
|
1911
|
-
Boolean(v) && typeof v === "object" && !Array.isArray(v) && typeof v.by === "string" && typeof v.at === "string";
|
|
1912
|
-
|
|
1913
|
-
// A `binding` item (the converge story's kind, made generic by the setup
|
|
1914
|
-
// story's rule 10): keys merged into the binding at apply time, after the
|
|
1915
|
-
// layout item, every other key kept. `set` maps a key — dotted for a nested
|
|
1916
|
-
// one — to its value, or to STAMP; `unset` lists the keys it removes. Both
|
|
1917
|
-
// are apply's data, kept off the envelope, as `said` is: the item's own
|
|
1918
|
-
// words for a binding that is not there, not an object, or already says it.
|
|
1919
|
-
// The steps are what PLAN shows and the envelope carries, in that order:
|
|
1920
|
-
// `mark` for a stamped key, `set` for a value, `unmark` for a removal. An
|
|
1921
|
-
// item that only removes a key is an `update` of a binding whose key is
|
|
1922
|
-
// `present`.
|
|
1923
|
-
function bindingItem({
|
|
1924
|
-
harness,
|
|
1925
|
-
surface,
|
|
1926
|
-
entry,
|
|
1927
|
-
path,
|
|
1928
|
-
bound,
|
|
1929
|
-
reason = null,
|
|
1930
|
-
set = {},
|
|
1931
|
-
unset = [],
|
|
1932
|
-
why = {},
|
|
1933
|
-
said = {},
|
|
1934
|
-
action = "add",
|
|
1935
|
-
state = "absent",
|
|
1936
|
-
}) {
|
|
1937
|
-
const step = (fields, key) => (why[key] ? { ...fields, why: why[key] } : fields);
|
|
1938
|
-
const steps = [
|
|
1939
|
-
...Object.entries(set).map(([key, value]) =>
|
|
1940
|
-
step(value === STAMP ? { kind: "mark", path, key } : { kind: "set", path, key, value }, key),
|
|
1941
|
-
),
|
|
1942
|
-
...unset.map((key) => step({ kind: "unmark", path, key }, key)),
|
|
1943
|
-
];
|
|
1944
|
-
const item = {
|
|
1945
|
-
harness,
|
|
1946
|
-
surface,
|
|
1947
|
-
kind: "binding",
|
|
1948
|
-
path,
|
|
1949
|
-
entry,
|
|
1950
|
-
state: bound ? state : "unbound",
|
|
1951
|
-
action,
|
|
1952
|
-
reason,
|
|
1953
|
-
steps,
|
|
1954
|
-
};
|
|
1955
|
-
for (const [k, v] of Object.entries({ set, unset, said }))
|
|
1956
|
-
Object.defineProperty(item, k, { value: v, enumerable: false });
|
|
1957
|
-
return item;
|
|
1958
|
-
}
|
|
1959
|
-
|
|
1960
|
-
function planJsonEntry(ctx, key, s) {
|
|
1961
|
-
const { projectDir, mode, root, home } = ctx;
|
|
1962
|
-
if (s.supported === false) {
|
|
1963
|
-
return [
|
|
1964
|
-
{
|
|
1965
|
-
surface: key,
|
|
1966
|
-
kind: "shared",
|
|
1967
|
-
path: join(projectDir, s.file),
|
|
1968
|
-
entry: s.marker?.pointer || null,
|
|
1969
|
-
state: "unsupported",
|
|
1970
|
-
action: "skip",
|
|
1971
|
-
reason: s.why_unsupported || "not supported for this harness yet",
|
|
1972
|
-
},
|
|
1973
|
-
];
|
|
1974
|
-
}
|
|
1975
|
-
const path = join(projectDir, s.file);
|
|
1976
|
-
const entryKey = (s.marker?.pointer || "statusLine.command").split(".")[0];
|
|
1977
|
-
// The status line is opt-in (projectstore.json → statusline.enabled), as
|
|
1978
|
-
// the SessionStart refresh already honours; naming the surface explicitly
|
|
1979
|
-
// is the other way to opt in. A verb with no --surface (setup, its rule 9)
|
|
1980
|
-
// names install's command, which takes it, in the core form.
|
|
1981
|
-
if (mode === "install" && !ctx.optIn.has(key)) {
|
|
1982
|
-
const anyway =
|
|
1983
|
-
ctx.takesSurface === false
|
|
1984
|
-
? `to wire it anyway: ${coreCommand("install", { args: `--harness ${ctx.harness.id} --surface ${key}` })}`
|
|
1985
|
-
: "name --surface statusline to wire it anyway";
|
|
1986
|
-
return [
|
|
1987
|
-
{
|
|
1988
|
-
surface: key,
|
|
1989
|
-
kind: "shared",
|
|
1990
|
-
path,
|
|
1991
|
-
entry: entryKey,
|
|
1992
|
-
state: "opt-out",
|
|
1993
|
-
action: "skip",
|
|
1994
|
-
reason: `statusline.enabled is not true in projectstore.json — ${anyway}`,
|
|
1995
|
-
},
|
|
1996
|
-
];
|
|
1997
|
-
}
|
|
1998
|
-
const renderRoot = ctx.renderRoot || root;
|
|
1999
|
-
// A root the package manager will collect (npx's cache, a node_modules) is
|
|
2000
|
-
// never wired directly: the entry would name a path that disappears. It is
|
|
2001
|
-
// wired against a registration's install path — or not at all (contract 4′).
|
|
2002
|
-
if (mode === "install" && !isPluginCacheRoot(renderRoot, home) && isEphemeralRoot(renderRoot)) {
|
|
2003
|
-
return [
|
|
2004
|
-
{
|
|
2005
|
-
surface: key,
|
|
2006
|
-
kind: "shared",
|
|
2007
|
-
path,
|
|
2008
|
-
entry: entryKey,
|
|
2009
|
-
state: "absent-or-present",
|
|
2010
|
-
action: "skip",
|
|
2011
|
-
reason:
|
|
2012
|
-
"this package root is a package-manager cache; the status line is wired only against a registered install (see the registration above)",
|
|
2013
|
-
},
|
|
2014
|
-
];
|
|
2015
|
-
}
|
|
2016
|
-
const a = analyseJsonEntry(projectDir, s, { root, home, renderRoot, harness: ctx.harness });
|
|
2017
|
-
if (a.state === "unparseable")
|
|
2018
|
-
return [
|
|
2019
|
-
{ surface: key, kind: "shared", path, entry: entryKey, state: "unparseable", action: "refuse", reason: a.reason },
|
|
2020
|
-
];
|
|
2021
|
-
|
|
2022
|
-
if (mode === "uninstall") {
|
|
2023
|
-
if (!a.curEntry)
|
|
2024
|
-
return [
|
|
2025
|
-
{ surface: key, kind: "shared", path, entry: entryKey, state: "ours-absent", action: "skip", reason: null },
|
|
2026
|
-
];
|
|
2027
|
-
if (!a.ours)
|
|
2028
|
-
return [
|
|
2029
|
-
{
|
|
2030
|
-
surface: key,
|
|
2031
|
-
kind: "shared",
|
|
2032
|
-
path,
|
|
2033
|
-
entry: entryKey,
|
|
2034
|
-
state: "theirs",
|
|
2035
|
-
action: "skip",
|
|
2036
|
-
reason: "the entry is not ours — left in place",
|
|
2037
|
-
},
|
|
2038
|
-
];
|
|
2039
|
-
const after = { ...a.settings };
|
|
2040
|
-
delete after[entryKey];
|
|
2041
|
-
return [
|
|
2042
|
-
{
|
|
2043
|
-
surface: key,
|
|
2044
|
-
kind: "shared",
|
|
2045
|
-
path,
|
|
2046
|
-
entry: entryKey,
|
|
2047
|
-
state: "ours-current",
|
|
2048
|
-
action: "remove",
|
|
2049
|
-
reason: null,
|
|
2050
|
-
before: a.settings,
|
|
2051
|
-
after,
|
|
2052
|
-
},
|
|
2053
|
-
];
|
|
2054
|
-
}
|
|
2055
|
-
if (a.state === "theirs") {
|
|
2056
|
-
ctx.slotForeign.add(key);
|
|
2057
|
-
return [
|
|
2058
|
-
{
|
|
2059
|
-
surface: key,
|
|
2060
|
-
kind: "shared",
|
|
2061
|
-
path,
|
|
2062
|
-
entry: entryKey,
|
|
2063
|
-
state: "theirs",
|
|
2064
|
-
action: "skip",
|
|
2065
|
-
reason: "a status line we did not write owns the slot — left to its owner, and nothing else is wired for it",
|
|
2066
|
-
},
|
|
2067
|
-
];
|
|
2068
|
-
}
|
|
2069
|
-
if (a.state === "ours-current")
|
|
2070
|
-
return [
|
|
2071
|
-
{ surface: key, kind: "shared", path, entry: entryKey, state: "ours-current", action: "skip", reason: null },
|
|
2072
|
-
];
|
|
2073
|
-
const after = {
|
|
2074
|
-
...a.settings,
|
|
2075
|
-
[entryKey]: {
|
|
2076
|
-
...(a.curEntry && typeof a.curEntry === "object" ? a.curEntry : {}),
|
|
2077
|
-
type: "command",
|
|
2078
|
-
command: a.desired,
|
|
2079
|
-
},
|
|
2080
|
-
};
|
|
2081
|
-
return [
|
|
2082
|
-
{
|
|
2083
|
-
surface: key,
|
|
2084
|
-
kind: "shared",
|
|
2085
|
-
path,
|
|
2086
|
-
entry: entryKey,
|
|
2087
|
-
state: a.state,
|
|
2088
|
-
action: a.curEntry ? "replace-entry" : a.cur.present ? "add" : "create",
|
|
2089
|
-
reason: a.curEntry ? a.reason : null,
|
|
2090
|
-
before: a.cur.present ? a.settings : null,
|
|
2091
|
-
after,
|
|
2092
|
-
},
|
|
2093
|
-
];
|
|
2094
|
-
}
|
|
2095
|
-
|
|
2096
|
-
function planStampedFile(ctx, key, s) {
|
|
2097
|
-
const { projectDir, mode, root, home, harness } = ctx;
|
|
2098
|
-
const renderRoot = ctx.renderRoot || root;
|
|
2099
|
-
const path = join(projectDir, s.file);
|
|
2100
|
-
const produced = !(s.condition === "plugin_cache_install" && !isPluginCacheRoot(renderRoot, home));
|
|
2101
|
-
// The policy early-outs come before the render-and-hash, which they make
|
|
2102
|
-
// unnecessary.
|
|
2103
|
-
if (produced && mode === "install" && !ctx.optIn.has(s.condition ? "statusline" : key) && !ctx.optIn.has(key)) {
|
|
2104
|
-
return [
|
|
2105
|
-
{
|
|
2106
|
-
surface: key,
|
|
2107
|
-
kind: "exclusive",
|
|
2108
|
-
path,
|
|
2109
|
-
entry: null,
|
|
2110
|
-
state: "opt-out",
|
|
2111
|
-
action: "skip",
|
|
2112
|
-
reason: "statusline.enabled is not true in projectstore.json",
|
|
2113
|
-
},
|
|
2114
|
-
];
|
|
2115
|
-
}
|
|
2116
|
-
if (produced && mode === "install" && ctx.slotForeign.size) {
|
|
2117
|
-
return [
|
|
2118
|
-
{
|
|
2119
|
-
surface: key,
|
|
2120
|
-
kind: "exclusive",
|
|
2121
|
-
path,
|
|
2122
|
-
entry: null,
|
|
2123
|
-
state: "absent-or-present",
|
|
2124
|
-
action: "skip",
|
|
2125
|
-
reason: "the status line slot is foreign; a launcher nothing points at is not written",
|
|
2126
|
-
},
|
|
2127
|
-
];
|
|
2128
|
-
}
|
|
2129
|
-
const a = analyseStampedFile(projectDir, s, { root, home, harness, renderRoot });
|
|
2130
|
-
// Not produced for this installation (a dev checkout is wired directly):
|
|
2131
|
-
// a root that cannot produce a file has, by construction, never written it,
|
|
2132
|
-
// so install and upgrade REPORT it and leave it (contract 13's wording;
|
|
2133
|
-
// contract 7 as amended 2026-09-05 — a dev checkout's plan used to prune a
|
|
2134
|
-
// cache install's launcher, the maintainer's habitual loop). Only uninstall
|
|
2135
|
-
// removes it: the user asked to disown, and the file is recognisably ours.
|
|
2136
|
-
// `prune` stays an action for the day a surface leaves the roster.
|
|
2137
|
-
// A file found at its legacy path (the layout ADR): classified from there,
|
|
2138
|
-
// removed from there, created at the new path — the legacy copy is then
|
|
2139
|
-
// the layout cleanup's to delete once nothing names it.
|
|
2140
|
-
const at = a.legacyPath || path;
|
|
2141
|
-
if (!a.produced) {
|
|
2142
|
-
if (!a.file.present) return [];
|
|
2143
|
-
if (!a.ours)
|
|
2144
|
-
return [
|
|
2145
|
-
{
|
|
2146
|
-
surface: key,
|
|
2147
|
-
kind: "exclusive",
|
|
2148
|
-
path: at,
|
|
2149
|
-
entry: null,
|
|
2150
|
-
state: "foreign",
|
|
2151
|
-
action: "skip",
|
|
2152
|
-
reason: "not produced for a dev checkout, and not ours — left in place",
|
|
2153
|
-
},
|
|
2154
|
-
];
|
|
2155
|
-
return [
|
|
2156
|
-
{
|
|
2157
|
-
surface: key,
|
|
2158
|
-
kind: "exclusive",
|
|
2159
|
-
path: at,
|
|
2160
|
-
entry: null,
|
|
2161
|
-
state: "stale",
|
|
2162
|
-
action: mode === "uninstall" ? "remove" : "skip",
|
|
2163
|
-
reason: a.reason,
|
|
2164
|
-
},
|
|
2165
|
-
];
|
|
2166
|
-
}
|
|
2167
|
-
if (a.refusal)
|
|
2168
|
-
return [{ surface: key, kind: "exclusive", path: at, state: "refused", action: "refuse", reason: a.refusal }];
|
|
2169
|
-
const base = {
|
|
2170
|
-
surface: key,
|
|
2171
|
-
kind: "exclusive",
|
|
2172
|
-
path: at,
|
|
2173
|
-
entry: null,
|
|
2174
|
-
state: a.state,
|
|
2175
|
-
reason: a.reason,
|
|
2176
|
-
writtenBy: a.writtenBy,
|
|
2177
|
-
sameProject: a.sameProject,
|
|
2178
|
-
};
|
|
2179
|
-
if (mode === "uninstall") {
|
|
2180
|
-
if (a.state === "absent") return [{ ...base, action: "skip" }];
|
|
2181
|
-
if (a.state === "foreign") return [{ ...base, action: "refuse", reason: FOREIGN_TEXT }];
|
|
2182
|
-
return [{ ...base, action: "remove" }];
|
|
2183
|
-
}
|
|
2184
|
-
if (a.state === "foreign") return [{ ...base, action: "refuse", reason: FOREIGN_TEXT }];
|
|
2185
|
-
// Current, but written for another project (a copied or moved checkout):
|
|
2186
|
-
// the file lives inside THIS project and its render names its project since
|
|
2187
|
-
// 2026-09-06, so it is re-rendered here — doctor still reports who wrote it
|
|
2188
|
-
// (contract 12); only the installer acts on it.
|
|
2189
|
-
if (a.state === "current" && !a.legacyPath && a.writtenBy && !a.sameProject)
|
|
2190
|
-
return [
|
|
2191
|
-
{
|
|
2192
|
-
...base,
|
|
2193
|
-
path,
|
|
2194
|
-
action: "update",
|
|
2195
|
-
reason: `current, written for ${a.writtenBy} — re-rendered for this project`,
|
|
2196
|
-
after: a.stamped.text,
|
|
2197
|
-
},
|
|
2198
|
-
];
|
|
2199
|
-
if (a.state === "current" && !a.legacyPath) return [{ ...base, action: "skip" }];
|
|
2200
|
-
// Written at the NEW path; a legacy file's state is why (stale, or current-but-moving).
|
|
2201
|
-
return [
|
|
2202
|
-
{
|
|
2203
|
-
...base,
|
|
2204
|
-
path,
|
|
2205
|
-
action: a.state === "absent" || a.legacyPath ? "create" : "update",
|
|
2206
|
-
reason: a.legacyPath
|
|
2207
|
-
? `${a.reason ? a.reason + "; " : ""}moving from ${rel(projectDir, a.legacyPath)} (the layout ADR)`
|
|
2208
|
-
: a.reason,
|
|
2209
|
-
after: a.stamped.text,
|
|
2210
|
-
legacyPath: a.legacyPath,
|
|
2211
|
-
},
|
|
2212
|
-
];
|
|
2213
|
-
}
|
|
2214
|
-
|
|
2215
1465
|
// ─── plan ──────────────────────────────────────────────────────────────
|
|
2216
1466
|
|
|
2217
|
-
// The surface rows a run leaves out: a --surface that does not name the key
|
|
2218
|
-
// (by prefix, so `statusline` covers the launcher), or --no-register's
|
|
2219
|
-
// registration. One predicate for plan() and the shell fetch's decision.
|
|
2220
|
-
function surfaceExcluded(key, s, { surfaces = null, register = true } = {}) {
|
|
2221
|
-
return (
|
|
2222
|
-
Boolean(surfaces && !surfaces.some((x) => key === x || key.startsWith(x + "_"))) ||
|
|
2223
|
-
(register === false && s.kind === "registration")
|
|
2224
|
-
);
|
|
2225
|
-
}
|
|
2226
|
-
|
|
2227
1467
|
// `state` selects by the state directory too (upgrade's set, the shell-fetch
|
|
2228
1468
|
// story's rule 5); `version` is the running root's, for a shell-rooted
|
|
2229
1469
|
// registration planned without its payload; `payloadRoots` holds a fetched
|
|
@@ -2492,7 +1732,7 @@ export function plan(
|
|
|
2492
1732
|
}
|
|
2493
1733
|
if (mode === "uninstall") return;
|
|
2494
1734
|
if (recordKeep && keeping && ctx.harness.id === keeping.harness.id) {
|
|
2495
|
-
const kept = planKeepRecord(ctx);
|
|
1735
|
+
const kept = planKeepRecord(ctx, keeping.copy);
|
|
2496
1736
|
if (kept) marks.push(kept);
|
|
2497
1737
|
}
|
|
2498
1738
|
};
|
|
@@ -2641,7 +1881,7 @@ export function plan(
|
|
|
2641
1881
|
// never an item: a --no-register run from a checkout re-pointed the
|
|
2642
1882
|
// status line at the checkout (the second review of the 2026-10-03
|
|
2643
1883
|
// fixes, S1).
|
|
2644
|
-
if (s.kind === "registration" && !
|
|
1884
|
+
if (s.kind === "registration" && !isHostInstallRoot(root, home)) {
|
|
2645
1885
|
const analyser =
|
|
2646
1886
|
s.format === "portable-plugin-registration" ? analysePortableRegistration : analyseRegistration;
|
|
2647
1887
|
const a = analyser(projectDir, s, { root, home, harness, env, version, fetched: ctx.fetched });
|
|
@@ -2678,8 +1918,12 @@ export function plan(
|
|
|
2678
1918
|
const items = handler(ctx, key, s);
|
|
2679
1919
|
const dependent =
|
|
2680
1920
|
s.kind !== "registration" && ctx.renderRoot !== root && ["json-entry", "mjs"].includes(s.format);
|
|
2681
|
-
for (const item of items)
|
|
2682
|
-
|
|
1921
|
+
for (const item of items) {
|
|
1922
|
+
const row = { harness: id, ...item, ...(dependent ? { plannedAgainst: ctx.renderRoot } : {}) };
|
|
1923
|
+
// DONE's next lines stay off the envelope, through the spread too.
|
|
1924
|
+
if (item.next) Object.defineProperty(row, "next", { value: item.next, enumerable: false });
|
|
1925
|
+
out.items.push(row);
|
|
1926
|
+
}
|
|
2683
1927
|
if (s.kind === "registration") {
|
|
2684
1928
|
// Phase two (contract 4′): the rest is planned against the root the
|
|
2685
1929
|
// registration produces — when it produces one on this run or has.
|
|
@@ -2774,1028 +2018,6 @@ export function plan(
|
|
|
2774
2018
|
return out;
|
|
2775
2019
|
}
|
|
2776
2020
|
|
|
2777
|
-
// One add per file (the presentation spec, contract 7 as amended 2026-10-14):
|
|
2778
|
-
// within one plan, a file an earlier item creates is added once. A later
|
|
2779
|
-
// `create` or `add` on that path changes the file the run has just made, so it
|
|
2780
|
-
// reads `update`; its state, steps and detail stay. Each item is planned
|
|
2781
|
-
// against the tree before the run, so the binding the vault item writes was
|
|
2782
|
-
// "absent" to the status-line flag and the channel mark, and all three read
|
|
2783
|
-
// "add". A creator is an earlier `create` or `add` item that is neither a
|
|
2784
|
-
// registration (its host-owned files are the host CLI's) nor a binding row (it
|
|
2785
|
-
// never makes the binding: apply skips it on an unbound project, and the vault
|
|
2786
|
-
// item or the layout move makes the file), counted for the paths absent at
|
|
2787
|
-
// plan time: its own and each step's `path` — never a move step's `to`, so a
|
|
2788
|
-
// migrating project's binding rows keep `add`. An entry row on a file that was
|
|
2789
|
-
// there keeps `add`.
|
|
2790
|
-
function oneAddPerFile(items) {
|
|
2791
|
-
const created = new Set();
|
|
2792
|
-
const writes = (i) => i.action === "create" || i.action === "add";
|
|
2793
|
-
for (const item of items) {
|
|
2794
|
-
if (!writes(item) || item.kind === "registration") continue;
|
|
2795
|
-
if (item.path && created.has(resolve(item.path))) {
|
|
2796
|
-
item.action = "update";
|
|
2797
|
-
continue;
|
|
2798
|
-
}
|
|
2799
|
-
if (item.kind === "binding") continue;
|
|
2800
|
-
for (const p of [item.path, ...(item.steps || []).map((s) => s.path)])
|
|
2801
|
-
if (p && !existsSync(p)) created.add(resolve(p));
|
|
2802
|
-
}
|
|
2803
|
-
}
|
|
2804
|
-
|
|
2805
|
-
// ─── the layout migration (the layout ADR; layout spec contract 6) ──────
|
|
2806
|
-
//
|
|
2807
|
-
// Two items, kind `layout`. The FIRST moves what only we read — the legacy
|
|
2808
|
-
// state directory (per-file collision policy), the entry log (merged), the
|
|
2809
|
-
// two legacy markers — and, last of its steps, the binding, whose `agents`
|
|
2810
|
-
// block becomes the harness's overlay. It never touches the launcher: the
|
|
2811
|
-
// settings entry names it, and the entry is re-pointed by the statusline item
|
|
2812
|
-
// only after the launcher item has written the new one. The LAST item, planned
|
|
2813
|
-
// after every surface, removes the legacy launcher once nothing names it and
|
|
2814
|
-
// prunes the emptied legacy runtime directory. Two config files is the one
|
|
2815
|
-
// refusal (a merge is the user's); uninstall is never blocked by it.
|
|
2816
|
-
function planLayout(ctx) {
|
|
2817
|
-
const { projectDir, mode, env, harness } = ctx;
|
|
2818
|
-
const a = analyseLayout(projectDir, { harness });
|
|
2819
|
-
const P = a.paths;
|
|
2820
|
-
const none = { first: [], last: [] };
|
|
2821
|
-
if (!a.pending) return none;
|
|
2822
|
-
const base = { surface: "layout", kind: "layout", path: P.legacy.binding, entry: null, state: a.state, reason: null };
|
|
2823
|
-
if (mode === "uninstall") {
|
|
2824
|
-
// Disowning: the legacy runtime directory goes with the new one when it is
|
|
2825
|
-
// ours (its header); the legacy binding is bind's, never uninstall's. A
|
|
2826
|
-
// narrowed uninstall leaves it alone: the way back from an npm switch is
|
|
2827
|
-
// `uninstall --surface plugin`, and it deleted a not-yet-moved project's
|
|
2828
|
-
// sessions, log and the launcher its status line still ran (the critic's
|
|
2829
|
-
// fourth pass, 2026-10-04). `surfaces` is null when nothing was named.
|
|
2830
|
-
if (ctx.surfaces) return none;
|
|
2831
|
-
if (!a.legacy.runtime || !a.legacy.runtimeOurs) return none;
|
|
2832
|
-
return {
|
|
2833
|
-
first: [],
|
|
2834
|
-
last: [
|
|
2835
|
-
{
|
|
2836
|
-
...base,
|
|
2837
|
-
surface: "layout_cleanup",
|
|
2838
|
-
path: P.legacy.runtime,
|
|
2839
|
-
state: "legacy",
|
|
2840
|
-
action: "remove",
|
|
2841
|
-
reason: "the legacy runtime directory is ours (its .gitignore header) and goes with the state",
|
|
2842
|
-
steps: [
|
|
2843
|
-
{
|
|
2844
|
-
kind: "remove-legacy-runtime",
|
|
2845
|
-
path: P.legacy.runtime,
|
|
2846
|
-
why: "the pre-0.28 state directory, removed whole",
|
|
2847
|
-
},
|
|
2848
|
-
{
|
|
2849
|
-
kind: "note",
|
|
2850
|
-
why: ".projectstore/state/ (sessions, the entry log) and the binding stay: the hooks' records and bind's file, not install's — delete .projectstore/ by hand to disown fully",
|
|
2851
|
-
},
|
|
2852
|
-
],
|
|
2853
|
-
},
|
|
2854
|
-
],
|
|
2855
|
-
};
|
|
2856
|
-
}
|
|
2857
|
-
if (a.twoConfigs && !a.resumable)
|
|
2858
|
-
return {
|
|
2859
|
-
first: [
|
|
2860
|
-
{
|
|
2861
|
-
...base,
|
|
2862
|
-
action: "refuse",
|
|
2863
|
-
reason: `two bindings: ${P.legacy.binding} (legacy) and ${P.binding} — keep one and delete the other (usually the legacy one), then run install again; nothing is written while both exist`,
|
|
2864
|
-
},
|
|
2865
|
-
],
|
|
2866
|
-
last: [],
|
|
2867
|
-
};
|
|
2868
|
-
if (insideHostSession(env, harness)) {
|
|
2869
|
-
ctx.incomplete = true;
|
|
2870
|
-
return {
|
|
2871
|
-
first: [
|
|
2872
|
-
{
|
|
2873
|
-
...base,
|
|
2874
|
-
action: "skip",
|
|
2875
|
-
deferred: true,
|
|
2876
|
-
reason: `the layout migration moves files this ${harness.display_name} session reads and writes — run it from a terminal outside the session`,
|
|
2877
|
-
},
|
|
2878
|
-
],
|
|
2879
|
-
last: [],
|
|
2880
|
-
};
|
|
2881
|
-
}
|
|
2882
|
-
const steps = [
|
|
2883
|
-
{
|
|
2884
|
-
kind: "note",
|
|
2885
|
-
why: `close other ${harness.display_name} sessions in this project first and restart afterwards: the migration moves files a running session reads and writes, and the status line does not reload mid-session`,
|
|
2886
|
-
},
|
|
2887
|
-
{ kind: "ensure", path: P.root, why: ".projectstore/ with its .gitignore (projectstore.json, state/)" },
|
|
2888
|
-
];
|
|
2889
|
-
if (a.legacy.state)
|
|
2890
|
-
steps.push({
|
|
2891
|
-
kind: "move-state",
|
|
2892
|
-
from: P.legacy.state,
|
|
2893
|
-
to: P.sessions,
|
|
2894
|
-
files: a.legacy.stateFiles.length,
|
|
2895
|
-
why: "session files move into state/sessions/; one present on both sides keeps the newer, a per-session directory keeps the new side",
|
|
2896
|
-
});
|
|
2897
|
-
if (a.legacy.entryLog)
|
|
2898
|
-
steps.push({
|
|
2899
|
-
kind: "merge-log",
|
|
2900
|
-
from: P.legacy.entryLog,
|
|
2901
|
-
to: P.entryLog,
|
|
2902
|
-
why: "the legacy entry log's lines go before the new log's",
|
|
2903
|
-
});
|
|
2904
|
-
if (a.legacy.welcomed)
|
|
2905
|
-
steps.push({
|
|
2906
|
-
kind: "move-marker",
|
|
2907
|
-
from: P.legacy.welcomed,
|
|
2908
|
-
to: P.welcomed(harness.id),
|
|
2909
|
-
why: "the welcome marker moves under state/<harness>/ — a migrated project is not welcomed twice",
|
|
2910
|
-
});
|
|
2911
|
-
if (a.legacy.sessionId) steps.push({ kind: "delete", path: P.legacy.sessionId, why: "the 0.6-era session-id file" });
|
|
2912
|
-
if (a.legacy.binding && a.resumable)
|
|
2913
|
-
steps.push({
|
|
2914
|
-
kind: "delete",
|
|
2915
|
-
path: P.legacy.binding,
|
|
2916
|
-
why: "the binding was already moved (an interrupted run left the legacy copy, byte-equal but for its agents block)",
|
|
2917
|
-
});
|
|
2918
|
-
else if (a.legacy.binding)
|
|
2919
|
-
steps.push({
|
|
2920
|
-
kind: "move-binding",
|
|
2921
|
-
from: P.legacy.binding,
|
|
2922
|
-
to: P.binding,
|
|
2923
|
-
overlay: P.overlay(harness.id),
|
|
2924
|
-
why: `the binding moves; its agents block becomes harness/${harness.id}.json`,
|
|
2925
|
-
});
|
|
2926
|
-
const first = [
|
|
2927
|
-
{
|
|
2928
|
-
...base,
|
|
2929
|
-
action: "migrate",
|
|
2930
|
-
steps,
|
|
2931
|
-
reason: `${a.legacy.binding ? "binding" : "state"} in the pre-0.28 layout under ${a.legacy.dir}/`,
|
|
2932
|
-
},
|
|
2933
|
-
];
|
|
2934
|
-
const last = [];
|
|
2935
|
-
if (a.legacy.launcher || a.legacy.runtime) {
|
|
2936
|
-
const cleanup = [];
|
|
2937
|
-
if (a.legacy.launcher)
|
|
2938
|
-
cleanup.push({
|
|
2939
|
-
kind: "remove-legacy-launcher",
|
|
2940
|
-
path: P.legacy.launcher,
|
|
2941
|
-
why: "removed once the new launcher is written and the settings entry names it — or at once when the status-line slot is not ours (foreign, or empty because the status line is off); kept while a settings file the host reads (this project's local or committed settings, or the user's) still runs it as the status line — a script that calls it indirectly is not seen",
|
|
2942
|
-
});
|
|
2943
|
-
if (a.legacy.runtime)
|
|
2944
|
-
cleanup.push({ kind: "rmdir-legacy", path: P.legacy.runtime, why: "the emptied pre-0.28 runtime directory" });
|
|
2945
|
-
last.push({
|
|
2946
|
-
...base,
|
|
2947
|
-
surface: "layout_cleanup",
|
|
2948
|
-
path: P.legacy.runtime,
|
|
2949
|
-
state: "legacy",
|
|
2950
|
-
action: "cleanup",
|
|
2951
|
-
reason: null,
|
|
2952
|
-
steps: cleanup,
|
|
2953
|
-
});
|
|
2954
|
-
}
|
|
2955
|
-
return { first, last };
|
|
2956
|
-
}
|
|
2957
|
-
|
|
2958
|
-
// A project uninstall's last item per harness (the shell-fetch story's rule
|
|
2959
|
-
// 5; install spec contract 13 as amended): .projectstore/state/<id>/ is
|
|
2960
|
-
// emptied of the welcome marker — recognised by analyseHarnessState — and
|
|
2961
|
-
// removed once empty, so the next bare upgrade stops selecting the harness
|
|
2962
|
-
// it disowned. Planned for a directory holding the marker, or nothing; a
|
|
2963
|
-
// directory holding only what is not ours is left without a row. It never
|
|
2964
|
-
// touches a launcher: that is its surface item's to remove, when it is ours.
|
|
2965
|
-
// What stays — a launcher this plan does not remove, a file of the user's —
|
|
2966
|
-
// is named in the preview, and keeps the directory. Not a guarantee: a
|
|
2967
|
-
// harness whose plugin stays installed for the machine fires its hooks here
|
|
2968
|
-
// again, its welcome comes back, and so does the signal — accurately.
|
|
2969
|
-
function planHarnessState(projectDir, harness, planned) {
|
|
2970
|
-
const a = analyseHarnessState(projectDir, harness.id);
|
|
2971
|
-
if (!a.present || (!a.marker && a.names.length)) return null;
|
|
2972
|
-
const removing = new Set(
|
|
2973
|
-
planned
|
|
2974
|
-
.filter((i) => i.kind === "exclusive" && i.action === "remove" && dirname(i.path) === a.dir)
|
|
2975
|
-
.map((i) => basename(i.path)),
|
|
2976
|
-
);
|
|
2977
|
-
const left = a.others.filter((n) => !removing.has(n));
|
|
2978
|
-
const steps = [];
|
|
2979
|
-
if (a.marker)
|
|
2980
|
-
steps.push({
|
|
2981
|
-
kind: "delete",
|
|
2982
|
-
path: a.marker,
|
|
2983
|
-
why: "the welcome marker the SessionStart hook wrote; a bare upgrade selects the harness by it",
|
|
2984
|
-
});
|
|
2985
|
-
steps.push({
|
|
2986
|
-
kind: "rmdir-state",
|
|
2987
|
-
path: a.dir,
|
|
2988
|
-
left,
|
|
2989
|
-
why: left.length
|
|
2990
|
-
? `${left.join(", ")} ${left.length === 1 ? "is" : "are"} not this step's to remove, and keep${left.length === 1 ? "s" : ""} the directory`
|
|
2991
|
-
: "removed once empty",
|
|
2992
|
-
});
|
|
2993
|
-
return {
|
|
2994
|
-
surface: "harness_state",
|
|
2995
|
-
kind: "layout",
|
|
2996
|
-
path: a.dir,
|
|
2997
|
-
entry: null,
|
|
2998
|
-
state: a.marker ? "ours" : "empty",
|
|
2999
|
-
action: "remove",
|
|
3000
|
-
reason: a.marker
|
|
3001
|
-
? `${harness.display_name}'s welcome marker — the state directory selects ${harness.display_name} for a bare upgrade`
|
|
3002
|
-
: `an empty state directory of ${harness.display_name}`,
|
|
3003
|
-
steps,
|
|
3004
|
-
};
|
|
3005
|
-
}
|
|
3006
|
-
|
|
3007
|
-
function applyLayout(p, i, { failed, home = homedir(), onStep = null }) {
|
|
3008
|
-
const out = { path: i.path, action: i.action, surface: i.surface, steps: [] };
|
|
3009
|
-
const within = p.projectDir;
|
|
3010
|
-
if (i.action === "cleanup" && failed) {
|
|
3011
|
-
out.action = "skipped";
|
|
3012
|
-
out.reason = "an earlier item failed; the legacy files stay until the next run";
|
|
3013
|
-
return out;
|
|
3014
|
-
}
|
|
3015
|
-
const fail = (step, message) => {
|
|
3016
|
-
out.failed = { step, status: null, stderr: message };
|
|
3017
|
-
return out;
|
|
3018
|
-
};
|
|
3019
|
-
for (const st of i.steps || []) {
|
|
3020
|
-
const seen = out.steps.length;
|
|
3021
|
-
if (onStep) onStep(st, "start");
|
|
3022
|
-
try {
|
|
3023
|
-
if (st.kind === "ensure") {
|
|
3024
|
-
ensureRuntimeDir(within);
|
|
3025
|
-
out.steps.push({ kind: st.kind, ok: true });
|
|
3026
|
-
} else if (st.kind === "move-state") {
|
|
3027
|
-
const r = moveStateDir(st.from, st.to, within);
|
|
3028
|
-
ensureStateDir(within);
|
|
3029
|
-
out.steps.push({ kind: st.kind, ok: true, ...r });
|
|
3030
|
-
} else if (st.kind === "merge-log") {
|
|
3031
|
-
const r = mergeEntryLog(st.from, st.to, within);
|
|
3032
|
-
out.steps.push({ kind: st.kind, ok: true, result: r });
|
|
3033
|
-
} else if (st.kind === "delete") {
|
|
3034
|
-
out.steps.push({ kind: st.kind, path: st.path, ok: true, removed: removeInside(st.path, within) });
|
|
3035
|
-
} else if (st.kind === "move-marker") {
|
|
3036
|
-
const r = movePath(st.from, st.to, within);
|
|
3037
|
-
if (r === "target-exists") removeInside(st.from, within);
|
|
3038
|
-
out.steps.push({ kind: st.kind, ok: true, result: r });
|
|
3039
|
-
} else if (st.kind === "move-binding") {
|
|
3040
|
-
let cfg;
|
|
3041
|
-
try {
|
|
3042
|
-
cfg = JSON.parse(readFileSync(st.from, "utf8"));
|
|
3043
|
-
} catch {
|
|
3044
|
-
return fail(st.kind, `${st.from} is not valid JSON; the binding was not moved`);
|
|
3045
|
-
}
|
|
3046
|
-
if (existsSync(st.to)) return fail(st.kind, `${st.to} appeared under the plan; two bindings are a refusal`);
|
|
3047
|
-
const { agents, ...binding } = cfg && typeof cfg === "object" ? cfg : {};
|
|
3048
|
-
if (agents && typeof agents === "object") {
|
|
3049
|
-
let overlay = {};
|
|
3050
|
-
try {
|
|
3051
|
-
overlay = JSON.parse(readFileSync(st.overlay, "utf8"));
|
|
3052
|
-
} catch {}
|
|
3053
|
-
mkdirSync(dirname(st.overlay), { recursive: true });
|
|
3054
|
-
writeFileAtomic(st.overlay, JSON.stringify({ ...overlay, agents }, null, 2) + "\n", { sweep: false });
|
|
3055
|
-
}
|
|
3056
|
-
mkdirSync(dirname(st.to), { recursive: true });
|
|
3057
|
-
writeFileAtomic(st.to, JSON.stringify(binding, null, 2) + "\n", { sweep: false });
|
|
3058
|
-
removeInside(st.from, within);
|
|
3059
|
-
out.steps.push({ kind: st.kind, ok: true, overlay: Boolean(agents) });
|
|
3060
|
-
} else if (st.kind === "remove-legacy-launcher") {
|
|
3061
|
-
// Only ours, and only when no settings entry names it any more.
|
|
3062
|
-
let text = null;
|
|
3063
|
-
try {
|
|
3064
|
-
text = readFileSync(st.path, "utf8");
|
|
3065
|
-
} catch {}
|
|
3066
|
-
// "Still in use" is decided by the FILE, not the spelling: an entry
|
|
3067
|
-
// written through a symlinked or differently-cased path, or living in
|
|
3068
|
-
// the committed or the user's settings, still runs it (the third review
|
|
3069
|
-
// of the 2026-10-03 fixes — a string comparison of the local file alone
|
|
3070
|
-
// deleted a launcher the status line was running).
|
|
3071
|
-
const named =
|
|
3072
|
-
entryNames(p.projectDir, i.harness, st.path) || settingsRunFile(p.projectDir, i.harness, st.path, home);
|
|
3073
|
-
// Moved, never just deleted: the legacy launcher goes only once the new
|
|
3074
|
-
// one exists (a dev root produces none — contract 7 leaves it in place)
|
|
3075
|
-
// — unless the status-line slot is not ours (any wiring of ours counts).
|
|
3076
|
-
// Under a foreign or empty slot the new launcher is never made, and
|
|
3077
|
-
// keeping the old one kept layout-legacy alive forever (the critic of
|
|
3078
|
-
// the layout spec's 2026-10-03 amendment, case S1).
|
|
3079
|
-
const moved = existsSync(layoutPaths(p.projectDir).launcher(i.harness));
|
|
3080
|
-
const slotOurs = slotIsOurs(p.projectDir, i.harness, { home, root: p.root });
|
|
3081
|
-
if (text === null) out.steps.push({ kind: st.kind, ok: true, removed: false });
|
|
3082
|
-
else if (!moved && slotOurs)
|
|
3083
|
-
out.steps.push({
|
|
3084
|
-
kind: st.kind,
|
|
3085
|
-
ok: true,
|
|
3086
|
-
removed: false,
|
|
3087
|
-
reason: "no launcher at the new path yet (this root does not produce one) — left in place",
|
|
3088
|
-
});
|
|
3089
|
-
else if (!isOurFile(text))
|
|
3090
|
-
out.steps.push({ kind: st.kind, ok: true, removed: false, reason: "not ours — left in place" });
|
|
3091
|
-
else if (named)
|
|
3092
|
-
out.steps.push({
|
|
3093
|
-
kind: st.kind,
|
|
3094
|
-
ok: true,
|
|
3095
|
-
removed: false,
|
|
3096
|
-
reason: "a settings file the host reads still runs it as the status line — left in place",
|
|
3097
|
-
});
|
|
3098
|
-
else out.steps.push({ kind: st.kind, ok: true, removed: removeInside(st.path, within) });
|
|
3099
|
-
} else if (st.kind === "rmdir-legacy") {
|
|
3100
|
-
// Prune the legacy runtime dir when only its own .gitignore (and an empty state/) remain.
|
|
3101
|
-
let left = [];
|
|
3102
|
-
try {
|
|
3103
|
-
left = readdirSync(st.path).filter((n) => n !== ".gitignore");
|
|
3104
|
-
} catch {
|
|
3105
|
-
out.steps.push({ kind: st.kind, ok: true, removed: false });
|
|
3106
|
-
continue;
|
|
3107
|
-
}
|
|
3108
|
-
if (left.length === 1 && left[0] === "state") {
|
|
3109
|
-
try {
|
|
3110
|
-
if (readdirSync(join(st.path, "state")).length === 0) {
|
|
3111
|
-
rmdirSync(join(st.path, "state"));
|
|
3112
|
-
left = [];
|
|
3113
|
-
}
|
|
3114
|
-
} catch {}
|
|
3115
|
-
}
|
|
3116
|
-
if (left.length) {
|
|
3117
|
-
out.steps.push({ kind: st.kind, ok: true, removed: false, reason: `${left.join(", ")} remain` });
|
|
3118
|
-
continue;
|
|
3119
|
-
}
|
|
3120
|
-
removeInside(st.path, within, { recursive: true });
|
|
3121
|
-
out.steps.push({ kind: st.kind, ok: true, removed: true });
|
|
3122
|
-
} else if (st.kind === "remove-legacy-runtime") {
|
|
3123
|
-
removeInside(st.path, within, { recursive: true });
|
|
3124
|
-
out.steps.push({ kind: st.kind, ok: true, removed: true });
|
|
3125
|
-
} else if (st.kind === "rmdir-state") {
|
|
3126
|
-
// rmdirSync on an empty directory only: whatever is left — read now,
|
|
3127
|
-
// after the items before it ran — keeps it, and is named.
|
|
3128
|
-
let left = [];
|
|
3129
|
-
try {
|
|
3130
|
-
left = readdirSync(st.path).sort();
|
|
3131
|
-
} catch {
|
|
3132
|
-
out.steps.push({ kind: st.kind, ok: true, removed: false });
|
|
3133
|
-
continue;
|
|
3134
|
-
}
|
|
3135
|
-
if (left.length) {
|
|
3136
|
-
out.steps.push({ kind: st.kind, ok: true, removed: false, reason: `${left.join(", ")} remain` });
|
|
3137
|
-
continue;
|
|
3138
|
-
}
|
|
3139
|
-
rmdirSync(st.path);
|
|
3140
|
-
out.steps.push({ kind: st.kind, ok: true, removed: true });
|
|
3141
|
-
}
|
|
3142
|
-
} catch (e) {
|
|
3143
|
-
return fail(st.kind, e && e.message ? e.message : String(e));
|
|
3144
|
-
} finally {
|
|
3145
|
-
if (onStep) onStep(st, "end", stepResult(out, seen));
|
|
3146
|
-
}
|
|
3147
|
-
}
|
|
3148
|
-
return out;
|
|
3149
|
-
}
|
|
3150
|
-
|
|
3151
|
-
// What one step left behind, for the APPLY line: its record when it pushed
|
|
3152
|
-
// one, and a failure when the item failed under it.
|
|
3153
|
-
function stepResult(out, seen) {
|
|
3154
|
-
const rec = out.steps.length > seen ? out.steps[out.steps.length - 1] : null;
|
|
3155
|
-
return { ok: !out.failed && (!rec || rec.ok !== false), kept: Boolean(rec && rec.removed === false && rec.reason) };
|
|
3156
|
-
}
|
|
3157
|
-
|
|
3158
|
-
// Does any settings file the host reads run this file as its status line?
|
|
3159
|
-
// Compared by identity (device and inode), so a path spelled through a symlink
|
|
3160
|
-
// or in another case still counts; the project-directory variable a command
|
|
3161
|
-
// may carry is substituted first.
|
|
3162
|
-
function settingsRunFile(projectDir, harnessId, file, home) {
|
|
3163
|
-
const id = (f) => {
|
|
3164
|
-
try {
|
|
3165
|
-
const s = statSync(f);
|
|
3166
|
-
return `${s.dev}:${s.ino}`;
|
|
3167
|
-
} catch {
|
|
3168
|
-
return null;
|
|
3169
|
-
}
|
|
3170
|
-
};
|
|
3171
|
-
const want = id(file);
|
|
3172
|
-
if (!want) return false;
|
|
3173
|
-
const h = loadHarness(harnessId);
|
|
3174
|
-
const dir = h.runtime?.harness_dir || ".claude",
|
|
3175
|
-
v = h.runtime?.project_dir_env;
|
|
3176
|
-
const files = [
|
|
3177
|
-
join(projectDir, h.surfaces?.statusline?.file || join(dir, "settings.local.json")),
|
|
3178
|
-
join(projectDir, dir, "settings.json"),
|
|
3179
|
-
join(claudeHome(home), "settings.json"),
|
|
3180
|
-
];
|
|
3181
|
-
return files.some((f) => {
|
|
3182
|
-
try {
|
|
3183
|
-
let cmd = JSON.parse(readFileSync(f, "utf8"))?.statusLine?.command;
|
|
3184
|
-
if (typeof cmd !== "string") return false;
|
|
3185
|
-
if (v)
|
|
3186
|
-
cmd = cmd
|
|
3187
|
-
.split("${" + v + "}")
|
|
3188
|
-
.join(projectDir)
|
|
3189
|
-
.split("$" + v)
|
|
3190
|
-
.join(projectDir);
|
|
3191
|
-
const sp = statusLineScriptPath(cmd);
|
|
3192
|
-
return Boolean(sp) && id(isAbsolute(sp) ? sp : join(projectDir, sp)) === want;
|
|
3193
|
-
} catch {
|
|
3194
|
-
return false;
|
|
3195
|
-
}
|
|
3196
|
-
});
|
|
3197
|
-
}
|
|
3198
|
-
|
|
3199
|
-
// Is the harness's status-line slot wired to anything of ours (any wiring)?
|
|
3200
|
-
function slotIsOurs(projectDir, harnessId, { home, root }) {
|
|
3201
|
-
try {
|
|
3202
|
-
const s = loadHarness(harnessId).surfaces?.statusline;
|
|
3203
|
-
if (!s || !s.file) return false;
|
|
3204
|
-
const cmd = JSON.parse(readFileSync(join(projectDir, s.file), "utf8"))?.statusLine?.command;
|
|
3205
|
-
return typeof cmd === "string" && statusLineIsOurWiring(cmd, projectDir, home, root);
|
|
3206
|
-
} catch {
|
|
3207
|
-
return false;
|
|
3208
|
-
}
|
|
3209
|
-
}
|
|
3210
|
-
|
|
3211
|
-
// Does the harness's settings entry still name this launcher path?
|
|
3212
|
-
function entryNames(projectDir, harnessId, launcherPath) {
|
|
3213
|
-
try {
|
|
3214
|
-
const h = loadHarness(harnessId);
|
|
3215
|
-
const s = h.surfaces?.statusline;
|
|
3216
|
-
if (!s || !s.file) return false;
|
|
3217
|
-
const settings = JSON.parse(readFileSync(join(projectDir, s.file), "utf8"));
|
|
3218
|
-
const cmd = settings?.statusLine?.command;
|
|
3219
|
-
const named = statusLineScriptPath(typeof cmd === "string" ? cmd : null);
|
|
3220
|
-
return Boolean(named) && resolve(named) === resolve(launcherPath);
|
|
3221
|
-
} catch {
|
|
3222
|
-
return false;
|
|
3223
|
-
}
|
|
3224
|
-
}
|
|
3225
|
-
|
|
3226
|
-
// Contract 14: the host installs and updates these itself; say how, from the
|
|
3227
|
-
// manifest, and write nothing. (PR #13's installElsewhere, as a plan line.)
|
|
3228
|
-
function hostManagedReport(m, rows, registration = null) {
|
|
3229
|
-
const inst = m.install || {};
|
|
3230
|
-
const lines = [
|
|
3231
|
-
`${m.display_name}: ${rows.join(", ")} are installed by ${inst.mechanism || "the host"} — nothing to write.`,
|
|
3232
|
-
];
|
|
3233
|
-
if (registration && registration.entry) {
|
|
3234
|
-
const how =
|
|
3235
|
-
registration.action === "skip" && registration.state === "current"
|
|
3236
|
-
? "registered here"
|
|
3237
|
-
: registration.action === "refuse"
|
|
3238
|
-
? "refused, see below"
|
|
3239
|
-
: registration.action === "skip"
|
|
3240
|
-
? "not registered on this run, see below"
|
|
3241
|
-
: `registered by this run (${registration.action})`;
|
|
3242
|
-
// A host plugin registration is a marketplace added from a local path, and
|
|
3243
|
-
// the host loads its plugin in place, from the plugin's root inside that
|
|
3244
|
-
// directory (`payload`, set by plan()); the install path is the registry's
|
|
3245
|
-
// copy, which the launcher renders (the story "The SessionStart hook and
|
|
3246
|
-
// install agree on the status line…", measured on host 2.1.293 and
|
|
3247
|
-
// 2.1.296). A portable registration's host loads the copy.
|
|
3248
|
-
const where = !registration.root
|
|
3249
|
-
? ""
|
|
3250
|
-
: registration.payload
|
|
3251
|
-
? ` — install path ${registration.root}; the host loads them in place from ${registration.payload}`
|
|
3252
|
-
: ` — the host loads them from ${registration.root}`;
|
|
3253
|
-
lines.push(` They come from the registration ${registration.entry}, ${how}${where}.`);
|
|
3254
|
-
}
|
|
3255
|
-
if (inst.why_not_scripted) lines.push(` ${inst.why_not_scripted}`);
|
|
3256
|
-
for (const s of inst.steps || []) lines.push(` ${s}`);
|
|
3257
|
-
for (const n of inst.notes || []) lines.push(` ${n}`);
|
|
3258
|
-
if (inst.docs) lines.push(` ${inst.docs}`);
|
|
3259
|
-
return lines.join("\n");
|
|
3260
|
-
}
|
|
3261
|
-
|
|
3262
|
-
const isWrite = (i) => !["skip", "refuse"].includes(i.action);
|
|
3263
|
-
|
|
3264
|
-
// ─── preview ───────────────────────────────────────────────────────────
|
|
3265
|
-
//
|
|
3266
|
-
// PLAN (presentation spec contract 7, which revises the layout clauses of
|
|
3267
|
-
// install spec contract 18): the badge, the project path, a summary line of
|
|
3268
|
-
// counts, then the rows grouped under each run harness's display name, in run
|
|
3269
|
-
// order. A row is one aligned line — glyph, action word, target, note — and
|
|
3270
|
-
// beneath it what the reader consents to (install spec contract 9: every path
|
|
3271
|
-
// written and every host argv, with the files it touches), the reason when
|
|
3272
|
-
// there is one, and a consequence the reader must know. Internal words —
|
|
3273
|
-
// kinds, provenance states, entry markers, roadmap ids — and each step's why
|
|
3274
|
-
// are behind `--verbose`. Groups and counts are computed here, never added to
|
|
3275
|
-
// the plan: plan --json keeps its keys. Colour and glyphs are the caller's
|
|
3276
|
-
// stream's (`caps`, term.mjs); the default is plain text in Unicode.
|
|
3277
|
-
const unicodeGlyph = (name) => termIcon({ ascii: false }, name);
|
|
3278
|
-
|
|
3279
|
-
// Every action a plan item can carry, as its glyph. A write never wears the
|
|
3280
|
-
// skip glyph: `add` and `replace-entry` are changes as much as `create` and
|
|
3281
|
-
// `update`.
|
|
3282
|
-
// A copy from another marketplace turned off for this checkout is a channel
|
|
3283
|
-
// switch, `disable` (the converge story; contract 7's `switch` word, ⇄).
|
|
3284
|
-
const ACTION_ICON = {
|
|
3285
|
-
create: "create",
|
|
3286
|
-
add: "create",
|
|
3287
|
-
update: "update",
|
|
3288
|
-
"replace-entry": "update",
|
|
3289
|
-
migrate: "migrate",
|
|
3290
|
-
disable: "switch",
|
|
3291
|
-
cleanup: "cleanup",
|
|
3292
|
-
remove: "remove",
|
|
3293
|
-
prune: "remove",
|
|
3294
|
-
skip: "skip",
|
|
3295
|
-
refuse: "refuse",
|
|
3296
|
-
};
|
|
3297
|
-
// …and as a person's word (contract 7's table). A registration's write takes
|
|
3298
|
-
// `register`; a skip reads `unchanged` when the item is current and carries
|
|
3299
|
-
// no reason, and `skipped` otherwise — deferred, opted out, unsupported, or
|
|
3300
|
-
// left in place: a current row with a reason is one the run did not act on
|
|
3301
|
-
// for that reason (uninstall keeping a user-global registration, a host CLI
|
|
3302
|
-
// not on PATH), and the reason beneath it is what the reader acts on.
|
|
3303
|
-
const ACTION_WORD = {
|
|
3304
|
-
create: "add",
|
|
3305
|
-
add: "add",
|
|
3306
|
-
update: "update",
|
|
3307
|
-
"replace-entry": "update",
|
|
3308
|
-
migrate: "update",
|
|
3309
|
-
disable: "switch",
|
|
3310
|
-
cleanup: "remove",
|
|
3311
|
-
remove: "remove",
|
|
3312
|
-
prune: "remove",
|
|
3313
|
-
refuse: "refused",
|
|
3314
|
-
};
|
|
3315
|
-
// Each word's colour role (contract 3): a refusal's ▲, a channel switch and a
|
|
3316
|
-
// removal are attention; red is for what failed.
|
|
3317
|
-
const WORD_ROLE = {
|
|
3318
|
-
add: "done",
|
|
3319
|
-
register: "done",
|
|
3320
|
-
update: "action",
|
|
3321
|
-
switch: "attention",
|
|
3322
|
-
remove: "attention",
|
|
3323
|
-
refused: "attention",
|
|
3324
|
-
unchanged: "explanation",
|
|
3325
|
-
skipped: "explanation",
|
|
3326
|
-
};
|
|
3327
|
-
// APPLY says a word while its step runs and once it has (contract 8).
|
|
3328
|
-
const WORD_TENSES = {
|
|
3329
|
-
add: ["adding", "added"],
|
|
3330
|
-
register: ["registering", "registered"],
|
|
3331
|
-
update: ["updating", "updated"],
|
|
3332
|
-
switch: ["switching", "switched"],
|
|
3333
|
-
remove: ["removing", "removed"],
|
|
3334
|
-
};
|
|
3335
|
-
|
|
3336
|
-
const CURRENT_STATES = new Set(["current", "ours-current", "ours"]);
|
|
3337
|
-
const isUnchanged = (i) => i.action === "skip" && !i.deferred && !i.reason && CURRENT_STATES.has(i.state);
|
|
3338
|
-
// A surface this harness does not have: folded into one line per harness.
|
|
3339
|
-
const isUnsupported = (i) => i.action === "skip" && i.state === "unsupported";
|
|
3340
|
-
// A row of something another project wrote last (install spec contract 12):
|
|
3341
|
-
// never collapsed, and its note says whose it is.
|
|
3342
|
-
const writtenElsewhere = (i) => i.state === "current" && Boolean(i.writtenBy) && !i.sameProject;
|
|
3343
|
-
// A row that writes nothing and asks nothing of the reader collapses into
|
|
3344
|
-
// one line per group naming it (contract 7).
|
|
3345
|
-
const isQuiet = (i) => isUnchanged(i) && !writtenElsewhere(i) && !(i.steps || []).length;
|
|
3346
|
-
// The registration row a fetched shell's registry lines sit beneath, when the
|
|
3347
|
-
// run fetched one from a registry that is not the user's.
|
|
3348
|
-
const fetchedFor = (p, i) =>
|
|
3349
|
-
p.fetchedFrom &&
|
|
3350
|
-
i.kind === "registration" &&
|
|
3351
|
-
!String(i.surface).endsWith("_others") &&
|
|
3352
|
-
p.fetchedFrom.harnesses.includes(i.harness)
|
|
3353
|
-
? p.fetchedFrom
|
|
3354
|
-
: null;
|
|
3355
|
-
|
|
3356
|
-
function actionWord(i) {
|
|
3357
|
-
if (i.action === "skip") return isUnchanged(i) ? "unchanged" : "skipped";
|
|
3358
|
-
if (i.kind === "registration" && ["create", "add", "update"].includes(i.action)) return "register";
|
|
3359
|
-
return ACTION_WORD[i.action] || "update";
|
|
3360
|
-
}
|
|
3361
|
-
const glyphName = (i) => ACTION_ICON[i.action] || (isWrite(i) ? "update" : "skip");
|
|
3362
|
-
// The surface key in words: `agents_block` reads "agents block".
|
|
3363
|
-
const surfaceWords = (key) => String(key).replace(/_/g, " ");
|
|
3364
|
-
// What a row names: a registration by its plugin id — its paths are the
|
|
3365
|
-
// consent lines beneath it — anything else by its path, project-relative.
|
|
3366
|
-
function rowTarget(p, i, home = homedir()) {
|
|
3367
|
-
// …and the keep (kind `channel`) by the key of the copy it keeps.
|
|
3368
|
-
if ((i.kind === "registration" || i.kind === "channel") && i.entry) return i.entry;
|
|
3369
|
-
if (i.path === null) return `${i.surface} [${i.harness}, no filesystem path]`;
|
|
3370
|
-
// Setup's vault and state directories, as directories; a vault outside the
|
|
3371
|
-
// project — beside it, usually — by its path from home, as the project's.
|
|
3372
|
-
if (i.kind === "vault") {
|
|
3373
|
-
const r = rel(p.projectDir, i.path);
|
|
3374
|
-
return `${r === i.path ? tilde(i.path, home) : r}/`;
|
|
3375
|
-
}
|
|
3376
|
-
if (i.kind === "state") return `${rel(p.projectDir, i.path)}/`;
|
|
3377
|
-
return rel(p.projectDir, i.path);
|
|
3378
|
-
}
|
|
3379
|
-
// The heading of the group of rows that belong to no harness: setup's vault
|
|
3380
|
-
// (presentation spec contract 7's VAULT group, first when present).
|
|
3381
|
-
const VAULT_GROUP = "Vault";
|
|
3382
|
-
function rowNote(i, { verbose, glyph }) {
|
|
3383
|
-
const words = writtenElsewhere(i) ? `last written by ${i.writtenBy}` : surfaceWords(i.surface);
|
|
3384
|
-
if (!verbose) return words;
|
|
3385
|
-
return `${words} ${glyph("dot")} ${i.kind}${i.entry && i.kind !== "registration" ? ` [${i.entry}]` : ""}`;
|
|
3386
|
-
}
|
|
3387
|
-
// --verbose's line beneath a row: the provenance state, the reason and the
|
|
3388
|
-
// plan's own action, as install spec contract 18 wrote them.
|
|
3389
|
-
function transitionText(i, glyph) {
|
|
3390
|
-
let state = writtenElsewhere(i) ? `current, last written by ${i.writtenBy}` : i.state;
|
|
3391
|
-
// A reason with commands gives its prose here; the commands follow, alone.
|
|
3392
|
-
const reason = i.advice ? i.advice.text : i.reason;
|
|
3393
|
-
if (reason && i.action !== "refuse") state += ` (${reason})`;
|
|
3394
|
-
// A dry run's unchanged registration: right by every host fact, its
|
|
3395
|
-
// payload digest still to compare. Said only where the reasoning is.
|
|
3396
|
-
if (i.digestDeferred) state += " (the payload digest is compared when install or upgrade fetches the shell)";
|
|
3397
|
-
return `${state} ${glyph("transition")} ${i.action}${i.action === "refuse" && i.reason ? ": " + i.reason : ""}`;
|
|
3398
|
-
}
|
|
3399
|
-
|
|
3400
|
-
// One step of an item as the lines it shows: always the action and its target,
|
|
3401
|
-
// then — under --verbose — why it runs.
|
|
3402
|
-
function stepLines(p, st, { verbose, paint, width = 0, glyph = unicodeGlyph, home = homedir() }) {
|
|
3403
|
-
const r = (x) => rel(p.projectDir, x);
|
|
3404
|
-
// A path outside the project — setup's vault, beside it usually — from
|
|
3405
|
-
// home, as its row and its APPLY lines name it.
|
|
3406
|
-
const t = (x) => tilde(r(x), home);
|
|
3407
|
-
const to = ` ${glyph("transition")} `;
|
|
3408
|
-
const lead = " ";
|
|
3409
|
-
const sub = " ";
|
|
3410
|
-
const why =
|
|
3411
|
-
verbose && st.why
|
|
3412
|
-
? wrap(st.why, width, sub)
|
|
3413
|
-
.split("\n")
|
|
3414
|
-
.map((l, k) => (k ? "" : sub) + paint("explanation", l))
|
|
3415
|
-
: [];
|
|
3416
|
-
// A consent line (install spec contract 9): the step's verb and what it
|
|
3417
|
-
// touches, in the explanation role (presentation spec contracts 3 and 7).
|
|
3418
|
-
const said = (text) => `${lead}${paint("explanation", text)}`;
|
|
3419
|
-
switch (st.kind) {
|
|
3420
|
-
case "host": {
|
|
3421
|
-
// The keep step's argv is whole (its bin is the Node that runs us), and
|
|
3422
|
-
// its touches are the kept copy's planned writes, each with its action.
|
|
3423
|
-
const out = [said(`$ ${(st.bin ? [st.bin, ...st.argv] : st.argv).join(" ")}`)];
|
|
3424
|
-
if (st.touches.length)
|
|
3425
|
-
out.push(
|
|
3426
|
-
`${sub}${paint("explanation", `touches ${st.touches.map((t) => (typeof t === "string" ? r(t) : `${t.path ? r(t.path) : t.surface} (${t.action})`)).join(", ")}`)}`,
|
|
3427
|
-
);
|
|
3428
|
-
return [...out, ...why];
|
|
3429
|
-
}
|
|
3430
|
-
// The fetch a dry run would run first (the shell-fetch story's rule 6),
|
|
3431
|
-
// with the fetch glyph (presentation spec contract 4).
|
|
3432
|
-
case "fetch":
|
|
3433
|
-
return [
|
|
3434
|
-
`${lead}${paint("action", glyph("fetch"))} ${paint("explanation", [st.bin, ...st.argv].join(" "))}`,
|
|
3435
|
-
...why,
|
|
3436
|
-
];
|
|
3437
|
-
case "note":
|
|
3438
|
-
return wrap(`note: ${st.why}`, width, sub)
|
|
3439
|
-
.split("\n")
|
|
3440
|
-
.map((l, k) => (k ? l : lead + paint("attention", "note:") + l.slice(5)));
|
|
3441
|
-
case "write":
|
|
3442
|
-
return [
|
|
3443
|
-
said(`write ${st.path}${st.manifestOnly ? " (manifest only)" : ` (${st.files} files + the manifest)`}`),
|
|
3444
|
-
...why,
|
|
3445
|
-
];
|
|
3446
|
-
// A dry run names no file count: the payload is counted after the fetch.
|
|
3447
|
-
case "portable-write":
|
|
3448
|
-
return [
|
|
3449
|
-
said(
|
|
3450
|
-
`stage ${st.path} (${Array.isArray(st.files) ? `${st.files.length} payload files` : "the fetched payload"} + catalogue + ownership)`,
|
|
3451
|
-
),
|
|
3452
|
-
...why,
|
|
3453
|
-
];
|
|
3454
|
-
case "rmdir-state":
|
|
3455
|
-
return [
|
|
3456
|
-
said(
|
|
3457
|
-
`remove ${r(st.path)}/ once empty${st.left?.length ? ` ${glyph("dash")} ${st.left.join(", ")} stay${st.left.length === 1 ? "s" : ""} and keep${st.left.length === 1 ? "s" : ""} it` : ""}`,
|
|
3458
|
-
),
|
|
3459
|
-
...why,
|
|
3460
|
-
];
|
|
3461
|
-
case "portable-remove":
|
|
3462
|
-
case "remove":
|
|
3463
|
-
return [said(`remove ${st.path}`), ...why];
|
|
3464
|
-
case "unregister":
|
|
3465
|
-
return [said(`edit ${r(st.path)} [${st.pointer}.${st.name}]${to}removed`), ...why];
|
|
3466
|
-
// The channel switch's record in the binding (the converge story's rules
|
|
3467
|
-
// 2 and 4): its time is the apply's, unknown to a preview.
|
|
3468
|
-
case "mark":
|
|
3469
|
-
return [said(`edit ${r(st.path)} [${st.key}]${to}by "setup", at the time of apply`), ...why];
|
|
3470
|
-
case "unmark":
|
|
3471
|
-
return [said(`edit ${r(st.path)} [${st.key}]${to}removed`), ...why];
|
|
3472
|
-
// A `binding` item's value (the setup story's status-line flag).
|
|
3473
|
-
case "set":
|
|
3474
|
-
return [said(`edit ${r(st.path)} [${st.key}]${to}${JSON.stringify(st.value)}`), ...why];
|
|
3475
|
-
// Setup's vault (rule 10), the binding plan's own steps: every path is in
|
|
3476
|
-
// the default view. A scaffold step's paths are the vault's, beneath the
|
|
3477
|
-
// row that names it.
|
|
3478
|
-
case "mkdir":
|
|
3479
|
-
return [said(`create ${t(st.path)}/`), ...why];
|
|
3480
|
-
case "bind":
|
|
3481
|
-
return [
|
|
3482
|
-
said(
|
|
3483
|
-
`write ${r(st.path)} (${st.from ? `the binding of ${st.from}${st.drop?.length ? `, without ${st.drop.join(" and ")}` : ""}` : `vault_path, layout ${st.layout}, language ${st.language}`})`,
|
|
3484
|
-
),
|
|
3485
|
-
...why,
|
|
3486
|
-
];
|
|
3487
|
-
case "git-init":
|
|
3488
|
-
return [
|
|
3489
|
-
said(`$ ${st.argv.join(" ")}`),
|
|
3490
|
-
`${sub}${paint("explanation", `in ${t(st.path)}, with no commit`)}`,
|
|
3491
|
-
...why,
|
|
3492
|
-
];
|
|
3493
|
-
case "scaffold":
|
|
3494
|
-
return [said(`create ${st.rels.join(" and ")}`), ...why];
|
|
3495
|
-
case "ensure":
|
|
3496
|
-
return [said(`ensure ${r(st.path)}/`), ...why];
|
|
3497
|
-
case "move-state":
|
|
3498
|
-
return [said(`move ${r(st.from)}/${to}${r(st.to)}/ (${st.files} entries)`), ...why];
|
|
3499
|
-
case "merge-log":
|
|
3500
|
-
return [said(`merge ${r(st.from)}${to}${r(st.to)}`), ...why];
|
|
3501
|
-
case "delete":
|
|
3502
|
-
return [said(`delete ${r(st.path)}`), ...why];
|
|
3503
|
-
case "move-marker":
|
|
3504
|
-
return [said(`move ${r(st.from)}${to}${r(st.to)}`), ...why];
|
|
3505
|
-
case "move-binding":
|
|
3506
|
-
return [said(`move ${r(st.from)}${to}${r(st.to)} (agents${to}${r(st.overlay)})`), ...why];
|
|
3507
|
-
case "remove-legacy-launcher":
|
|
3508
|
-
case "rmdir-legacy":
|
|
3509
|
-
case "remove-legacy-runtime":
|
|
3510
|
-
return [said(`remove ${r(st.path)}`), ...why];
|
|
3511
|
-
default:
|
|
3512
|
-
return [];
|
|
3513
|
-
}
|
|
3514
|
-
}
|
|
3515
|
-
|
|
3516
|
-
const displayName = (id) => loadHarness(id)?.display_name || id;
|
|
3517
|
-
// The harnesses a plan names, by their display names — for the badge.
|
|
3518
|
-
function displayNames(p) {
|
|
3519
|
-
return p.harnesses.map(displayName).join(", ") || "(no harness)";
|
|
3520
|
-
}
|
|
3521
|
-
|
|
3522
|
-
const tilde = (path, home = homedir()) =>
|
|
3523
|
-
path === home || path.startsWith(home + "/") ? "~" + path.slice(home.length) : path;
|
|
3524
|
-
|
|
3525
|
-
// The badge (presentation spec contract 7): `projectstore <verb> · <display
|
|
3526
|
-
// names>`, the first line of a verb's output. A run that fetches prints it
|
|
3527
|
-
// before the fetch line, and its preview then leaves it out (`badge: false`),
|
|
3528
|
-
// so it is printed once.
|
|
3529
|
-
export function badgeLine(p, { verb = null, caps: c = PLAIN } = {}) {
|
|
3530
|
-
return badgeText(c, `projectstore ${verb || p.mode}`, [displayNames(p)]);
|
|
3531
|
-
}
|
|
3532
|
-
|
|
3533
|
-
// The update notice in the badge's slot (the converge story's rule 8;
|
|
3534
|
-
// presentation spec contracts 6 and 7): the note glyph, the text in the
|
|
3535
|
-
// explanation role, then the command alone on its line after a blank one, in
|
|
3536
|
-
// the action role, and a blank line before the preview.
|
|
3537
|
-
export function noticeText(n, c = PLAIN) {
|
|
3538
|
-
const paint = painter(c);
|
|
3539
|
-
return (
|
|
3540
|
-
[
|
|
3541
|
-
` ${paint("attention", termIcon(c, "note"))} ${paint("explanation", n.text)}`,
|
|
3542
|
-
...commandBlock(c, [n.command], { indent: 4 }),
|
|
3543
|
-
"",
|
|
3544
|
-
].join("\n") + "\n"
|
|
3545
|
-
);
|
|
3546
|
-
}
|
|
3547
|
-
|
|
3548
|
-
// The summary line: the counts by kind of change, each in its word's role.
|
|
3549
|
-
// Unsupported rows are not counted: they are the harness's, not the run's.
|
|
3550
|
-
const COUNTED = [
|
|
3551
|
-
["add", (n) => `${n} to add`],
|
|
3552
|
-
["register", (n) => `${n} to register`],
|
|
3553
|
-
["update", (n) => `${n} to update`],
|
|
3554
|
-
["switch", (n) => `${n} to switch`],
|
|
3555
|
-
["remove", (n) => `${n} to remove`],
|
|
3556
|
-
["unchanged", (n) => `${n} unchanged`],
|
|
3557
|
-
["skipped", (n) => `${n} skipped`],
|
|
3558
|
-
];
|
|
3559
|
-
function planSummary(p, c) {
|
|
3560
|
-
if (!p.ok) return heading(c, "Plan", [["refused", "attention"]]);
|
|
3561
|
-
const n = {};
|
|
3562
|
-
for (const i of p.items) if (!isUnsupported(i)) n[actionWord(i)] = (n[actionWord(i)] || 0) + 1;
|
|
3563
|
-
const counts = COUNTED.filter(([w]) => n[w]).map(([w, say]) => [say(n[w]), WORD_ROLE[w]]);
|
|
3564
|
-
return heading(c, "Plan", counts.length ? counts : [["nothing to change", "explanation"]]);
|
|
3565
|
-
}
|
|
3566
|
-
|
|
3567
|
-
// `home` shortens the project path to ~ (the caller's home; a test's fixed
|
|
3568
|
-
// one); everything else the screen needs is in `p`. `header` is a caller's
|
|
3569
|
-
// lines beneath the project path — setup's, naming the git work tree's top
|
|
3570
|
-
// when it runs in a subfolder (the setup story, rule 4).
|
|
3571
|
-
export function renderPreview(
|
|
3572
|
-
p,
|
|
3573
|
-
{ verbose = false, verb = null, caps: c = PLAIN, badge = true, home = homedir(), header = [] } = {},
|
|
3574
|
-
) {
|
|
3575
|
-
const paint = painter(c);
|
|
3576
|
-
const glyph = (name) => termIcon(c, name);
|
|
3577
|
-
const width = c.live ? c.width : 0;
|
|
3578
|
-
const writes = p.items.filter(isWrite);
|
|
3579
|
-
// A text's lines, each of its own lines wrapped on its own, returned without
|
|
3580
|
-
// the indent for the caller to lay out. wrap indents only the lines it
|
|
3581
|
-
// breaks, so a line the text carried itself must never lose the indent's
|
|
3582
|
-
// width of characters (a refusal's "--harness claude-code" read "laude-code").
|
|
3583
|
-
const textLines = (text, indent) =>
|
|
3584
|
-
String(text)
|
|
3585
|
-
.split("\n")
|
|
3586
|
-
.flatMap((para) =>
|
|
3587
|
-
wrap(para, width, indent)
|
|
3588
|
-
.split("\n")
|
|
3589
|
-
.map((l, k) => (k ? l.slice(indent.length) : l)),
|
|
3590
|
-
);
|
|
3591
|
-
// Prose, wrapped to the terminal with every line indented; a path or a
|
|
3592
|
-
// command inside it is one word and never broken (term.mjs wrap).
|
|
3593
|
-
const prose = (indent, text, role = "explanation") => textLines(text, indent).map((l) => indent + paint(role, l));
|
|
3594
|
-
const lines = [
|
|
3595
|
-
...(badge ? [badgeLine(p, { verb, caps: c })] : []),
|
|
3596
|
-
` ${paint("explanation", tilde(p.projectDir, home))}`,
|
|
3597
|
-
...(header || []).flatMap((h) => prose(" ", h)),
|
|
3598
|
-
"",
|
|
3599
|
-
];
|
|
3600
|
-
if (verbose) {
|
|
3601
|
-
for (const [h, r] of Object.entries(p.plannedAgainst || {}))
|
|
3602
|
-
lines.push(
|
|
3603
|
-
...prose(
|
|
3604
|
-
" ",
|
|
3605
|
-
`${h}: the surfaces below are planned against the host's install path ${r}, not this package at ${p.root}.`,
|
|
3606
|
-
),
|
|
3607
|
-
"",
|
|
3608
|
-
);
|
|
3609
|
-
for (const r of p.reports) lines.push(...r.split("\n").flatMap((l) => (l.trim() ? prose(" ", l) : [""])), "");
|
|
3610
|
-
}
|
|
3611
|
-
lines.push(planSummary(p, c));
|
|
3612
|
-
// An emptied state directory is pruned with the launcher removed from it;
|
|
3613
|
-
// said once, beneath the first such row.
|
|
3614
|
-
let pruneSaid = false;
|
|
3615
|
-
const beneath = (i) => {
|
|
3616
|
-
const out = [];
|
|
3617
|
-
// A user-scope registration reaches past this project: contract 7's
|
|
3618
|
-
// consequence line, on the line beneath its row, before anything else.
|
|
3619
|
-
if (isWrite(i) && i.kind === "registration" && i.scope === "user")
|
|
3620
|
-
out.push(
|
|
3621
|
-
...prose(
|
|
3622
|
-
" ",
|
|
3623
|
-
`user-wide: the plugin is ${i.action === "remove" ? "removed" : "registered"} for every project ${displayName(i.harness)} opens, not only this one`,
|
|
3624
|
-
),
|
|
3625
|
-
);
|
|
3626
|
-
// …and a channel switch's (the converge story's rule 2), carried as its
|
|
3627
|
-
// note step: first, before the reason and the consent lines.
|
|
3628
|
-
for (const st of (i.steps || []).filter(isConsequence)) out.push(...prose(" ", st.why));
|
|
3629
|
-
if (verbose) out.push(...prose(" ", transitionText(i, glyph)));
|
|
3630
|
-
else if (i.reason && !isUnsupported(i))
|
|
3631
|
-
out.push(
|
|
3632
|
-
...prose(" ", i.advice ? i.advice.text : i.reason, i.action === "refuse" ? "attention" : "explanation"),
|
|
3633
|
-
);
|
|
3634
|
-
// The reason's ways out (the converge story): each lead, then its command
|
|
3635
|
-
// alone on its line after a blank one (contract 6), and a blank line after.
|
|
3636
|
-
if (i.advice?.blocks?.length) {
|
|
3637
|
-
for (const b of i.advice.blocks)
|
|
3638
|
-
out.push(...prose(" ", b.lead), ...commandBlock(c, [b.command], { indent: 10 }), "");
|
|
3639
|
-
}
|
|
3640
|
-
// A shell this run fetched from a registry that is not the user's (the
|
|
3641
|
-
// converge story's rule 8, as amended 2026-10-09): both named. Neutral
|
|
3642
|
-
// words: the fetch's URL can come from the project's .npmrc or from the
|
|
3643
|
-
// run's own environment (`npx --registry`), and either reads the same.
|
|
3644
|
-
const from = fetchedFor(p, i);
|
|
3645
|
-
if (from)
|
|
3646
|
-
out.push(
|
|
3647
|
-
...prose(" ", `fetched from ${from.registry}`),
|
|
3648
|
-
...prose(" ", `your npm's own registry is ${from.user}`),
|
|
3649
|
-
);
|
|
3650
|
-
for (const st of (i.steps || []).filter((x) => !isConsequence(x)))
|
|
3651
|
-
out.push(...stepLines(p, st, { verbose, paint, width, glyph, home }));
|
|
3652
|
-
if (i.kind === "registration" && i.home && i.surface && !i.surface.endsWith("_others"))
|
|
3653
|
-
out.push(` ${paint("explanation", `(harness home ${i.home}${i.scope ? `, scope ${i.scope}` : ""})`)}`);
|
|
3654
|
-
if (i.deleteIfEmpty && typeof i.after === "string" && !i.after.trim())
|
|
3655
|
-
out.push(` ${paint("explanation", "(the file would hold nothing else and is removed)")}`);
|
|
3656
|
-
if (!pruneSaid && i.action === "remove" && i.kind === "exclusive") {
|
|
3657
|
-
pruneSaid = true;
|
|
3658
|
-
out.push(` ${paint("explanation", `(an emptied ${rel(p.projectDir, dirname(i.path))}/ is pruned)`)}`);
|
|
3659
|
-
}
|
|
3660
|
-
return out;
|
|
3661
|
-
};
|
|
3662
|
-
// The run's one agents block, named again where each other harness's row
|
|
3663
|
-
// would have been (contract 7; the story "One run plans the agents block
|
|
3664
|
-
// once…", rule 1): in that harness's group, before its first item at or
|
|
3665
|
-
// after the recorded index, else at the group's end. Not a step and not an
|
|
3666
|
-
// item: nothing is planned there.
|
|
3667
|
-
const sameBlock = () =>
|
|
3668
|
-
` ${paint("explanation", glyph("skip"))} ${p.sharedBlock.file} ${glyph("dash")} the same block as ${p.sharedBlock.display}, above`;
|
|
3669
|
-
const hostLines = (h) => {
|
|
3670
|
-
const from = h.entry ? `from the registration ${h.entry}` : "from the host's own plugin system";
|
|
3671
|
-
return wrap(`${h.display} installs ${h.rows.join(", ")} itself, ${from}`, width, " ")
|
|
3672
|
-
.split("\n")
|
|
3673
|
-
.map((l, k) =>
|
|
3674
|
-
k
|
|
3675
|
-
? ` ${paint("explanation", l.slice(4))}`
|
|
3676
|
-
: ` ${paint("explanation", glyph("skip"))} ${paint("explanation", l)}`,
|
|
3677
|
-
);
|
|
3678
|
-
};
|
|
3679
|
-
// The VAULT group first, when setup plans one: the rows of no harness.
|
|
3680
|
-
const ids = [
|
|
3681
|
-
...(p.items.some((i) => i.harness === null) ? [null] : []),
|
|
3682
|
-
...p.harnesses,
|
|
3683
|
-
...new Set(p.items.map((i) => i.harness).filter((h) => h !== null && !p.harnesses.includes(h))),
|
|
3684
|
-
];
|
|
3685
|
-
for (const id of ids) {
|
|
3686
|
-
const entries = p.items.map((i, at) => ({ i, at })).filter((e) => e.i.harness === id);
|
|
3687
|
-
const others = (p.sharedBlock?.others || []).filter((o) => o.harness === id).sort((a, b) => a.at - b.at);
|
|
3688
|
-
const host = (p.hostManaged || []).filter((h) => h.harness === id);
|
|
3689
|
-
if (!entries.length && !others.length && !host.length) continue;
|
|
3690
|
-
const folded = verbose ? [] : entries.filter((e) => isUnsupported(e.i));
|
|
3691
|
-
// A registration this run fetched from another registry keeps its row, so
|
|
3692
|
-
// the registry lines beneath it show on every run, not only on a change.
|
|
3693
|
-
const quiet = verbose ? [] : entries.filter((e) => isQuiet(e.i) && !fetchedFor(p, e.i));
|
|
3694
|
-
const shown = entries.filter((e) => !folded.includes(e) && !quiet.includes(e));
|
|
3695
|
-
const specs = shown.map(({ i }) => ({
|
|
3696
|
-
glyph: glyphName(i),
|
|
3697
|
-
role: WORD_ROLE[actionWord(i)],
|
|
3698
|
-
action: actionWord(i),
|
|
3699
|
-
target: rowTarget(p, i, home),
|
|
3700
|
-
note: rowNote(i, { verbose, glyph }),
|
|
3701
|
-
}));
|
|
3702
|
-
if (quiet.length)
|
|
3703
|
-
specs.push({
|
|
3704
|
-
glyph: "unchanged",
|
|
3705
|
-
role: "explanation",
|
|
3706
|
-
action: "unchanged",
|
|
3707
|
-
target: paint("explanation", quiet.map((e) => surfaceWords(e.i.surface)).join(", ")),
|
|
3708
|
-
});
|
|
3709
|
-
// Unsupported rows keep one line per harness (contract 18), named in
|
|
3710
|
-
// words as the unchanged line names its rows; --verbose lists each with
|
|
3711
|
-
// the manifest's reason.
|
|
3712
|
-
if (folded.length)
|
|
3713
|
-
specs.push({
|
|
3714
|
-
glyph: "unchanged",
|
|
3715
|
-
role: "explanation",
|
|
3716
|
-
action: "skipped",
|
|
3717
|
-
target: paint(
|
|
3718
|
-
"explanation",
|
|
3719
|
-
`not on ${displayName(id)}: ${folded.map((e) => surfaceWords(e.i.surface)).join(", ")}`,
|
|
3720
|
-
),
|
|
3721
|
-
note: "--verbose says why",
|
|
3722
|
-
});
|
|
3723
|
-
const drawn = rowLines(c, specs);
|
|
3724
|
-
lines.push("", paint("emphasis", id === null ? VAULT_GROUP : displayName(id)));
|
|
3725
|
-
const pending = [...others];
|
|
3726
|
-
shown.forEach(({ i, at }, k) => {
|
|
3727
|
-
while (pending.length && pending[0].at <= at) {
|
|
3728
|
-
pending.shift();
|
|
3729
|
-
lines.push(sameBlock());
|
|
3730
|
-
}
|
|
3731
|
-
lines.push(...drawn[k], ...beneath(i));
|
|
3732
|
-
});
|
|
3733
|
-
for (const _ of pending) lines.push(sameBlock());
|
|
3734
|
-
for (const extra of drawn.slice(shown.length)) lines.push(...extra);
|
|
3735
|
-
for (const h of host) lines.push(...hostLines(h));
|
|
3736
|
-
}
|
|
3737
|
-
if (p.refusals.length) lines.push("");
|
|
3738
|
-
// A plan-level refusal: its message in the attention role, as an item's
|
|
3739
|
-
// refusal reason is (presentation spec contract 3 as amended 2026-10-11).
|
|
3740
|
-
p.refusals.forEach((r, k) => {
|
|
3741
|
-
const lead = ` ${paint("attention", glyph("refuse"))} ${paint("attention", "refused")} `;
|
|
3742
|
-
const pad = " ".repeat(plain(lead).length);
|
|
3743
|
-
// The stop (the converge story's rule 6) carries its commands apart from
|
|
3744
|
-
// its words: each is printed alone on its line after a blank one, with
|
|
3745
|
-
// nothing after it (contract 6), the way a person copies it.
|
|
3746
|
-
const stop = k === 0 && p.stop ? p.stop : null;
|
|
3747
|
-
// The same for a refusal that names its ways out (the channel "git" with
|
|
3748
|
-
// nothing to keep): its prose, then each lead and its command alone.
|
|
3749
|
-
const advice = k === 0 && !stop && p.refusalAdvice ? p.refusalAdvice : null;
|
|
3750
|
-
const [first, ...rest] = textLines(stop ? stop.lead : advice ? advice.text : r, pad);
|
|
3751
|
-
lines.push(lead + paint("attention", first), ...rest.map((l) => pad + paint("attention", l)));
|
|
3752
|
-
if (stop)
|
|
3753
|
-
lines.push(
|
|
3754
|
-
...commandBlock(c, [stop.commands[0]], { indent: 4 }),
|
|
3755
|
-
"",
|
|
3756
|
-
...prose(" ", stop.between),
|
|
3757
|
-
...commandBlock(c, stop.commands.slice(1), { indent: 4 }),
|
|
3758
|
-
);
|
|
3759
|
-
if (advice)
|
|
3760
|
-
for (const b of advice.blocks)
|
|
3761
|
-
lines.push("", ...prose(" ", b.lead), ...commandBlock(c, [b.command], { indent: 4 }));
|
|
3762
|
-
});
|
|
3763
|
-
// The closing lines, in a person's words: only what is listed is written,
|
|
3764
|
-
// and a $ line is the host's own CLI at work.
|
|
3765
|
-
const closing = [];
|
|
3766
|
-
if (p.ok && writes.length)
|
|
3767
|
-
closing.push(
|
|
3768
|
-
...prose(
|
|
3769
|
-
" ",
|
|
3770
|
-
`Only the entries listed above are written ${glyph("dash")} nothing else in those files is read, rewritten or removed.`,
|
|
3771
|
-
),
|
|
3772
|
-
);
|
|
3773
|
-
if (p.items.some((i) => (i.steps || []).some((s) => s.kind === "host" && s.name !== "keep")))
|
|
3774
|
-
closing.push(
|
|
3775
|
-
...prose(" ", "Each $ line runs the host's own CLI, which writes the host-owned files named after it."),
|
|
3776
|
-
);
|
|
3777
|
-
if (p.items.some((i) => isWrite(i) && (i.steps || []).some((s) => s.kind === "host" && s.name === "keep")))
|
|
3778
|
-
closing.push(
|
|
3779
|
-
...prose(
|
|
3780
|
-
" ",
|
|
3781
|
-
"The kept copy's $ line runs that copy's own projectstore, which writes the files named after it.",
|
|
3782
|
-
),
|
|
3783
|
-
);
|
|
3784
|
-
if (closing.length) lines.push("", ...closing);
|
|
3785
|
-
if (!p.ok) lines.push("", ` ${paint("attention", "Nothing will be written: resolve the refusals above first.")}`);
|
|
3786
|
-
// A plan that wrote nothing because part of it could not be planned never
|
|
3787
|
-
// reads as converged (the converge story's keep, a copy with work pending).
|
|
3788
|
-
else if (!writes.length)
|
|
3789
|
-
lines.push(
|
|
3790
|
-
"",
|
|
3791
|
-
p.incomplete
|
|
3792
|
-
? " Nothing will be written: one surface could not be planned (see above)."
|
|
3793
|
-
: " Nothing to change.",
|
|
3794
|
-
);
|
|
3795
|
-
else if (p.incomplete) lines.push("", " One surface could not be planned (see above); the rest proceeds.");
|
|
3796
|
-
return lines.join("\n") + "\n";
|
|
3797
|
-
}
|
|
3798
|
-
|
|
3799
2021
|
// ─── gate ──────────────────────────────────────────────────────────────
|
|
3800
2022
|
|
|
3801
2023
|
// Contract 9, amended 2026-10-04: a person at a terminal is asked, even when
|
|
@@ -3835,7 +2057,7 @@ export function asksAtTerminal({ stdin = null, stdout = null, ask = null, env =
|
|
|
3835
2057
|
// confirm it, a bare run is refused for it too, and nothing is written:
|
|
3836
2058
|
// `channel`, ahead of the whole non-interactive branch. A plan built without
|
|
3837
2059
|
// plan() (no `channel` on it) reads as no answer.
|
|
3838
|
-
export const switchUnanswered = (p) => (p.channel ?? null) === null && p.items.some(isSwitch);
|
|
2060
|
+
export const switchUnanswered = (p) => (p.channel ?? null) === null && p.items.some((i) => isSwitch(i) || isShadow(i));
|
|
3839
2061
|
export async function confirm(
|
|
3840
2062
|
p,
|
|
3841
2063
|
{
|
|
@@ -3926,7 +2148,11 @@ export function apply(
|
|
|
3926
2148
|
return r;
|
|
3927
2149
|
}
|
|
3928
2150
|
if (i.kind === "binding") {
|
|
3929
|
-
|
|
2151
|
+
// A directory copy's "switch" is the registration itself (PS-DIRECTORY):
|
|
2152
|
+
// the mark waits on it as on a switch.
|
|
2153
|
+
const shadowed = p.items.some((x) => x.harness === i.harness && isShadow(x));
|
|
2154
|
+
const outcome = [...(switched.get(i.harness) || []), ...(shadowed ? [registered.get(i.harness) === true] : [])];
|
|
2155
|
+
const r = applyBinding(p, i, { env, onStep, switched: outcome });
|
|
3930
2156
|
if (r.failed) done.failed = r.failed;
|
|
3931
2157
|
return r;
|
|
3932
2158
|
}
|
|
@@ -5482,189 +3708,6 @@ export async function runVerb(verb, projectDir, opts = {}) {
|
|
|
5482
3708
|
}
|
|
5483
3709
|
}
|
|
5484
3710
|
|
|
5485
|
-
// APPLY, one line per step as it finishes (presentation spec contract 8):
|
|
5486
|
-
// the glyph, what was done in the past tense — "added CLAUDE.md", having read
|
|
5487
|
-
// "adding CLAUDE.md" while it ran on a terminal — and the duration. An item
|
|
5488
|
-
// with steps — a registration, the layout move — prints its own line as it
|
|
5489
|
-
// starts ("registering …"), one line per step beneath it — each host
|
|
5490
|
-
// command, staging write, write and move — and, once all of them have run,
|
|
5491
|
-
// its finished line in the past tense ("registered …") with the item's whole
|
|
5492
|
-
// time, so a transcript never ends on a line that reads as still running.
|
|
5493
|
-
// No heading: the plan's last line and the question are above it. `now` is
|
|
5494
|
-
// the test's clock; `home` shortens paths to ~.
|
|
5495
|
-
export function applyReporter(out, c, p, { now = Date.now, home = homedir() } = {}) {
|
|
5496
|
-
const paint = painter(c);
|
|
5497
|
-
const glyph = (name) => termIcon(c, name);
|
|
5498
|
-
const item = stepReporter(out, c, { indent: 2, now });
|
|
5499
|
-
const step = stepReporter(out, c, { indent: 6, now });
|
|
5500
|
-
const said = (i) => {
|
|
5501
|
-
const word = actionWord(i);
|
|
5502
|
-
const [running, done] = WORD_TENSES[word] || [word, word];
|
|
5503
|
-
const target = rowTarget(p, i, home);
|
|
5504
|
-
return { word, running: `${running} ${target}`, done: `${done} ${target}` };
|
|
5505
|
-
};
|
|
5506
|
-
// The mark too: a skip prints its reason (the project not bound, the switch
|
|
5507
|
-
// not done) beneath the line it started.
|
|
5508
|
-
// …and the keep, whose $ line and the kept copy's own items print beneath it.
|
|
5509
|
-
// …and setup's vault, whose binding, git and scaffold steps print beneath it.
|
|
5510
|
-
const stepped = (i) =>
|
|
5511
|
-
i.kind === "registration" ||
|
|
5512
|
-
i.kind === "layout" ||
|
|
5513
|
-
i.kind === "binding" ||
|
|
5514
|
-
i.kind === "channel" ||
|
|
5515
|
-
i.kind === "vault";
|
|
5516
|
-
const began = new Map();
|
|
5517
|
-
return {
|
|
5518
|
-
onItem(i, phase, r) {
|
|
5519
|
-
const s = said(i);
|
|
5520
|
-
if (stepped(i)) {
|
|
5521
|
-
if (phase === "start") {
|
|
5522
|
-
began.set(i, now());
|
|
5523
|
-
out.write(` ${paint(WORD_ROLE[s.word], glyph(glyphName(i)))} ${s.running}\n`);
|
|
5524
|
-
return;
|
|
5525
|
-
}
|
|
5526
|
-
const t0 = began.get(i) ?? now();
|
|
5527
|
-
began.delete(i);
|
|
5528
|
-
if (phase === "abort") step.abort();
|
|
5529
|
-
else if (r && (r.action === "skipped" || r.action === "skip"))
|
|
5530
|
-
out.write(
|
|
5531
|
-
` ${paint("explanation", glyph("skip"))} ${paint("explanation", `${r.action === "skip" ? "nothing to do" : "skipped"}: ${r.reason || "an earlier item failed"}`)}\n`,
|
|
5532
|
-
);
|
|
5533
|
-
else if (r && r.failed) out.write(` ${paint("error", glyph("fail"))} ${paint("error", "stopped")}\n`);
|
|
5534
|
-
else item.finish(s.done, t0);
|
|
5535
|
-
return;
|
|
5536
|
-
}
|
|
5537
|
-
if (phase === "start") item.start(s.running);
|
|
5538
|
-
else if (phase === "abort") item.abort();
|
|
5539
|
-
else {
|
|
5540
|
-
// An item that failed or was skipped keeps the words it ran under.
|
|
5541
|
-
const failed = Boolean(r && (r.failed || r.action === "skipped"));
|
|
5542
|
-
item.end(!failed, r && r.action === "skipped" ? "skipped" : "", failed ? {} : { label: s.done });
|
|
5543
|
-
}
|
|
5544
|
-
},
|
|
5545
|
-
onStep(st, phase, r) {
|
|
5546
|
-
const l = stepLabel(p, st, { home, glyph });
|
|
5547
|
-
if (!l) return;
|
|
5548
|
-
if (phase === "start") {
|
|
5549
|
-
step.start(l.running);
|
|
5550
|
-
return;
|
|
5551
|
-
}
|
|
5552
|
-
const ok = r ? r.ok : true;
|
|
5553
|
-
const kept = Boolean(r && r.kept);
|
|
5554
|
-
// A step that failed without stopping the run (setup's git init) is ▲,
|
|
5555
|
-
// in its own words.
|
|
5556
|
-
if (r && r.warn) {
|
|
5557
|
-
step.end(true, "", { warn: true, label: r.warn });
|
|
5558
|
-
return;
|
|
5559
|
-
}
|
|
5560
|
-
// A step that left its target in place did not do its verb; one that
|
|
5561
|
-
// says why in its own words (a git init skipped) needs no note.
|
|
5562
|
-
const own = r && r.label ? r.label : null;
|
|
5563
|
-
step.end(ok, kept && !own ? "kept" : "", ok ? { kept, label: own || (kept ? l.base : l.done) } : {});
|
|
5564
|
-
},
|
|
5565
|
-
// The wrapped spawn prints each host command as a step. Its `unwrapped`
|
|
5566
|
-
// is the caller's own spawn, for an item that reports its commands as its
|
|
5567
|
-
// own steps (setup's vault: `$ git init --quiet`).
|
|
5568
|
-
spawn(inner) {
|
|
5569
|
-
const wrapped = (bin, argv, o) => {
|
|
5570
|
-
step.start(`$ ${basename(String(bin))} ${argv.join(" ")}`);
|
|
5571
|
-
let r;
|
|
5572
|
-
try {
|
|
5573
|
-
r = inner(bin, argv, o);
|
|
5574
|
-
} catch (e) {
|
|
5575
|
-
step.abort();
|
|
5576
|
-
throw e;
|
|
5577
|
-
}
|
|
5578
|
-
step.end(!r.error && r.status === 0);
|
|
5579
|
-
return r;
|
|
5580
|
-
};
|
|
5581
|
-
Object.defineProperty(wrapped, "unwrapped", { value: inner });
|
|
5582
|
-
return wrapped;
|
|
5583
|
-
},
|
|
5584
|
-
};
|
|
5585
|
-
}
|
|
5586
|
-
|
|
5587
|
-
// A step as its APPLY line names it: the verb and the target, short — the
|
|
5588
|
-
// full form, file counts and entry pointers, was in PLAN. `base` is the plain
|
|
5589
|
-
// verb, for a step that left its target in place.
|
|
5590
|
-
const STEP_TENSES = {
|
|
5591
|
-
stage: ["staging", "staged"],
|
|
5592
|
-
lift: ["lifting", "lifted"],
|
|
5593
|
-
write: ["writing", "wrote"],
|
|
5594
|
-
edit: ["editing", "edited"],
|
|
5595
|
-
ensure: ["ensuring", "ensured"],
|
|
5596
|
-
move: ["moving", "moved"],
|
|
5597
|
-
merge: ["merging", "merged"],
|
|
5598
|
-
remove: ["removing", "removed"],
|
|
5599
|
-
create: ["creating", "created"],
|
|
5600
|
-
};
|
|
5601
|
-
function stepLabel(p, st, { home = homedir(), glyph = unicodeGlyph } = {}) {
|
|
5602
|
-
const r = (x) => tilde(rel(p.projectDir, x), home);
|
|
5603
|
-
const to = ` ${glyph("transition")} `;
|
|
5604
|
-
const said = (verb, rest) => ({
|
|
5605
|
-
base: `${verb} ${rest}`,
|
|
5606
|
-
running: `${STEP_TENSES[verb][0]} ${rest}`,
|
|
5607
|
-
done: `${STEP_TENSES[verb][1]} ${rest}`,
|
|
5608
|
-
});
|
|
5609
|
-
switch (st.kind) {
|
|
5610
|
-
case "portable-write":
|
|
5611
|
-
return said("stage", r(st.path));
|
|
5612
|
-
case "recover":
|
|
5613
|
-
return said(
|
|
5614
|
-
"lift",
|
|
5615
|
-
`${r(st.path)} ${glyph("dash")} the previous state proved${st.lifted ? `; it held: ${String(st.lifted).split("\n")[0]}` : ""}`,
|
|
5616
|
-
);
|
|
5617
|
-
case "write":
|
|
5618
|
-
return said("write", r(st.path));
|
|
5619
|
-
case "unregister":
|
|
5620
|
-
return said("edit", `${r(st.path)} [${st.pointer}.${st.name}]`);
|
|
5621
|
-
case "mark":
|
|
5622
|
-
case "unmark":
|
|
5623
|
-
case "set":
|
|
5624
|
-
return said("edit", `${r(st.path)} [${st.key}]`);
|
|
5625
|
-
case "ensure":
|
|
5626
|
-
return said("ensure", `${r(st.path)}/`);
|
|
5627
|
-
// Setup's vault (rule 10): the directory, the binding, git, the scaffold
|
|
5628
|
-
// — a scaffold step's paths are the vault's, beneath the vault's line.
|
|
5629
|
-
case "mkdir":
|
|
5630
|
-
return said("create", `${r(st.path)}/`);
|
|
5631
|
-
case "bind":
|
|
5632
|
-
return said("write", r(st.path));
|
|
5633
|
-
case "git-init": {
|
|
5634
|
-
const line = `$ ${st.argv.join(" ")}`;
|
|
5635
|
-
return { base: line, running: line, done: line };
|
|
5636
|
-
}
|
|
5637
|
-
case "scaffold":
|
|
5638
|
-
return said("create", st.rels.join(" and "));
|
|
5639
|
-
case "move-state":
|
|
5640
|
-
return said("move", `${r(st.from)}/${to}${r(st.to)}/`);
|
|
5641
|
-
case "merge-log":
|
|
5642
|
-
return said("merge", `${r(st.from)}${to}${r(st.to)}`);
|
|
5643
|
-
case "move-marker":
|
|
5644
|
-
case "move-binding":
|
|
5645
|
-
return said("move", `${r(st.from)}${to}${r(st.to)}`);
|
|
5646
|
-
case "delete":
|
|
5647
|
-
case "portable-remove":
|
|
5648
|
-
case "remove":
|
|
5649
|
-
case "remove-legacy-launcher":
|
|
5650
|
-
case "rmdir-legacy":
|
|
5651
|
-
case "remove-legacy-runtime":
|
|
5652
|
-
return said("remove", r(st.path));
|
|
5653
|
-
case "rmdir-state":
|
|
5654
|
-
return said("remove", `${r(st.path)}/`);
|
|
5655
|
-
// An item the kept copy applied (the converge story's rule 5), in this
|
|
5656
|
-
// run's words for its action; one the preview did not list says so.
|
|
5657
|
-
case "kept": {
|
|
5658
|
-
const word = ACTION_WORD[st.action] || "update";
|
|
5659
|
-
const [running, done] = WORD_TENSES[word] || [word, word];
|
|
5660
|
-
const target = `${st.path ? r(st.path) : st.surface}${st.unplanned ? " (not in the preview)" : ""}`;
|
|
5661
|
-
return { base: `${word} ${target}`, running: `${running} ${target}`, done: `${done} ${target}` };
|
|
5662
|
-
}
|
|
5663
|
-
default:
|
|
5664
|
-
return null;
|
|
5665
|
-
}
|
|
5666
|
-
}
|
|
5667
|
-
|
|
5668
3711
|
// DONE and STOPPED (presentation spec contract 8). DONE: the count and the
|
|
5669
3712
|
// time; Next, one line per harness — its display name in a column, its
|
|
5670
3713
|
// manifest's next steps beside it, deduplicated; the facts the run settled
|
|
@@ -5757,7 +3800,9 @@ export function renderDone(r, { caps: c = PLAIN, verbose = false, cwd = process.
|
|
|
5757
3800
|
// that could not run the keep needs, its own upgrade when its plan printed
|
|
5758
3801
|
// no result, the npm row's uninstall — each lead, then its command alone on
|
|
5759
3802
|
// its line (contract 6).
|
|
5760
|
-
|
|
3803
|
+
// An uninstall's registration row carries one too: the directory copy that
|
|
3804
|
+
// loads again (PS-DIRECTORY).
|
|
3805
|
+
const kept = p.items.filter((i) => Array.isArray(i.next)).flatMap((i) => i.next);
|
|
5761
3806
|
const more = extra?.blocks || [];
|
|
5762
3807
|
if (next.length || kept.length || more.length) lines.push("", paint("emphasis", "Next"));
|
|
5763
3808
|
if (next.length) {
|
|
@@ -5889,9 +3934,12 @@ export function gateText(r, { verb, harnesses = [], caps: c = PLAIN, args = "" }
|
|
|
5889
3934
|
if (why === "dry-run") return "Nothing written: this was a dry run.\n";
|
|
5890
3935
|
if (why === "channel") {
|
|
5891
3936
|
const keys = [...new Set(r.plan.items.filter(isSwitch).map((i) => i.entry))];
|
|
3937
|
+
const directory = [...new Set(r.plan.items.filter(isShadow).map((i) => i.entry))];
|
|
5892
3938
|
return (
|
|
5893
3939
|
channelText(
|
|
5894
|
-
channelChoices({
|
|
3940
|
+
channelChoices({
|
|
3941
|
+
gate: { args: { harnesses }, keys, directory, version: pinFor(r.plan.root, r.plan.version) },
|
|
3942
|
+
}),
|
|
5895
3943
|
c,
|
|
5896
3944
|
) + "\n"
|
|
5897
3945
|
);
|