dsh-openwrite 0.2.28 → 0.2.34

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 (125) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +49 -8
  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 +1 -1
  7. package/docs/COMPANION_PLUGINS.md +142 -0
  8. package/docs/CONTEXT_SLOTS.md +1 -1
  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 +2 -2
  13. package/docs/INSTALL.md +112 -4
  14. package/docs/ISSUES_121_123.md +4 -4
  15. package/docs/ISSUES_140_142.md +58 -0
  16. package/docs/ISSUES_149_150_151.md +77 -0
  17. package/docs/ISSUE_123_FOLLOWUP.md +3 -2
  18. package/docs/ISSUE_146_REVIEW_EVIDENCE.md +53 -0
  19. package/docs/LEARNING.md +1 -1
  20. package/docs/MOBILE_READINESS.md +62 -0
  21. package/docs/PLUGIN_MAINTENANCE.md +1 -3
  22. package/docs/RELEASE_ACCEPTANCE.md +20 -3
  23. package/docs/REVIEW_ROUND_22.md +67 -0
  24. package/docs/REVIEW_ROUND_23.md +48 -0
  25. package/docs/REVIEW_ROUND_25.md +51 -0
  26. package/docs/SELECTION_UPGRADE_REPAIRS.md +2 -2
  27. package/docs/TOOLS.md +181 -604
  28. package/docs/WORKFLOWS.md +5 -5
  29. package/package.json +17 -21
  30. package/packages/openwrite-bridge/lib/client.d.ts +0 -1
  31. package/packages/openwrite-bridge/lib/client.js +90 -9
  32. package/packages/openwrite-bridge/lib/contracts-generated.js +2 -1
  33. package/packages/openwrite-bridge/lib/dog-delivery.d.ts +1 -0
  34. package/packages/openwrite-bridge/lib/dog-delivery.js +3 -1
  35. package/packages/openwrite-bridge/lib/dog-review.js +15 -3
  36. package/packages/openwrite-bridge/lib/domain.d.ts +27 -0
  37. package/packages/openwrite-bridge/lib/domain.js +250 -29
  38. package/packages/openwrite-bridge/lib/host-llm.d.ts +188 -0
  39. package/packages/openwrite-bridge/lib/host-llm.js +657 -0
  40. package/packages/openwrite-bridge/lib/host-web.d.ts +61 -0
  41. package/packages/openwrite-bridge/lib/host-web.js +166 -0
  42. package/packages/openwrite-bridge/lib/index.d.ts +4 -0
  43. package/packages/openwrite-bridge/lib/index.js +150 -6
  44. package/packages/openwrite-bridge/lib/managed-runtime.d.ts +47 -2
  45. package/packages/openwrite-bridge/lib/managed-runtime.js +176 -34
  46. package/packages/openwrite-bridge/lib/tool-policy.js +37 -12
  47. package/packages/openwrite-bridge/lib/tools.js +1223 -1227
  48. package/packages/openwrite-bridge/package.json +7 -5
  49. package/packages/studio-panel/lib/client.js +123207 -121534
  50. package/packages/studio-panel/lib/client.js.map +1 -1
  51. package/packages/studio-panel/lib/types/client/LaunchOpenWrite.d.ts +10 -0
  52. package/packages/studio-panel/lib/types/client/ModelView.d.ts +28 -4
  53. package/packages/studio-panel/lib/types/client/OpenWriteSettings.d.ts +64 -0
  54. package/packages/studio-panel/lib/types/client/SceneWorkbench.d.ts +2 -0
  55. package/packages/studio-panel/lib/types/client/SearchSettings.d.ts +2 -0
  56. package/packages/studio-panel/lib/types/client/SerialWritingView.d.ts +2 -1
  57. package/packages/studio-panel/lib/types/client/TurnMutationSummary.d.ts +2 -0
  58. package/packages/studio-panel/lib/types/client/VditorBody.d.ts +1 -1
  59. package/packages/studio-panel/lib/types/client/api.d.ts +41 -2
  60. package/packages/studio-panel/lib/types/client/browser-download.d.ts +4 -0
  61. package/packages/studio-panel/lib/types/client/companions.d.ts +46 -0
  62. package/packages/studio-panel/lib/types/client/dto.d.ts +22 -0
  63. package/packages/studio-panel/lib/types/client/locales.d.ts +1 -1
  64. package/packages/studio-panel/lib/types/client/workspace-chrome.d.ts +41 -0
  65. package/packages/studio-panel/package.json +5 -3
  66. package/plugin.mjs +159 -20
  67. package/preset-definition.mjs +144 -4
  68. package/presets/openwrite/agent.cordis.yml +7 -9
  69. package/presets/openwrite/ops-integration.mjs +28 -0
  70. package/presets/openwrite/preset.yml +2 -1
  71. package/presets/openwrite/skills/canon/SKILL.md +3 -3
  72. package/presets/openwrite/skills/delivery-artifact-query/SKILL.md +41 -0
  73. package/presets/openwrite/skills/dialoguequality/SKILL.md +16 -17
  74. package/presets/openwrite/skills/dog-delivery-verification/SKILL.md +113 -0
  75. package/presets/openwrite/skills/export-book/SKILL.md +3 -3
  76. package/presets/openwrite/skills/foreshadow/SKILL.md +1 -1
  77. package/presets/openwrite/skills/foreshadowing-system/SKILL.md +16 -25
  78. package/presets/openwrite/skills/import-artifact-query/SKILL.md +30 -0
  79. package/presets/openwrite/skills/learn/SKILL.md +3 -3
  80. package/presets/openwrite/skills/novel-creator/SKILL.md +10 -10
  81. package/presets/openwrite/skills/novel-reviewer/SKILL.md +11 -11
  82. package/presets/openwrite/skills/progress/SKILL.md +1 -1
  83. package/presets/openwrite/skills/review-artifact-query/SKILL.md +41 -0
  84. package/presets/openwrite/skills/review-chapter/SKILL.md +2 -2
  85. package/presets/openwrite/skills/revise-span/SKILL.md +5 -5
  86. package/presets/openwrite/skills/style-system/SKILL.md +13 -6
  87. package/presets/openwrite/skills/truth-validation/SKILL.md +9 -12
  88. package/presets/openwrite/skills/workflow-manager/SKILL.md +13 -13
  89. package/presets/openwrite/skills/world-query/SKILL.md +16 -11
  90. package/presets/openwrite/skills/write-next/SKILL.md +1 -1
  91. package/release/{openwrite-5.8.20-py3-none-any.whl → openwrite-5.8.30-py3-none-any.whl} +0 -0
  92. package/release/runtime-manifest.json +4 -13
  93. package/release/source.json +21 -21
  94. package/release/wheels/licenses/OPENSSL-3.6.3-LICENSE.txt +225 -0
  95. package/scripts/maintenance.mjs +67 -7
  96. package/scripts/plugin-doctor.mjs +179 -0
  97. package/scripts/prepare.mjs +26 -4
  98. package/scripts/safe-upgrade.mjs +38 -5
  99. package/dog-tools.mjs +0 -13
  100. package/presets/openwrite/skills/dog-delivery-query/SKILL.md +0 -42
  101. package/presets/openwrite/skills/dog-import-query/SKILL.md +0 -29
  102. package/presets/openwrite/skills/dog-review-query/SKILL.md +0 -42
  103. package/scripts/dog/delivery-stage.js +0 -32
  104. package/scripts/dog/import-chapter.js +0 -20
  105. package/scripts/dog/import-record.js +0 -35
  106. package/scripts/dog/review-dimension.js +0 -41
  107. package/scripts/dog/review-record.js +0 -42
  108. package/vendor/dsh-dog/LICENSE +0 -29
  109. package/vendor/dsh-dog/README.md +0 -114
  110. package/vendor/dsh-dog/UPSTREAM.md +0 -30
  111. package/vendor/dsh-dog/cordis.patch.yml +0 -14
  112. package/vendor/dsh-dog/lib/client.js +0 -2960
  113. package/vendor/dsh-dog/lib/client.js.map +0 -1
  114. package/vendor/dsh-dog/lib/core--byUPJL8.js +0 -2191
  115. package/vendor/dsh-dog/lib/core-BiEBMLz3.d.ts +0 -495
  116. package/vendor/dsh-dog/lib/core.d.ts +0 -2
  117. package/vendor/dsh-dog/lib/core.js +0 -2
  118. package/vendor/dsh-dog/lib/index.d.ts +0 -143
  119. package/vendor/dsh-dog/lib/index.js +0 -1230
  120. package/vendor/dsh-dog/package.json +0 -110
  121. package/vendor/dsh-dog/schemas/schema-0.2/graph.schema.json +0 -139
  122. package/vendor/dsh-dog/schemas/schema-0.2/report.schema.json +0 -87
  123. package/vendor/dsh-dog/schemas/schema-0.2/run.schema.json +0 -259
  124. package/vendor/dsh-dog/schemas/schema-0.2/runtime-event.schema.json +0 -105
  125. package/vendor/dsh-dog/schemas/schema-0.2/verification.schema.json +0 -40
