@try-works/dsh-recursive-mode 0.3.0 → 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.
- package/README.md +959 -0
- package/lib/client.js +9 -2
- package/lib/closeout-report.d.ts +113 -0
- package/lib/closeout-standards.d.ts +35 -0
- package/lib/closeout.d.ts +12 -0
- package/lib/commands.d.ts +1 -1
- package/lib/config.d.ts +202 -0
- package/lib/delegation.d.ts +123 -3
- package/lib/enforcement.d.ts +90 -1
- package/lib/errors.d.ts +168 -0
- package/lib/git-context.d.ts +17 -0
- package/lib/guard-log.d.ts +39 -0
- package/lib/handoff.d.ts +29 -0
- package/lib/hooks.d.ts +103 -0
- package/lib/identity.d.ts +61 -0
- package/lib/index.d.ts +33 -12
- package/lib/index.js +10017 -3969
- package/lib/job-log.d.ts +34 -0
- package/lib/jobs-runner.d.ts +105 -0
- package/lib/json-safe.d.ts +33 -0
- package/lib/lock.d.ts +42 -0
- package/lib/memory-feedback.d.ts +52 -0
- package/lib/memory-select.d.ts +78 -0
- package/lib/memory.d.ts +137 -0
- package/lib/model-inventory.d.ts +106 -0
- package/lib/phase-graph.d.ts +111 -0
- package/lib/phase-rules.d.ts +67 -8
- package/lib/plan-gate.d.ts +68 -0
- package/lib/policy-globs.d.ts +222 -0
- package/lib/policy-write.d.ts +42 -0
- package/lib/policy.d.ts +39 -0
- package/lib/recursive_ask.tool.d.ts +88 -0
- package/lib/recursive_closeout.tool.d.ts +1 -1
- package/lib/recursive_delegate.tool.d.ts +22 -0
- package/lib/recursive_preview.tool.d.ts +48 -0
- package/lib/recursive_review.tool.d.ts +28 -0
- package/lib/result-cap.d.ts +70 -0
- package/lib/review-round.d.ts +82 -0
- package/lib/review.d.ts +9 -0
- package/lib/role-route.d.ts +122 -0
- package/lib/router.d.ts +90 -5
- package/lib/runtime.d.ts +252 -12
- package/lib/settlement.d.ts +132 -0
- package/lib/skills-phase.d.ts +71 -0
- package/lib/skills.d.ts +70 -0
- package/lib/status.d.ts +53 -1
- package/lib/teams-loop.d.ts +91 -2
- package/lib/training.d.ts +211 -0
- package/lib/ts-lint.d.ts +15 -0
- package/lib/types.d.ts +48 -0
- package/lib/workflow-audit.d.ts +207 -0
- package/package.json +31 -31
- package/preset/recursive.patch.yml +312 -0
- package/scripts/e2e-run.mjs +51 -0
- package/scripts/link-dsh.mjs +233 -0
- package/scripts/live/fake-llm.mjs +150 -0
- package/scripts/live-session-plugin.mjs +179 -0
- package/scripts/live-session-stock.mjs +106 -0
- package/scripts/live-session.mjs +139 -0
- package/skills/recursive-mode/SKILL.md +66 -0
- package/src/client/derive.ts +18 -2
- package/src/closeout-report.ts +274 -0
- package/src/closeout-standards.ts +102 -0
- package/src/closeout.ts +39 -2
- package/src/commands.ts +116 -4
- package/src/config.ts +113 -0
- package/src/delegation.ts +336 -18
- package/src/enforcement.ts +262 -72
- package/src/errors.ts +197 -0
- package/src/git-context.ts +33 -2
- package/src/guard-log.ts +134 -0
- package/src/handoff.ts +62 -0
- package/src/hooks.ts +316 -0
- package/src/identity.ts +230 -0
- package/src/index.ts +394 -20
- package/src/job-log.ts +112 -0
- package/src/jobs-runner.ts +222 -0
- package/src/json-safe.ts +75 -0
- package/src/lock.ts +153 -16
- package/src/memory-feedback.ts +185 -0
- package/src/memory-select.ts +187 -0
- package/src/memory.ts +309 -0
- package/src/model-inventory.ts +196 -0
- package/src/phase-graph.ts +191 -0
- package/src/phase-rules.ts +236 -0
- package/src/plan-gate.ts +111 -0
- package/src/policy-globs.ts +636 -0
- package/src/policy-write.ts +210 -0
- package/src/policy.ts +70 -5
- package/src/recursive_ask.tool.ts +276 -0
- package/src/recursive_audit_team.tool.ts +7 -3
- package/src/recursive_closeout.tool.ts +36 -35
- package/src/recursive_delegate.tool.ts +194 -0
- package/src/recursive_init.tool.ts +4 -3
- package/src/recursive_lint.tool.ts +81 -6
- package/src/recursive_lock.tool.ts +21 -4
- package/src/recursive_phase.tool.ts +3 -2
- package/src/recursive_preview.tool.ts +142 -0
- package/src/recursive_review.tool.ts +190 -0
- package/src/recursive_scratch.tool.ts +5 -4
- package/src/recursive_status.tool.ts +3 -2
- package/src/recursive_worktree.tool.ts +6 -5
- package/src/result-cap.ts +130 -0
- package/src/review-round.ts +335 -0
- package/src/review.ts +17 -3
- package/src/role-route.ts +230 -0
- package/src/router.ts +128 -2
- package/src/runtime.ts +968 -39
- package/src/settlement.ts +355 -0
- package/src/skills-phase.ts +143 -0
- package/src/skills.ts +151 -0
- package/src/snapshot.ts +39 -8
- package/src/status.ts +209 -4
- package/src/teams-loop.ts +223 -9
- package/src/training.ts +565 -0
- package/src/ts-lint.ts +38 -4
- package/src/types.ts +51 -0
- package/src/workflow-audit.ts +288 -0
- package/scripts/install-preset.cmd +0 -7
- package/scripts/install-preset.js +0 -101
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* FU-1 — the one-command end-to-end runner.
|
|
4
|
+
*
|
|
5
|
+
* Runs the E: harness (`tests/e2e-harness.spec.ts`) and then points at the run directory it produced, so
|
|
6
|
+
* a human can open the artifacts the workflow actually wrote. It KEEPS the run by default: a harness
|
|
7
|
+
* that deletes its own evidence is not debuggable, which is the whole reason this exists.
|
|
8
|
+
*
|
|
9
|
+
* Why a wrapper at all: the harness must import TypeScript sources, so it runs under vitest rather than
|
|
10
|
+
* bare node. This is the thin, repeatable entry point; the assertions live in the spec.
|
|
11
|
+
*
|
|
12
|
+
* Usage: pnpm e2e (keeps the run)
|
|
13
|
+
* E2E_RUN_ROOT=D:\x pnpm e2e
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from 'node:child_process'
|
|
16
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
|
|
19
|
+
const preferred = process.env.E2E_RUN_ROOT ?? 'E:\\dsh-rm-e2e'
|
|
20
|
+
const root = existsSync('E:\\') ? preferred : join(process.env.TEMP ?? '.', 'dsh-rm-e2e')
|
|
21
|
+
|
|
22
|
+
console.log('[e2e] scratch root: ' + root)
|
|
23
|
+
const result = spawnSync(
|
|
24
|
+
'pnpm',
|
|
25
|
+
['exec', 'vitest', 'run', 'tests/e2e-harness.spec.ts'],
|
|
26
|
+
{
|
|
27
|
+
// ⚠ `shell: true` IS REQUIRED ON WINDOWS: spawning `pnpm.cmd` directly fails with EINVAL because
|
|
28
|
+
// Node cannot execute a batch file without a shell, and the failure is a NULL status with no output —
|
|
29
|
+
// the runner would exit 1 having printed nothing about why. The error is reported below for the same
|
|
30
|
+
// reason: a silent runner is worse than a failing one.
|
|
31
|
+
shell: true,
|
|
32
|
+
stdio: 'inherit',
|
|
33
|
+
env: { ...process.env, E2E_KEEP: '1', E2E_RUN_ROOT: root },
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
if (result.error) console.error('[e2e] could not run vitest: ' + result.error.message)
|
|
37
|
+
if (result.status !== 0 && !result.error) console.error('[e2e] vitest exited ' + result.status)
|
|
38
|
+
|
|
39
|
+
// Point at the newest run so the artifacts can be inspected without hunting for them.
|
|
40
|
+
if (existsSync(root)) {
|
|
41
|
+
const runs = readdirSync(root)
|
|
42
|
+
.map((name) => ({ name, path: join(root, name), at: statSync(join(root, name)).mtimeMs }))
|
|
43
|
+
.sort((a, b) => b.at - a.at)
|
|
44
|
+
if (runs.length > 0) {
|
|
45
|
+
console.log('\n[e2e] newest run: ' + runs[0].path)
|
|
46
|
+
console.log('[e2e] report: ' + join(runs[0].path, 'e2e-report.md'))
|
|
47
|
+
console.log('[e2e] run dir: ' + join(runs[0].path, '.recursive', 'run', 'e2e-run'))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
process.exit(result.status ?? 1)
|
|
@@ -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)
|