dsh-openwrite 0.2.27 → 0.2.33

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 (130) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +55 -6
  3. package/THIRD_PARTY_NOTICES.md +4 -3
  4. package/companions.mjs +508 -0
  5. package/cordis.patch.yml +0 -2
  6. package/docs/CLOSED_ISSUE_FOLLOWUPS.md +5 -3
  7. package/docs/COMPANION_PLUGINS.md +142 -0
  8. package/docs/CONTEXT_SLOTS.md +60 -0
  9. package/docs/COST_MEASUREMENT.md +2 -2
  10. package/docs/DEVELOPMENT.md +6 -11
  11. package/docs/DSH_STORE.md +14 -2
  12. package/docs/EDITOR_SAFETY_REPAIRS.md +17 -0
  13. package/docs/INSTALL.md +112 -4
  14. package/docs/ISSUES_121_123.md +97 -0
  15. package/docs/ISSUES_140_142.md +58 -0
  16. package/docs/ISSUES_149_150_151.md +77 -0
  17. package/docs/ISSUES_98_100.md +2 -0
  18. package/docs/ISSUE_123_FOLLOWUP.md +55 -0
  19. package/docs/ISSUE_146_REVIEW_EVIDENCE.md +53 -0
  20. package/docs/LEARNING.md +1 -1
  21. package/docs/MOBILE_READINESS.md +62 -0
  22. package/docs/PLUGIN_MAINTENANCE.md +1 -3
  23. package/docs/RELEASE_ACCEPTANCE.md +90 -3
  24. package/docs/REVIEW_ROUND_22.md +67 -0
  25. package/docs/REVIEW_ROUND_23.md +48 -0
  26. package/docs/REVIEW_ROUND_25.md +51 -0
  27. package/docs/SELECTION_UPGRADE_REPAIRS.md +16 -0
  28. package/docs/TOOLS.md +933 -0
  29. package/docs/WORKFLOWS.md +5 -5
  30. package/package.json +18 -21
  31. package/packages/openwrite-bridge/lib/client.d.ts +0 -1
  32. package/packages/openwrite-bridge/lib/client.js +90 -9
  33. package/packages/openwrite-bridge/lib/contracts-generated.js +2 -1
  34. package/packages/openwrite-bridge/lib/dog-delivery.d.ts +1 -0
  35. package/packages/openwrite-bridge/lib/dog-delivery.js +10 -4
  36. package/packages/openwrite-bridge/lib/dog-review.js +15 -3
  37. package/packages/openwrite-bridge/lib/domain.d.ts +27 -0
  38. package/packages/openwrite-bridge/lib/domain.js +250 -29
  39. package/packages/openwrite-bridge/lib/host-llm.d.ts +188 -0
  40. package/packages/openwrite-bridge/lib/host-llm.js +657 -0
  41. package/packages/openwrite-bridge/lib/host-web.d.ts +61 -0
  42. package/packages/openwrite-bridge/lib/host-web.js +166 -0
  43. package/packages/openwrite-bridge/lib/index.d.ts +4 -0
  44. package/packages/openwrite-bridge/lib/index.js +150 -6
  45. package/packages/openwrite-bridge/lib/managed-runtime.d.ts +47 -2
  46. package/packages/openwrite-bridge/lib/managed-runtime.js +179 -35
  47. package/packages/openwrite-bridge/lib/tool-policy.js +39 -12
  48. package/packages/openwrite-bridge/lib/tools.js +1238 -1219
  49. package/packages/openwrite-bridge/package.json +7 -5
  50. package/packages/studio-panel/lib/client.js +116667 -114773
  51. package/packages/studio-panel/lib/client.js.map +1 -1
  52. package/packages/studio-panel/lib/types/client/LaunchOpenWrite.d.ts +10 -0
  53. package/packages/studio-panel/lib/types/client/ModelView.d.ts +28 -4
  54. package/packages/studio-panel/lib/types/client/OpenWriteSettings.d.ts +64 -0
  55. package/packages/studio-panel/lib/types/client/SceneWorkbench.d.ts +2 -0
  56. package/packages/studio-panel/lib/types/client/SearchSettings.d.ts +2 -0
  57. package/packages/studio-panel/lib/types/client/SerialWritingView.d.ts +2 -1
  58. package/packages/studio-panel/lib/types/client/TurnMutationSummary.d.ts +2 -0
  59. package/packages/studio-panel/lib/types/client/VditorBody.d.ts +8 -2
  60. package/packages/studio-panel/lib/types/client/api.d.ts +41 -2
  61. package/packages/studio-panel/lib/types/client/browser-download.d.ts +4 -0
  62. package/packages/studio-panel/lib/types/client/companions.d.ts +46 -0
  63. package/packages/studio-panel/lib/types/client/dto.d.ts +22 -0
  64. package/packages/studio-panel/lib/types/client/locales.d.ts +1 -1
  65. package/packages/studio-panel/lib/types/client/manuscript-annotations.d.ts +2 -1
  66. package/packages/studio-panel/lib/types/client/manuscript-overlay.d.ts +2 -1
  67. package/packages/studio-panel/lib/types/client/manuscript-selection.d.ts +18 -5
  68. package/packages/studio-panel/lib/types/client/selection-coordinates.d.ts +15 -0
  69. package/packages/studio-panel/lib/types/client/workspace-chrome.d.ts +41 -0
  70. package/packages/studio-panel/package.json +5 -3
  71. package/plugin.mjs +159 -20
  72. package/preset-definition.mjs +144 -4
  73. package/presets/openwrite/agent.cordis.yml +7 -9
  74. package/presets/openwrite/ops-integration.mjs +28 -0
  75. package/presets/openwrite/preset.yml +2 -1
  76. package/presets/openwrite/skills/canon/SKILL.md +3 -3
  77. package/presets/openwrite/skills/delivery-artifact-query/SKILL.md +41 -0
  78. package/presets/openwrite/skills/dialoguequality/SKILL.md +16 -17
  79. package/presets/openwrite/skills/dog-delivery-verification/SKILL.md +113 -0
  80. package/presets/openwrite/skills/export-book/SKILL.md +3 -3
  81. package/presets/openwrite/skills/foreshadow/SKILL.md +1 -1
  82. package/presets/openwrite/skills/foreshadowing-system/SKILL.md +16 -25
  83. package/presets/openwrite/skills/import-artifact-query/SKILL.md +30 -0
  84. package/presets/openwrite/skills/learn/SKILL.md +3 -3
  85. package/presets/openwrite/skills/novel-creator/SKILL.md +10 -10
  86. package/presets/openwrite/skills/novel-reviewer/SKILL.md +11 -11
  87. package/presets/openwrite/skills/progress/SKILL.md +1 -1
  88. package/presets/openwrite/skills/review-artifact-query/SKILL.md +41 -0
  89. package/presets/openwrite/skills/review-chapter/SKILL.md +2 -2
  90. package/presets/openwrite/skills/revise-span/SKILL.md +5 -5
  91. package/presets/openwrite/skills/style-system/SKILL.md +13 -6
  92. package/presets/openwrite/skills/truth-validation/SKILL.md +9 -12
  93. package/presets/openwrite/skills/workflow-manager/SKILL.md +13 -13
  94. package/presets/openwrite/skills/world-query/SKILL.md +16 -11
  95. package/presets/openwrite/skills/write-next/SKILL.md +1 -1
  96. package/release/{openwrite-5.8.16-py3-none-any.whl → openwrite-5.8.30-py3-none-any.whl} +0 -0
  97. package/release/runtime-manifest.json +4 -12
  98. package/release/source.json +21 -21
  99. package/release/wheels/licenses/OPENSSL-3.6.3-LICENSE.txt +225 -0
  100. package/scripts/maintenance.mjs +67 -7
  101. package/scripts/plugin-doctor.mjs +179 -0
  102. package/scripts/prepare.mjs +11 -2
  103. package/scripts/safe-upgrade.mjs +141 -13
  104. package/dog-tools.mjs +0 -11
  105. package/presets/openwrite/skills/dog-delivery-query/SKILL.md +0 -42
  106. package/presets/openwrite/skills/dog-import-query/SKILL.md +0 -29
  107. package/presets/openwrite/skills/dog-review-query/SKILL.md +0 -42
  108. package/scripts/dog/delivery-stage.js +0 -32
  109. package/scripts/dog/import-chapter.js +0 -20
  110. package/scripts/dog/import-record.js +0 -35
  111. package/scripts/dog/review-dimension.js +0 -41
  112. package/scripts/dog/review-record.js +0 -42
  113. package/vendor/dsh-dog/LICENSE +0 -29
  114. package/vendor/dsh-dog/README.md +0 -114
  115. package/vendor/dsh-dog/UPSTREAM.md +0 -30
  116. package/vendor/dsh-dog/cordis.patch.yml +0 -14
  117. package/vendor/dsh-dog/lib/client.js +0 -2960
  118. package/vendor/dsh-dog/lib/client.js.map +0 -1
  119. package/vendor/dsh-dog/lib/core-BAlgZpLq.js +0 -2074
  120. package/vendor/dsh-dog/lib/core-BiEBMLz3.d.ts +0 -495
  121. package/vendor/dsh-dog/lib/core.d.ts +0 -2
  122. package/vendor/dsh-dog/lib/core.js +0 -2
  123. package/vendor/dsh-dog/lib/index.d.ts +0 -143
  124. package/vendor/dsh-dog/lib/index.js +0 -1279
  125. package/vendor/dsh-dog/package.json +0 -110
  126. package/vendor/dsh-dog/schemas/schema-0.2/graph.schema.json +0 -139
  127. package/vendor/dsh-dog/schemas/schema-0.2/report.schema.json +0 -87
  128. package/vendor/dsh-dog/schemas/schema-0.2/run.schema.json +0 -259
  129. package/vendor/dsh-dog/schemas/schema-0.2/runtime-event.schema.json +0 -105
  130. package/vendor/dsh-dog/schemas/schema-0.2/verification.schema.json +0 -40
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+ // Offline maintenance checks. Never reads credentials, starts a model, or edits profiles.
3
+ import { readFile, realpath } from 'node:fs/promises'
4
+ import { execFileSync } from 'node:child_process'
5
+ import { fileURLToPath, pathToFileURL } from 'node:url'
6
+ import { isAbsolute, join, resolve } from 'node:path'
7
+ import { homedir } from 'node:os'
8
+ import { load } from 'js-yaml'
9
+ import { inspectProfile } from './maintenance.mjs'
10
+ import { detectCompanions } from '../companions.mjs'
11
+
12
+ const root = fileURLToPath(new URL('../', import.meta.url))
13
+ const packageDirs = ['.', 'packages/openwrite-bridge', 'packages/studio-panel']
14
+ const isDsh = name => /^@deepseek-ai\/dsh(?:-|$)/.test(name)
15
+ const lockName = path => path.match(/(?:^|\/)node_modules\/(@deepseek-ai\/[^/]+)$/)?.[1]
16
+ const readJson = async path => JSON.parse(await readFile(path, 'utf8'))
17
+ const exactVersion = value => /^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(value ?? '')
18
+
19
+ export function auditVersions(manifest, lock, baseline, graph = {}) {
20
+ const errors = []
21
+ if (!exactVersion(baseline)) errors.push('DSH baseline must be an exact version')
22
+ for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
23
+ const declared = manifest[field] ?? {}
24
+ const locked = lock.packages?.['']?.[field] ?? {}
25
+ for (const name of new Set([...Object.keys(declared), ...Object.keys(locked)])) {
26
+ if (declared[name] !== locked[name]) errors.push(`lock metadata differs: ${field}.${name}`)
27
+ // Build dependencies remain pinned; public peers may enumerate verified hosts.
28
+ const peerVersions = typeof declared[name] === 'string' ? declared[name].split(' || ') : []
29
+ const verified = manifest.dsh?.compatibility?.dshReleases ?? {}
30
+ const compatiblePeer = field === 'peerDependencies' && peerVersions.length > 0
31
+ && peerVersions.includes(graph[name] ?? baseline)
32
+ && peerVersions.every(value => exactVersion(value)
33
+ && (value === (graph[name] ?? baseline) || verified[value] === 'compatible'))
34
+ if (isDsh(name) && declared[name] !== undefined && declared[name] !== (graph[name] ?? baseline) && !compatiblePeer) {
35
+ errors.push(`unpinned DSH dependency: ${name}=${declared[name]}`)
36
+ }
37
+ if (isDsh(name) && declared[name] !== undefined
38
+ && (field !== 'peerDependencies' || !manifest.peerDependenciesMeta?.[name]?.optional)
39
+ && !lock.packages?.[`node_modules/${name}`]) {
40
+ errors.push(`required SDK missing from lock: ${name}`)
41
+ }
42
+ }
43
+ }
44
+ for (const [path, entry] of Object.entries(lock.packages ?? {})) {
45
+ const name = lockName(path)
46
+ if (name && isDsh(name) && entry.version !== (graph[name] ?? baseline)) {
47
+ errors.push(`DSH lock drift: ${name}=${entry.version}, expected ${baseline}`)
48
+ }
49
+ if (name === '@deepseek-ai/cordis' && entry.version !== (graph[name] ?? '4.0.2')) {
50
+ errors.push(`Cordis lock drift: ${entry.version}, expected ${graph[name] ?? '4.0.2'}`)
51
+ }
52
+ }
53
+ if (!lock.packages?.['']) errors.push('missing lockfile root metadata')
54
+ return errors
55
+ }
56
+
57
+ const exportPaths = value => typeof value === 'string' ? [value]
58
+ : value && typeof value === 'object' ? Object.values(value).flatMap(exportPaths) : []
59
+ const packagePath = path => path?.replace(/^\.\//, '')
60
+
61
+ export function auditBundle(manifest, files, patch) {
62
+ const errors = []
63
+ const available = new Set(files)
64
+ const required = [manifest.main, manifest.types, manifest.dsh?.bundle?.patch,
65
+ ...exportPaths(manifest.exports)]
66
+ if (!manifest.dsh?.bundle?.patch) errors.push('missing dsh.bundle.patch')
67
+ if (!manifest.main || !manifest.types) errors.push('missing host JS/types entry')
68
+ for (const path of required.filter(Boolean)) {
69
+ if (isAbsolute(path) || path.split(/[\\/]/).includes('..') || path.includes('\\')) {
70
+ errors.push(`entry escapes package: ${path}`)
71
+ } else if (!available.has(packagePath(path))) errors.push(`entry missing from tarball: ${path}`)
72
+ }
73
+ const rows = Array.isArray(patch) ? patch.flatMap(op => op?.insert ?? []) : []
74
+ if (rows.filter(row => row.name === manifest.name).length !== 1) {
75
+ errors.push('bundle patch must mount its host package exactly once')
76
+ }
77
+ const ids = rows.map(row => row.id)
78
+ if (ids.some(id => typeof id !== 'string' || !id) || new Set(ids).size !== ids.length) {
79
+ errors.push('bundle patch has missing/duplicate row ids')
80
+ }
81
+ if (manifest.dsh?.client || manifest.exports?.['./client']) {
82
+ if (manifest.dsh?.client?.platform !== 'web' || !manifest.exports?.['./client']) {
83
+ errors.push('web client needs platform=web and ./client export')
84
+ }
85
+ // The host route serves these on demand; a source-tree-only build can mask omissions.
86
+ for (const path of ['LICENSE', 'dist/index.css', 'dist/index.min.js',
87
+ 'dist/css/content-theme/dark.css', 'dist/css/content-theme/light.css',
88
+ 'dist/js/lute/lute.min.js', 'dist/js/icons/ant.js', 'dist/js/i18n/zh_CN.js']) {
89
+ if (!available.has(`vendor/vditor/${path}`)) errors.push(`editor asset missing from tarball: ${path}`)
90
+ }
91
+ }
92
+ return errors
93
+ }
94
+
95
+ async function checkProfiles(report) {
96
+ const home = process.env.DSH_HOME ?? join(process.env.HOME ?? '', '.dsh')
97
+ for (const profile of ['web', 'headless']) {
98
+ const dir = join(home, 'profiles', profile)
99
+ await report(`profile ${profile}`, async () => {
100
+ const manifest = await readJson(join(dir, 'package.json'))
101
+ const name = 'dsh-openwrite'
102
+ if (manifest.dsh?.profile?.bundles?.filter(n => n === name).length !== 1) throw new Error('standard bundle not installed exactly once')
103
+ const installed = await realpath(join(dir, 'node_modules', name))
104
+ const pkg = await readJson(join(installed, 'package.json'))
105
+ if (pkg.name !== name || !pkg.dsh?.bundle?.patch) throw new Error('invalid installed bundle')
106
+ await readFile(join(installed, 'release/runtime-manifest.json'))
107
+ const leftover = inspectProfile(manifest).leftover
108
+ if (leftover.length) throw new Error(`legacy packages still in bundles or dependencies (${leftover.join(', ')}); run openwrite-maintenance migrate --apply with dsh stopped`)
109
+ })
110
+ }
111
+ }
112
+
113
+ export async function doctor({ profiles = false } = {}) {
114
+ let failures = 0
115
+ const report = async (label, check) => {
116
+ try { await check(); console.log(`PASS ${label}`) }
117
+ catch (error) { failures++; console.error(`FAIL ${label}: ${error.message}`) }
118
+ }
119
+ const assertClean = errors => { if (errors.length) throw new Error(errors.slice(0, 8).join('; ') + (errors.length > 8 ? `; +${errors.length - 8} more` : '')) }
120
+ const rootManifest = await readJson(join(root, 'package.json'))
121
+ const compatibility = await readJson(join(root, 'release/sdk-compatibility.json'))
122
+ const baseline = rootManifest.devDependencies?.['@deepseek-ai/dsh']
123
+ console.log(`dsh-Openwrite doctor — DSH ${baseline}, Cordis 4.0.2 (offline)`)
124
+ await report('Node >= 22.19.0', () => {
125
+ const [major, minor] = process.versions.node.split('.').map(Number)
126
+ if (major < 22 || (major === 22 && minor < 19)) throw new Error(`found ${process.versions.node}`)
127
+ })
128
+ for (const relative of packageDirs) {
129
+ const dir = resolve(root, relative)
130
+ const manifest = await readJson(join(dir, 'package.json'))
131
+ const lock = await readJson(join(dir, 'package-lock.json'))
132
+ await report(`${relative}: manifest + lock compatibility`, () => assertClean(auditVersions(manifest, lock, baseline, compatibility.packages)))
133
+ await report(`${relative}: installed SDK matches lock`, async () => {
134
+ const errors = []
135
+ for (const [path, entry] of Object.entries(lock.packages ?? {})) {
136
+ const name = lockName(path)
137
+ if (!name || (!isDsh(name) && name !== '@deepseek-ai/cordis')) continue
138
+ try {
139
+ const installed = await readJson(join(dir, path, 'package.json'))
140
+ if (installed.version !== entry.version) errors.push(`${name}=${installed.version}, lock=${entry.version}`)
141
+ } catch { errors.push(`missing ${name}; run npm ci in ${relative}`) }
142
+ }
143
+ assertClean(errors)
144
+ })
145
+ if (relative === '.') continue
146
+ await report(`${manifest.name}: packed entries + patch + assets`, async () => {
147
+ const packed = JSON.parse(execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'],
148
+ { cwd: dir, encoding: 'utf8', timeout: 30000, stdio: ['ignore', 'pipe', 'pipe'] }))
149
+ const patch = load(await readFile(join(dir, manifest.dsh.bundle.patch), 'utf8'))
150
+ assertClean(auditBundle(manifest, packed[0].files.map(file => file.path), patch))
151
+ const host = await import(pathToFileURL(join(dir, manifest.main)).href)
152
+ if (host.name !== manifest.name || typeof host.apply !== 'function') throw new Error('host must export name + apply')
153
+ })
154
+ }
155
+ if (profiles) await checkProfiles(report)
156
+ await report('companion plugins', async () => {
157
+ const home = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh')
158
+ const companions = await detectCompanions(home)
159
+ const lines = []
160
+ if (companions.dog.installed) lines.push(`dsh-dog ${companions.dog.usable ? 'active' : `inactive (${companions.dog.reason})`}`)
161
+ if (companions.ops.installed) lines.push(`dsh-ops ${companions.ops.usable ? 'active' : `inactive (${companions.ops.reason})`}`)
162
+ if (companions.freeModel.installed) lines.push(`dsh-our-free-model ${companions.freeModel.usable ? 'active' : `inactive (${companions.freeModel.reason})`}`)
163
+ if (companions.host === null && (companions.dog.installed || companions.ops.installed || companions.freeModel.installed)) {
164
+ throw new Error(`host version unresolved; companion decisions stay fail-closed (${lines.join(', ')})`)
165
+ }
166
+ console.log(` ${lines.join(', ') || 'none installed'}`)
167
+ })
168
+ console.log(failures ? `${failures} check(s) failed.` : 'All checks passed. No model or live-service call was made.')
169
+ return failures ? 1 : 0
170
+ }
171
+
172
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
173
+ const unknown = process.argv.slice(2).filter(arg => arg !== '--profiles')
174
+ if (unknown.length) { console.error('Usage: node scripts/plugin-doctor.mjs [--profiles]'); process.exitCode = 2 }
175
+ else {
176
+ try { process.exitCode = await doctor({ profiles: process.argv.includes('--profiles') }) }
177
+ catch (error) { console.error(`FAIL ${error.message}`); process.exitCode = 1 }
178
+ }
179
+ }
@@ -7,7 +7,7 @@ const root = fileURLToPath(new URL('../', import.meta.url))
7
7
  const manifest = JSON.parse(await readFile(new URL('../release/runtime-manifest.json', import.meta.url), 'utf8'))
