@nanmicoder/dsh-agent-teams 0.1.22-rc.1 → 0.1.23

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.
Files changed (46) hide show
  1. package/README.md +35 -12
  2. package/README_ZH.md +35 -12
  3. package/compatibility.json +6 -10
  4. package/lib/artifacts.js +342 -0
  5. package/lib/capabilities.js +6 -4
  6. package/lib/client/ActivityPanel.js +5 -2
  7. package/lib/client/agent-teams-card-definition.js +100 -6
  8. package/lib/client.js +90 -7
  9. package/lib/client.js.map +1 -1
  10. package/lib/command.js +3 -2
  11. package/lib/git-artifact-stamp.json +2 -2
  12. package/lib/harness-compat.js +70 -21
  13. package/lib/index.js +26 -3
  14. package/lib/lineage.js +198 -0
  15. package/lib/mailbox.js +59 -34
  16. package/lib/members.js +301 -53
  17. package/lib/profiles.js +3 -2
  18. package/lib/quality-gates.js +850 -65
  19. package/lib/scheduler.js +22 -3
  20. package/lib/snapshot.js +22 -1
  21. package/lib/state.js +279 -13
  22. package/lib/tool-policies.js +187 -0
  23. package/lib/tools.js +370 -89
  24. package/lib/types/artifacts.d.ts +66 -0
  25. package/lib/types/client/activity-monitor.d.ts +6 -0
  26. package/lib/types/client/agent-teams-card-definition.d.ts +56 -0
  27. package/lib/types/harness-compat.d.ts +56 -14
  28. package/lib/types/index.d.ts +26 -1
  29. package/lib/types/lineage.d.ts +92 -0
  30. package/lib/types/members.d.ts +112 -1
  31. package/lib/types/profiles.d.ts +6 -0
  32. package/lib/types/quality-gates.d.ts +216 -10
  33. package/lib/types/scheduler.d.ts +2 -1
  34. package/lib/types/snapshot.d.ts +20 -0
  35. package/lib/types/state.d.ts +22 -5
  36. package/lib/types/tool-policies.d.ts +119 -0
  37. package/lib/types/tools.d.ts +12 -1
  38. package/lib/types/types.d.ts +95 -0
  39. package/lib/types/workspace-audit.d.ts +27 -0
  40. package/lib/workspace-audit.js +52 -0
  41. package/package.json +59 -52
  42. package/pnpm-workspace.yaml +273 -273
  43. package/release-notes/v0.1.22.md +18 -0
  44. package/release-notes/v0.1.23.md +34 -0
  45. package/scripts/doctor.mjs +4 -4
  46. package/scripts/legacy-session-source-repair.mjs +344 -0
