pantheon-opencode 1.6.0-beta.10 → 1.6.0-beta.12
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/AGENTS.global.md +42 -18
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +46 -0
- package/README.md +122 -38
- package/README.pt-BR.md +5 -4
- package/bin/pantheon-init.mjs +322 -101
- package/package.json +7 -6
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/build-agents-md.mjs +128 -10
- package/scripts/check-plugin-v2-coverage.mjs +7 -7
- package/scripts/doctor.mjs +242 -19
- package/scripts/install/backup.mjs +648 -0
- package/scripts/install/cli-ui.mjs +79 -1
- package/scripts/install/exit-codes.mjs +26 -0
- package/scripts/install/file-inventory.mjs +555 -0
- package/scripts/install/interactive.mjs +15 -1
- package/scripts/install/migrate.mjs +32 -6
- package/scripts/install/model-picker.mjs +63 -22
- package/scripts/install/opencode-version.mjs +9 -1
- package/scripts/install/opencode.mjs +761 -63
- package/scripts/install/plan-artifact.mjs +517 -0
- package/scripts/install/plan.mjs +449 -0
- package/scripts/install/plugin.mjs +136 -18
- package/scripts/install/shared.mjs +249 -94
- package/scripts/install/state.mjs +63 -8
- package/scripts/install/strings.mjs +20 -0
- package/scripts/install/venv.mjs +25 -6
- package/scripts/install/verify.mjs +128 -0
- package/scripts/install/vision-prompt.mjs +13 -1
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/manifest-inventory.mjs +49 -3
- package/scripts/mcp_persistence.py +50 -15
- package/scripts/node-engine-policy.mjs +74 -0
- package/scripts/package-evidence.mjs +6 -0
- package/scripts/postinstall.mjs +3 -4
- package/scripts/prune.mjs +9 -3
- package/scripts/reconcile.mjs +325 -0
- package/scripts/restore.mjs +222 -0
- package/scripts/uninstall.mjs +258 -34
- package/scripts/update.mjs +164 -0
- package/scripts/validate-node-engine-policy.mjs +13 -0
- package/scripts/version-check.mjs +34 -1
- package/scripts/versioning.mjs +9 -1
- package/skills-lock.json +4 -4
- package/src/agents/iris.md +6 -0
- package/src/agents/mnemosyne.md +7 -2
- package/src/agents/zeus.md +19 -62
- package/src/instructions/agent-return-format.instructions.md +17 -15
- package/src/instructions/memory-protocol.instructions.md +3 -2
- package/src/instructions/test-economy.instructions.md +21 -0
- package/src/instructions/zeus-anti-stall.instructions.md +6 -6
- package/src/instructions/zeus-council-synthesis.instructions.md +10 -11
- package/src/instructions/zeus-memory-operations.instructions.md +11 -6
- package/src/instructions/zeus-timeout-retry.instructions.md +28 -9
- package/src/mcp/mcp_persistence.py +50 -15
- package/src/pantheon/cost-command.ts +5 -2
- package/src/pantheon/model-agents.ts +19 -0
- package/src/pantheon/model-command.ts +218 -56
- package/src/pantheon/v2-model-overrides.ts +275 -0
- package/src/pantheon/v2-tools.ts +18 -29
- package/src/pantheon/v2-unsupported.mjs +2 -3
- package/src/plugin-v2.ts +13 -0
- package/src/plugins/tui/dist/tui-v2.js +2 -2
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/package-lock.json +3 -3
- package/src/plugins/tui/package.json +2 -2
- package/src/skills/artifact-management/SKILL.md +5 -1
- package/src/skills/auto-continue/SKILL.md +14 -6
- package/src/skills/memory-bank/SKILL.md +5 -1
- package/src/skills/worktrees/SKILL.md +30 -11
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plan.mjs — Per-file change planning for the Pantheon installer.
|
|
3
|
+
*
|
|
4
|
+
* Beta 11, Setup phase 1: `pantheon-opencode init ... --dry-run` must print the
|
|
5
|
+
* REAL per-file change set (create / update / remove / unchanged) that the SAME
|
|
6
|
+
* code path would produce, and write nothing.
|
|
7
|
+
*
|
|
8
|
+
* The plan is built by a change recorder threaded through the writer helpers as
|
|
9
|
+
* an OPTIONAL argument whose default is a module-level no-op singleton, so any
|
|
10
|
+
* caller (or writer) that is not instrumented keeps byte-identical behavior.
|
|
11
|
+
*
|
|
12
|
+
* NET-OUTCOME semantics: several writers can touch the same path in one pass
|
|
13
|
+
* (the TUI `unregister`→`register` toggle; the code-mode `syncDir` payload and
|
|
14
|
+
* its generated `manifest.json`). The LAST writer decides the net disk result,
|
|
15
|
+
* so a later no-write (skipped/unchanged) leaves the path UNCHANGED even when an
|
|
16
|
+
* earlier writer computed a would-be update. A no-write never promotes a path to
|
|
17
|
+
* a scored change.
|
|
18
|
+
*
|
|
19
|
+
* "Scored" vs "unscored": writers that increment the install summary
|
|
20
|
+
* (`stats.created`) alongside a genuine change are SCORED. Artifacts that are
|
|
21
|
+
* not part of that summary (the synthetic `.venv` entry, the state-only
|
|
22
|
+
* `.pantheon/install-state.json` write, the backup-set manifest, and the TUI
|
|
23
|
+
* stale-ref cleanup) are recorded UNSCORED so they still appear in the printed
|
|
24
|
+
* plan but never affect the consistency guard.
|
|
25
|
+
*
|
|
26
|
+
* Security: the recorder, the summary and the formatter deal in paths, actions
|
|
27
|
+
* and byte sizes ONLY. File contents and hashes stay internal — hashes are kept
|
|
28
|
+
* so a later phase can detect drift between a preview and an apply, and are
|
|
29
|
+
* never printed.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createHash } from 'node:crypto'
|
|
33
|
+
import { lstatSync, readdirSync, readFileSync, rmdirSync, unlinkSync } from 'node:fs'
|
|
34
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
35
|
+
import {
|
|
36
|
+
assertNoSymlinkAncestors,
|
|
37
|
+
assertPathWithin,
|
|
38
|
+
assertSafeRemoval,
|
|
39
|
+
verifyPathRemoved,
|
|
40
|
+
} from './verify.mjs'
|
|
41
|
+
|
|
42
|
+
/** The action vocabulary shared by the recorder, the summary and the formatter. */
|
|
43
|
+
export const ACTIONS = Object.freeze({
|
|
44
|
+
CREATE: 'create',
|
|
45
|
+
UPDATE: 'update',
|
|
46
|
+
REMOVE: 'remove',
|
|
47
|
+
UNCHANGED: 'unchanged',
|
|
48
|
+
CONFLICT: 'conflict',
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const ACTION_VALUES = new Set(Object.values(ACTIONS))
|
|
52
|
+
|
|
53
|
+
/** Whether an action represents a real change to the target (not a no-write). */
|
|
54
|
+
function isChange(action) {
|
|
55
|
+
return action === ACTIONS.CREATE || action === ACTIONS.UPDATE || action === ACTIONS.REMOVE
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Display order for the formatted plan.
|
|
59
|
+
const DISPLAY_ORDER = Object.freeze([
|
|
60
|
+
ACTIONS.CREATE,
|
|
61
|
+
ACTIONS.UPDATE,
|
|
62
|
+
ACTIONS.REMOVE,
|
|
63
|
+
ACTIONS.CONFLICT,
|
|
64
|
+
ACTIONS.UNCHANGED,
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The module-level no-op recorder. Instrumented writers whose caller did not
|
|
69
|
+
* pass a recorder fall back to this, so their behavior is byte-identical to an
|
|
70
|
+
* uninstrumented run (no allocation, no side effects).
|
|
71
|
+
*/
|
|
72
|
+
export function noopRecorder() {}
|
|
73
|
+
|
|
74
|
+
/** The default recorder instance — the no-op singleton. */
|
|
75
|
+
export const NOOP_RECORDER = noopRecorder
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether a recorder will actually collect anything. Writers in hot loops use
|
|
79
|
+
* this to skip `Buffer.byteLength(content)` on the common (uninstrumented) path.
|
|
80
|
+
*
|
|
81
|
+
* @param {unknown} recorder
|
|
82
|
+
* @returns {boolean}
|
|
83
|
+
*/
|
|
84
|
+
export function isRecording(recorder) {
|
|
85
|
+
return typeof recorder === 'function' && recorder !== NOOP_RECORDER
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Normalize an arbitrary change into the `{ path, action, bytes, scored }` shape. */
|
|
89
|
+
function normalizeChange(change) {
|
|
90
|
+
const action = change && ACTION_VALUES.has(change.action) ? change.action : ACTIONS.UNCHANGED
|
|
91
|
+
return {
|
|
92
|
+
path: String(change?.path ?? ''),
|
|
93
|
+
action,
|
|
94
|
+
bytes: Number.isFinite(change?.bytes) ? change.bytes : 0,
|
|
95
|
+
outputFingerprint:
|
|
96
|
+
typeof change?.outputFingerprint === 'string' ? change.outputFingerprint : null,
|
|
97
|
+
// Scored by default: most writers feed the install summary. Synthetic and
|
|
98
|
+
// state-only entries opt out explicitly with `scored: false`.
|
|
99
|
+
scored: !(change && change.scored === false),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create a recorder that collects changes in memory. `recorder` is the function
|
|
105
|
+
* to thread into the writer helpers; `entries` holds the raw recorded changes.
|
|
106
|
+
*
|
|
107
|
+
* @returns {{ recorder: (change: object) => void, entries: object[] }}
|
|
108
|
+
*/
|
|
109
|
+
export function createRecorder() {
|
|
110
|
+
const entries = []
|
|
111
|
+
const recorder = (change) => {
|
|
112
|
+
entries.push(normalizeChange(change))
|
|
113
|
+
}
|
|
114
|
+
return { recorder, entries }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Record a single change. Writers call this instead of touching fs bookkeeping.
|
|
119
|
+
* With no explicit recorder the module-level no-op singleton is used.
|
|
120
|
+
*
|
|
121
|
+
* @param {{ path: string, action: string, bytes?: number, scored?: boolean }} change
|
|
122
|
+
* @param {(change: object) => void} [recorder]
|
|
123
|
+
*/
|
|
124
|
+
export function recordChange(change, recorder = NOOP_RECORDER) {
|
|
125
|
+
if (!isRecording(recorder)) return
|
|
126
|
+
recorder(normalizeChange(change))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Return the non-displayed SHA-256 fingerprint used by reviewed plan artifacts. */
|
|
130
|
+
export function fingerprintOutput(content) {
|
|
131
|
+
return createHash('sha256').update(content).digest('hex')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Sort helper: display order first, then path for determinism. */
|
|
135
|
+
function compareFiles(a, b) {
|
|
136
|
+
const order = DISPLAY_ORDER.indexOf(a.action) - DISPLAY_ORDER.indexOf(b.action)
|
|
137
|
+
if (order !== 0) return order
|
|
138
|
+
return a.path.localeCompare(b.path)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function zeroTotals() {
|
|
142
|
+
return { create: 0, update: 0, remove: 0, unchanged: 0, conflict: 0 }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Tally a change into one of the five action buckets. */
|
|
146
|
+
function tally(totals, action) {
|
|
147
|
+
if (Object.hasOwn(totals, action)) totals[action] += 1
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Collapse recorded changes into the final change set: one entry per path,
|
|
152
|
+
* reflecting the NET disk outcome, plus totals.
|
|
153
|
+
*
|
|
154
|
+
* The last record for a path decides the action, so a later no-write wins over
|
|
155
|
+
* an earlier would-be write. A path is scored only if at least one of its
|
|
156
|
+
* CHANGE records was scored AND the net action is still a change — a no-write
|
|
157
|
+
* can never turn a path into a scored change.
|
|
158
|
+
*
|
|
159
|
+
* @param {object[]} entries
|
|
160
|
+
* @returns {{
|
|
161
|
+
* files: object[],
|
|
162
|
+
* totals: { create: number, update: number, remove: number, unchanged: number, conflict: number },
|
|
163
|
+
* scored: { create: number, update: number, remove: number, unchanged: number, conflict: number },
|
|
164
|
+
* }}
|
|
165
|
+
*/
|
|
166
|
+
export function summarizeChanges(entries = []) {
|
|
167
|
+
const byPath = new Map()
|
|
168
|
+
for (const entry of entries ?? []) {
|
|
169
|
+
const change = normalizeChange(entry)
|
|
170
|
+
let record = byPath.get(change.path)
|
|
171
|
+
if (!record) {
|
|
172
|
+
record = {
|
|
173
|
+
path: change.path,
|
|
174
|
+
action: change.action,
|
|
175
|
+
bytes: change.bytes,
|
|
176
|
+
outputFingerprint: change.outputFingerprint,
|
|
177
|
+
scored: false,
|
|
178
|
+
}
|
|
179
|
+
byPath.set(change.path, record)
|
|
180
|
+
}
|
|
181
|
+
// The LAST writer decides the net on-disk outcome. A later no-write
|
|
182
|
+
// (skipped/unchanged) leaves the path unchanged even if an earlier writer
|
|
183
|
+
// computed a would-be update (e.g. unregister→register toggling).
|
|
184
|
+
record.action = change.action
|
|
185
|
+
record.bytes = change.bytes
|
|
186
|
+
record.outputFingerprint = change.outputFingerprint
|
|
187
|
+
// Only a genuine change can score a path; a no-write never does.
|
|
188
|
+
if (isChange(change.action) && change.scored) record.scored = true
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const files = [...byPath.values()].sort(compareFiles)
|
|
192
|
+
for (const file of files) {
|
|
193
|
+
if (!isChange(file.action)) file.scored = false
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const totals = zeroTotals()
|
|
197
|
+
const scored = zeroTotals()
|
|
198
|
+
for (const file of files) {
|
|
199
|
+
tally(totals, file.action)
|
|
200
|
+
if (file.scored) tally(scored, file.action)
|
|
201
|
+
}
|
|
202
|
+
return { files, totals, scored }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Human-readable byte size. The caller never sees raw bytes or hashes. */
|
|
206
|
+
function formatBytes(bytes) {
|
|
207
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
|
208
|
+
if (bytes < 1024) return `${bytes} B`
|
|
209
|
+
const kb = bytes / 1024
|
|
210
|
+
if (kb < 1024) return `${kb.toFixed(1)} KB`
|
|
211
|
+
return `${(kb / 1024).toFixed(1)} MB`
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Path shown in the plan: relative to the target when it lives inside it. */
|
|
215
|
+
export function displayPath(target, filePath) {
|
|
216
|
+
if (!target) return filePath
|
|
217
|
+
const rel = relative(target, filePath)
|
|
218
|
+
if (rel === '') return '.'
|
|
219
|
+
return rel.startsWith('..') || isAbsolute(rel) ? filePath : rel
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Render the secret-safe plan: paths + action + byte size ONLY. No contents,
|
|
224
|
+
* no hashes.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} target - install target (used to relativize paths)
|
|
227
|
+
* @param {{ files: object[], totals: object }} plan
|
|
228
|
+
* @returns {string}
|
|
229
|
+
*/
|
|
230
|
+
export function formatChangePlan(target, plan) {
|
|
231
|
+
const { files, totals } = plan
|
|
232
|
+
const lines = []
|
|
233
|
+
lines.push(`Change plan — ${target}`)
|
|
234
|
+
for (const file of files) {
|
|
235
|
+
const isWrite = file.action === ACTIONS.CREATE || file.action === ACTIONS.UPDATE
|
|
236
|
+
const size = isWrite ? ` (${formatBytes(file.bytes)})` : ''
|
|
237
|
+
lines.push(` ${file.action.padEnd(9)} ${displayPath(target, file.path)}${size}`)
|
|
238
|
+
}
|
|
239
|
+
lines.push(` ${'─'.repeat(28)}`)
|
|
240
|
+
lines.push(
|
|
241
|
+
` ${totals.create} create · ${totals.update} update · ${totals.remove} remove · ` +
|
|
242
|
+
`${totals.unchanged} unchanged · ${totals.conflict} conflict`,
|
|
243
|
+
)
|
|
244
|
+
lines.push(' No files were written.')
|
|
245
|
+
return lines.join('\n')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Snapshot a path for later drift comparison. Hashes stay internal.
|
|
250
|
+
*
|
|
251
|
+
* @param {string} filePath
|
|
252
|
+
* @returns {{ exists: boolean, sha256: string|null, size: number, mode: number|null }}
|
|
253
|
+
*/
|
|
254
|
+
export function snapshot(filePath) {
|
|
255
|
+
let stat
|
|
256
|
+
try {
|
|
257
|
+
stat = lstatSync(filePath)
|
|
258
|
+
} catch {
|
|
259
|
+
return { exists: false, sha256: null, size: 0, mode: null }
|
|
260
|
+
}
|
|
261
|
+
if (stat.isDirectory()) {
|
|
262
|
+
// Directory snapshots include the direct child inventory. Descendant
|
|
263
|
+
// directories/files are snapshotted separately by collectRemovalTree().
|
|
264
|
+
const entries = readdirSync(filePath, { withFileTypes: true })
|
|
265
|
+
.map((entry) => [
|
|
266
|
+
entry.name,
|
|
267
|
+
entry.isDirectory()
|
|
268
|
+
? 'directory'
|
|
269
|
+
: entry.isFile()
|
|
270
|
+
? 'file'
|
|
271
|
+
: entry.isSymbolicLink()
|
|
272
|
+
? 'symlink'
|
|
273
|
+
: 'other',
|
|
274
|
+
])
|
|
275
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
276
|
+
return {
|
|
277
|
+
exists: true,
|
|
278
|
+
sha256: createHash('sha256').update(JSON.stringify(entries)).digest('hex'),
|
|
279
|
+
size: stat.size,
|
|
280
|
+
mode: stat.mode,
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (!stat.isFile()) {
|
|
284
|
+
return { exists: true, sha256: null, size: stat.size, mode: stat.mode }
|
|
285
|
+
}
|
|
286
|
+
let buffer
|
|
287
|
+
try {
|
|
288
|
+
buffer = readFileSync(filePath)
|
|
289
|
+
} catch {
|
|
290
|
+
return { exists: true, sha256: null, size: stat.size, mode: stat.mode }
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
exists: true,
|
|
294
|
+
sha256: createHash('sha256').update(buffer).digest('hex'),
|
|
295
|
+
size: buffer.length,
|
|
296
|
+
mode: stat.mode,
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Enumerate a path and its complete subtree as removal operations with
|
|
302
|
+
* pre-apply snapshots. Symlinks are treated as leaf entries and never followed.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} rootPath
|
|
305
|
+
* @returns {{path: string, snapshot: object}[]}
|
|
306
|
+
*/
|
|
307
|
+
export function collectRemovalTree(rootPath) {
|
|
308
|
+
const entries = []
|
|
309
|
+
const visit = (filePath, allowMissing = false) => {
|
|
310
|
+
let stat
|
|
311
|
+
try {
|
|
312
|
+
stat = lstatSync(filePath)
|
|
313
|
+
} catch (error) {
|
|
314
|
+
if (allowMissing && error?.code === 'ENOENT') return
|
|
315
|
+
throw error
|
|
316
|
+
}
|
|
317
|
+
entries.push({ path: filePath, snapshot: snapshot(filePath) })
|
|
318
|
+
if (stat.isDirectory()) {
|
|
319
|
+
for (const name of readdirSync(filePath).sort()) visit(`${filePath}${sep}${name}`)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
visit(rootPath, true)
|
|
323
|
+
return entries
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Record every path that a recursive removal will delete. */
|
|
327
|
+
export function recordRemovalTree(rootPath, recorder, { scored = false } = {}) {
|
|
328
|
+
const entries = collectRemovalTree(rootPath)
|
|
329
|
+
for (const entry of entries) {
|
|
330
|
+
recordChange({ path: entry.path, action: ACTIONS.REMOVE, bytes: 0, scored }, recorder)
|
|
331
|
+
}
|
|
332
|
+
return entries
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Fail closed unless the exact reviewed subtree and its snapshots still match.
|
|
337
|
+
*
|
|
338
|
+
* @param {string} rootPath
|
|
339
|
+
* @param {Set<string>|null} reviewedRemovals
|
|
340
|
+
* @param {Map<string, object>|null} reviewedSnapshots
|
|
341
|
+
*/
|
|
342
|
+
export function validateRemovalTree(rootPath, reviewedRemovals, reviewedSnapshots) {
|
|
343
|
+
if (!reviewedRemovals || !reviewedSnapshots) {
|
|
344
|
+
throw new Error(`Refusing destructive removal without a reviewed snapshot: ${rootPath}`)
|
|
345
|
+
}
|
|
346
|
+
const root = resolve(rootPath)
|
|
347
|
+
const isWithinRoot = (filePath) => {
|
|
348
|
+
const rel = relative(root, resolve(filePath))
|
|
349
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
|
|
350
|
+
}
|
|
351
|
+
const expected = new Set(
|
|
352
|
+
[...reviewedRemovals].filter(isWithinRoot).map((filePath) => resolve(filePath)),
|
|
353
|
+
)
|
|
354
|
+
const current = collectRemovalTree(rootPath)
|
|
355
|
+
const actual = new Set(current.map((entry) => resolve(entry.path)))
|
|
356
|
+
if (actual.size !== expected.size || [...actual].some((filePath) => !expected.has(filePath))) {
|
|
357
|
+
throw new Error(`Removal set changed after review: ${rootPath}`)
|
|
358
|
+
}
|
|
359
|
+
for (const entry of current) {
|
|
360
|
+
const filePath = resolve(entry.path)
|
|
361
|
+
const before = reviewedSnapshots.get(filePath)
|
|
362
|
+
const now = entry.snapshot
|
|
363
|
+
if (
|
|
364
|
+
!before ||
|
|
365
|
+
before.exists !== now.exists ||
|
|
366
|
+
before.sha256 !== now.sha256 ||
|
|
367
|
+
before.size !== now.size ||
|
|
368
|
+
before.mode !== now.mode
|
|
369
|
+
) {
|
|
370
|
+
throw new Error(`Removal target drifted after review: ${entry.path}`)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return true
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Remove only the reviewed entries, without recursive deletion that could
|
|
378
|
+
* consume a path added after preflight. Directory removal uses rmdir, which
|
|
379
|
+
* fails closed if any unreviewed entry appears during cleanup.
|
|
380
|
+
*/
|
|
381
|
+
export function removeReviewedTree(rootPath, reviewedRemovals, reviewedSnapshots) {
|
|
382
|
+
validateRemovalTree(rootPath, reviewedRemovals, reviewedSnapshots)
|
|
383
|
+
const entries = collectRemovalTree(rootPath)
|
|
384
|
+
for (const entry of entries.reverse()) {
|
|
385
|
+
const info = lstatSync(entry.path)
|
|
386
|
+
if (info.isDirectory()) {
|
|
387
|
+
assertSafeRemoval(rootPath, entry.path)
|
|
388
|
+
try {
|
|
389
|
+
rmdirSync(entry.path)
|
|
390
|
+
} catch (error) {
|
|
391
|
+
if (error?.code === 'ENOTEMPTY' || error?.code === 'EEXIST') {
|
|
392
|
+
throw new Error(`Removal set changed after review: ${entry.path}`, { cause: error })
|
|
393
|
+
}
|
|
394
|
+
throw error
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
// Unlinking a reviewed symlink leaf is safe and preserves historical
|
|
398
|
+
// cleanup behavior; reject linked ancestors so the parent is never
|
|
399
|
+
// redirected outside the reviewed root.
|
|
400
|
+
assertPathWithin(rootPath, entry.path)
|
|
401
|
+
assertNoSymlinkAncestors(entry.path, { includeLeaf: false })
|
|
402
|
+
const before = reviewedSnapshots?.get(resolve(entry.path))
|
|
403
|
+
const now = snapshot(entry.path)
|
|
404
|
+
if (
|
|
405
|
+
!before ||
|
|
406
|
+
before.exists !== now.exists ||
|
|
407
|
+
before.sha256 !== now.sha256 ||
|
|
408
|
+
before.size !== now.size ||
|
|
409
|
+
before.mode !== now.mode
|
|
410
|
+
) {
|
|
411
|
+
throw new Error(`Removal target drifted after review: ${entry.path}`)
|
|
412
|
+
}
|
|
413
|
+
unlinkSync(entry.path)
|
|
414
|
+
}
|
|
415
|
+
verifyPathRemoved(entry.path)
|
|
416
|
+
}
|
|
417
|
+
return true
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Compare the preview's SCORED change count against this call's install
|
|
422
|
+
* summary delta. Returns a descriptor on mismatch, or `null` when they agree.
|
|
423
|
+
*
|
|
424
|
+
* Only the CHANGED count is gated (`create + update + remove` vs
|
|
425
|
+
* `stats.created`). `stats.skipped` deliberately mixes genuine file-unchanged
|
|
426
|
+
* outcomes with logical no-op skips that never touch a file (e.g. the V2 TUI
|
|
427
|
+
* registration skip), so it cannot be reconciled against `plan.scored.unchanged`
|
|
428
|
+
* without making the guard cry wolf. The meaningful invariant is: every change
|
|
429
|
+
* the summary counted must have been recorded — a preview that silently missed
|
|
430
|
+
* a writer is exactly a `stats.created` with no recorded change.
|
|
431
|
+
*
|
|
432
|
+
* Both sides must be scoped to a SINGLE `installOpenCode` call.
|
|
433
|
+
*
|
|
434
|
+
* @param {{ scored: object }} plan
|
|
435
|
+
* @param {{ created: number, skipped: number }} stats - per-call delta
|
|
436
|
+
* @returns {{ changed: number, unchanged: number, recordedChanged: number, recordedUnchanged: number }|null}
|
|
437
|
+
*/
|
|
438
|
+
export function planStatsMismatch(plan, stats) {
|
|
439
|
+
const changed = plan.scored.create + plan.scored.update + plan.scored.remove
|
|
440
|
+
if (changed !== stats.created) {
|
|
441
|
+
return {
|
|
442
|
+
changed,
|
|
443
|
+
unchanged: plan.scored.unchanged,
|
|
444
|
+
recordedChanged: stats.created,
|
|
445
|
+
recordedUnchanged: stats.skipped,
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return null
|
|
449
|
+
}
|