8
8
  let built = false
9
9
  await access(new URL('../release/' + manifest.wheel.file, import.meta.url))
10
- for (const dir of ['packages/openwrite-bridge', 'packages/studio-panel', 'vendor/dsh-dog']) {
10
+ for (const dir of ['packages/openwrite-bridge', 'packages/studio-panel']) {
11
11
  try { await access(new URL(`../${dir}/lib/index.js`, import.meta.url)); await access(new URL(`../${dir}/lib/${dir.endsWith('openwrite-bridge') ? 'preset-tools.js' : 'client.js'}`, import.meta.url)); continue } catch {}
12
12
  built = true
13
13
  for (const args of [['ci', '--ignore-scripts', '--no-audit', '--no-fund'], ['run', 'build']]) {
@@ -15,7 +15,16 @@ for (const dir of ['packages/openwrite-bridge', 'packages/studio-panel', 'vendor
15
15
  if (result.status !== 0) throw new Error(`${dir}: ${args.join(' ')} failed`)
16
16
  }
17
17
  }
18
- if (built) {
18
+ // Provenance must be refreshed whenever the source tree no longer matches the
19
+ // recorded hash, not merely when this run happened to build. Gating on `built`
20
+ // alone means "edit src/, then pack without rebuilding" publishes a tarball
21
+ // whose release/source.json still names the previous tree — and that file is
22
+ // gitignored, so release-report.mjs's dirty check cannot see it either.
23
+ let provenanceStale = true
24
+ try {
25
+ provenanceStale = !JSON.parse(await readFile(new URL('../release/source.json', import.meta.url), 'utf8')).sourceTreeSha256
26
+ } catch { /* missing or unreadable: regenerate */ }
27
+ if (built || provenanceStale) {
19
28
  const result = spawnSync(process.execPath, [fileURLToPath(new URL('./release-provenance.mjs', import.meta.url))], { cwd: root, stdio: 'inherit' })
20
29
  if (result.status !== 0) throw new Error('Release provenance generation failed')
21
30
  }
@@ -1,16 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
  // Offline profile upgrades retain the previous complete dependency tree until commit.
3
- import { cp, mkdir, readFile, readdir, rename, writeFile, open, access, readlink, symlink, unlink } from 'node:fs/promises'
3
+ import { cp, mkdir, readFile, readdir, rename, writeFile, open, access, readlink, symlink, unlink, realpath, lstat, rmdir, rm, utimes } from 'node:fs/promises'
4
4
  import { spawn, spawnSync } from 'node:child_process'
5
5
  import { join, resolve, relative, isAbsolute, dirname, sep } from 'node:path'
6
6
  import { randomUUID, createHash } from 'node:crypto'
7
7
  import { homedir } from 'node:os'
8
8
  import { pathToFileURL } from 'node:url'
9
- import { lock as acquireLock } from 'proper-lockfile'
10
- import yaml from 'js-yaml'
9
+ import { createRequire } from 'node:module'
11
10
 
12
11
  const PROFILE = /^[a-zA-Z0-9_-]+$/
13
12
  const digest = bytes => createHash('sha256').update(bytes).digest('hex')
13
+ function upgradeDependencies(home, profile) {
14
+ // Release archives do not contain node_modules. Resolve the same dependencies
15
+ // from the explicitly selected installed profile, without loading its plugin.
16
+ const resolvers = [createRequire(import.meta.url), createRequire(join(profileDirectory(home, profile), 'package.json'))]
17
+ const load = name => {
18
+ for (const require of resolvers) {
19
+ try { return require(name) } catch (error) {
20
+ if (error.code !== 'MODULE_NOT_FOUND') throw error
21
+ }
22
+ }
23
+ throw new Error(`UPGRADE_DEPENDENCY_MISSING: ${name}; run this tool from an installed OpenWrite package or repair dependencies in the selected profile first`)
24
+ }
25
+ return { acquireLock: load('proper-lockfile').lock, yaml: load('js-yaml') }
26
+ }
14
27
  async function exists(path) { try { await access(path); return true } catch (error) { if (error.code === 'ENOENT') return false; throw error } }
15
28
  async function save(path, value) {
16
29
  const pending = path + '.pending-' + randomUUID()
@@ -18,7 +31,70 @@ async function save(path, value) {
18
31
  await rename(pending, path)
19
32
  }
20
33
 
21
- async function relocateProfileLinks(staging, previous, replacementRoot = staging) {
34
+ // Recovery cannot load dependencies through a profile which is between renames.
35
+ // Use proper-lockfile's same canonical <profiles>.lock directory and fresh mtime
36
+ // protocol, without importing any code from the missing or backup profile.
37
+ export async function acquireRecoveryLock(home) {
38
+ const path = (await realpath(join(resolve(home), 'profiles'))) + '.lock'
39
+ const locked = () => Object.assign(new Error('ELOCKED: another profile operation holds the upgrade lock'), { code: 'ELOCKED' })
40
+ const same = (a, b) => a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs
41
+ let owner
42
+ for (let attempt = 0; attempt < 3; attempt++) {
43
+ try { await mkdir(path, { mode: 0o700 }); owner = await lstat(path); break }
44
+ catch (error) {
45
+ if (error.code !== 'EEXIST') throw error
46
+ let observed
47
+ try { observed = await lstat(path) } catch (cause) { if (cause.code === 'ENOENT') continue; throw cause }
48
+ if (!observed.isDirectory() || Date.now() - observed.mtimeMs <= 30_000) throw locked()
49
+ const current = await lstat(path)
50
+ if (!same(observed, current) || observed.mtimeMs !== current.mtimeMs) throw locked()
51
+ try { await rmdir(path) } catch (cause) { if (cause.code !== 'ENOENT') throw locked() }
52
+ }
53
+ }
54
+ if (!owner) throw locked()
55
+ let pending = Promise.resolve(), compromised
56
+ const assertOwned = async () => {
57
+ if (compromised) throw compromised
58
+ const current = await lstat(path)
59
+ if (!same(owner, current)) throw locked()
60
+ }
61
+ const timer = setInterval(() => {
62
+ pending = pending.then(async () => {
63
+ await assertOwned()
64
+ const now = new Date()
65
+ await utimes(path, now, now)
66
+ }).catch(error => { compromised = error })
67
+ }, 1000)
68
+ timer.unref()
69
+ return {
70
+ async assertOwned() { await pending; await assertOwned() },
71
+ async release() {
72
+ clearInterval(timer)
73
+ await pending
74
+ await assertOwned()
75
+ await rmdir(path)
76
+ },
77
+ }
78
+ }
79
+
80
+ async function pinArchive(home, archive) {
81
+ const bytes = await readFile(archive)
82
+ const sha256 = digest(bytes)
83
+ const cache = join(resolve(home), 'openwrite', 'packages')
84
+ await mkdir(cache, { recursive: true, mode: 0o700 })
85
+ const destination = join(cache, sha256 + '.tgz')
86
+ if (await exists(destination) && digest(await readFile(destination)) === sha256) return { path: destination, sha256 }
87
+ const pending = join(cache, '.' + sha256 + '-' + randomUUID() + '.pending')
88
+ try {
89
+ const handle = await open(pending, 'wx', 0o600)
90
+ try { await handle.writeFile(bytes); await handle.sync() } finally { await handle.close() }
91
+ if (digest(await readFile(pending)) !== sha256) throw new Error('PACKAGE_CACHE_CONFLICT: temporary archive verification failed')
92
+ await rename(pending, destination)
93
+ return { path: destination, sha256 }
94
+ } finally { try { await unlink(pending) } catch (error) { if (error.code !== 'ENOENT') throw error } }
95
+ }
96
+
97
+ async function relocateProfileLinks(staging, previous, replacementRoot, yaml) {
22
98
  const visit = async directory => {
23
99
  for (const entry of await readdir(directory, { withFileTypes: true })) {
24
100
  const path = join(directory, entry.name)
@@ -87,6 +163,16 @@ async function runInstall(home, profile, spec, log) {
87
163
  } finally { clearTimeout(timer); clearTimeout(drainTimer); clearTimeout(escalation); await writes }
88
164
  }
89
165
 
166
+ /**
167
+ * `.plugin-manager` is host-owned state, not ours: it must never be staged, or a
168
+ * POSIX upgrade would commit a snapshot of it that later upgrades then restore.
169
+ * Compared on a normalized relative path so the check is separator-agnostic —
170
+ * `path.join` yields `\` on win32, which a literal '/' test can never match.
171
+ */
172
+ function isHostManagedState(relativePath) {
173
+ return relativePath === '.plugin-manager' || relativePath.startsWith(`.plugin-manager${sep}`)
174
+ }
175
+
90
176
  function profileDirectory(home, profile) {
91
177
  if (!PROFILE.test(profile)) throw new Error('Invalid profile name')
92
178
  return join(resolve(home), 'profiles', profile)
@@ -99,10 +185,15 @@ export async function recoverUpgrade(home, operationId, options = {}) {
99
185
  const operation = JSON.parse(await readFile(join(root, 'operation.json'), 'utf8'))
100
186
  const target = profileDirectory(home, operation.profile)
101
187
  await (options.assertStopped || assertHostStopped)(home, operation.profile)
102
- const previous = join(root, 'previous-profile')
103
- const release = await acquireLock(join(home, 'profiles'), { realpath: true, retries: 0 })
188
+
189
+ const previous = join(root, 'previous-profile')
190
+ const lease = await acquireRecoveryLock(home)
104
191
  try {
105
192
  if (!await exists(target) && await exists(previous)) {
193
+ if (!operation.old_manifest_sha256 || digest(await readFile(join(previous, 'package.json'))) !== operation.old_manifest_sha256) {
194
+ throw new Error('PROFILE_CHANGED: backup manifest differs from the approved operation; no profile restored')
195
+ }
196
+ await lease.assertOwned()
106
197
  await rename(previous, target)
107
198
  operation.status = 'rolled_back'
108
199
  } else if (await exists(target)) {
@@ -111,17 +202,22 @@ export async function recoverUpgrade(home, operationId, options = {}) {
111
202
  else if (actual === operation.old_manifest_sha256) operation.status = 'rolled_back'
112
203
  else throw new Error('PROFILE_CHANGED: preserve the backup and reconcile the profile manually')
113
204
  } else throw new Error('RECOVERY_REQUIRED: neither active nor previous profile is available')
205
+ await lease.assertOwned()
114
206
  await save(join(root, 'operation.json'), operation)
115
207
  return { operation_id: operationId, status: operation.status, backup: previous }
116
- } finally { await release() }
208
+ } finally { await lease.release() }
117
209
  }
118
210
 
119
211
  /** Stage a complete profile beside the active tree, validate, then swap while stopped. */
120
212
  export async function upgradeProfile(home, profile, spec, options = {}) {
121
213
  if (typeof spec !== 'string' || !spec || spec.startsWith('-') || /[\r\n]/.test(spec)) throw new Error('A package spec or Release tarball is required')
214
+ // A bare add can preserve a profile's old exact dependency/lock selection
215
+ // even after npm latest moved (#142). An upgrade must request a new version.
216
+ if (spec === 'dsh-openwrite') spec = 'dsh-openwrite@latest'
122
217
  const target = profileDirectory(home, profile)
123
218
  const stopped = options.assertStopped || assertHostStopped
124
219
  await stopped(home, profile)
220
+ const { acquireLock, yaml } = upgradeDependencies(home, profile)
125
221
  const release = await acquireLock(join(home, 'profiles'), { realpath: true, retries: 0 })
126
222
  const id = 'upgrade_' + randomUUID()
127
223
  const root = join(resolve(home), 'openwrite', 'upgrades', id)
@@ -140,9 +236,21 @@ export async function upgradeProfile(home, profile, spec, options = {}) {
140
236
  operation.old_manifest_sha256 = digest(oldManifest)
141
237
  await save(join(root, 'operation.json'), operation)
142
238
  await cp(target, staging, { recursive: true, dereference: false, verbatimSymlinks: true,
143
- filter: source => !source.includes('/.plugin-manager/') && !source.endsWith('/.plugin-manager') })
144
- await relocateProfileLinks(staging, target)
145
- await (options.run || runInstall)(home, stagingName, spec, log)
239
+ filter: source => !isHostManagedState(relative(target, source)) })
240
+ await relocateProfileLinks(staging, target, staging, yaml)
241
+ // A downloaded tarball may live in a disposable directory. Pin its bytes
242
+ // below DSH_HOME before the installer writes file: references into locks.
243
+ let installSpec = spec
244
+ const localPath = spec.startsWith('file:') ? spec.slice(5) : spec
245
+ if (/\.(?:tgz|tar\.gz)$/i.test(localPath) && await exists(resolve(localPath))) {
246
+ const archive = resolve(localPath)
247
+ const pinned = await pinArchive(home, archive)
248
+ installSpec = pinned.path
249
+ operation.install_spec = installSpec
250
+ operation.package_sha256 = pinned.sha256
251
+ await save(join(root, 'operation.json'), operation)
252
+ }
253
+ await (options.run || runInstall)(home, stagingName, installSpec, log)
146
254
  const installed = JSON.parse(await readFile(join(staging, 'node_modules', 'dsh-openwrite', 'package.json'), 'utf8'))
147
255
  if (installed.name !== 'dsh-openwrite' || !installed.version || !installed.dsh?.bundle?.patch) throw new Error('BUNDLE_INCOMPLETE: staged OpenWrite manifest is invalid')
148
256
  const bundleRoot = join(staging, 'node_modules', 'dsh-openwrite')
@@ -152,7 +260,7 @@ export async function upgradeProfile(home, profile, spec, options = {}) {
152
260
  operation.new_manifest_sha256 = digest(await readFile(join(staging, 'package.json')))
153
261
  if (digest(await readFile(join(target, 'package.json'))) !== operation.old_manifest_sha256) throw new Error('PROFILE_CHANGED: the active profile changed during staging')
154
262
  await stopped(home, profile)
155
- await relocateProfileLinks(staging, staging, target)
263
+ await relocateProfileLinks(staging, staging, target, yaml)
156
264
  operation.status = 'swapping'
157
265
  await save(join(root, 'operation.json'), operation)
158
266
  await rename(target, previous)
@@ -172,7 +280,16 @@ export async function upgradeProfile(home, profile, spec, options = {}) {
172
280
  await save(join(root, 'operation.json'), operation)
173
281
  error.message += `; operation=${id}; log=${logPath}`
174
282
  throw error
175
- } finally { try { await log?.close() } finally { await release() } }
283
+ } finally {
284
+ try { await log?.close() } finally {
285
+ // The staging directory is a full profile copy including node_modules, so
286
+ // every failed upgrade used to leave hundreds of MB behind with nothing to
287
+ // reclaim it. On success it has been renamed onto the target, so removing
288
+ // it here is a no-op; on any failure path it is reclaimed.
289
+ try { await rm(staging, { recursive: true, force: true }) } catch { /* best effort */ }
290
+ await release()
291
+ }
292
+ }
176
293
  }
177
294
 
178
295
  /** Disable only an incomplete OpenWrite bundle so a stopped profile can boot for repair. */
@@ -187,6 +304,7 @@ export async function repairProfile(home, profile, apply = false, options = {})
187
304
  const enabled = manifest.dsh?.profile?.bundles?.includes('dsh-openwrite') === true
188
305
  if (!apply || !broken || !enabled) return { profile, incomplete: broken, enabled, changed: false }
189
306
  await (options.assertStopped || assertHostStopped)(home, profile)
307
+ const { acquireLock } = upgradeDependencies(home, profile)
190
308
  const release = await acquireLock(join(home, 'profiles'), { realpath: true, retries: 0 })
191
309
  try {
192
310
  if (await readFile(manifestPath, 'utf8') !== original) throw new Error('PROFILE_CHANGED: refresh repair inspection')
@@ -202,7 +320,17 @@ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1]
202
320
  const value = name => { const i = process.argv.indexOf(name); return i < 0 ? undefined : process.argv[i + 1] }
203
321
  const home = resolve(process.env.DSH_HOME || join(homedir(), '.dsh'))
204
322
  try {
205
- const profile = value('--profile') || 'web'
323
+ // `--profile` as the last argument yields undefined, and `|| 'web'` then
324
+ // silently operates on `web` and exits 0 - the same bug fixed in
325
+ // maintenance.mjs. A trailing flag is a usage error, not a default.
326
+ const profileIndex = process.argv.indexOf('--profile')
327
+ if (profileIndex >= 0) {
328
+ const named = process.argv[profileIndex + 1]
329
+ if (named === undefined || named === '') {
330
+ throw new Error('Usage: openwrite-safe-upgrade [--upgrade|--repair|--recover <id>] --profile <name> [--apply] [--package <path>]')
331
+ }
332
+ }
333
+ const profile = profileIndex < 0 ? 'web' : process.argv[profileIndex + 1]
206
334
  const result = value('--recover') ? await recoverUpgrade(home, value('--recover'))
207
335
  : process.argv.includes('--repair') ? await repairProfile(home, profile, process.argv.includes('--apply'))
208
336
  : await upgradeProfile(home, profile, value('--package'))
package/dog-tools.mjs DELETED
@@ -1,11 +0,0 @@
1
- export const name = '@dsh-novel/openwrite-dog-tools'
2
- export const inject = ['tools', 'openwriteDog']
3
- export function apply(ctx) {
4
- for (const tool of ctx.openwriteDog.tools) ctx.effect(() => ctx.tools.register(tool))
5
- ctx.effect(() => ctx.tools.guard(execution => {
6
- if (!execution.name.startsWith('dog_') || ['dog_status', 'dog_wait', 'dog_validate'].includes(execution.name)) return
7
- if (!execution.agent) return 'DoG requires an Agent session'
8
- if (execution.agent.ctx.get('planMode')?.get(execution.agent).active) return 'DoG execution is disabled in plan mode'
9
- if (!ctx.get('sandboxPolicy') || ctx.get('sandboxPolicy').resolve({ session: execution.agent.session }).mode === 'read-only') return 'DoG execution is disabled in read-only mode'
10
- }))
11
- }
@@ -1,42 +0,0 @@
1
- ---
2
- name: dog-delivery-query
3
- description: Query a chapter delivery DoG graph across manuscript, review, revision application, and re-review closure. Use after writing or reviewing a chapter, after revision actions, or when the user asks whether a chapter is truly ready to deliver.
4
- user-invocable: false
5
- ---
6
-
7
- Language: follow the author's current conversation language for responses and help; preserve the manuscript language. Translate these instructions when presenting them.
8
-
9
- # 章节交付 DoG 查询
10
-
11
- 章节写作、评审和修订工具会生成:
12
-
13
- `data/novels/{id}/data/dog/deliveries/{chapter}/dog-graph.json`
14
-
15
- 这张图查询六个阶段:`writing → review → revision → application → rereview → closure`。
16
- 它不替代 OpenWrite 的写章、六域累加评审或修订服务。
17
-
18
- ## 查询流程
19
-
20
- 1. 从最近一次 `novel_write_chapter`、`novel_review_chapter`、`novel_revision_*`
21
- 或 `novel_task_get` 结果的 `dog_delivery.graphPath` 找到图;conductor 输出使用
22
- `dog_delivery_graph_path`。
23
- 2. 依次调用 `dog_validate`、`dog_create`、`dog_run`、`dog_wait`,等待终态。
24
- 3. 用 `dog_status` 读取 `review`、`revision` 和 `closure` evidence,再决定领域动作。
25
- 4. 修订应用后必须重新调用 `novel_review_chapter`;只有复评对应当前正文 SHA 且门禁通过,
26
- `closure` 才能成为 `closed`。
27
-
28
- ## 状态解释
29
-
30
- - `review.missing`:正文存在但尚未评审。
31
- - `closure.review_failed`:当前正文已评审但未通过门禁,进入修订闭环。
32
- - `revision.proposal_pending`:提案已生成,等待作者确认应用或驳回。
33
- - `revision.applied_requires_rereview`:修订已写入正文,但不能视为问题已解决。
34
- - `closure.rereview_required`:必须对修订后的正文重新跑六域评审。
35
- - `closure.closed`:当前正文的最新评审通过,可以交付。
36
-
37
- ## 约束
38
-
39
- - DoG 只查询 canonical manuscript/review/revision 文件,不直接修改它们。
40
- - 不因提案状态为 `applied` 就宣称问题关闭;必须以当前正文 SHA 对应的复评为准。
41
- - `needs_human` 是等待评审、确认或复评,不得描述成成功。
42
- - 应用或驳回修订仍遵守 OpenWrite 创作 Agent 的用户确认边界。
@@ -1,29 +0,0 @@
1
- ---
2
- name: dog-import-query
3
- description: Query a smart-import DoG graph after a novel has been split into chapters. Use to inspect import completeness, AI precheck errors, and pending outline/asset/canonical setup.
4
- user-invocable: false
5
- ---
6
-
7
- Language: follow the author's current conversation language for responses and help; preserve the manuscript language. Translate these instructions when presenting them.
8
-
9
- # 拆书导入 DoG 验收
10
-
11
- `conductor/smart_import.py` 完成导入后会生成 `dog-graph.json`。这张图只验收导入
12
- 结果,不负责写入章节、资产或大纲。
13
-
14
- ## 工作流
15
-
16
- 1. 从导入输出中找到 `data/novels/{id}/data/dog/imports/{IMPORT_ID}/dog-graph.json`。
17
- 2. 依次调用 `dog_validate`、`dog_create`、`dog_run`、`dog_wait`,直到终态。
18
- 3. 用 `dog_status` 查看 `manifest` 和每个 `chapter-ch_*` 节点的 evidence。
19
- 4. 根节点通过后,读取 manifest 的 `construction.nextActions`,再调用规划和资产工具建立大纲、角色、世界观、进度和正典事件。
20
- 5. 资产/大纲建立完成后重新运行 `novel_review_chapter`,再用 `dog-review-query`
21
- 查询六域评审和 37 个 legacy leaf。
22
-
23
- ## 约束
24
-
25
- - DoG 失败只说明导入文件缺失、为空或 manifest 不一致;不要直接修改正文。
26
- - `status=partial|failed` 表示导入中途出错;先读取 manifest 的 `error` 和已完成的章节,再修复或重试导入。
27
- - `aiCheck.status=failed` 要如实报告,不能当成“无问题”。
28
- - `construction` 中的 `pending` 是后续建模待办,不是导入失败。
29
- - 不要让 DoG verifier 再调用 Studio 导入接口或重新读取原始整本小说。
@@ -1,42 +0,0 @@
1
- ---
2
- name: dog-review-query
3
- description: Query an existing hierarchical OpenWrite review-v2 artifact through dsh-dog. Use after novel_review_chapter or when inspecting six quality domains, evidence, coverage, blockers, delivery status, or the 37 legacy checks. Never rerun model review from DoG.
4
- user-invocable: false
5
- ---
6
-
7
- Language: follow the author's current conversation language for responses and help; preserve the manuscript language. Translate these instructions when presenting them.
8
-
9
- # 分层评审 DoG 查询
10
-
11
- 这个 Skill 只查询 OpenWrite 已物化的 review-v2 artifact。OpenWrite 的
12
- `novel_review_chapter` / conductor 是评审真源;DoG 读取 47 节点分层图:上下文、
13
- 六个质量域、硬门禁、确定性聚合和 37 个兼容查询 leaf。DoG 不调用评审模型。
14
-
15
- ## 工作流
16
-
17
- 1. 如果本轮还没有评审结果,先按 OpenWrite 写作流程调用 `novel_review_chapter`。
18
- 2. 找到评审输出目录中的 `dog-graph.json`。graph 与 `review.json`、`context.json`、
19
- `domain-*.json`、`gate.json`、`aggregate.json` 和 `dim_01.json` …
20
- `dim_37.json` 在同一目录。
21
- 3. 读取 graph 后依次调用 `dog_validate`、`dog_create`、`dog_run`、`dog_wait`。
22
- 所有节点均使用 programmatic `review-record` verifier;`dog_run` 后必须等到终态。
23
- 4. 用 `dog_status` 分别汇报 `qualityScore`、`coverage`、`gateStatus` 和
24
- `deliveryStatus`,再按需展开域、criterion、evidence、issue 和 legacy ID。
25
- `inconclusive` 表示覆盖不足、未请求或 artifact 不可判定,不能描述为通过。
26
- 5. 只有 OpenWrite 的审查问题才能进入 `novel_revisions_*` 回炉闭环。应用修订后
27
- 必须复评,再用 `dog-delivery-query` 确认 `closure.closed`。DoG 的 `failure`
28
- 只负责定位问题,不直接改正文或资产。
29
-
30
- ## 状态约定
31
-
32
- - `qualityScore` 是有可定位正文证据的正向累加分;issue 不直接扣分。
33
- - `coverage` 单独表达实际完成的评审范围;`inconclusive` 会降低覆盖率。
34
- - `gateStatus=blocked` 来自 hard-gate 或 critical finding,不改写质量分。
35
- - `deliveryStatus` 综合质量、覆盖、门禁和正文 SHA 新鲜度;它不是 `passed` 的别名。
36
- - 37 个 leaf 是 `legacy_check_ids` 查询入口,不是 37 个顶层计分维度。
37
-
38
- ## 边界
39
-
40
- - 不要用 DoG verifier 直接调用 Studio 写入接口。
41
- - 不要把 warning 自动升级成失败;先展示 evidence,由 `review_gate` 和作者决定。
42
- - 不要用 `dog_run` 生成、补写或刷新评审;artifact 缺失或 stale 时回到 OpenWrite 流程。
@@ -1,32 +0,0 @@
1
- #!/usr/bin/env node
2
- // dsh-dog verifier for one materialized chapter-delivery stage.
3
-
4
- import { readFile } from 'node:fs/promises'
5
-
6
- const inputPath = process.argv[2]
7
- if (!inputPath) {
8
- process.stdout.write(JSON.stringify({ verdict: 'inconclusive', evidence: { error: 'missing input path' } }))
9
- process.exit(0)
10
- }
11
-
12
- try {
13
- const value = JSON.parse(await readFile(inputPath, 'utf8'))
14
- if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('delivery stage must be an object')
15
- if (!['delivery-stage', 'chapter-delivery'].includes(value.recordType)) throw new Error('record is not a delivery artifact')
16
- if (!['pass', 'fail', 'inconclusive'].includes(value.verdict)) throw new Error('delivery stage has an invalid verdict')
17
- process.stdout.write(JSON.stringify({
18
- verdict: value.verdict,
19
- evidence: {
20
- chapterId: value.chapterId,
21
- stage: value.stage,
22
- status: value.status ?? (value.readyForDelivery ? 'ready' : 'not_ready'),
23
- readyForDelivery: value.readyForDelivery,
24
- ...(value.evidence !== null && typeof value.evidence === 'object' ? value.evidence : {}),
25
- },
26
- }))
27
- } catch (error) {
28
- process.stdout.write(JSON.stringify({
29
- verdict: 'inconclusive',
30
- evidence: { error: error instanceof Error ? error.message : String(error) },
31
- }))
32
- }
@@ -1,20 +0,0 @@
1
- #!/usr/bin/env node
2
- // dsh-dog verifier for one imported manuscript chapter.
3
-
4
- import { readFile } from 'node:fs/promises'
5
-
6
- const inputPath = process.argv[2]
7
- if (!inputPath) {
8
- process.stdout.write(JSON.stringify({ verdict: 'inconclusive', evidence: { error: 'missing input path' } }))
9
- process.exit(0)
10
- }
11
-
12
- try {
13
- const text = await readFile(inputPath, 'utf8')
14
- if (!text.trim()) throw new Error('chapter file is empty')
15
- const heading = text.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? ''
16
- if (!heading) throw new Error('chapter file has no markdown title')
17
- process.stdout.write(JSON.stringify({ verdict: 'pass', evidence: { heading, bytes: Buffer.byteLength(text) } }))
18
- } catch (error) {
19
- process.stdout.write(JSON.stringify({ verdict: 'fail', evidence: { error: error instanceof Error ? error.message : String(error) } }))
20
- }