@try-works/dsh-recursive-mode 0.3.1 → 0.4.0

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 (117) hide show
  1. package/README.md +959 -0
  2. package/lib/client.js +9 -2
  3. package/lib/closeout-report.d.ts +113 -0
  4. package/lib/closeout-standards.d.ts +35 -0
  5. package/lib/closeout.d.ts +12 -0
  6. package/lib/commands.d.ts +1 -1
  7. package/lib/config.d.ts +202 -0
  8. package/lib/delegation.d.ts +123 -3
  9. package/lib/enforcement.d.ts +90 -1
  10. package/lib/errors.d.ts +168 -0
  11. package/lib/git-context.d.ts +17 -0
  12. package/lib/guard-log.d.ts +39 -0
  13. package/lib/handoff.d.ts +29 -0
  14. package/lib/hooks.d.ts +103 -0
  15. package/lib/identity.d.ts +61 -0
  16. package/lib/index.d.ts +32 -12
  17. package/lib/index.js +9819 -3858
  18. package/lib/job-log.d.ts +34 -0
  19. package/lib/jobs-runner.d.ts +105 -0
  20. package/lib/json-safe.d.ts +33 -0
  21. package/lib/lock.d.ts +42 -0
  22. package/lib/memory-feedback.d.ts +52 -0
  23. package/lib/memory-select.d.ts +78 -0
  24. package/lib/memory.d.ts +137 -0
  25. package/lib/model-inventory.d.ts +106 -0
  26. package/lib/phase-graph.d.ts +111 -0
  27. package/lib/phase-rules.d.ts +67 -8
  28. package/lib/plan-gate.d.ts +68 -0
  29. package/lib/policy-globs.d.ts +222 -0
  30. package/lib/policy-write.d.ts +42 -0
  31. package/lib/policy.d.ts +39 -0
  32. package/lib/recursive_ask.tool.d.ts +88 -0
  33. package/lib/recursive_closeout.tool.d.ts +1 -1
  34. package/lib/recursive_delegate.tool.d.ts +22 -0
  35. package/lib/recursive_preview.tool.d.ts +48 -0
  36. package/lib/recursive_review.tool.d.ts +28 -0
  37. package/lib/result-cap.d.ts +70 -0
  38. package/lib/review-round.d.ts +82 -0
  39. package/lib/review.d.ts +9 -0
  40. package/lib/role-route.d.ts +122 -0
  41. package/lib/router.d.ts +90 -5
  42. package/lib/runtime.d.ts +252 -12
  43. package/lib/settlement.d.ts +132 -0
  44. package/lib/skills-phase.d.ts +71 -0
  45. package/lib/status.d.ts +53 -1
  46. package/lib/teams-loop.d.ts +91 -2
  47. package/lib/training.d.ts +211 -0
  48. package/lib/ts-lint.d.ts +15 -0
  49. package/lib/types.d.ts +48 -0
  50. package/lib/workflow-audit.d.ts +207 -0
  51. package/package.json +29 -30
  52. package/preset/recursive.patch.yml +312 -0
  53. package/scripts/e2e-run.mjs +51 -0
  54. package/scripts/link-dsh.mjs +233 -0
  55. package/scripts/live/fake-llm.mjs +150 -0
  56. package/scripts/live-session-plugin.mjs +179 -0
  57. package/scripts/live-session-stock.mjs +106 -0
  58. package/scripts/live-session.mjs +139 -0
  59. package/src/client/derive.ts +18 -2
  60. package/src/closeout-report.ts +274 -0
  61. package/src/closeout-standards.ts +102 -0
  62. package/src/closeout.ts +39 -2
  63. package/src/commands.ts +116 -4
  64. package/src/config.ts +113 -0
  65. package/src/delegation.ts +336 -18
  66. package/src/enforcement.ts +262 -72
  67. package/src/errors.ts +197 -0
  68. package/src/git-context.ts +33 -2
  69. package/src/guard-log.ts +134 -0
  70. package/src/handoff.ts +62 -0
  71. package/src/hooks.ts +316 -0
  72. package/src/identity.ts +230 -0
  73. package/src/index.ts +385 -20
  74. package/src/job-log.ts +112 -0
  75. package/src/jobs-runner.ts +222 -0
  76. package/src/json-safe.ts +75 -0
  77. package/src/lock.ts +153 -16
  78. package/src/memory-feedback.ts +185 -0
  79. package/src/memory-select.ts +187 -0
  80. package/src/memory.ts +309 -0
  81. package/src/model-inventory.ts +196 -0
  82. package/src/phase-graph.ts +191 -0
  83. package/src/phase-rules.ts +236 -0
  84. package/src/plan-gate.ts +111 -0
  85. package/src/policy-globs.ts +636 -0
  86. package/src/policy-write.ts +210 -0
  87. package/src/policy.ts +70 -5
  88. package/src/recursive_ask.tool.ts +276 -0
  89. package/src/recursive_audit_team.tool.ts +7 -3
  90. package/src/recursive_closeout.tool.ts +36 -35
  91. package/src/recursive_delegate.tool.ts +194 -0
  92. package/src/recursive_init.tool.ts +4 -3
  93. package/src/recursive_lint.tool.ts +81 -6
  94. package/src/recursive_lock.tool.ts +21 -4
  95. package/src/recursive_phase.tool.ts +3 -2
  96. package/src/recursive_preview.tool.ts +142 -0
  97. package/src/recursive_review.tool.ts +190 -0
  98. package/src/recursive_scratch.tool.ts +5 -4
  99. package/src/recursive_status.tool.ts +3 -2
  100. package/src/recursive_worktree.tool.ts +6 -5
  101. package/src/result-cap.ts +130 -0
  102. package/src/review-round.ts +335 -0
  103. package/src/review.ts +17 -3
  104. package/src/role-route.ts +230 -0
  105. package/src/router.ts +128 -2
  106. package/src/runtime.ts +968 -39
  107. package/src/settlement.ts +355 -0
  108. package/src/skills-phase.ts +143 -0
  109. package/src/snapshot.ts +39 -8
  110. package/src/status.ts +209 -4
  111. package/src/teams-loop.ts +223 -9
  112. package/src/training.ts +565 -0
  113. package/src/ts-lint.ts +38 -4
  114. package/src/types.ts +51 -0
  115. package/src/workflow-audit.ts +288 -0
  116. package/scripts/install-preset.cmd +0 -7
  117. package/scripts/install-preset.js +0 -101
