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
package/companions.mjs ADDED
@@ -0,0 +1,508 @@
1
+ // Companion-plugin detection. OpenWrite ships its own DoG engine and works
2
+ // without any companion; when a companion plugin is installed in the same
3
+ // profile AND its declared host range covers the running host, the companion's
4
+ // newest implementation is used instead of ours. Every unknown answer is
5
+ // fail-closed: detection problems keep our own implementation.
6
+ import { readFile, readdir, stat, mkdir, writeFile, realpath } from 'node:fs/promises'
7
+ import { createRequire } from 'node:module'
8
+ import { pathToFileURL } from 'node:url'
9
+ import { dirname, join, resolve } from 'node:path'
10
+
11
+ /** Bundle names that identify each companion, with and without scope. */
12
+ const companionNames = {
13
+ dog: ['@dsh-external/dsh-dog'],
14
+ ops: ['dsh-ops'],
15
+ freeModel: ['dsh-our-free-model', '@ebony-vinyl/dsh-our-free-model'],
16
+ }
17
+
18
+ /**
19
+ * The host constraint a companion actually declares.
20
+ *
21
+ * Most plugins declare `peerDependencies["@deepseek-ai/dsh"]`, and the host reads
22
+ * exactly that. Some (dsh-dog 1.4.x) declare only `@deepseek-ai/dsh-*`
23
+ * sub-packages instead; the host needs every one of them, so their ranges are the
24
+ * real constraint. Treating a missing `@deepseek-ai/dsh` peer as "no constraint"
25
+ * would report such a plugin usable on hosts where the host itself refuses it.
26
+ */
27
+ function hostConstraint(manifest) {
28
+ const peers = manifest?.peerDependencies ?? {}
29
+ const direct = peers['@deepseek-ai/dsh']
30
+ if (typeof direct === 'string' && direct !== '') return { range: direct, source: '@deepseek-ai/dsh' }
31
+ const subpackage = Object.entries(peers)
32
+ .filter(([name]) => name.startsWith('@deepseek-ai/dsh-'))
33
+ .map(([, range]) => String(range))
34
+ if (subpackage.length > 0) return { range: subpackage.join(' '), source: '@deepseek-ai/dsh-*' }
35
+ return { range: null, source: null }
36
+ }
37
+
38
+ /**
39
+ * The public entry each companion must expose for OpenWrite to use it. A
40
+ * companion that is installed but does not expose its entry (an older release,
41
+ * or one that moved the API) is treated as absent: mounting a row whose import
42
+ * fails would take the whole preset composition down with it.
43
+ */
44
+ const companionEntries = { ops: 'dsh-ops/integration' }
45
+
46
+ /**
47
+ * The exact dsh-ops integration OpenWrite mounts. Detection builds it too, so an
48
+ * upstream release that stops accepting these options is reported instead of
49
+ * breaking the preset composition at load time.
50
+ */
51
+ /** The exact options OpenWrite passes to the dsh-ops integration factory. */
52
+ export const OPENWRITE_OPS_OPTIONS = Object.freeze({
53
+ preset: 'openwrite',
54
+ files: true,
55
+ discovery: true,
56
+ presentation: 'native',
57
+ replaceOfficial: false,
58
+ shell: false,
59
+ })
60
+
61
+ const opsIntegration = factory => factory({ ...OPENWRITE_OPS_OPTIONS })
62
+
63
+ /** The small, serializable summary of a resolved dsh-ops plan. */
64
+ export function summarizeOpsPlan(plan) {
65
+ return {
66
+ files: plan.files === true,
67
+ discovery: plan.discovery === true,
68
+ presentation: String(plan.presentation ?? ''),
69
+ shell: plan.shell === true,
70
+ replaceOfficial: plan.replaceOfficial === true,
71
+ residentTools: plan.residentTools === true,
72
+ residentNames: Array.isArray(plan.residentNames) ? plan.residentNames.length : 0,
73
+ }
74
+ }
75
+
76
+ const readJson = async path => JSON.parse(await readFile(path, 'utf8'))
77
+
78
+ /** Compare two parsed versions, prerelease identifiers included. */
79
+ function compareVersions(a, b) {
80
+ for (const key of ['major', 'minor', 'patch']) {
81
+ if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1
82
+ }
83
+ const [x, y] = [a.pre, b.pre]
84
+ if (x.length === 0 && y.length === 0) return 0
85
+ if (x.length === 0) return 1
86
+ if (y.length === 0) return -1
87
+ for (let index = 0; index < Math.max(x.length, y.length); index += 1) {
88
+ if (index >= x.length) return -1
89
+ if (index >= y.length) return 1
90
+ const left = x[index], right = y[index]
91
+ if (left === right) continue
92
+ const numeric = /^\d+$/.test(left) && /^\d+$/.test(right)
93
+ if (numeric) return Number(left) < Number(right) ? -1 : 1
94
+ return left < right ? -1 : 1
95
+ }
96
+ return 0
97
+ }
98
+
99
+ /** Parse `1.2.3-rc.4` into comparable parts; returns null when unparseable. */
100
+ function parseVersion(value) {
101
+ const match = /^(\d+)(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec((value ?? '').trim())
102
+ if (!match) return null
103
+ const wildcard = part => part === undefined || /[xX*]/.test(part)
104
+ // `1.x.3` is not a version spelling npm accepts.
105
+ if (wildcard(match[2]) && match[3] !== undefined && !wildcard(match[3])) return null
106
+ if (wildcard(match[2]) && match[4] !== undefined) return null
107
+ return { major: +match[1], minor: +(wildcard(match[2]) ? 0 : match[2]), patch: +(wildcard(match[3]) ? 0 : match[3]),
108
+ pre: match[4] ? match[4].split('.') : [], partial: 3 - [match[2], match[3]].filter(wildcard).length }
109
+ }
110
+
111
+ /** One comparator: an optional operator plus its version spelling. */
112
+ function parseComparator(comparator) {
113
+ const match = /^(>=|<=|>|<|=|\^|~)?\s*(.+)$/.exec(comparator.trim())
114
+ if (!match) return null
115
+ const version = parseVersion(match[2].trim().replace(/^[vV]/, ''))
116
+ if (!version) return null
117
+ return { op: match[1] ?? '=', version }
118
+ }
119
+
120
+ /** The exclusive end of a spelled version prefix (`0.3` -> 0.4.0-0, `1` -> 2.0.0-0). */
121
+ function upperBoundOf(spelling) {
122
+ const version = parseVersion(spelling.replace(/^[vV]/, ''))
123
+ if (!version) return null
124
+ if (version.partial === 3) return { ...version, pre: ['0'] }
125
+ return version.partial === 1
126
+ ? { major: version.major + 1, minor: 0, patch: 0, pre: ['0'] }
127
+ : { major: version.major, minor: version.minor + 1, patch: 0, pre: ['0'] }
128
+ }
129
+
130
+ /** Expand one comparator (`1.2.3`, `^1.2.3`, `~1.2.3`, `>=1.2.3`, `*`) into bounds. */
131
+ function expandComparator(comparator) {
132
+ const trimmed = comparator.trim()
133
+ // `x`, `*`, `x.x.x` and most operators applied to them accept any version;
134
+ // the strict `>x` / `<x` accept none.
135
+ const wildcard = /^((?:>=|<=|>|<|=|\^|~)?)\s*[xX*](?:\.[xX*]){0,2}$/.exec(trimmed)
136
+ if (trimmed === '' || wildcard) {
137
+ const strict = wildcard !== null && (wildcard[1] === '>' || wildcard[1] === '<')
138
+ return strict ? [] : [{ op: '>=', version: { major: 0, minor: 0, patch: 0, pre: [] } }]
139
+ }
140
+ const parsed = parseComparator(trimmed)
141
+ if (!parsed) return null
142
+ const { op, version } = parsed
143
+ const upper = (major, minor, patch) => ({ op: '<', version: { major, minor, patch, pre: ['0'] } })
144
+ if (op !== '^' && op !== '~') {
145
+ // npm X-range semantics for a partial spelling, taken from semver's own Range
146
+ // expansion (verified comparator by comparator):
147
+ // `0.2` -> >=0.2.0 <0.3.0-0 `<=0.2` -> <0.3.0-0
148
+ // `<0.3` -> <0.3.0-0 `<=0.3` -> <0.4.0-0
149
+ // `>0.2` -> >=0.3.0 `>=0.3` -> >=0.3.0
150
+ // `>0.3` -> >=0.4.0 `<1` -> <1.0.0-0
151
+ // `<=0` -> <1.0.0-0 `>0` -> >=1.0.0
152
+ // `<` bounds at the spelled prefix; `<=`, `>` and `>=` bound one component
153
+ // further. Zero-filling the bound instead would make `>0.2` accept 0.2.5
154
+ // (fail open) and `<=0.2` reject it (fail closed).
155
+ if (version.partial < 3) {
156
+ const start = { major: version.major, minor: version.minor, patch: version.patch, pre: [] }
157
+ const end = version.partial === 1
158
+ ? { major: version.major + 1, minor: 0, patch: 0, pre: ['0'] }
159
+ : { major: version.major, minor: version.minor + 1, patch: 0, pre: ['0'] }
160
+ const here = { major: version.major, minor: version.minor, patch: 0, pre: ['0'] }
161
+ if (op === '=') return [{ op: '>=', version: start }, { op: '<', version: end }]
162
+ if (op === '>') return [{ op: '>=', version: { ...end, pre: [] } }]
163
+ if (op === '<') return [{ op: '<', version: here }]
164
+ if (op === '<=') return [{ op: '<', version: end }]
165
+ // `>=0.3` starts AT the spelled prefix; `>0.3` starts one component later.
166
+ return [{ op: '>=', version: op === '>=' ? start : end }]
167
+ }
168
+ return [{ op, version }]
169
+ }
170
+ const floor = { op: '>=', version }
171
+ // npm X-ranges inside a caret/tilde: `^0` and `~0` both stop at 1.0.0.
172
+ if (version.partial === 1) return [floor, upper(version.major + 1, 0, 0)]
173
+ if (op === '~') return [floor, upper(version.major, version.minor + 1, 0)]
174
+ if (op === '^' && version.partial === 2 && version.major === 0) return [floor, upper(0, version.minor + 1, 0)]
175
+ if (version.major > 0) return [floor, upper(version.major + 1, 0, 0)]
176
+ if (version.minor > 0) return [floor, upper(0, version.minor + 1, 0)]
177
+ return [floor, upper(0, 0, version.patch + 1)]
178
+ }
179
+
180
+ /** Split one `||` alternative into comparators, keeping `>= 1.2.3` together. */
181
+ function expandAlternative(alternative) {
182
+ const trimmed = alternative.trim()
183
+ if (trimmed === '') return [{ op: '>=', version: { major: 0, minor: 0, patch: 0, pre: [] } }]
184
+ const comparators = []
185
+ const verbatim = []
186
+ let cursor = 0
187
+ for (const match of trimmed.matchAll(/(>=|<=|>|<|=|\^|~)?\s*(v?[0-9xX*][^\s]*)/g)) {
188
+ verbatim.push({ op: match[1] ?? '=', version: match[2].replace(/^[vV]/, '') })
189
+ // Anything but whitespace between two comparators is a spelling we refuse,
190
+ // except the lone `-` of a hyphen range (`1.2.3 - 2.3.4`).
191
+ const gap = trimmed.slice(cursor, match.index)
192
+ if (gap.trim() !== '' && gap.trim() !== '-') return null
193
+ const expanded = expandComparator(`${match[1] ?? ''} ${match[2]}`)
194
+ if (expanded === null) return null
195
+ comparators.push(...expanded)
196
+ cursor = match.index + match[0].length
197
+ }
198
+ // (hyphen operands are re-emitted verbatim below)
199
+ if (trimmed.slice(cursor).trim() !== '') return null
200
+ // A hyphen range (`1.2.3 - 2.3.4`, `0.2 - 0.3`) is two partial-or-full
201
+ // versions with a lone `-` between them; npm expands it to `>=lo <=hi` with
202
+ // both bounds verbatim, so the X-range rule must not rewrite them. The
203
+ // tokenizer drops the `-`, so detect it from the original text.
204
+ if (verbatim.length === 2 && verbatim.every(item => item.op === '=')
205
+ && /(^|\s)-(\s|$)/.test(trimmed)) {
206
+ // npm's hyphen range is the X-range of the lower bound plus the prefix end of
207
+ // the upper one: `0.2 - 0.3` is `>=0.2.0 <0.4.0-0`, `1 - 2` is
208
+ // `>=1.0.0 <3.0.0-0`. Both operands keep their own partial expansion.
209
+ const lower = parseVersion(verbatim[0].version.replace(/^[vV]/, ''))
210
+ const upper = parseVersion(verbatim[1].version.replace(/^[vV]/, ''))
211
+ // The lower operand is always inclusive; the upper one keeps its own value
212
+ // when it is a full version and bumps to its prefix end when it is partial.
213
+ return [{ op: '>=', version: lower }, upper.partial === 3
214
+ ? { op: '<=', version: upper }
215
+ : { op: '<', version: upperBoundOf(verbatim[1].version) }]
216
+ }
217
+ return comparators
218
+ }
219
+
220
+ /**
221
+ * Whether one version satisfies one range. Implements the subset the dsh host
222
+ * uses: `||` alternatives, `=`/`>`/`>=`/`<`/`<=`/`^`/`~`, X-ranges.
223
+ *
224
+ * The host decides with `semver.satisfies(version, range, { includePrerelease: true })`
225
+ * (dsh-app-boot `unmetRequirements` → `satisfies`). That flag relaxes npm's rule
226
+ * that a prerelease only matches a comparator carrying the same
227
+ * major/minor/patch, so `0.2.1-rc.5` DOES satisfy `>=0.2.0-rc.2 <0.2.2-0`.
228
+ *
229
+ * Implementing the stricter rule here made this detection more conservative than
230
+ * the host: dsh-ops and dsh-our-free-model were reported "installed but the host
231
+ * does not allow it" on 0.2.1-rc.* while the host loaded both. Detection must
232
+ * match the host, not npm's install-time resolution.
233
+ */
234
+ export function satisfiesRange(version, range) {
235
+ const parsed = parseVersion(version)
236
+ // Only a complete version is testable; partial spellings belong to ranges.
237
+ if (!parsed || parsed.partial !== 3 || typeof range !== 'string') return false
238
+ for (const alternative of range.split('||')) {
239
+ const comparators = expandAlternative(alternative)
240
+ if (comparators === null || comparators.length === 0) continue
241
+ if (comparators.every(({ op, version: bound }) => {
242
+ const order = compareVersions(parsed, bound)
243
+ return op === '=' ? order === 0
244
+ : op === '>' ? order > 0
245
+ : op === '>=' ? order >= 0
246
+ : op === '<' ? order < 0
247
+ : order <= 0
248
+ })) return true
249
+ }
250
+ return false
251
+ }
252
+
253
+ /** Walk up from one path to the version of the host package that owns it. */
254
+ async function hostVersionAbove(path) {
255
+ // Resolve symlinks first. The running host is usually started through the
256
+ // `dsh` shim in the npm global bin directory, which is a symlink into
257
+ // lib/node_modules/@deepseek-ai/dsh/lib/bin.js. `path.resolve` does not follow
258
+ // symlinks, so walking up from the shim never reaches the package and the
259
+ // version silently falls through to whatever this checkout happens to depend
260
+ // on - reported a source checkout's 0.1.2-rc.1 devDependency while the real
261
+ // host was 0.2.1-rc.5.
262
+ let directory = dirname(await realpath(path).catch(() => resolve(path)))
263
+ while (true) {
264
+ const manifest = await readJson(join(directory, 'package.json')).catch(() => null)
265
+ if (manifest?.name === '@deepseek-ai/dsh' && typeof manifest.version === 'string') return manifest.version
266
+ const parent = dirname(directory)
267
+ if (parent === directory) return null
268
+ directory = parent
269
+ }
270
+ }
271
+
272
+ /**
273
+ * The running host version. A host installed into a profile is read from that
274
+ * profile; otherwise the host sub-packages this plugin already resolves are
275
+ * read, because the harness publishes them in lockstep with `@deepseek-ai/dsh`.
276
+ * `OPENWRITE_HOST_VERSION` overrides every source for diagnostics and tests.
277
+ * Unknown when nothing resolves, which keeps every companion decision
278
+ * fail-closed.
279
+ */
280
+ export async function resolveHostVersion(home) {
281
+ const override = process.env.OPENWRITE_HOST_VERSION
282
+ if (typeof override === 'string' && parseVersion(override)) return override
283
+ for (const profile of await readdir(join(home, 'profiles')).catch(() => [])) {
284
+ const installed = join(home, 'profiles', profile, 'node_modules', '@deepseek-ai', 'dsh')
285
+ const version = await hostVersionAbove(join(installed, 'lib', 'index.js'))
286
+ ?? (await readJson(join(installed, 'package.json')).catch(() => null))?.version
287
+ if (typeof version === 'string' && parseVersion(version)) return version
288
+ }
289
+ // The entry this process was started from is the host that is actually
290
+ // running; it outranks whatever this checkout happens to depend on.
291
+ const entry = process.argv?.[1]
292
+ if (typeof entry === 'string') {
293
+ const version = await hostVersionAbove(entry)
294
+ if (version) return version
295
+ }
296
+ // Last resort: the host packages resolvable from here. In a source checkout
297
+ // that is this repo's own pinned devDependency, never the running host, so it
298
+ // can answer wrongly in both directions — prefer it over nothing only.
299
+ for (const name of ['@deepseek-ai/dsh', '@deepseek-ai/dsh-home-paths', '@deepseek-ai/dsh-settings', '@deepseek-ai/dsh-tools']) {
300
+ const version = await readJson(createRequire(import.meta.url).resolve(`${name}/package.json`)).then(manifest => manifest?.version).catch(() => null)
301
+ if (typeof version === 'string' && parseVersion(version)) return version
302
+ }
303
+ return null
304
+ }
305
+
306
+ /** Every profile bundle list plus a fingerprint of the manifests behind them. */
307
+ async function readProfiles(home) {
308
+ const profiles = []
309
+ let fingerprint = ''
310
+ for (const name of await readdir(join(home, 'profiles')).catch(() => [])) {
311
+ const manifest = join(home, 'profiles', name, 'package.json')
312
+ let parsed, modified
313
+ try {
314
+ parsed = await readJson(manifest)
315
+ modified = (await stat(manifest)).mtimeMs
316
+ } catch { continue }
317
+ const bundles = parsed?.dsh?.profile?.bundles
318
+ if (!Array.isArray(bundles)) continue
319
+ profiles.push({ name, directory: join(home, 'profiles', name), bundles })
320
+ fingerprint += `${name}:${Math.round(modified)};`
321
+ // The companion's own manifest decides whether the running host is inside its
322
+ // declared range, so an upgrade that widens (or narrows) that range must
323
+ // invalidate the cache. Keying only on the profile manifest kept serving the
324
+ // previous verdict after a companion upgrade - the exact #150/#151/#142 class
325
+ // of bug, reachable again the moment the range changes.
326
+ for (const candidates of Object.values(companionNames)) {
327
+ for (const companion of candidates) {
328
+ const companionManifest = join(profiles[profiles.length - 1].directory, 'node_modules', ...companion.split('/'), 'package.json')
329
+ try {
330
+ fingerprint += `${companion}:${Math.round((await stat(companionManifest)).mtimeMs)};`
331
+ } catch { /* companion not installed in this profile */ }
332
+ }
333
+ }
334
+ }
335
+ return { profiles, fingerprint }
336
+ }
337
+
338
+ /** Locate one installed companion package inside a profile. */
339
+ async function installedPackage(profile, names) {
340
+ for (const name of names) {
341
+ if (!profile.bundles.some(bundle => String(bundle).toLowerCase() === name.toLowerCase())) continue
342
+ const manifest = join(profile.directory, 'node_modules', ...name.split('/'), 'package.json')
343
+ const parsed = await readJson(manifest).catch(() => null)
344
+ if (parsed) {
345
+ const constraint = hostConstraint(parsed)
346
+ return { name, directory: join(profile.directory, 'node_modules', ...name.split('/')),
347
+ version: parsed.version, range: constraint.range, rangeSource: constraint.source }
348
+ }
349
+ }
350
+ return null
351
+ }
352
+
353
+ /**
354
+ * Load a companion's entry and build the integration OpenWrite would mount, so an
355
+ * entry that no longer accepts our options is caught here instead of taking the
356
+ * preset composition down with it. Returns null when usable, or why it is not.
357
+ */
358
+ async function validateEntry(directory, entry, build) {
359
+ if (!resolvesEntry(directory, entry)) return `installed package does not expose ${entry}`
360
+ try {
361
+ const resolved = createRequire(join(directory, 'package.json')).resolve(entry)
362
+ const module = await import(pathToFileURL(resolved).href)
363
+ const factory = module.integration ?? module.default
364
+ if (typeof factory !== 'function') return `${entry} no longer exports an integration factory`
365
+ // The mounted row immediately calls `.apply` on the result, so a factory
366
+ // that returns nothing must be refused here rather than at activation.
367
+ const built = build(factory)
368
+ if (built === null || typeof built !== 'object' || typeof built.apply !== 'function') {
369
+ return `${entry} returned no plugin object`
370
+ }
371
+ return null
372
+ } catch (error) {
373
+ return `${entry} rejected the OpenWrite integration: ${error instanceof Error ? error.message : String(error)}`
374
+ }
375
+ }
376
+
377
+ /** Whether one package directory exposes the entry OpenWrite would import. */
378
+ function resolvesEntry(directory, entry) {
379
+ try {
380
+ createRequire(join(directory, 'package.json')).resolve(entry)
381
+ return true
382
+ } catch { return false }
383
+ }
384
+
385
+ /**
386
+ * Whether a companion's own bundle patch is self-consistent.
387
+ *
388
+ * The host imports each `insert` row by its `name`, resolved as a package from
389
+ * the profile. A scoped package that ships a patch naming the bare package
390
+ * (`@ebony-vinyl/dsh-our-free-model` declaring `name: dsh-our-free-model`)
391
+ * therefore never activates: the host reports
392
+ * "Cannot find package 'dsh-our-free-model'". Detecting that here turns a
393
+ * panel that shows "available" and then fails to read the gateway into an
394
+ * accurate "installed but the host cannot load it".
395
+ */
396
+ async function bundlePatchReason(directory, packageName) {
397
+ const manifest = await readJson(join(directory, 'package.json')).catch(() => null)
398
+ const patch = manifest?.dsh?.bundle?.patch
399
+ if (typeof patch !== 'string' || patch === '') return null
400
+ const text = await readFile(join(directory, patch), 'utf8').catch(() => null)
401
+ if (text === null) return `bundle patch ${patch} is missing`
402
+ // Minimal scan: every `name:` value in an insert block. A full YAML parse is
403
+ // deliberately avoided so a malformed patch cannot make detection itself fail.
404
+ const names = [...text.matchAll(/^\s*-?\s*name:\s*['"]?([^'"\n]+)['"]?\s*$/gm)].map(m => m[1].trim())
405
+ if (names.length === 0) return null
406
+ for (const name of names) {
407
+ if (name === packageName) continue
408
+ // A relative or subpath specifier is resolved against the package itself.
409
+ if (name.startsWith('.') || name.startsWith('#')) continue
410
+ try {
411
+ const resolved = createRequire(join(directory, 'package.json')).resolve(name)
412
+ if (resolved.startsWith(resolve(directory))) continue
413
+ } catch { /* fall through to the mismatch report */ }
414
+ return `bundle patch names ${name}, which is not this package (${packageName}); the host cannot load it`
415
+ }
416
+ return null
417
+ }
418
+ /**
419
+ * Why a companion is not usable because of its declared host constraint, or null
420
+ * when the constraint covers the running host.
421
+ *
422
+ * A companion may declare several constraints (dsh-dog declares one range per
423
+ * `@deepseek-ai/dsh-*` sub-package); the host needs all of them, so every one
424
+ * must cover. An unknown host version is reported rather than silently accepted,
425
+ * because "no constraint declared" and "cannot tell" must not read the same.
426
+ */
427
+ function rangeExclusionReason(host, candidate) {
428
+ const range = candidate.range
429
+ if (range === null || range === undefined) {
430
+ return host === null || host === undefined
431
+ ? 'the running host version is unknown, so its declared range cannot be checked'
432
+ : null
433
+ }
434
+ if (host === null || host === undefined) {
435
+ const shown = candidate.rangeSource === '@deepseek-ai/dsh-*'
436
+ ? `${candidate.range.split(' ').filter(Boolean).length} @deepseek-ai/dsh-* peer ranges`
437
+ : `declared range ${range}`
438
+ return `the running host version is unknown, so ${shown} cannot be checked`
439
+ }
440
+ if (satisfiesRange(host, range)) return null
441
+ const source = candidate.rangeSource === '@deepseek-ai/dsh-*'
442
+ ? 'its @deepseek-ai/dsh-* peer ranges'
443
+ : `declared host range ${range}`
444
+ return `${source} excludes dsh ${host}`
445
+ }
446
+
447
+ /**
448
+ * Describe every companion: whether it is installed and whether the running host
449
+ * would load it.
450
+ */
451
+ export async function detectCompanions(home, options = {}) {
452
+ const { profiles, fingerprint } = await readProfiles(home)
453
+ // An explicit host version wins; `undefined` means "resolve it" while an
454
+ // explicit null forces the unknown-host path.
455
+ const host = 'hostVersion' in options ? options.hostVersion : await resolveHostVersion(home)
456
+ const describe = async (names, entry, build) => {
457
+ // Every profile is examined: readdir order is unspecified, so stopping at
458
+ // the first match would make the answer depend on directory order when one
459
+ // profile carries a usable copy and another does not.
460
+ const found = []
461
+ for (const profile of profiles) {
462
+ const installed = await installedPackage(profile, names)
463
+ if (installed) found.push({ profile, ...installed })
464
+ }
465
+ if (found.length === 0) return { installed: false, usable: false, version: null, range: null, profile: null, reason: null }
466
+ const described = []
467
+ for (const candidate of found) {
468
+ const entryReason = entry === undefined ? null : await validateEntry(candidate.directory, entry, build)
469
+ // A companion whose own bundle patch cannot be loaded by the host is not
470
+ // usable however well its range matches: the panel would offer an
471
+ // integration that never activates.
472
+ const patchReason = entryReason ?? await bundlePatchReason(candidate.directory, candidate.name)
473
+ // A null range means the plugin declares no host constraint at all. That is
474
+ // not "usable everywhere": an unknown constraint is treated as covering,
475
+ // because the host would also accept it, but it is reported so the panel
476
+ // can say the range is unknown rather than asserting compatibility.
477
+ const rangeReason = patchReason ?? rangeExclusionReason(host, candidate)
478
+ const usable = rangeReason === null
479
+ described.push({ installed: true, usable, version: candidate.version ?? null, range: candidate.range ?? null,
480
+ rangeSource: candidate.rangeSource ?? null,
481
+ profile: candidate.profile.name, reason: rangeReason })
482
+ }
483
+ return described.find(candidate => candidate.usable) ?? described[0]
484
+ }
485
+ const [dog, ops, freeModel] = await Promise.all([
486
+ describe(companionNames.dog),
487
+ describe(companionNames.ops, companionEntries.ops, opsIntegration),
488
+ describe(companionNames.freeModel),
489
+ ])
490
+ const result = { host, dog, ops, freeModel }
491
+ const cache = join(home, 'openwrite', 'companions.json')
492
+ // The cache key must include this plugin's own version. Keying only on the
493
+ // profile manifests and the host meant an upgrade that changes the detection
494
+ // logic (new companion, corrected range semantics) kept serving the previous
495
+ // answer: a real 0.2.1-rc.5 host showed the old "ops excludes dsh 0.2.1-rc.5"
496
+ // verdict and no DoG row at all, until the manifests happened to change.
497
+ const build = await readJson(new URL('package.json', import.meta.url)).then(m => m?.version).catch(() => null)
498
+ const cached = await readJson(cache).catch(() => null)
499
+ if (cached?.fingerprint === fingerprint && cached.host === host && cached.build === build) {
500
+ // Serve the stored answer: recomputing on every call would re-read every
501
+ // profile and re-import the companion entry each time.
502
+ const { fingerprint: _fingerprint, build: _build, ...rest } = cached
503
+ return { ...rest, host, cached: true }
504
+ }
505
+ await mkdir(join(home, 'openwrite'), { recursive: true }).catch(() => {})
506
+ await writeFile(cache, JSON.stringify({ fingerprint, host, build, ...result }) + '\n').catch(() => {})
507
+ return { ...result, cached: false }
508
+ }
package/cordis.patch.yml CHANGED
@@ -7,5 +7,3 @@
7
7
  mode: managed
8
8
  - id: studio-panel
9
9
  name: ./packages/studio-panel/lib/index.js
10
- - id: openwrite-dog
11
- name: ./vendor/dsh-dog/lib/index.js
@@ -11,6 +11,10 @@
11
11
  - 运行时:跨平台回归发现自动恢复耗尽后的手动重试可能复用尚未清理的旧启动 promise,返回失效连接。现在等待旧启动结束后重新准备;并发重试共享新连接,后来发生的取消优先。五项可控时序回归在旧实现上失败,修复后通过。
12
12
  - #114:本版包含 Core 5.8.15 的工作台 Console 认证、自动工作区头与请求前凭据更新。此前真实工作台连接/流式写作通过;此次真实 Go 安全评审首次请求 max_tokens=24000,流式调用 4.424 秒通过,没有写入作者正文或作品配置。
13
13
 
14
+ ## 后续实机证据(2026-10-27)
15
+
16
+ 报告者已补充[旧会话恢复记录](https://github.com/LiPu-jpg/Openwrite/issues/123#issuecomment-6422870871)和 [B-13 Windows 实测](https://github.com/LiPu-jpg/Openwrite/issues/123#issuecomment-6423904648):旧预设目录缺失时历史会话正常打开;行首/行中 `>`、混合行尾锚点预览通过。两项原场景验证据此完成,不再索要同一材料。此前“待最小复现/实机”的记录反映当时证据边界;新补修与候选状态见 [#123 回复补修](ISSUE_123_FOLLOWUP.md)。
17
+
14
18
  ## 保留的未完成工作
15
19
 
16
20
  统一跟踪:[issue #123](https://github.com/LiPu-jpg/Openwrite/issues/123)。
@@ -18,11 +22,9 @@
18
22
  | 来源 | 已有证据 | 尚待完成 |
19
23
  | --- | --- | --- |
20
24
  | #98.1 | 独立停机旁路升级工具已发布;宿主候选补丁通过 Windows/Linux 专项 CI。 | [fork PR #1](https://github.com/LiPu-jpg/deepseek-harness/pull/1) 尚未成为正式宿主;[上游讨论](https://github.com/deepseek-ai/deepseek-harness/discussions/10745) 继续跟踪。原生安装命令不受插件工具保护。 |
21
- | #105 | 稳定 ID、永久别名、目录保留、原日志与自定义预设保护回归通过。 | 报告者 Windows Electron 历史会话完整恢复的实机验收。 |
22
- | #109 B-13 | 字面匹配及两层传输回归通过。 | 原始失败文件片段、完整参数和行尾/空格信息的最小复现。 |
23
25
  | #111 | 不支持平台返回明确错误并提供外部 Studio 配置;原报告接受此降级方向。 | Android/Termux 真机与外部 Studio 实测;当前没有 Android 原生运行时。 |
24
26
 
25
- 新增的 [#121](https://github.com/LiPu-jpg/Openwrite/issues/121)、[#122](https://github.com/LiPu-jpg/Openwrite/issues/122) 属于独立反馈,保持打开,本次发布不将其列为已解决。
27
+ 后续独立反馈 [#121](https://github.com/LiPu-jpg/Openwrite/issues/121)、[#122](https://github.com/LiPu-jpg/Openwrite/issues/122) 已修复、验收并合并关闭;修复已包含于正式 0.2.28。发行和关闭代码报告不代替上述两项外部条件。
26
28
 
27
29
  ## 后续关闭条件
28
30