package/plugin.mjs CHANGED
@@ -3,23 +3,122 @@ import { createHash, randomUUID } from 'node:crypto'
3
3
  import { lock } from 'proper-lockfile'
4
4
  import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
5
5
  import { join } from 'node:path'
6
- import { fileURLToPath } from 'node:url'
7
- import { legacyPresetIds, presetDefinition } from './preset-definition.mjs'
6
+ import { fileURLToPath, pathToFileURL } from 'node:url'
7
+ import { createRequire } from 'node:module'
8
+ import { legacyPresetIds, legacyAliasName, presetDefinition, referencedPresetIds } from './preset-definition.mjs'
9
+ import { detectCompanions, OPENWRITE_OPS_OPTIONS, summarizeOpsPlan } from './companions.mjs'
8
10
 
9
11
  export const name = 'dsh-openwrite'
10
12
 
11
- async function digestTree(dir) {
12
- const hash = createHash('sha256')
13
- async function visit(path, prefix = '') {
14
- for (const entry of (await readdir(path, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
15
- if (entry.name === '.openwrite-managed.json') continue
16
- const key = prefix + entry.name
17
- hash.update(key)
18
- if (entry.isDirectory()) await visit(join(path, entry.name), key + '/')
19
- else hash.update(await readFile(join(path, entry.name)))
13
+ /**
14
+ * Which companion plugins are installed and loadable here, for the workbench's
15
+ * companion section. Best-effort by design: an unreadable home reports nothing
16
+ * rather than failing the route.
17
+ */
18
+ async function loadOpsResolver(home, entry) {
19
+ // The companion may live in a profile whose node_modules is not on this
20
+ // plugin's resolution path (dsh-openwrite can be a symlinked dev install), so
21
+ // resolve from the profile that actually owns dsh-ops first.
22
+ const candidates = []
23
+ if (typeof entry.profile === 'string' && entry.profile !== '') {
24
+ try {
25
+ const fromProfile = createRequire(join(home, 'profiles', entry.profile, 'package.json'))
26
+ candidates.push(fromProfile.resolve('dsh-ops/integration'))
27
+ } catch { /* fall through to the normal resolution path */ }
28
+ }
29
+ candidates.push('dsh-ops/integration')
30
+ for (const candidate of candidates) {
31
+ try {
32
+ const module = await import(candidate.startsWith('/') ? pathToFileURL(candidate).href : candidate)
33
+ if (typeof module.resolveIntegrationConfig === 'function') return module.resolveIntegrationConfig
34
+ } catch { /* try the next candidate */ }
35
+ }
36
+ return null
37
+ }
38
+
39
+ async function opsPlan(home, entry) {
40
+ if (!entry.usable) return null
41
+ try {
42
+ // Read-only normalization: the same options the preset row uses. A dsh-ops
43
+ // release without this export (or one that changed the option names) is
44
+ // reported as no plan rather than failing the companion inventory.
45
+ const resolveIntegrationConfig = await loadOpsResolver(home, entry)
46
+ if (resolveIntegrationConfig === null) return null
47
+ return summarizeOpsPlan(resolveIntegrationConfig({ ...OPENWRITE_OPS_OPTIONS }))
48
+ } catch { return null }
49
+ }
50
+
51
+ export async function companionSummary(home) {
52
+ try {
53
+ const companions = await detectCompanions(home)
54
+ const describe = entry => ({ installed: entry.installed, usable: entry.usable, version: entry.version, reason: entry.reason })
55
+ return {
56
+ host: companions.host,
57
+ dog: describe(companions.dog),
58
+ ops: { ...describe(companions.ops), plan: await opsPlan(home, companions.ops) },
59
+ freeModel: describe(companions.freeModel),
20
60
  }
61
+ } catch { return null }
62
+ }
63
+
64
+ /**
65
+ * The host's own default-model selection, read from the service the host
66
+ * exposes to plugins (`ctx.agentDefaultModel`).
67
+ *
68
+ * The API key is deliberately NOT part of this answer. The selection is a fact
69
+ * the author already sees in the host's own settings; the key is a secret that
70
+ * must never travel to a browser, and the host keeps it in its credentials
71
+ * service rather than in this selection at all.
72
+ *
73
+ * A host that does not compose the service, or one whose selection is not
74
+ * configured yet, answers `available: false` instead of throwing: the settings
75
+ * page shows "not configured", which is true, rather than failing to open.
76
+ */
77
+ function hostModelSelection(ctx) {
78
+ const empty = { available: false, provider: '', model: '', reasoningEffort: '' }
79
+ let service
80
+ try { service = ctx.get('agentDefaultModel') } catch { return empty }
81
+ if (service === undefined || service === null) return empty
82
+ let selection
83
+ try { selection = service.currentSelection() } catch { return empty }
84
+ const provider = typeof selection?.provider === 'string' ? selection.provider : ''
85
+ const model = typeof selection?.model === 'string' ? selection.model : ''
86
+ const reasoningEffort = typeof selection?.reasoningEffort === 'string' ? selection.reasoningEffort : ''
87
+ // A selection with no provider or no model names no model: report it as
88
+ // unconfigured rather than as a selection of two empty strings.
89
+ return { available: provider !== '' && model !== '', provider, model, reasoningEffort }
90
+ }
91
+
92
+ /** Every non-hidden file of a tree, keyed by its path relative to the root. */
93
+ async function listFiles(dir, prefix = '', found = new Map()) {
94
+ for (const entry of (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
95
+ // Editor and OS noise is not an author edit: a `.DS_Store` must not make the
96
+ // next activation refuse to start.
97
+ if (entry.name.startsWith('.')) continue
98
+ const key = prefix + entry.name
99
+ const path = join(dir, entry.name)
100
+ if (entry.isDirectory()) await listFiles(path, key + '/', found)
101
+ else found.set(key, path)
102
+ }
103
+ return found
104
+ }
105
+
106
+ /**
107
+ * Digest of a managed preset directory, over the files the package ships only.
108
+ * A foreign file (the author's note, a skill they added, a dangling symlink) is
109
+ * not a modification: counting it would make one stray file brick the plugin.
110
+ */
111
+ async function digestShipped(source, destination) {
112
+ const shipped = await listFiles(source)
113
+ const present = await listFiles(destination)
114
+ const hash = createHash('sha256')
115
+ for (const key of [...shipped.keys()].sort()) {
116
+ const path = present.get(key)
117
+ if (path === undefined) { hash.update(key + '\u0000missing'); continue }
118
+ hash.update(key)
119
+ try { hash.update(await readFile(path)) }
120
+ catch (error) { if (error.code !== 'ENOENT') hash.update(key + '\u0000unreadable') }
21
121
  }
22
- await visit(dir)
23
122
  return hash.digest('hex')
24
123
  }
25
124
 
@@ -39,12 +138,13 @@ export async function apply(ctx) {
39
138
  const release = await lock(leases, { realpath: false, stale: 30_000, retries: { retries: 30, minTimeout: 100, maxTimeout: 500 } })
40
139
  try { return await operation() } finally { await release() }
41
140
  }
42
- const expected = await digestTree(source)
43
141
  const marker = join(destination, '.openwrite-managed.json')
44
142
  await withLock(async () => {
45
143
  const existing = await readFile(marker, 'utf8').then(JSON.parse).catch(() => null)
46
- if (existing && (existing.source !== expected || existing.tree !== await digestTree(destination))) {
47
- throw new Error(`OpenWrite preset ${id} was modified; copy it to a custom preset before reinstalling this version`)
144
+ if (existing) {
145
+ if (existing.source !== await digestShipped(source, destination)) {
146
+ throw new Error(`OpenWrite preset ${id} was modified; copy it to a custom preset before reinstalling this version`)
147
+ }
48
148
  }
49
149
  if (!existing) {
50
150
  if (await lstat(destination).then(() => true).catch(error => { if (error.code === 'ENOENT') return false; throw error })) {
@@ -54,7 +154,8 @@ export async function apply(ctx) {
54
154
  await mkdir(staging, { recursive: true })
55
155
  try {
56
156
  await cp(source, staging, { recursive: true })
57
- await writeFile(join(staging, '.openwrite-managed.json'), JSON.stringify({ source: expected, tree: expected, version }))
157
+ const digest = await digestShipped(source, staging)
158
+ await writeFile(join(staging, '.openwrite-managed.json'), JSON.stringify({ source: digest, tree: digest, version }))
58
159
  await rename(staging, destination)
59
160
  } finally { await rm(staging, { recursive: true, force: true }) }
60
161
  }
@@ -79,9 +180,16 @@ export async function apply(ctx) {
79
180
  if (typeof presetCtx.agentPresets.register !== 'function') return
80
181
  const definition = await presetDefinition(version, true)
81
182
  yield await presetCtx.agentPresets.register(definition)
82
- const ids = new Set([...legacyPresetIds, id,
83
- ...(await readdir(join(home, '.agent-presets'))).filter(name => /^openwrite-\d+-\d+-\d+(?:-[a-z0-9-]+)?$/.test(name)),
84
- ])
183
+ const discovered = (await readdir(join(home, '.agent-presets')).catch(() => []))
184
+ .filter(name => /^openwrite-\d+-\d+-\d+(?:-[a-z0-9-]+)?$/.test(name))
185
+ // An alias keeps a session written by an older release resumable. Registering
186
+ // every released id instead floods the new-session selector with entries no
187
+ // session references, so the alias set follows the session store and only
188
+ // falls back to the durable list when that store cannot be read. This
189
+ // release's own identity is excluded: it is registered as the stable preset
190
+ // and its directory belongs to this plugin, so an alias for it is noise.
191
+ const referenced = await referencedPresetIds(home)
192
+ const ids = new Set([...(referenced ?? legacyPresetIds), ...discovered].filter(candidate => candidate !== id))
85
193
  for (const legacyId of ids) {
86
194
  // An author-supplied definition wins; readDocument checks presence without
87
195
  // waiting for host activation (list/resolve would deadlock startup).
@@ -90,8 +198,39 @@ export async function apply(ctx) {
90
198
  catch (error) { if (error?.code !== 'agent-preset/not-found') throw error }
91
199
  }
92
200
  yield await presetCtx.agentPresets.register({ ...definition, id: legacyId,
93
- order: 1000, name: `${definition.name}(历史会话 ${legacyId})`,
201
+ order: 1000, name: legacyAliasName(legacyId, definition.name),
94
202
  })
95
203
  }
96
204
  })
205
+ // Companion status for the workbench's companion section, and the host's own
206
+ // default-model selection, so OpenWrite's page in the host's settings dialog
207
+ // can show which model dsh itself would use. The companion route reports
208
+ // which implementation serves DoG and whether the optional integrations are
209
+ // live; it never changes what the preset mounts. The model route carries no
210
+ // key: the selection is a browser-visible fact, the key is not. Like every
211
+ // other /studio-panel/* route both sit behind the host's
212
+ // browser-authentication fence: without it any local process could read the
213
+ // host version, the companion inventory and the host's model selection.
214
+ ctx.inject(['webServer'], webCtx => webCtx.effect(() => {
215
+ webCtx.webServer.register({
216
+ kind: 'exact', path: '/studio-panel/companions', handler: async (req, res) => {
217
+ const respond = (code, data) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(data)) }
218
+ const connection = webCtx.get('connection')
219
+ const rejection = connection ? connection.requestRejection({ headers: req.headers }) : 503
220
+ if (rejection !== undefined) { respond(rejection, { error: 'dsh browser authentication required', message: 'dsh browser authentication required', code: 'AUTHENTICATION_REQUIRED' }); return }
221
+ if (req.method !== 'GET') { respond(405, { error: 'Method not allowed', message: 'Method not allowed', code: 'METHOD_NOT_ALLOWED' }); return }
222
+ respond(200, await companionSummary(home) ?? { host: null, dog: { installed: false, usable: false, version: null, reason: null }, ops: { installed: false, usable: false, version: null, reason: null }, freeModel: { installed: false, usable: false, version: null, reason: null } })
223
+ },
224
+ }, 'openwrite: companion status')
225
+ webCtx.webServer.register({
226
+ kind: 'exact', path: '/studio-panel/host-model', handler: async (req, res) => {
227
+ const respond = (code, data) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(data)) }
228
+ const connection = webCtx.get('connection')
229
+ const rejection = connection ? connection.requestRejection({ headers: req.headers }) : 503
230
+ if (rejection !== undefined) { respond(rejection, { error: 'dsh browser authentication required', message: 'dsh browser authentication required', code: 'AUTHENTICATION_REQUIRED' }); return }
231
+ if (req.method !== 'GET') { respond(405, { error: 'Method not allowed', message: 'Method not allowed', code: 'METHOD_NOT_ALLOWED' }); return }
232
+ respond(200, hostModelSelection(ctx))
233
+ },
234
+ }, 'openwrite: host model selection')
235
+ }))
97
236
  }
@@ -1,6 +1,11 @@
1
- import { readFile } from 'node:fs/promises'
1
+ import { open, readFile, readdir, stat, mkdir, writeFile } from 'node:fs/promises'
2
+ import { createHash } from 'node:crypto'
3
+ import { zstdDecompressSync } from 'node:zlib'
4
+ import { join } from 'node:path'
2
5
  import { fileURLToPath } from 'node:url'
3
6
  import { load, Type, JSON_SCHEMA } from 'js-yaml'
7
+ import { detectCompanions } from './companions.mjs'
8
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
4
9
 
5
10
  // Preserve Loader expressions; evaluating them here loses the preset's baseUrl
6
11
  // and entry-local realm. This is the host's entry-list !!js wire representation.
@@ -12,16 +17,150 @@ const schema = JSON_SCHEMA.extend(new Type('tag:yaml.org,2002:js', {
12
17
  export const stablePresetId = 'openwrite'
13
18
 
14
19
  // These released identities are durable session references, even after their
15
- // package-managed directories have been removed by older plugin versions.
16
- export const legacyPresetIds = Array.from({ length: 28 }, (_, i) => `openwrite-0-2-${i + 1}`)
20
+ // package-managed directories have been removed by older plugin versions. They
21
+ // are the safety net used when the session store cannot be read; the live alias
22
+ // set follows the sessions that actually reference them (see below).
23
+ /**
24
+ * Every released identity on the current `major.minor` line.
25
+ *
26
+ * Derived from the package version rather than a hardcoded count: `{ length: 29 }`
27
+ * could only ever express `0.2.x`, so a move to 0.3.0 or 1.0.0 structurally could
28
+ * not produce the new identity and the durable-resume safety net would silently
29
+ * omit it.
30
+ */
31
+ const releaseVersion = JSON.parse(await readFile(new URL('package.json', import.meta.url), 'utf8')).version
32
+ const releaseLine = /^(\d+)\.(\d+)\.(\d+)/.exec(String(releaseVersion || ''))
33
+ export const legacyPresetIds = releaseLine === null
34
+ ? []
35
+ : Array.from({ length: Number(releaseLine[3]) }, (_, i) => `openwrite-${releaseLine[1]}-${releaseLine[2]}-${i + 1}`)
17
36
 
18
- export async function presetDefinition(version, modern = false) {
37
+ /** One released versioned preset identity, including any pre-release suffix. */
38
+ const versionedPresetId = /^openwrite-\d+-\d+-\d+(?:-[a-z0-9-]+)?$/
39
+
40
+ /** Session log artifacts, in both encodings the host persistence backend writes. */
41
+ const sessionLogSuffixes = ['.jsonl.zstd', '.jsonl']
42
+
43
+ /** A session header is the first frame of its log; a bounded prefix reads it. */
44
+ const headerPrefixBytes = 256 * 1024
45
+
46
+ /**
47
+ * Name an alias after the release it restores. A bare id is an internal detail
48
+ * that leaves dozens of identically prefixed entries in the preset selector.
49
+ */
50
+ export function legacyAliasName(id, name) {
51
+ if (!versionedPresetId.test(id)) return `${name}(历史会话 ${id})`
52
+ return `${name}(旧版 ${id.replace(/^openwrite-/, '').replaceAll('-', '.')} · 仅用于恢复历史会话)`
53
+ }
54
+
55
+ async function collectSessionLogs(root) {
56
+ const logs = []
57
+ const walk = async directory => {
58
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
59
+ const path = join(directory, entry.name)
60
+ if (entry.isDirectory()) await walk(path)
61
+ else if (sessionLogSuffixes.some(suffix => entry.name.endsWith(suffix))) logs.push(path)
62
+ }
63
+ }
64
+ await walk(root)
65
+ return logs
66
+ }
67
+
68
+ /**
69
+ * A digest over every log's identity, not just {count, newest mtime}: deleting
70
+ * one session and adding another in the same second keeps that pair unchanged
71
+ * and would serve a stale alias set (a missing id, a lingering one). Stats only,
72
+ * so this stays cheap enough to re-derive on every start.
73
+ */
74
+ async function fingerprintLogs(logs) {
75
+ const hash = createHash('sha256')
76
+ const rows = []
77
+ for (const path of logs) {
78
+ let info
79
+ try { info = await stat(path) } catch { continue }
80
+ rows.push(`${path}\u0000${Math.round(info.mtimeMs)}\u0000${info.size}\n`)
81
+ }
82
+ rows.sort()
83
+ for (const row of rows) hash.update(row)
84
+ return { files: rows.length, digest: hash.digest('hex') }
85
+ }
86
+
87
+ /** The `agentPreset` a stored session header carries, or undefined when unreadable. */
88
+ async function headerPresetId(path) {
89
+ const handle = await open(path, 'r')
90
+ try {
91
+ const buffer = Buffer.alloc(headerPrefixBytes)
92
+ const { bytesRead } = await handle.read(buffer, 0, headerPrefixBytes, 0)
93
+ const head = buffer.subarray(0, bytesRead)
94
+ // node:zlib decodes the leading frame and ignores the appended event frames.
95
+ const text = path.endsWith('.zstd') ? zstdDecompressSync(head).toString('utf8') : head.toString('utf8')
96
+ const header = JSON.parse(text.split('\n', 1)[0])
97
+ return typeof header?.agentPreset === 'string' ? header.agentPreset : undefined
98
+ } finally {
99
+ await handle.close()
100
+ }
101
+ }
102
+
103
+ /**
104
+ * The versioned preset ids stored sessions still reference. Registering every
105
+ * released id instead would fill the new-session selector with entries no
106
+ * session can resume, so the alias set is derived from the session store.
107
+ * Returns null when that store cannot be read at all, which keeps the durable
108
+ * alias list rather than silently dropping historical session resume.
109
+ */
110
+ export async function referencedPresetIds(home) {
111
+ const root = join(home, 'sessions')
112
+ let logs
113
+ try {
114
+ logs = await collectSessionLogs(root)
115
+ } catch (error) {
116
+ if (error?.code !== 'ENOENT') return null
117
+ logs = []
118
+ }
119
+ // fingerprintLogs skips logs it cannot stat (a dangling symlink, or one the
120
+ // host prunes mid-scan); a walk that cannot even be listed is the only case
121
+ // that falls back to the durable list.
122
+ const fingerprint = await fingerprintLogs(logs).catch(() => null)
123
+ if (!fingerprint) return null
124
+ const cache = join(home, 'openwrite', 'preset-aliases.json')
125
+ const cached = await readFile(cache, 'utf8').then(JSON.parse).catch(() => null)
126
+ if (cached?.fingerprint?.files === fingerprint.files && cached.fingerprint.digest === fingerprint.digest) {
127
+ return new Set((cached.ids ?? []).filter(id => versionedPresetId.test(id)))
128
+ }
129
+ const ids = new Set()
130
+ for (const path of logs) {
131
+ const id = await headerPresetId(path).catch(() => undefined)
132
+ if (id && versionedPresetId.test(id)) ids.add(id)
133
+ }
134
+ await mkdir(join(home, 'openwrite'), { recursive: true }).catch(() => {})
135
+ await writeFile(cache, JSON.stringify({ fingerprint, ids: [...ids] }) + '\n').catch(() => {})
136
+ return ids
137
+ }
138
+
139
+ /**
140
+ * Mount dsh-ops into this preset when the companion is installed, its declared
141
+ * host range covers the running host, and it exposes the integration entry.
142
+ */
143
+ async function adaptCompanions(plugins, options) {
144
+ if (plugins.some(row => row.id === 'openwrite-ops')) return
145
+ let companions = null
146
+ try {
147
+ companions = await detectCompanions(resolveDshHome(), options)
148
+ } catch { /* an unreadable home leaves the preset exactly as shipped */ }
149
+ if (!companions?.ops?.usable) return
150
+ plugins.push({ id: 'openwrite-ops', name: 'dsh-openwrite/ops-integration' })
151
+ }
152
+
153
+ export async function presetDefinition(version, modern = false, options = {}) {
19
154
  const baseUrl = new URL('./presets/openwrite/', import.meta.url)
20
155
  const plugins = load(await readFile(new URL('agent.cordis.yml', baseUrl), 'utf8'), { schema })
21
156
  const metadata = load(await readFile(new URL('preset.yml', baseUrl), 'utf8'))
22
157
  if (modern) {
23
158
  const adapt = rows => {
24
159
  for (const row of rows) {
160
+ // The workflow row is renamed for hosts that ship the PTC variant. This
161
+ // repo's node_modules carries an older host without that package, so the
162
+ // resolution gate treats it as expected-absent rather than failing (see
163
+ // scripts/preset-smoke.mjs).
25
164
  if (row.name === '@deepseek-ai/dsh-workflow-worker-thread') {
26
165
  row.name = '@deepseek-ai/dsh-workflow-ptc'
27
166
  }
@@ -33,5 +172,6 @@ export async function presetDefinition(version, modern = false) {
33
172
  }
34
173
  adapt(plugins)
35
174
  }
175
+ await adaptCompanions(plugins, options)
36
176
  return { ...metadata, id: stablePresetId, order: -1, plugins }
37
177
  }
@@ -20,7 +20,7 @@
20
20
  outlines and assets unless the author asks to translate them. Chinese skill instructions
21
21
  describe procedures; they do not require Chinese output. Translate their headings and help
22
22
  when presenting them. In English conversations, use English throughout the creative loop.
23
- Writing tasks return a task_id immediately; poll novel_task_get until terminal and present
23
+ Writing tasks return a task_id immediately; poll novel_task(action="get") until terminal and present
24
24
  cancellation as requested until the task reports cancelled. Resume saved drafts via run_id_v2.
25
25
 
26
26
  你是 OpenWrite 创作 Agent,运行在 DeepSeek Harness(dsh)的统一 OpenWrite 预设中。
@@ -66,12 +66,12 @@
66
66
  → rereview 闭环。低质量、硬门禁失败或上下文不完整时先报告原因,不强行交付。
67
67
  修改已有正文、人物、世界、正典、伏笔或其他项目文档时,先用 novel_search / novel_status
68
68
  定位正式实体并读取最新版本。正文、正典和其他普通文档的每个独立修改分别调用
69
- novel_document_change_plan(action="preview");大纲、资产、创作重点、伏笔和写作目标的每个独立
70
- 修改分别调用 novel_structured_change_plan(action="preview")。向用户展示返回的 before/after 与来源版本;
69
+ novel_change_plan(action="document", step="preview");大纲、资产、创作重点、伏笔和写作目标的每个独立
70
+ 修改分别调用 novel_change_plan(action="structured", step="preview")。向用户展示返回的 before/after 与来源版本;
71
71
  只有用户明确确认后,才把原 preview_token 原样传给 action="apply", confirm=true,不得重新生成路径、
72
72
  锚点或内容。用户拒绝时用 action="reject";源文件冲突时用 action="retry" 生成新预览;
73
73
  已应用项需要撤销时,只能把返回的 undo_preview_token 传给 action="undo", confirm=true。
74
- novel_doc_write 是已获明确授权的完整文档写入兼容入口,不得用它绕过逐项预览。
74
+ novel_doc(action="write") 是已获明确授权的完整文档写入兼容入口,不得用它绕过逐项预览。
75
75
  完成写入后可用 novel_trace_list 核对来源、预算、模型调用、工具调用和领域变更。Trace 只保留摘要、
76
76
  revision 与用量,作品正文、提示原文、模型原文、凭据和推理内容不得写入 trace。
77
77
  普通讨论、分析和未确认建议不得写入资产。
@@ -81,9 +81,9 @@
81
81
  或阶段仍在大纲滚动期但用户明确要求“根据现在/当前大纲写下一章”、
82
82
  “就按这个大纲写”或等价表达,先向用户确认大纲范围,再执行章节预检与写作。
83
83
  用户要求修改大纲时,先用 novel_outline_read 定位结构,再用
84
- novel_structured_change_plan(change_kind="outline") 对一个结构操作逐项预览和确认;不要用
85
- novel_doc_write 绕过大纲门控,也不要整篇覆盖 src/outline.md。novel_outline_edit、
86
- novel_asset_update、novel_focus、novel_foreshadowing 和 novel_writing_targets 仅在用户本轮已经
84
+ novel_change_plan(action="structured", step="preview", change_kind="outline") 对一个结构操作逐项预览和确认;不要用
85
+ novel_doc(action="write") 绕过大纲门控,也不要整篇覆盖 src/outline.md。novel_outline_edit、
86
+ novel_assets(action="update")、novel_focus、novel_foreshadowing 和 novel_writing_targets 仅在用户本轮已经
87
87
  明确授权对应的精确变更时作为直接写入兼容入口使用。
88
88
  用户要求删除已写正文或章节时,先向用户说明将删除的章节与影响并得到明确同意。
89
89
  单章使用 novel_chapter_delete;清理连续尾段时使用 novel_chapter_delete_batch,
@@ -335,5 +335,3 @@
335
335
  - id: openwrite-tools
336
336
  name: dsh-openwrite/tools
337
337
 
338
- - id: openwrite-dog-tools
339
- name: dsh-openwrite/dog-tools
@@ -0,0 +1,28 @@
1
+ // dsh-ops preset integration. The row that mounts this module is only added
2
+ // when dsh-ops is installed, its declared host range covers the running host,
3
+ // and it exposes `dsh-ops/integration`. The import is still defensive: a
4
+ // companion that changed its entry between detection and load must degrade to
5
+ // a no-op row rather than take the whole preset composition down.
6
+ export const name = '@dsh-novel/openwrite-ops'
7
+
8
+ export async function apply(ctx) {
9
+ let integration
10
+ try {
11
+ ({ integration } = await import('dsh-ops/integration'))
12
+ } catch {
13
+ ctx.effect(() => () => {}, 'openwrite-ops: dsh-ops integration unavailable')
14
+ return
15
+ }
16
+ // Bounded file tools plus progressive discovery for the OpenWrite preset.
17
+ // Official file tools stay available (`replaceOfficial: false`), no shell is
18
+ // published (`shell: false`), and the preset's own guidance is not replaced.
19
+ const plugin = integration({
20
+ preset: 'openwrite',
21
+ files: true,
22
+ discovery: true,
23
+ presentation: 'native',
24
+ replaceOfficial: false,
25
+ shell: false,
26
+ })
27
+ return plugin.apply(ctx)
28
+ }
@@ -1,3 +1,4 @@
1
1
  name: OpenWrite 创作
2
2
  description: 长篇小说全流程 Agent:在同一会话中完成灵感收敛、人物/设定/大纲、正文写作、六域累加评审与修订交付,保留 37 项展开查询,按项目阶段自动切换规划与写作模式。
3
- order: 10
3
+ # `order` is set by presetDefinition (the stable id leads the selector); a value
4
+ # here would be silently overridden.
@@ -9,9 +9,9 @@ Language: follow the author's current conversation language for responses and he
9
9
 
10
10
  接到已有 `novel_*`。人物、地点、世界观以 OpenWrite 资产为准。
11
11
 
12
- 1. `novel_assets_list` 列出人物/世界资产(含名称与别名)。
13
- 2. 指定实体用 `novel_asset_read`。
12
+ 1. `novel_assets(action="list"` 列出人物/世界资产(含名称与别名)。
13
+ 2. 指定实体用 `novel_assets(action="read", kind="character"|"world"|"progression", id=…)`。
14
14
  3. 按名称或别名找不到时 `novel_search`(scope 可 `assets`)。
15
15
  4. 关系与状态漂移用 `novel_continuity`。
16
16
 
17
- 向作者报告规范名、别名、摘要。改设定走 `novel_structured_change_plan(change_kind="asset")`;普通讨论不得写入。
17
+ 向作者报告规范名、别名、摘要。改设定走 `novel_change_plan(action="structured"(change_kind="asset")`;普通讨论不得写入。
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: delivery-artifact-query
3
+ description: Query a chapter delivery record 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
+ # 章节交付状态查询
10
+
11
+ 章节写作、评审和修订工具会直接返回交付状态,并把同一份记录落盘:
12
+ `data/novels/{id}/data/dog/deliveries/{chapter}/dog-graph.json`。
13
+ 它覆盖六个阶段:`writing → review → revision → application → rereview → closure`。
14
+ 它不替代 OpenWrite 的写章、六域累加评审或修订服务。
15
+
16
+ ## 查询流程
17
+
18
+ 1. 从最近一次 `novel_write_chapter`、`novel_review_chapter`、`novel_revision`
19
+ 或 `novel_task(action="get")` 结果里的 `dog_delivery` 读状态;需要完整阶段明细时再读
20
+ 上面的 `dog-graph.json`(`stages` 里有每一阶段的 verdict 与 evidence)。
21
+ 2. 按 `closure` 的状态给结论(见下表),不要自行推断。
22
+ 3. 修订应用后必须重新调用 `novel_review_chapter`;只有复评对应当前正文 SHA 且门禁通过,
23
+ `closure` 才能成为 `closed`。
24
+
25
+ ## 状态解释
26
+
27
+ - `review.missing`:正文存在但尚未评审。
28
+ - `closure.review_failed`:当前正文已评审但未通过门禁,进入修订闭环。
29
+ - `revision.proposal_pending`:提案已生成,等待作者确认应用或驳回。
30
+ - `revision.applied_requires_rereview`:修订已写入正文,但不能视为问题已解决。
31
+ - `closure.rereview_required`:必须对修订后的正文重新跑六域评审。
32
+ - `closure.closed`:当前正文的最新评审通过,可以交付。
33
+
34
+ `readyForDelivery=true` 等价于「当前正文 SHA 的最新复评通过且门禁通过」,
35
+ 可以直接作为交付结论;为 `false` 时说明上面某一项未满足。
36
+
37
+ ## 约束
38
+
39
+ - 只查询 canonical manuscript/review/revision 文件,不直接修改它们。
40
+ - 不因提案状态为 `applied` 就宣称问题关闭;必须以当前正文 SHA 对应的复评为准。
41
+ - 记录缺失时回到 OpenWrite 流程重新评审,不要把上一次的结论说成最新。
@@ -19,14 +19,15 @@ Language: follow the author's current conversation language for responses and he
19
19
  3. `data/style/composed.md` — 当前作品风格约束
20
20
  4. `src/outline.md` / canonical packet — 本章戏剧位置与章节目标
21
21
 
22
- 如果需要完整判断某章对白是否失真,优先结合 `review_chapter` 或章节 packet,而不是只看几行台词。
22
+ 如果需要完整判断某章对白是否失真,优先结合 `novel_review_chapter` 或 `novel_chapter_work` 的章节 packet,而不是只看几行台词。
23
23
 
24
24
  ## 可用工具
25
25
 
26
26
  | 工具 | 说明 |
27
27
  |------|------|
28
- | `extract_dialogue_fingerprint` | 提取角色对白指纹 |
29
- | `review_chapter` | 对整章做审查,可发现台词失真、解释性对话、套路对白 |
28
+ | `novel_manuscript_metrics(chapter_id=…)` | 章节可量化信号:字数、句长、对白占比等 |
29
+ | `novel_review_chapter(chapter_id=…, domains=["prose","character"])` | 对整章做审查,可发现台词失真、解释性对话、套路对白 |
30
+ | `novel_chapter_work(chapter_id=…)` | 章节工作 brief:戏剧位置、出场角色、章节目标 |
30
31
 
31
32
  ## 什么时候用
32
33
 
@@ -37,26 +38,24 @@ Language: follow the author's current conversation language for responses and he
37
38
 
38
39
  ## 工作方式
39
40
 
40
- ### 1. 提取对白指纹
41
+ ### 1. 读取章节信号
41
42
 
42
- 先定位章节和角色,再提取:
43
+ 先定位章节,再读可量化指标:
43
44
 
44
45
  ```text
45
46
  用户: 分析一下林月在第六章的对话风格
46
47
 
47
- AI:
48
- [COMMAND] extract_dialogue_fingerprint {
49
- "chapter_id": "ch_006",
50
- "character_names": ["林月"]
51
- }
48
+ AI: 调用 novel_manuscript_metrics(chapter_id="ch_006")
52
49
  ```
53
50
 
54
- 重点观察:
51
+ 重点观察返回里的:
55
52
 
56
- - `avg_sentence_length`:句长偏短还是偏长
57
- - `question_ratio`:疑问句比例
58
- - `common_bigrams`:高频词组
59
- - `speech_patterns`:口头禅、重复习惯、语气特征
53
+ - 字数与对白占比:这一章是谁在说话
54
+ - 句长相关指标:偏短还是偏长
55
+ - 出场角色:林月是否真的有戏份
56
+
57
+ 没有工具会直接给出"对白指纹";上面的指标加上正文摘录就是判断依据。
58
+ 需要整章判断时再调用 `novel_review_chapter(chapter_id="ch_006", domains=["prose","character"])`。
60
59
 
61
60
  ### 2. 结合角色档案复核
62
61
 
@@ -163,6 +162,6 @@ AI:
163
162
 
164
163
  ## 注意
165
164
 
166
- - `extract_dialogue_fingerprint` 适合看“说话指纹”
167
- - `review_chapter` 适合看“对白放在整章里是否成立”
165
+ - `novel_manuscript_metrics` 提供可量化信号,判断"说话指纹"要靠它加上正文摘录
166
+ - `novel_review_chapter` 适合看“对白放在整章里是否成立”
168
167
  - 真要改对白,必须结合人物文档和章节戏剧位置一起判断