@@ -0,0 +1,34 @@
1
+ # AgentTeams v0.1.23
2
+
3
+ Stable plugin release on npm `latest`. The recommended host remains DeepSeek Harness `0.2.0-rc.2`; the exact eight-host support matrix is unchanged.
4
+
5
+ ## Changes
6
+
7
+ - Fix Unicode team cards and legacy event folding so member rosters use the same team identity as persisted state (#203).
8
+ - Reject invalid finding line numbers before persistence and normalize recoverable historical values (#188).
9
+ - Resolve member models together with their configured provider, and preserve explicit provider selections (#189).
10
+ - Record member turn failures that arrive after task settlement as durable captain notes (#205).
11
+ - Bind review and verification verdicts to the actual artifact bytes and complete repair lineage; changed files block delivery until covered by a sound verdict (#161).
12
+ - Preserve repair contracts, expose stale source warnings, and create repair/reverification follow-ups for failed verification without deadlocking on failed dependencies (#174, #175).
13
+ - Escalate findings outside the permitted repair scope with a reason instead of creating impossible repair tasks (#183).
14
+ - Preserve captain identity across warm and cold forks, including multihop lineage and member wake-ups (#176).
15
+ - Write session source metadata in the current format and ship an explicit, non-destructive historical recovery utility with source backups (#160).
16
+ - Add owner-configured member tool policies. Members select a policy ID; the four required coordination tools remain available. This controls tool availability and is not an operating-system sandbox (#59).
17
+
18
+ ## Install
19
+
20
+ In Plugins → Add plugin, enter `@nanmicoder/dsh-agent-teams@0.1.23`. For CLI profiles:
21
+
22
+ ```sh
23
+ dsh plugin --profile web add --save-exact @nanmicoder/dsh-agent-teams@0.1.23
24
+ ```
25
+
26
+ Restart the target profile after installation. A global CLI update does not change a desktop application's embedded host.
27
+
28
+ ## Verification scope
29
+
30
+ The source candidate passed typechecking, build, the full project verification suite, and 121 added regression cases. Local macOS arm64 acceptance used the official Harness `0.2.0-rc.2` Loader, real workspaces, and the existing `deepseek-account / deepseek-flash` login without new API keys. It exercised actual member file writes, failed verification followed by repair and independent rechecking, read-only review, cold fork recovery, and content-drift delivery blocking. Integration acceptance included a captain-driven role/contract correction; the original failed observations are retained.
31
+
32
+ Publication requires Ubuntu/Windows static checks and the eight-host runtime matrix against one immutable tarball. CI uses deterministic model fixtures with real host implementations; those checks are separate from the local real-provider acceptance. Real account HTTP 429 responses, packaged Electron, and Windows product execution were not covered by that local acceptance.
33
+
34
+ See [the issue triage and acceptance records](../docs/issue-maintenance-2026-10-10/README.md) for individual evidence and limits. #103's original reproduction already passed on the baseline; no speculative scheduler change is included. Third-party plugin reports and the broader #70/#71 feature requests are outside this release.
@@ -20,8 +20,8 @@ function locatePackage(name, from) {
20
20
  }
21
21
 
22
22
  /** Inspect manifests only; never execute packages or read credentials/configuration. */
23
- export function inspectInstallation(hostRoot, profileRoot) {
24
- const allowed = validatePolicy(policy)
23
+ export function inspectInstallation(hostRoot, profileRoot, support = policy) {
24
+ const allowed = validatePolicy(support)
25
25
  const root = resolve(hostRoot)
26
26
  const own = join(root, 'package.json')
27
27
  const hostPath = existsSync(own) && manifest(own).name === '@deepseek-ai/dsh'
@@ -68,12 +68,12 @@ export function inspectInstallation(hostRoot, profileRoot) {
68
68
  }
69
69
  const mixed = packages.filter(pkg => pkg.version !== host.version)
70
70
  const problems = []
71
- const sourceCandidate = policy.sourceCandidates?.find(candidate => candidate.version === host.version)
71
+ const sourceCandidate = support.sourceCandidates?.find(candidate => candidate.version === host.version)
72
72
  const policyPackage = manifest(new URL('../package.json', import.meta.url))
73
73
  if (plugin && plugin.version !== policyPackage.version) {
74
74
  problems.push(`Installed plugin ${plugin.version} differs from this policy's plugin ${policyPackage.version}; verify its own host compatibility`)
75
75
  }
76
- if (!allowed.includes(host.version) && !sourceCandidate) problems.push(`Unsupported host ${host.version}; recommended target is ${policy.recommendedHost}`)
76
+ if (!allowed.includes(host.version) && !sourceCandidate) problems.push(`Unsupported host ${host.version}; recommended target is ${support.recommendedHost}`)
77
77
  if (mixed.length) problems.push(`${mixed.length} resolved DSH packages differ from host ${host.version}`)
78
78
  if (missing.length) problems.push(`Missing packages: ${[...new Set(missing)].join(', ')}`)
79
79
  const identities = new Map()
@@ -0,0 +1,344 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Repair one historical AgentTeams session generation for #160.
4
+ *
5
+ * The released v2→v3 migration edge audits every message source against a
6
+ * closed list of released kinds, so a stored pre-v3 generation that contains
7
+ * the plugin's own producer kind (`agent-teams-command`) is refused in full by
8
+ * every later Harness (`SessionFormatUnsupportedMigrationError: cannot safely
9
+ * transform unclassified message source`). No plugin can change that read path:
10
+ * the published catalog is assembled from direct imports and runs before any
11
+ * plugin mounts. This tool is the reviewable, non-destructive recovery path:
12
+ *
13
+ * - read-only unless `--apply` is passed;
14
+ * - the stored source generation is never modified: it is copied to a backup
15
+ * beside it (exclusive create, so an existing backup is never overwritten)
16
+ * before anything is published, and its hash is compared before/after;
17
+ * - a generation the installed catalog already migrates is left untouched;
18
+ * - only source kinds outside the released vocabulary are normalized to the
19
+ * released plugin wrapper (`{ kind: 'plugin', plugin: 'dsh-agent-teams' }`,
20
+ * the shape the fixed plugin writes on those hosts). The released wrapper's
21
+ * audit admits exactly those two members, so extra historical source fields
22
+ * (goal, profile, ...) are *not* carried into the repaired generation: they
23
+ * are listed in the report and remain only in the untouched source
24
+ * generation and its byte-identical backup. Message content, ordering and
25
+ * every other row value are preserved;
26
+ * - the artifact is then migrated and validated by the *installed* Harness
27
+ * catalog, re-encoded as the current generation through the installed
28
+ * encoder, verified from its exact bytes, and published exclusively
29
+ * (temporary file + link, never replacing an existing generation).
30
+ *
31
+ * Usage:
32
+ * node scripts/legacy-session-source-repair.mjs --session-dir <dir> [--apply]
33
+ * [--kinds agent-teams-command]
34
+ *
35
+ * Exit codes: 0 success (dry runs report `"applied": false`), 1 refusal or
36
+ * failure (nothing published).
37
+ */
38
+ import { constants as fsConstants, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
39
+ import { createRequire } from 'node:module'
40
+ import { dirname, join, resolve } from 'node:path'
41
+ import { pathToFileURL } from 'node:url'
42
+ import { createHash } from 'node:crypto'
43
+ import { constants as zlibConstants, zstdCompressSync, zstdDecompressSync } from 'node:zlib'
44
+
45
+ /**
46
+ * Resolve one Harness package through the installed host cohort. The format
47
+ * packages are transitive to the exact `@deepseek-ai/dsh-session` this checkout
48
+ * pins, so they are resolved from that package's own dependency scope instead
49
+ * of duplicating a private copy of the format contract.
50
+ */
51
+ function harnessImport(name) {
52
+ const require = createRequire(import.meta.url)
53
+ const sessionDir = dirname(require.resolve('@deepseek-ai/dsh-session/package.json'))
54
+ const nested = createRequire(join(sessionDir, 'package.json'))
55
+ return import(pathToFileURL(nested.resolve(name)).href)
56
+ }
57
+
58
+ const DEFAULT_KINDS = ['agent-teams-command']
59
+ const BACKUP_SUFFIX = '.pre-160-repair.bak'
60
+
61
+ /** Lowercase hex SHA-256 of one file's exact bytes. */
62
+ function sha256(path) {
63
+ return createHash('sha256').update(readFileSync(path)).digest('hex')
64
+ }
65
+
66
+ function fail(message) {
67
+ console.error(`legacy session source repair: ${message}`)
68
+ process.exitCode = 1
69
+ return undefined
70
+ }
71
+
72
+ function parseArguments(argv) {
73
+ const options = { apply: false, kinds: DEFAULT_KINDS.slice(), sessionDir: undefined }
74
+ for (let index = 0; index < argv.length; index += 1) {
75
+ const flag = argv[index]
76
+ if (flag === '--apply') options.apply = true
77
+ else if (flag === '--session-dir') options.sessionDir = argv[++index]
78
+ else if (flag === '--kinds') options.kinds = String(argv[++index] ?? '').split(',').map(value => value.trim()).filter(Boolean)
79
+ else return fail(`unknown argument ${flag}`)
80
+ }
81
+ if (options.sessionDir === undefined || options.sessionDir === '') return fail('--session-dir is required')
82
+ if (options.kinds.length === 0) return fail('--kinds must name at least one source kind')
83
+ return options
84
+ }
85
+
86
+ /** Stored generation files in one session directory, newest generation last. */
87
+ function storedGenerations(sessionDir) {
88
+ if (!existsSync(sessionDir)) return []
89
+ return readdirSync(sessionDir)
90
+ .map(name => {
91
+ const match = name.match(/^session\.v(\d+)\.jsonl(\.zstd)?$/)
92
+ return match === null ? undefined : { name, version: Number(match[1]), zstd: match[2] === '.zstd' }
93
+ })
94
+ .filter(entry => entry !== undefined)
95
+ .sort((left, right) => left.version - right.version)
96
+ }
97
+
98
+ /**
99
+ * Measure one complete Zstandard frame starting at `start` (RFC 8878):
100
+ * magic, frame header descriptor, optional window/dictionary/content-size
101
+ * fields, the block chain, and the optional checksum. Node's zstd APIs decode
102
+ * only the first frame of the store's concatenated container, and searching for
103
+ * the magic bytes would split on compressed payload that happens to contain
104
+ * them, so every frame is measured exactly.
105
+ */
106
+ function zstdFrameLength(buffer, start) {
107
+ // The magic is the byte sequence 28 B5 2F FD, i.e. 0xFD2FB528 little-endian.
108
+ if (start + 5 > buffer.length || buffer.readUInt32LE(start) !== 0xfd2fb528) {
109
+ throw new Error(`corrupt Zstandard session log: invalid frame magic at byte ${start}`)
110
+ }
111
+ const descriptor = buffer[start + 4]
112
+ if (((descriptor >> 3) & 1) !== 0) throw new Error(`corrupt Zstandard session log: reserved frame-header bit at byte ${start}`)
113
+ const contentSizeFlag = descriptor >> 6
114
+ const singleSegment = (descriptor >> 5) & 1
115
+ const checksum = (descriptor >> 2) & 1
116
+ const dictionarySize = [0, 1, 2, 4][descriptor & 3]
117
+ let cursor = start + 5 + (singleSegment === 1 ? 0 : 1) + dictionarySize
118
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment === 1 ? 1 : 0) : [0, 2, 4, 8][contentSizeFlag]
119
+ let contentSize
120
+ if (contentSizeBytes === 1) contentSize = buffer.readUInt8(cursor)
121
+ else if (contentSizeBytes === 2) contentSize = buffer.readUInt16LE(cursor) + 256
122
+ else if (contentSizeBytes === 4) contentSize = buffer.readUInt32LE(cursor)
123
+ else if (contentSizeBytes === 8) contentSize = Number(buffer.readBigUInt64LE(cursor))
124
+ cursor += contentSizeBytes
125
+ for (;;) {
126
+ if (cursor + 3 > buffer.length) throw new Error(`corrupt Zstandard session log: truncated block header at byte ${cursor}`)
127
+ const blockHeader = buffer.readUIntLE(cursor, 3)
128
+ cursor += 3
129
+ const last = blockHeader & 1
130
+ const blockType = (blockHeader >> 1) & 3
131
+ const blockSize = blockHeader >> 3
132
+ if (blockType === 3) throw new Error(`corrupt Zstandard session log: reserved block type at byte ${cursor}`)
133
+ // RFC 8878 §3.1.1.2.2/3: Block_Size counts the block's *content*; an RLE
134
+ // block stores that content as a single repeated byte, so its payload is
135
+ // one byte regardless of Block_Size.
136
+ cursor += blockType === 1 ? 1 : blockSize
137
+ if (last === 1) break
138
+ }
139
+ if (checksum === 1) cursor += 4
140
+ if (cursor > buffer.length) throw new Error('corrupt Zstandard session log: truncated final frame')
141
+ if (contentSize !== undefined && contentSize !== 0) {
142
+ const decoded = zstdDecompressSync(buffer.subarray(start, cursor))
143
+ if (decoded.byteLength !== contentSize) throw new Error('corrupt Zstandard session log: frame content size disagrees with its header')
144
+ }
145
+ return cursor - start
146
+ }
147
+
148
+ /**
149
+ * Decode the store's concatenated-frame container: the first frame holds
150
+ * exactly the header line, later frames hold event batches.
151
+ */
152
+ export function decodeStoredFrames(buffer) {
153
+ let offset = 0
154
+ let text = ''
155
+ while (offset < buffer.length) {
156
+ const length = zstdFrameLength(buffer, offset)
157
+ text += zstdDecompressSync(buffer.subarray(offset, offset + length)).toString('utf8')
158
+ offset += length
159
+ }
160
+ return text
161
+ }
162
+
163
+ /** Physical log text: the first line is the header, the rest are event rows. */
164
+ function readPhysicalLog(path, zstd) {
165
+ const raw = readFileSync(path)
166
+ const text = zstd ? decodeStoredFrames(raw) : raw.toString('utf8')
167
+ const lines = text.split('\n')
168
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
169
+ return { bytes: raw, header: JSON.parse(lines[0]), rows: lines.slice(1).map(line => JSON.parse(line)) }
170
+ }
171
+
172
+ /** Normalize one out-of-vocabulary message source to the released plugin wrapper. */
173
+ function normalizeSources(value, kinds, counter, depth = 0) {
174
+ if (depth > 12 || value === null || typeof value !== 'object') return value
175
+ if (Array.isArray(value)) return value.map(item => normalizeSources(item, kinds, counter, depth + 1))
176
+ const record = { ...value }
177
+ const source = record['source']
178
+ if (source !== null && typeof source === 'object' && !Array.isArray(source)
179
+ && typeof source['kind'] === 'string' && kinds.includes(source['kind'])) {
180
+ // The released wrapper's source audit admits exactly {kind, plugin}, so any
181
+ // extra historical metadata on this source cannot travel into the repaired
182
+ // generation. It is reported instead, and stays readable in the untouched
183
+ // source generation and its byte-identical backup.
184
+ const dropped = Object.keys(source).filter(key => key !== 'kind' && key !== 'plugin')
185
+ record['source'] = { kind: 'plugin', plugin: 'dsh-agent-teams' }
186
+ counter.normalized += 1
187
+ for (const key of dropped) counter.droppedFields.add(key)
188
+ }
189
+ for (const [key, item] of Object.entries(record)) {
190
+ if (key === 'source') continue
191
+ record[key] = normalizeSources(item, kinds, counter, depth + 1)
192
+ }
193
+ return record
194
+ }
195
+
196
+ /** Write the current-generation artifact with the store's checksummed frame container. */
197
+ function encodeFrames(headerLine, rowLines) {
198
+ const frame = text => zstdCompressSync(Buffer.from(text, 'utf8'), { params: { [zlibConstants.ZSTD_c_checksumFlag]: 1 } })
199
+ const frames = [frame(`${headerLine}\n`)]
200
+ for (let index = 0; index < rowLines.length; index += 500) {
201
+ frames.push(frame(`${rowLines.slice(index, index + 500).join('\n')}\n`))
202
+ }
203
+ return Buffer.concat(frames)
204
+ }
205
+
206
+ async function repair(options) {
207
+ const sessionDir = resolve(options.sessionDir)
208
+ const catalogModule = await harnessImport('@deepseek-ai/dsh-session-format-catalog')
209
+ const catalog = catalogModule.sessionFormatCatalog
210
+ const withChildren = catalogModule.createSessionFormatCatalogWithChildren([])
211
+
212
+ const generations = storedGenerations(sessionDir)
213
+ if (generations.length === 0) return fail(`no stored session generation under ${sessionDir}`)
214
+ const source = generations[generations.length - 1]
215
+ if (source.version >= catalog.currentVersion) {
216
+ return fail(`stored generation v${source.version} is already current (v${catalog.currentVersion}); nothing to repair`)
217
+ }
218
+ const publishedName = `session.v${catalog.currentVersion}.jsonl${source.zstd ? '.zstd' : ''}`
219
+ const publishedPath = join(sessionDir, publishedName)
220
+ if (existsSync(publishedPath)) return fail(`${publishedName} already exists; refusing to overwrite it`)
221
+
222
+ const { header, rows } = readPhysicalLog(join(sessionDir, source.name), source.zstd)
223
+
224
+ // Only logs the installed catalog cannot migrate are touched. A generation
225
+ // whose sources are already readable (v3 producer kinds included) is a no-op
226
+ // so this tool never rewrites unrelated provenance.
227
+ const readableAsIs = (() => {
228
+ try {
229
+ const restore = withChildren.createRestore(header, { recovery: 'strict', validation: 'current' })
230
+ for (const row of rows) restore.decodeRow(row)
231
+ restore.finish()
232
+ return true
233
+ } catch {
234
+ return false
235
+ }
236
+ })()
237
+ if (readableAsIs) {
238
+ return {
239
+ sessionDir,
240
+ sourceGeneration: source.version,
241
+ sourceFile: source.name,
242
+ sourceBytes: readFileSync(join(sessionDir, source.name)).byteLength,
243
+ targetGeneration: catalog.currentVersion,
244
+ applied: false,
245
+ skipped: 'already-readable',
246
+ }
247
+ }
248
+
249
+ const counter = { normalized: 0, droppedFields: new Set() }
250
+ const normalizedRows = rows.map(row => normalizeSources(row, options.kinds, counter))
251
+ if (counter.normalized === 0) {
252
+ return fail(`${source.name} is refused by the installed Harness but carries none of ${options.kinds.join(', ')}; nothing this tool may repair`)
253
+ }
254
+
255
+ // Migrate and validate with the installed catalog: the whole artifact must be
256
+ // accepted before anything is published.
257
+ let artifact
258
+ try {
259
+ const restore = withChildren.createRestore(header, { recovery: 'strict', validation: 'current' })
260
+ for (const row of normalizedRows) restore.decodeRow(row)
261
+ artifact = restore.finish()
262
+ } catch (error) {
263
+ return fail(`the installed Harness still refuses ${source.name} after source normalization: ${error?.constructor?.name}: ${error?.message}`)
264
+ }
265
+
266
+ const currentHeader = catalog.encodeCurrentHeader(artifact.header, artifact.inheritedEventCount)
267
+ const currentRows = artifact.events.map(event => catalog.encodeCurrentEvent(event))
268
+ const report = {
269
+ sessionDir,
270
+ sourceGeneration: source.version,
271
+ sourceFile: source.name,
272
+ sourceBytes: readFileSync(join(sessionDir, source.name)).byteLength,
273
+ normalizedSources: counter.normalized,
274
+ normalizedDroppedSourceFields: [...counter.droppedFields].sort(),
275
+ events: artifact.events.length,
276
+ targetGeneration: catalog.currentVersion,
277
+ published: publishedName,
278
+ applied: false,
279
+ }
280
+ if (!options.apply) return report
281
+
282
+ const sourcePath = join(sessionDir, source.name)
283
+ const sourceHashBefore = sha256(sourcePath)
284
+ const backupPath = join(sessionDir, `${source.name}${BACKUP_SUFFIX}`)
285
+ // Exclusive copy (`COPYFILE_EXCL`): an existing backup is never overwritten,
286
+ // and a racing second run fails here instead of silently replacing it.
287
+ try {
288
+ copyFileSync(sourcePath, backupPath, fsConstants.COPYFILE_EXCL)
289
+ } catch (error) {
290
+ if (error?.code === 'EEXIST') return fail(`${backupPath} already exists; refusing to overwrite a backup`)
291
+ throw error
292
+ }
293
+ report.backup = `${source.name}${BACKUP_SUFFIX}`
294
+
295
+ const encoded = encodeFrames(JSON.stringify(currentHeader), currentRows.map(row => JSON.stringify(row)))
296
+ // Read the exact bytes back before publishing: a repair that cannot be read
297
+ // again must not replace a diagnosable state with an unreadable one.
298
+ const verify = (() => {
299
+ const text = decodeStoredFrames(encoded)
300
+ const lines = text.split('\n').filter(line => line !== '')
301
+ const restored = catalog.createRestore(JSON.parse(lines[0]), { recovery: 'strict', validation: 'current' })
302
+ for (const line of lines.slice(1)) restored.decodeRow(JSON.parse(line))
303
+ return restored.finish()
304
+ })()
305
+ if (verify.events.length !== artifact.events.length) {
306
+ return fail(`re-encoded artifact lost events (${verify.events.length} of ${artifact.events.length}); nothing published`)
307
+ }
308
+ // Publish exclusively: the complete artifact is first written to a temporary
309
+ // file in the same directory, verified there, and only then linked to its
310
+ // final name — a half-written current generation can never be visible to the
311
+ // host, and an existing one is never replaced.
312
+ const temporaryPath = join(sessionDir, `${publishedName}.tmp-${process.pid}`)
313
+ // Exclusive create: residue from a crashed run is never overwritten.
314
+ try {
315
+ writeFileSync(temporaryPath, encoded, { flag: 'wx' })
316
+ } catch (error) {
317
+ if (error?.code === 'EEXIST') return fail(`${temporaryPath} exists from an earlier interrupted run; remove it and retry`)
318
+ throw error
319
+ }
320
+ try {
321
+ linkSync(temporaryPath, publishedPath)
322
+ } catch (error) {
323
+ rmSync(temporaryPath, { force: true })
324
+ if (error?.code === 'EEXIST') return fail(`${publishedName} appeared while repairing; refusing to overwrite it`)
325
+ throw error
326
+ }
327
+ rmSync(temporaryPath, { force: true })
328
+ report.applied = true
329
+ report.publishedBytes = encoded.byteLength
330
+ report.sourceUnchanged = sha256(sourcePath) === sourceHashBefore
331
+ report.backupUnchanged = sha256(backupPath) === sourceHashBefore
332
+ return report
333
+ }
334
+
335
+ // CLI body: skipped when this module is imported for its container decoder.
336
+ const invokedDirectly = process.argv[1] !== undefined
337
+ && pathToFileURL(process.argv[1]).href === import.meta.url
338
+ if (invokedDirectly) {
339
+ const options = parseArguments(process.argv.slice(2))
340
+ if (options !== undefined) {
341
+ const report = await repair(options)
342
+ if (report !== undefined) console.log(JSON.stringify(report, null, 2))
343
+ }
344
+ }