@@ -0,0 +1,233 @@
1
+ /**
2
+ * T34 — re-link the `@deepseek-ai/*` devDependencies at whatever depth this repo
3
+ * happens to sit.
4
+ *
5
+ * THE DEFECT THIS FIXES. `package.json` declares each harness dependency as
6
+ * `link:D:/deepseek-harness/packages/...` — an ABSOLUTE path — but pnpm records the
7
+ * target RELATIVE in the lockfile (`version: link:../../deepseek-harness/...`). So
8
+ * `pnpm install` only produces working links when the repo sits at exactly the depth
9
+ * the lockfile assumed: two levels below the drive root, beside the checkout. Clone
10
+ * it anywhere else — deeper, or on another drive — and every `@deepseek-ai/*` module
11
+ * becomes unresolvable: `tsc` reports `TS2307: Cannot find module` for all of `src/`,
12
+ * and the specs that import them fail to load. A public repo that cannot be cloned
13
+ * anywhere is a defect, not a documentation gap.
14
+ *
15
+ * THE FIX, AND WHY IT IS THIS ONE. The absolute targets are already IN
16
+ * `package.json`, so the links can be rebuilt from it with no network, no registry
17
+ * and no re-resolution — which matters because the pinned revision is a local
18
+ * checkout that is not published. `DSH_HARNESS_ROOT` relocates the whole set when the
19
+ * checkout itself moved. An absent checkout DEGRADES with a clear message and exit 0
20
+ * rather than failing the install: a machine without the harness can still read the
21
+ * repo, and the failure it will see is the honest "this module is not linked".
22
+ *
23
+ * Windows note: directory symlinks need elevation, junctions do not — so this uses
24
+ * `junction` on win32, which is also what pnpm itself does.
25
+ */
26
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync } from 'node:fs'
27
+ import { dirname, join, relative, resolve, sep } from 'node:path'
28
+
29
+ /** The package name prefix every harness dependency shares. */
30
+ export const LINKED_SCOPE = '@deepseek-ai/'
31
+
32
+ /**
33
+ * Layout directories that the inferred common prefix must not swallow: dropping one
34
+ * of these from a link target would point it at the wrong place.
35
+ */
36
+ const LAYOUT_SEGMENTS = new Set(['packages', 'vendor'])
37
+
38
+ /**
39
+ * The root the recorded absolute targets share, so a moved checkout can be
40
+ * relocated coherently. Returns null when the specs do not share one (which would
41
+ * mean the repo was hand-edited into an inconsistent state).
42
+ *
43
+ * The common prefix can OVERSHOOT into a layout directory: when every dependency
44
+ * happens to live under one subtree (all under `packages/`, say), the prefix stops
45
+ * at `packages` and re-rooting would silently drop that segment, pointing every link
46
+ * at `<root>/<pkg>` instead of `<root>/packages/<pkg>`. This repo's real dependency
47
+ * set spans `packages/` and `vendor/`, so the prefix already lands on the checkout
48
+ * root — but a set that does not must not silently produce wrong targets, so a
49
+ * trailing layout segment is stripped. `DSH_HARNESS_ROOT` therefore always means the
50
+ * CHECKOUT ROOT, which is what a user would expect to pass.
51
+ * @param {{ [name: string]: string }} devDependencies
52
+ * @returns {string | null}
53
+ */
54
+ export function pinnedRootOf(devDependencies) {
55
+ const targets = linkTargetsOf(devDependencies).map((entry) => entry.target)
56
+ if (targets.length === 0) return null
57
+ const split = targets.map((target) => target.split(/[\\/]/))
58
+ const first = split[0]
59
+ const common = []
60
+ for (let i = 0; i < first.length; i += 1) {
61
+ const candidate = first[i]
62
+ if (!split.every((parts) => parts[i] === candidate)) break
63
+ common.push(candidate)
64
+ }
65
+ if (common.length > 1 && LAYOUT_SEGMENTS.has(common[common.length - 1])) common.pop()
66
+ return common.join('/') || null
67
+ }
68
+
69
+ /**
70
+ * Every scoped dependency declared as a `link:` spec, with its absolute target.
71
+ * @param {{ [name: string]: string }} devDependencies
72
+ * @returns {Array<{ name: string; target: string }>}
73
+ */
74
+ export function linkTargetsOf(devDependencies) {
75
+ const out = []
76
+ for (const [name, spec] of Object.entries(devDependencies ?? {})) {
77
+ if (!name.startsWith(LINKED_SCOPE)) continue
78
+ if (typeof spec !== 'string' || !spec.startsWith('link:')) continue
79
+ out.push({ name, target: spec.slice('link:'.length).replace(/\\/g, '/') })
80
+ }
81
+ return out.sort((a, b) => a.name.localeCompare(b.name))
82
+ }
83
+
84
+ /**
85
+ * Where each dependency should point, given an optional relocated checkout root.
86
+ * @param {{ devDependencies?: object, harnessRoot?: string | null }} input
87
+ * @returns {Array<{ name: string; target: string; exists: boolean }>}
88
+ */
89
+ export function planLinks(input) {
90
+ const targets = linkTargetsOf(input.devDependencies)
91
+ const pinned = pinnedRootOf(input.devDependencies)
92
+ const root = input.harnessRoot ?? pinned
93
+ return targets.map(({ name, target }) => {
94
+ let resolvedTarget = target
95
+ if (input.harnessRoot && pinned && target.startsWith(pinned)) {
96
+ const rest = relative(pinned, target).split(sep).join('/')
97
+ resolvedTarget = join(input.harnessRoot, rest).replace(/\\/g, '/')
98
+ }
99
+ return { name, target: resolvedTarget, exists: existsSync(resolvedTarget) }
100
+ })
101
+ }
102
+
103
+ /** Where a scoped package's link lives inside the repo. */
104
+ export function linkPathOf(repoRoot, name) {
105
+ return join(repoRoot, 'node_modules', ...name.split('/'))
106
+ }
107
+
108
+ /** True when the path is already a link (or junction) to the wanted target. */
109
+ function alreadyLinked(path, target) {
110
+ try {
111
+ const stat = lstatSync(path)
112
+ if (!stat.isSymbolicLink()) return false
113
+ const current = readlinkSync(path).replace(/\\/g, '/')
114
+ return resolve(current) === resolve(target)
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Remove whatever currently occupies a link path.
122
+ *
123
+ * A LINK IS NOT A DIRECTORY. `rmSync(..., { recursive: true })` does not reliably
124
+ * remove a junction whose target is gone — which is exactly the state a clone at the
125
+ * wrong depth leaves behind, because pnpm created links to a relative path that does
126
+ * not exist. The removal then silently failed and every `symlinkSync` after it threw
127
+ * `EEXIST`. Links are therefore unlinked, not recursed into (which is also the SAFE
128
+ * choice: recursing through a live junction would delete the checkout's contents).
129
+ */
130
+ function removeExisting(path) {
131
+ try {
132
+ const stat = lstatSync(path)
133
+ if (stat.isSymbolicLink()) {
134
+ unlinkSync(path)
135
+ return
136
+ }
137
+ rmSync(path, { recursive: true, force: true })
138
+ } catch (err) {
139
+ if (err && err.code === 'ENOENT') return
140
+ throw err
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Create or repair every harness link under `<repoRoot>/node_modules`.
146
+ *
147
+ * Idempotent, and never throws for a missing checkout: the caller gets a report so
148
+ * it can print an honest message instead of a stack trace. Failures are RETURNED in
149
+ * `errors` as well as logged, because a logger that defaults to silence once hid
150
+ * fifteen of them.
151
+ * @param {{ repoRoot: string; devDependencies?: object; harnessRoot?: string | null; log?: (line: string) => void }} input
152
+ * @returns {{ linked: string[]; repaired: string[]; missing: string[]; errors: Array<{ name: string; message: string }> }}
153
+ */
154
+ export function linkDsh(input) {
155
+ const log = input.log ?? (() => {})
156
+ const plan = planLinks({ devDependencies: input.devDependencies, harnessRoot: input.harnessRoot })
157
+ const linked = []
158
+ const repaired = []
159
+ const missing = []
160
+ const errors = []
161
+ for (const entry of plan) {
162
+ if (!entry.exists) {
163
+ missing.push(entry.name)
164
+ continue
165
+ }
166
+ const path = linkPathOf(input.repoRoot, entry.name)
167
+ if (alreadyLinked(path, entry.target)) {
168
+ repaired.push(entry.name)
169
+ continue
170
+ }
171
+ try {
172
+ mkdirSync(dirname(path), { recursive: true })
173
+ removeExisting(path)
174
+ symlinkSync(entry.target, path, process.platform === 'win32' ? 'junction' : 'dir')
175
+ linked.push(entry.name)
176
+ } catch (err) {
177
+ // One un-linkable package must not abort the rest: the remaining links are
178
+ // still worth having, and the caller reports what failed.
179
+ const message = err instanceof Error ? err.message : String(err)
180
+ errors.push({ name: entry.name, message })
181
+ missing.push(entry.name)
182
+ log(' ! ' + entry.name + ': ' + message)
183
+ }
184
+ }
185
+ return { linked, repaired, missing, errors }
186
+ }
187
+
188
+ /** Read this repo's devDependencies (the absolute targets live there, not in the lockfile). */
189
+ export function readDevDependencies(repoRoot) {
190
+ try {
191
+ const pkg = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'))
192
+ return pkg.devDependencies ?? {}
193
+ } catch {
194
+ return {}
195
+ }
196
+ }
197
+
198
+ function main() {
199
+ const repoRoot = resolve(process.argv[2] ?? process.cwd())
200
+ const harnessRoot = process.env.DSH_HARNESS_ROOT ? resolve(process.env.DSH_HARNESS_ROOT) : null
201
+ const devDependencies = readDevDependencies(repoRoot)
202
+ const planned = planLinks({ devDependencies, harnessRoot })
203
+
204
+ if (planned.length === 0) {
205
+ console.log('[link-dsh] no ' + LINKED_SCOPE + '* link: dependencies to place')
206
+ return
207
+ }
208
+ const absentRoot = planned.every((entry) => !entry.exists)
209
+ if (absentRoot) {
210
+ console.log('[link-dsh] the DSH checkout was not found at ' + (harnessRoot ?? pinnedRootOf(devDependencies)))
211
+ console.log('[link-dsh] set DSH_HARNESS_ROOT to its location to link ' + planned.length + ' packages, e.g.')
212
+ console.log('[link-dsh] DSH_HARNESS_ROOT=/path/to/deepseek-harness pnpm run link:dsh')
213
+ console.log('[link-dsh] continuing WITHOUT links: typecheck and tests will report unresolved modules')
214
+ return
215
+ }
216
+
217
+ const report = linkDsh({ repoRoot, devDependencies, harnessRoot, log: (line) => console.log(line) })
218
+ const placed = report.linked.length
219
+ const kept = report.repaired.length
220
+ console.log('[link-dsh] linked ' + placed + ', already correct ' + kept + ', unresolved ' + report.missing.length)
221
+ if (report.errors.length > 0) {
222
+ // Failures are printed unconditionally: a logger defaulting to silence once hid
223
+ // fifteen of them behind a cheerful "unresolved 15".
224
+ console.log('[link-dsh] FAILED to link ' + report.errors.length + ' package(s); first error: ' + report.errors[0].message)
225
+ }
226
+ if (report.missing.length > 0) {
227
+ console.log('[link-dsh] unresolved: ' + report.missing.join(', '))
228
+ }
229
+ }
230
+
231
+ if (process.argv[1] && import.meta.url === new URL('file://' + resolve(process.argv[1]).replace(/\\/g, '/')).href) {
232
+ main()
233
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * FU-10 — a SCRIPTED LLM for the live-session harness.
3
+ *
4
+ * WHY THIS EXISTS. A real session needs a model, and a real model would make the verification
5
+ * non-deterministic and cost money. The harness's own e2e does exactly this: it registers a keyless
6
+ * `LlmAdapter` on the default provider route and scripts the tool calls it wants to see. The shape below
7
+ * is copied from `apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs` — a class extending
8
+ * `LlmAdapter` whose `stream()` yields chunk objects, registered through `ctx.llm.registerAdapter`.
9
+ *
10
+ * WHAT IT SCRIPTS: the smallest sequence that reaches the delegated review path in a REAL session —
11
+ * `recursive_init`, then `recursive_review`, then a plain-text answer. Everything else about the session
12
+ * (the Agent, the tool runtime, the plugin, the enforcement) is the real thing; only the model is fake.
13
+ */
14
+ import { ToolCallId } from '@deepseek-ai/dsh-llm'
15
+ import { LlmAdapter } from '@deepseek-ai/dsh-llm'
16
+ import { writeFileSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+
19
+ /**
20
+ * ⚠ FU-9 — THE ADAPTER PLAYS THE CHILD TOO.
21
+ *
22
+ * The parent leg alone could never exercise the repair: the review parks waiting for its child's reply, so
23
+ * a REVISE verdict is what makes the plugin send a `followup` to the SAME child. This adapter therefore
24
+ * also answers the child's brief, and it flips its verdict on the SECOND round so the loop closes:
25
+ *
26
+ * round 1 (the brief) → write a REVISE verdict to the reply file the brief names
27
+ * round 2 (after REPAIR) → write an APPROVE verdict to that SAME file
28
+ *
29
+ * The reply format is measured from `parseReplyVerdict` (`src/delegation.ts`): a JSON object with a
30
+ * `verdict` field, fenced or bare. Anything else and the runtime would fail closed — which is correct
31
+ * behaviour and would prove nothing about the repair.
32
+ */
33
+ const repliesWritten = new Map()
34
+
35
+ const REVISE_REPLY = [
36
+ '# Review reply',
37
+ '',
38
+ '```json',
39
+ '{"verdict":"REVISE","findings":[{"title":"the lock chain permits an out-of-order transition"}]}',
40
+ '```',
41
+ '',
42
+ ].join('\n')
43
+
44
+ const APPROVE_REPLY = [
45
+ '# Review reply (after repair)',
46
+ '',
47
+ '```json',
48
+ '{"verdict":"APPROVE","findings":[]}',
49
+ '```',
50
+ '',
51
+ ].join('\n')
52
+
53
+ /** The text a turn was handed, from the user and system messages. */
54
+ function inputText(messages) {
55
+ return messages
56
+ .filter((message) => message.role === 'user' || message.role === 'system')
57
+ .flatMap((message) => (message.content ?? []).filter((block) => block.type === 'text').map((block) => block.text))
58
+ .join('\n')
59
+ }
60
+
61
+ /** Every tool call this session has already made, read back from the conversation. */
62
+ function calledTools(messages) {
63
+ return messages.flatMap((message) => message.role === 'assistant'
64
+ ? message.content.filter((block) => block.type === 'tool-call').map((block) => block.name)
65
+ : [])
66
+ }
67
+
68
+ function toolCallChunks(specs, counter) {
69
+ const chunks = []
70
+ for (const [index, spec] of specs.entries()) {
71
+ const id = ToolCallId(`live-fixture-${counter.n++}`)
72
+ const args = JSON.stringify(spec.args)
73
+ chunks.push(
74
+ { type: 'block-start', index, blockType: 'tool-call' },
75
+ { type: 'tool-call-delta', index, id, name: spec.name, argumentsDelta: args },
76
+ { type: 'block-end', index, block: { type: 'tool-call', id, name: spec.name, arguments: args } },
77
+ )
78
+ }
79
+ chunks.push(
80
+ { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
81
+ { type: 'finish', reason: { kind: 'tool-calls' } },
82
+ )
83
+ return chunks
84
+ }
85
+
86
+ function textChunks(text) {
87
+ return [
88
+ { type: 'block-start', index: 0, blockType: 'text' },
89
+ { type: 'text-delta', index: 0, text },
90
+ { type: 'block-end', index: 0, block: { type: 'text', text } },
91
+ { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
92
+ { type: 'finish', reason: { kind: 'stop' } },
93
+ ]
94
+ }
95
+
96
+ const RUN_ID = 'live-run'
97
+
98
+ class LiveFixtureAdapter extends LlmAdapter {
99
+ counter = { n: 0 }
100
+
101
+ async * stream(options) {
102
+ const messages = options.messages ?? []
103
+ const called = calledTools(messages)
104
+
105
+ // THE CHILD'S TURN — the brief names the reply file, so this turn belongs to the delegated reviewer.
106
+ // First time: REVISE (which is what makes the plugin send a repair to this SAME child). Second time:
107
+ // APPROVE, closing the loop.
108
+ const reply = inputText(messages).match(/Reply file:\s*(\S+)/i)
109
+ if (reply) {
110
+ const replyPath = reply[1]
111
+ const already = repliesWritten.get(replyPath) === true
112
+ writeFileSync(join(process.cwd(), replyPath), already ? APPROVE_REPLY : REVISE_REPLY, 'utf8')
113
+ repliesWritten.set(replyPath, true)
114
+ yield* textChunks(already
115
+ ? 'APPROVE written to ' + replyPath + ' after the repair.'
116
+ : 'REVISE written to ' + replyPath + '.')
117
+ return
118
+ }
119
+
120
+ // 1. Scaffold the run.
121
+ if (!called.includes('recursive_init')) {
122
+ yield* toolCallChunks([{ name: 'recursive_init', args: { runId: RUN_ID } }], this.counter)
123
+ return
124
+ }
125
+ // 2. Ask for a review of phase 03 — the delegated path FU-9 is about.
126
+ //
127
+ // ⚠ ASKED REPEATEDLY, ON PURPOSE, because the review is TURN-SHAPED: the first call PARKS waiting for
128
+ // the child, and it is a LATER turn that resumes the SAME child (T36: "park, resume the SAME child").
129
+ // A script that asked once left the child with no turn at all — proven by a run whose reply.md was
130
+ // never written. Each further call drives one more round, which is what lets a REVISE verdict produce a
131
+ // repair and then an APPROVE.
132
+ const reviewCalls = called.filter((name) => name === 'recursive_review').length
133
+ if (reviewCalls < 4) {
134
+ yield* toolCallChunks([{ name: 'recursive_review', args: { runId: RUN_ID, phase: '03', role: 'code-reviewer' } }], this.counter)
135
+ return
136
+ }
137
+ // 3. Report and stop. The marker is what the harness greps for in the session log.
138
+ yield* textChunks('LIVE_SESSION_REACHED_REVIEW')
139
+ }
140
+ }
141
+
142
+ /** Cordis plugin name. */
143
+ export const name = 'live-fixture-llm'
144
+ /** The LLM registry this adapter registers into. */
145
+ export const inject = ['llm']
146
+
147
+ /** Register the keyless adapter on the shipped default provider route. */
148
+ export function apply(ctx) {
149
+ ctx.llm.registerAdapter(['deepseek-official'], new LiveFixtureAdapter())
150
+ }
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * FU-10 step 2 — THE LIVE SESSION WITH THIS PLUGIN MOUNTED.
4
+ *
5
+ * Derived from `live-session-stock.mjs`, which is PROVEN to boot (exit 0, agents ran, session logs
6
+ * written). Exactly two things change, and nothing else — that is the point of deriving rather than
7
+ * rewriting:
8
+ *
9
+ * 1. the scripted LLM is OURS (`scripts/live/fake-llm.mjs`), scripting `recursive_init` then
10
+ * `recursive_review`, so a session that gets that far has reached the delegated review path;
11
+ * 2. THIS PLUGIN is inserted into the profile **by file URL**, the pattern the harness uses for its own
12
+ * fixture — so no install and no dependency added to this repo.
13
+ *
14
+ * ⚠ EVERYTHING ELSE IS THE PROVEN RECIPE: the fixture's `dependencies` AND its three bundles (my
15
+ * hand-written profile was rejected as invalid without them), the same patch shape, the filtered env (a
16
+ * child must not inherit this session's `DSH_PROFILE=web`), and the absolute tsx loader file URL.
17
+ */
18
+ import { spawnSync } from 'node:child_process'
19
+ import { existsSync, mkdirSync, openSync, closeSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
20
+ import { tmpdir } from 'node:os'
21
+ import { join } from 'node:path'
22
+
23
+ const HARNESS = 'D:\\deepseek-harness'
24
+ const PLUGIN = 'D:\\DEV\\dsh-recursive-mode'
25
+ const SRC_BIN = join(HARNESS, 'apps', 'cli', 'src', 'bin.ts')
26
+ const TSCONFIG = join(HARNESS, 'tsconfig.json')
27
+ const TSX_LOADER = 'file:///D:/deepseek-harness/node_modules/.pnpm/tsx@4.22.4/node_modules/tsx/dist/loader.mjs'
28
+ const PROMPT = 'Run recursive_init for run live-run, then ask recursive_review to review phase 03.'
29
+
30
+ const fileUrl = (path) => 'file:///' + path.replace(/\\/g, '/')
31
+ const base = join(existsSync('E:\\') ? 'E:\\dsh-rm-live' : tmpdir(), 'plugin-' + new Date().toISOString().replace(/[:.]/g, '-'))
32
+ const home = join(base, '.dsh')
33
+ const repo = join(base, 'repo')
34
+ const sessions = join(home, 'sessions')
35
+ const profileDir = join(home, 'profiles', 'headless')
36
+
37
+ mkdirSync(profileDir, { recursive: true })
38
+ mkdirSync(sessions, { recursive: true })
39
+ mkdirSync(join(repo, 'src'), { recursive: true })
40
+ writeFileSync(join(repo, 'README.md'), '# live session scratch repo\n', 'utf8')
41
+ spawnSync('git', ['init', '-q'], { cwd: repo, stdio: 'ignore' })
42
+
43
+ // The PROVEN profile (fixture deps + fixture bundles), verbatim.
44
+ writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
45
+ name: 'dsh-profile-headless',
46
+ private: true,
47
+ dependencies: { '@deepseek-ai/dsh-experimental-agent-team-profile': 'workspace:^' },
48
+ dsh: {
49
+ profile: {
50
+ bundles: [
51
+ '@deepseek-ai/dsh-base',
52
+ '@deepseek-ai/dsh-headless',
53
+ '@deepseek-ai/dsh-experimental-agent-team-profile',
54
+ ],
55
+ },
56
+ },
57
+ }, undefined, 2) + '\n', 'utf8')
58
+
59
+ // The patch: real provider off, sessions into the temp home, OUR LLM and OUR PLUGIN inserted by file URL.
60
+ writeFileSync(join(profileDir, 'cordis.patch.yml'), [
61
+ '- id: llm-deepseek',
62
+ ' disabled: true',
63
+ '- id: session-persistence-jsonl',
64
+ ' config:',
65
+ " root: '" + sessions.replace(/\\/g, '/') + "'",
66
+ ' compression: none',
67
+ '- insert:',
68
+ ' - id: live-fixture-llm',
69
+ " name: '" + fileUrl(join(PLUGIN, 'scripts', 'live', 'fake-llm.mjs')) + "'",
70
+ ' - id: recursive-mode',
71
+ " name: '" + fileUrl(join(PLUGIN, 'src', 'index.ts')) + "'",
72
+ '',
73
+ ].join('\n'), 'utf8')
74
+
75
+ const outFile = join(base, 'stdout.txt')
76
+ const errFile = join(base, 'stderr.txt')
77
+ const outFd = openSync(outFile, 'w')
78
+ const errFd = openSync(errFile, 'w')
79
+
80
+ const env = { ...process.env }
81
+ for (const key of ['DSH_PROFILE', 'DSH_PROFILE_DIR', 'DSH_SESSION_ID', 'DSH_SHELL', 'DSH_WEB_URL']) delete env[key]
82
+
83
+ console.log('[plugin] base: ' + base)
84
+ console.log('[plugin] repo: ' + repo)
85
+ const result = spawnSync(process.execPath, [
86
+ '--import', TSX_LOADER,
87
+ SRC_BIN,
88
+ '--profile', 'headless',
89
+ PROMPT,
90
+ ], {
91
+ cwd: repo,
92
+ stdio: ['ignore', outFd, errFd],
93
+ env: {
94
+ ...env,
95
+ DSH_HOME: home,
96
+ DSH_AGENTS_HOME: join(base, '.agents'),
97
+ DSH_TELEMETRY_DISABLED: '1',
98
+ DEEPSEEK_API_KEY: '',
99
+ TSX_TSCONFIG_PATH: TSCONFIG,
100
+ },
101
+ })
102
+ closeSync(outFd)
103
+ closeSync(errFd)
104
+
105
+ const stdout = existsSync(outFile) ? readFileSync(outFile, 'utf8') : ''
106
+ const stderr = existsSync(errFile) ? readFileSync(errFile, 'utf8') : ''
107
+ console.log('[plugin] exit: ' + result.status + (result.error ? ' (spawn error: ' + result.error.message + ')' : ''))
108
+ console.log('[plugin] stdout: ' + stdout.length + ' bytes | stderr: ' + stderr.length + ' bytes')
109
+ if (stdout.trim() !== '') console.log('--- stdout tail ---\n' + stdout.trim().split('\n').slice(-14).join('\n'))
110
+ if (stderr.trim() !== '') console.log('--- stderr tail ---\n' + stderr.trim().split('\n').slice(-14).join('\n'))
111
+
112
+ /**
113
+ * FU-9 — THE SECOND, RESUMED INVOCATION.
114
+ *
115
+ * ⚠ WHY IT IS NEEDED, MEASURED: the review PARKS (T36's turn-shaped driver treats waiting as not-an-error)
116
+ * and a ONE-SHOT session exits before the child's first turn is ever scheduled — a run left no child
117
+ * session, no settlement and no reply. The headless app supports `--session-id`, documented as *"resumes a
118
+ * conversation that already exists"* (`packages/bundle/headless/src/index.ts`, via
119
+ * `agents.resume({ resumeSessionId })`), so the parked round gets a later turn in the SAME conversation —
120
+ * which is the shape this plugin was built for.
121
+ */
122
+ function sessionIdUnder(root) {
123
+ if (!existsSync(root)) return null
124
+ for (const scoped of readdirSync(root, { withFileTypes: true })) {
125
+ if (!scoped.isDirectory()) continue
126
+ for (const inner of readdirSync(join(root, scoped.name), { withFileTypes: true })) {
127
+ // ⚠ THE PREFIX STAYS. Measured: passing the bare uuid gives
128
+ // `dsh: session "<uuid>" does not exist; omit --session-id to start a new Session` — the session id
129
+ // the CLI knows is the whole `session-<uuid>`, which is also what the log's first record carries.
130
+ if (inner.isDirectory() && inner.name.startsWith('session-')) return inner.name
131
+ }
132
+ }
133
+ return null
134
+ }
135
+
136
+ const sessionId = sessionIdUnder(sessions)
137
+ if (sessionId !== null) {
138
+ const outFile2 = join(base, 'stdout-resume.txt')
139
+ const errFile2 = join(base, 'stderr-resume.txt')
140
+ const outFd2 = openSync(outFile2, 'w')
141
+ const errFd2 = openSync(errFile2, 'w')
142
+ console.log('[plugin] resuming session: ' + sessionId)
143
+ const resumed = spawnSync(process.execPath, [
144
+ '--import', TSX_LOADER,
145
+ SRC_BIN,
146
+ '--profile', 'headless',
147
+ '--session-id', sessionId,
148
+ 'The reviewer has replied. Continue the review round and report the verdict.',
149
+ ], {
150
+ cwd: repo,
151
+ stdio: ['ignore', outFd2, errFd2],
152
+ env: {
153
+ ...env,
154
+ DSH_HOME: home,
155
+ DSH_AGENTS_HOME: join(base, '.agents'),
156
+ DSH_TELEMETRY_DISABLED: '1',
157
+ DEEPSEEK_API_KEY: '',
158
+ TSX_TSCONFIG_PATH: TSCONFIG,
159
+ },
160
+ })
161
+ closeSync(outFd2)
162
+ closeSync(errFd2)
163
+ const out2 = existsSync(outFile2) ? readFileSync(outFile2, 'utf8') : ''
164
+ console.log('[plugin] resume exit: ' + resumed.status + ' | stdout: ' + out2.trim().split('\n').slice(-4).join(' / '))
165
+ } else {
166
+ console.log('[plugin] resume SKIPPED: no session id found to resume')
167
+ }
168
+
169
+ // The evidence a run happened: OUR plugin's run directory, written by its own tools in a real session.
170
+ const runDir = join(repo, '.recursive', 'run', 'live-run')
171
+ console.log('[plugin] run directory exists: ' + existsSync(runDir))
172
+ if (existsSync(runDir)) {
173
+ const files = readdirSync(runDir).slice(0, 12)
174
+ console.log('[plugin] run artifacts: ' + files.join(', '))
175
+ }
176
+ const reviews = existsSync(join(runDir, 'subagents')) ? readdirSync(join(runDir, 'subagents')).slice(0, 6) : []
177
+ console.log('[plugin] subagents dir: ' + (reviews.length > 0 ? reviews.join(', ') : '(none)'))
178
+ const logs = existsSync(sessions) ? readdirSync(sessions, { recursive: true }).map(String).filter((n) => n.endsWith('.jsonl')) : []
179
+ console.log('[plugin] session logs: ' + logs.length)
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * FU-10 step 1 — THE STOCK SESSION, copied VERBATIM from the harness's own e2e.
4
+ *
5
+ * WHY A SEPARATE SCRIPT. My first attempt added this plugin on the very first run, which mixed two
6
+ * unknowns: whether the launch works at all, and whether my plugin mounts. This script removes the second
7
+ * unknown completely — it is the harness's own fixture (same profile package.json, same bundles, same
8
+ * patch, same scripted LLM, same prompt, same invocation) with nothing of mine added. If this boots, the
9
+ * remaining question is only how to mount a plugin; if it does not, the route is blocked here and no
10
+ * amount of plugin work would have fixed it.
11
+ *
12
+ * Source of every line below: apps/cli/tests/agent-team-headless.e2e.ts (L21-78) and the invocation
13
+ * printed from resolveExampleLaunch.
14
+ */
15
+ import { spawnSync } from 'node:child_process'
16
+ import { existsSync, mkdirSync, mkdtempSync, openSync, closeSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
17
+ import { tmpdir } from 'node:os'
18
+ import { join } from 'node:path'
19
+
20
+ const HARNESS = 'D:\\deepseek-harness'
21
+ const SRC_BIN = join(HARNESS, 'apps', 'cli', 'src', 'bin.ts')
22
+ const TSCONFIG = join(HARNESS, 'tsconfig.json')
23
+ // The absolute file URL to tsx's loader, exactly as resolveExampleLaunch emits it.
24
+ const TSX_LOADER = 'file:///D:/deepseek-harness/node_modules/.pnpm/tsx@4.22.4/node_modules/tsx/dist/loader.mjs'
25
+ const FIXTURE_LLM = join(HARNESS, 'apps', 'cli', 'tests', 'profiles', 'headless', 'tests', 'fixtures', 'team-llm.mjs')
26
+ // The prompt the e2e uses, which is what its scripted LLM is written against.
27
+ const PROMPT = '请先运行 workflow 检查,再使用 Agent Teams 把调研和实现拆给两个 teammate,等待完成后汇总。'
28
+
29
+ const fileUrl = (path) => 'file:///' + path.replace(/\\/g, '/')
30
+ const base = join(existsSync('E:\\') ? 'E:\\dsh-rm-live' : tmpdir(), 'stock-' + new Date().toISOString().replace(/[:.]/g, '-'))
31
+ const home = join(base, '.dsh')
32
+ const sessions = join(home, 'sessions')
33
+ const profileDir = join(home, 'profiles', 'headless')
34
+
35
+ mkdirSync(profileDir, { recursive: true })
36
+ mkdirSync(sessions, { recursive: true })
37
+
38
+ // 1:1 with the e2e's profile.
39
+ writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
40
+ name: 'dsh-profile-headless',
41
+ private: true,
42
+ dependencies: { '@deepseek-ai/dsh-experimental-agent-team-profile': 'workspace:^' },
43
+ dsh: {
44
+ profile: {
45
+ bundles: [
46
+ '@deepseek-ai/dsh-base',
47
+ '@deepseek-ai/dsh-headless',
48
+ '@deepseek-ai/dsh-experimental-agent-team-profile',
49
+ ],
50
+ },
51
+ },
52
+ }, undefined, 2) + '\n', 'utf8')
53
+
54
+ // 1:1 with the e2e's patch: real provider off, sessions into the temp home, fixture LLM inserted.
55
+ writeFileSync(join(profileDir, 'cordis.patch.yml'), [
56
+ '- id: llm-deepseek',
57
+ ' disabled: true',
58
+ '- id: session-persistence-jsonl',
59
+ ' config:',
60
+ " root: '" + sessions.replace(/\\/g, '/') + "'",
61
+ ' compression: none',
62
+ '- insert:',
63
+ ' - id: team-fixture-llm',
64
+ " name: '" + fileUrl(FIXTURE_LLM) + "'",
65
+ '',
66
+ ].join('\n'), 'utf8')
67
+
68
+ const outFile = join(base, 'stdout.txt')
69
+ const errFile = join(base, 'stderr.txt')
70
+ const outFd = openSync(outFile, 'w')
71
+ const errFd = openSync(errFile, 'w')
72
+
73
+ // ⚠ The child must not inherit this session's identity: DSH_PROFILE=web made the CLI see two profiles.
74
+ const env = { ...process.env }
75
+ for (const key of ['DSH_PROFILE', 'DSH_PROFILE_DIR', 'DSH_SESSION_ID', 'DSH_SHELL', 'DSH_WEB_URL']) delete env[key]
76
+
77
+ console.log('[stock] base: ' + base)
78
+ const result = spawnSync(process.execPath, [
79
+ '--import', TSX_LOADER,
80
+ SRC_BIN,
81
+ '--profile', 'headless',
82
+ PROMPT,
83
+ ], {
84
+ cwd: base,
85
+ stdio: ['ignore', outFd, errFd],
86
+ env: {
87
+ ...env,
88
+ DSH_HOME: home,
89
+ DSH_AGENTS_HOME: join(base, '.agents'),
90
+ DSH_TELEMETRY_DISABLED: '1',
91
+ DEEPSEEK_API_KEY: '',
92
+ TSX_TSCONFIG_PATH: TSCONFIG,
93
+ },
94
+ })
95
+ closeSync(outFd)
96
+ closeSync(errFd)
97
+
98
+ const stdout = existsSync(outFile) ? readFileSync(outFile, 'utf8') : ''
99
+ const stderr = existsSync(errFile) ? readFileSync(errFile, 'utf8') : ''
100
+ console.log('[stock] exit: ' + result.status + (result.error ? ' (spawn error: ' + result.error.message + ')' : ''))
101
+ console.log('[stock] stdout: ' + stdout.length + ' bytes | stderr: ' + stderr.length + ' bytes')
102
+ if (stdout.trim() !== '') console.log('--- stdout tail ---\n' + stdout.trim().split('\n').slice(-10).join('\n'))
103
+ if (stderr.trim() !== '') console.log('--- stderr tail ---\n' + stderr.trim().split('\n').slice(-10).join('\n'))
104
+ const logs = existsSync(sessions) ? readdirSync(sessions, { recursive: true }).map(String).filter((n) => n.endsWith('.jsonl')) : []
105
+ console.log('[stock] session logs: ' + logs.length)
106
+ console.log('[stock] workflow marker (TEAM_WORKFLOW_OK): ' + stdout.includes('TEAM_WORKFLOW_OK'))