@try-works/dsh-recursive-mode 0.1.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/cordis.patch.yml +12 -0
- package/lib/bootstrap.d.ts +35 -0
- package/lib/client/board.d.ts +10 -0
- package/lib/client/contract.d.ts +51 -0
- package/lib/client/derive.d.ts +92 -0
- package/lib/client/index.d.ts +21 -0
- package/lib/client/inspector.d.ts +10 -0
- package/lib/client/node.d.ts +71 -0
- package/lib/client/settings.d.ts +6 -0
- package/lib/client/slots.d.ts +7 -0
- package/lib/client/strip.d.ts +7 -0
- package/lib/client.d.ts +10 -0
- package/lib/client.js +490 -0
- package/lib/closeout.d.ts +23 -0
- package/lib/commands.d.ts +51 -0
- package/lib/delegation.d.ts +92 -0
- package/lib/enforcement.d.ts +53 -0
- package/lib/events.d.ts +173 -0
- package/lib/handoff.d.ts +51 -0
- package/lib/index.d.ts +40 -0
- package/lib/lifecycle.d.ts +107 -0
- package/lib/lock.d.ts +92 -0
- package/lib/policy.d.ts +12 -0
- package/lib/projection.d.ts +29 -0
- package/lib/recursive_closeout.tool.d.ts +8 -0
- package/lib/recursive_init.tool.d.ts +2 -0
- package/lib/recursive_lint.tool.d.ts +2 -0
- package/lib/recursive_lock.tool.d.ts +2 -0
- package/lib/recursive_scratch.tool.d.ts +7 -0
- package/lib/recursive_status.tool.d.ts +2 -0
- package/lib/review.d.ts +39 -0
- package/lib/router.d.ts +77 -0
- package/lib/run.d.ts +29 -0
- package/lib/runtime.d.ts +241 -0
- package/lib/scratch.d.ts +18 -0
- package/lib/status.d.ts +19 -0
- package/lib/types.d.ts +104 -0
- package/lib/workspace.d.ts +50 -0
- package/package.json +119 -0
- package/preset/recursive/agent.cordis.yml +282 -0
- package/preset/recursive/preset.yml +3 -0
- package/scripts/install-recursive-mode.ps1 +956 -0
- package/scripts/install-recursive-mode.py +750 -0
- package/scripts/lint-recursive-run.py +2868 -0
- package/scripts/recursive-closeout.py +541 -0
- package/scripts/recursive-init.py +356 -0
- package/scripts/recursive-lock.py +302 -0
- package/scripts/recursive-status.py +2124 -0
- package/scripts/recursive_phase_rules.py +367 -0
- package/scripts/recursive_router_lib.py +2282 -0
- package/scripts/test-recursive-mode-smoke.ts +204 -0
- package/scripts/verify-locks.py +353 -0
- package/src/bootstrap.ts +118 -0
- package/src/client/board.tsx +61 -0
- package/src/client/contract.ts +58 -0
- package/src/client/derive.ts +241 -0
- package/src/client/index.ts +28 -0
- package/src/client/inspector.tsx +49 -0
- package/src/client/node.ts +156 -0
- package/src/client/settings.tsx +18 -0
- package/src/client/slots.ts +67 -0
- package/src/client/strip.tsx +28 -0
- package/src/client.ts +11 -0
- package/src/closeout.ts +183 -0
- package/src/commands.ts +142 -0
- package/src/delegation.ts +306 -0
- package/src/enforcement.ts +180 -0
- package/src/events.ts +173 -0
- package/src/handoff.ts +165 -0
- package/src/index.ts +283 -0
- package/src/lifecycle.ts +235 -0
- package/src/lock.ts +369 -0
- package/src/policy.ts +56 -0
- package/src/projection.ts +237 -0
- package/src/recursive_closeout.tool.ts +35 -0
- package/src/recursive_init.tool.ts +28 -0
- package/src/recursive_lint.tool.ts +29 -0
- package/src/recursive_lock.tool.ts +33 -0
- package/src/recursive_scratch.tool.ts +42 -0
- package/src/recursive_status.tool.ts +24 -0
- package/src/review.ts +178 -0
- package/src/router.ts +197 -0
- package/src/run.ts +85 -0
- package/src/runtime.ts +564 -0
- package/src/scratch.ts +85 -0
- package/src/status.ts +194 -0
- package/src/types.ts +112 -0
- package/src/workspace.ts +67 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import type { JsonValue } from '@deepseek-ai/dsh-tools'
|
|
3
|
+
import type { RecursiveRuntime } from './runtime.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `recursive_closeout` — scaffold a Phase 4/5/6/7/8 closeout receipt under the
|
|
7
|
+
* SESSION's workspace only (R1 workspace-scoping invariant). The run is resolved
|
|
8
|
+
* via the session agent's cwd -> workspace registry; a runId outside the current
|
|
9
|
+
* workspace is rejected.
|
|
10
|
+
*/
|
|
11
|
+
export function createRecursiveCloseoutTool(recursive: RecursiveRuntime) {
|
|
12
|
+
return defineTool({
|
|
13
|
+
name: 'recursive_closeout',
|
|
14
|
+
description: 'Scaffold a closeout receipt stub (Phase 4/5/6/7/8 delta receipt) for a run in the CURRENT session workspace only. Workspace-scoped: refuses runIds outside the session\'s workspace. Delegates to the RecursiveRuntime service.',
|
|
15
|
+
parameters: {
|
|
16
|
+
phase: { type: 'string', description: 'Closeout phase to scaffold: 04, 05, 06, 07, or 08 (e.g. \'06\' for 06-decisions-update.md). Required.' },
|
|
17
|
+
runId: { type: 'string', description: 'Run id (e.g. 03-something). Required. Must resolve inside the current workspace.' },
|
|
18
|
+
},
|
|
19
|
+
output: {
|
|
20
|
+
schema: { type: 'json' },
|
|
21
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
22
|
+
},
|
|
23
|
+
async execute(args: { phase?: string; runId?: string }, exec) {
|
|
24
|
+
if (!args.phase || !args.runId || args.runId.trim() === '') {
|
|
25
|
+
return { error: 'phase and runId are required' } as const
|
|
26
|
+
}
|
|
27
|
+
const root = await recursive.resolveWorkspaceRoot(exec.agent)
|
|
28
|
+
if (!root) {
|
|
29
|
+
return { error: 'session is not attached to a registered workspace (cannot resolve control-plane root)' } as const
|
|
30
|
+
}
|
|
31
|
+
const result = recursive.closeoutRun(root, args.runId.trim(), args.phase.trim())
|
|
32
|
+
return result as unknown as JsonValue
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import type { JsonValue } from '@deepseek-ai/dsh-tools'
|
|
3
|
+
import type { RecursiveRuntime } from './runtime.ts'
|
|
4
|
+
|
|
5
|
+
export function createRecursiveInitTool(recursive: RecursiveRuntime) {
|
|
6
|
+
return defineTool({
|
|
7
|
+
name: 'recursive_init',
|
|
8
|
+
description: 'Scaffold a new recursive-mode run directory (or ensure an existing one) with stub artifact headers. Delegates to the RecursiveRuntime service (no duplicated scaffolding logic).',
|
|
9
|
+
parameters: {
|
|
10
|
+
runId: { type: 'string', description: 'Run id (e.g. 03-something). Required.' },
|
|
11
|
+
},
|
|
12
|
+
output: {
|
|
13
|
+
schema: { type: 'json' },
|
|
14
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
15
|
+
},
|
|
16
|
+
async execute(args: { runId?: string }, exec) {
|
|
17
|
+
if (!args.runId || args.runId.trim() === '') {
|
|
18
|
+
return { error: 'runId is required' } as const
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const result = await recursive.initRun(args.runId.trim(), exec.agent as { session?: { header?: { cwd?: string } } } | null)
|
|
22
|
+
return result as unknown as JsonValue
|
|
23
|
+
} catch (err) {
|
|
24
|
+
return { error: err instanceof Error ? err.message : String(err) } as const
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import type { JsonValue } from '@deepseek-ai/dsh-tools'
|
|
3
|
+
import type { RecursiveRuntime } from './runtime.ts'
|
|
4
|
+
|
|
5
|
+
export function createRecursiveLintTool(recursive: RecursiveRuntime) {
|
|
6
|
+
return defineTool({
|
|
7
|
+
name: 'recursive_lint',
|
|
8
|
+
description: 'Lint a recursive-mode run artifact for phase-specific issues (gates, TODO, traceability, diff audit). Delegates to the RecursiveRuntime service.',
|
|
9
|
+
parameters: {
|
|
10
|
+
runId: { type: 'string', description: 'Run id. Required.' },
|
|
11
|
+
artifact: { type: 'string', description: 'Optional artifact file name; defaults to the current phase.' },
|
|
12
|
+
},
|
|
13
|
+
output: {
|
|
14
|
+
schema: { type: 'json' },
|
|
15
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
16
|
+
},
|
|
17
|
+
async execute(args: { runId?: string; artifact?: string }, exec) {
|
|
18
|
+
if (!args.runId || args.runId.trim() === '') {
|
|
19
|
+
return { error: 'runId is required' } as const
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const result = await recursive.lintArtifact(args.runId.trim(), args.artifact?.trim(), exec.agent as { session?: { header?: { cwd?: string } } } | null)
|
|
23
|
+
return result as unknown as JsonValue
|
|
24
|
+
} catch (err) {
|
|
25
|
+
return { error: err instanceof Error ? err.message : String(err) } as const
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import type { JsonValue } from '@deepseek-ai/dsh-tools'
|
|
3
|
+
import type { RecursiveRuntime } from './runtime.ts'
|
|
4
|
+
|
|
5
|
+
export function createRecursiveLockTool(recursive: RecursiveRuntime) {
|
|
6
|
+
return defineTool({
|
|
7
|
+
name: 'recursive_lock',
|
|
8
|
+
description: 'Lock a DRAFT recursive-mode artifact: writes Status: LOCKED, LockedAt, LockHash and validates prerequisites (monotonic phase gating). Delegates to the RecursiveRuntime service.',
|
|
9
|
+
parameters: {
|
|
10
|
+
runId: { type: 'string', description: 'Run id. Required.' },
|
|
11
|
+
artifact: { type: 'string', description: 'Artifact file name, e.g. 03-implementation-summary.md. Required.' },
|
|
12
|
+
reopen: { type: 'boolean', description: 'If true, reopen a locked artifact back to DRAFT (invalidates downstream receipts).' },
|
|
13
|
+
},
|
|
14
|
+
output: {
|
|
15
|
+
schema: { type: 'json' },
|
|
16
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
17
|
+
},
|
|
18
|
+
async execute(args: { runId?: string; artifact?: string; reopen?: boolean }, exec) {
|
|
19
|
+
if (!args.runId || args.runId.trim() === '') {
|
|
20
|
+
return { error: 'runId is required' } as const
|
|
21
|
+
}
|
|
22
|
+
if (!args.artifact || args.artifact.trim() === '') {
|
|
23
|
+
return { error: 'artifact is required' } as const
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const result = await recursive.lockArtifact(args.runId.trim(), args.artifact.trim(), args.reopen === true, exec.agent as { session?: { header?: { cwd?: string } } } | null)
|
|
27
|
+
return result as unknown as JsonValue
|
|
28
|
+
} catch (err) {
|
|
29
|
+
return { error: err instanceof Error ? err.message : String(err) } as const
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import type { JsonValue } from '@deepseek-ai/dsh-tools'
|
|
3
|
+
import type { RecursiveRuntime } from './runtime.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `recursive_scratch` — read/write/append the run-scoped disposable scratchpad
|
|
7
|
+
* (R5) under the CURRENT session workspace only (R1). Scratch is git-ignored
|
|
8
|
+
* and never citable as an Input.
|
|
9
|
+
*/
|
|
10
|
+
export function createRecursiveScratchTool(recursive: RecursiveRuntime) {
|
|
11
|
+
return defineTool({
|
|
12
|
+
name: 'recursive_scratch',
|
|
13
|
+
description: 'Read, write, or append the run-scoped disposable scratchpad (scratch/scratch.md or scratch/scratch.ts) for a run in the CURRENT session workspace. Workspace-scoped; scratch is git-ignored and never citable as an Input.',
|
|
14
|
+
parameters: {
|
|
15
|
+
action: { type: 'string', description: 'read | write | append. Required.' },
|
|
16
|
+
runId: { type: 'string', description: 'Run id (e.g. 03-something). Required; must resolve inside the current workspace.' },
|
|
17
|
+
target: { type: 'string', description: 'md | ts. Required.' },
|
|
18
|
+
content: { type: 'string', description: 'Content for write/append. Optional for read.' },
|
|
19
|
+
},
|
|
20
|
+
output: {
|
|
21
|
+
schema: { type: 'json' },
|
|
22
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
23
|
+
},
|
|
24
|
+
async execute(args: { action?: string; runId?: string; target?: string; content?: string }, exec) {
|
|
25
|
+
const action = args.action ?? ''
|
|
26
|
+
const runId = args.runId?.trim() ?? ''
|
|
27
|
+
const target = args.target ?? ''
|
|
28
|
+
if (!action || !runId || !target) {
|
|
29
|
+
return { error: 'action, runId, and target are required' } as const
|
|
30
|
+
}
|
|
31
|
+
if (target !== 'md' && target !== 'ts') {
|
|
32
|
+
return { error: 'target must be md or ts' } as const
|
|
33
|
+
}
|
|
34
|
+
const root = await recursive.resolveWorkspaceRoot(exec.agent)
|
|
35
|
+
if (!root) {
|
|
36
|
+
return { error: 'session is not attached to a registered workspace (cannot resolve control-plane root)' } as const
|
|
37
|
+
}
|
|
38
|
+
const result = recursive.scratchRun(root, runId, action, target as 'md' | 'ts', args.content)
|
|
39
|
+
return result as unknown as JsonValue
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import type { JsonValue } from '@deepseek-ai/dsh-tools'
|
|
3
|
+
import type { RecursiveRuntime } from './runtime.ts'
|
|
4
|
+
|
|
5
|
+
export function createRecursiveStatusTool(recursive: RecursiveRuntime) {
|
|
6
|
+
return defineTool({
|
|
7
|
+
name: 'recursive_status',
|
|
8
|
+
description: 'Show the folded status of a recursive-mode run: phase table, current phase, lock validity. Reads through the RecursiveRuntime service (no duplicated parsing logic).',
|
|
9
|
+
parameters: {
|
|
10
|
+
runId: { type: 'string', description: 'Optional run id (defaults to the latest run by mtime)' },
|
|
11
|
+
},
|
|
12
|
+
output: {
|
|
13
|
+
schema: { type: 'json' },
|
|
14
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
15
|
+
},
|
|
16
|
+
async execute(args: { runId?: string }, exec) {
|
|
17
|
+
// B3: per-call workspace root via exec.agent.session.header.cwd (registry
|
|
18
|
+
// first, cwd fallback) — never the host checkout process.cwd().
|
|
19
|
+
const result = await recursive.status(args.runId, exec.agent as { session?: { header?: { cwd?: string } } } | null)
|
|
20
|
+
if (!result) return { error: 'no recursive run found' } as const
|
|
21
|
+
return result as unknown as JsonValue
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
}
|
package/src/review.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review-bundle builder (Phase B R1). TS port of the canonical
|
|
3
|
+
* recursive-review-bundle minimum-input contract (PROPOSAL 10.4/10.6):
|
|
4
|
+
* gathers the context-in bundle a delegated reviewer needs and materializes it
|
|
5
|
+
* as a durable markdown bundle under evidence/review-bundles/ with an
|
|
6
|
+
* Artifact Path + Artifact Content Hash (LF-normalized sha256) header.
|
|
7
|
+
*
|
|
8
|
+
* Workspace-scoped (run 03 R1): every path resolves under the given control-
|
|
9
|
+
* plane root; this module never scans a registry or another workspace.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { join, resolve, sep } from 'node:path'
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
|
|
15
|
+
export interface ReviewBundleInput {
|
|
16
|
+
root: string
|
|
17
|
+
runId: string
|
|
18
|
+
phase: string
|
|
19
|
+
role: string
|
|
20
|
+
artifactPath: string
|
|
21
|
+
upstreamArtifacts: string[]
|
|
22
|
+
auditQuestions: string[]
|
|
23
|
+
requiredOutput: string
|
|
24
|
+
diffBasis?: {
|
|
25
|
+
baselineType: string
|
|
26
|
+
baselineReference: string
|
|
27
|
+
comparisonReference: string
|
|
28
|
+
normalizedBaseline: string
|
|
29
|
+
normalizedComparison: string
|
|
30
|
+
normalizedDiffCommand: string
|
|
31
|
+
}
|
|
32
|
+
codeRefs?: string[]
|
|
33
|
+
addenda?: string[]
|
|
34
|
+
priorEvidence?: string[]
|
|
35
|
+
memoryRefs?: string[]
|
|
36
|
+
changedFiles?: string[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ReviewBundleResult {
|
|
40
|
+
bundlePath: string
|
|
41
|
+
repoRelativePath: string
|
|
42
|
+
artifactContentHash: string
|
|
43
|
+
markdown: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** LF-normalized sha256 (matches recursive-lock.py content_sha256). */
|
|
47
|
+
export function contentSha256(content: string): string {
|
|
48
|
+
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
49
|
+
return createHash('sha256').update(normalized, 'utf8').digest('hex')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function norm(repoRelative: string): string {
|
|
53
|
+
return repoRelative.replace(/\\/g, '/').replace(/^\/+/, '')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveUnderRoot(root: string, repoRelative: string): string {
|
|
57
|
+
const normalized = norm(repoRelative)
|
|
58
|
+
const rootAbs = resolve(root)
|
|
59
|
+
const abs = resolve(rootAbs, normalized)
|
|
60
|
+
const rootPrefix = rootAbs.endsWith(sep) ? rootAbs : rootAbs + sep
|
|
61
|
+
if (abs !== rootAbs && !abs.startsWith(rootPrefix)) {
|
|
62
|
+
throw new Error('Path escapes the workspace root: ' + repoRelative)
|
|
63
|
+
}
|
|
64
|
+
return abs
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function pathList(title: string, values: string[]): string[] {
|
|
68
|
+
const lines: string[] = [title]
|
|
69
|
+
if (!values.length) {
|
|
70
|
+
lines.push('- none')
|
|
71
|
+
return lines
|
|
72
|
+
}
|
|
73
|
+
for (const v of values) lines.push('- `' + norm(v) + '`')
|
|
74
|
+
return lines
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build a canonical review bundle. Throws when the artifact path is missing or
|
|
79
|
+
* when a path would escape the workspace root (fail loud, never a silent
|
|
80
|
+
* bundle).
|
|
81
|
+
*/
|
|
82
|
+
export function buildReviewBundle(input: ReviewBundleInput): ReviewBundleResult {
|
|
83
|
+
const { root, runId, phase, role } = input
|
|
84
|
+
const runDir = resolveUnderRoot(root, '.recursive/run/' + runId)
|
|
85
|
+
if (!existsSync(runDir)) {
|
|
86
|
+
throw new Error('Run directory does not exist: ' + runId)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const artifactAbs = resolveUnderRoot(root, input.artifactPath)
|
|
90
|
+
if (!existsSync(artifactAbs)) {
|
|
91
|
+
throw new Error('Review-bundle artifact path does not exist: ' + input.artifactPath)
|
|
92
|
+
}
|
|
93
|
+
const artifactHash = contentSha256(readFileSync(artifactAbs, 'utf8'))
|
|
94
|
+
|
|
95
|
+
const bundlesDir = join(runDir, 'evidence', 'review-bundles')
|
|
96
|
+
mkdirSync(bundlesDir, { recursive: true })
|
|
97
|
+
const slug = phase.trim().toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+|-+$/g, '')
|
|
98
|
+
const bundleFileName = (slug || 'review') + '-bundle.md'
|
|
99
|
+
const bundlePath = join(bundlesDir, bundleFileName)
|
|
100
|
+
const rootNorm = resolve(root).replace(/\\/g, '/')
|
|
101
|
+
const repoRelativePath = bundlePath.replace(/\\/g, '/').replace(rootNorm + '/', '')
|
|
102
|
+
|
|
103
|
+
const diff = input.diffBasis
|
|
104
|
+
const diffLines = diff
|
|
105
|
+
? [
|
|
106
|
+
'- Baseline type: ' + diff.baselineType,
|
|
107
|
+
'- Baseline reference: ' + diff.baselineReference,
|
|
108
|
+
'- Comparison reference: ' + diff.comparisonReference,
|
|
109
|
+
'- Normalized baseline: ' + diff.normalizedBaseline,
|
|
110
|
+
'- Normalized comparison: ' + diff.normalizedComparison,
|
|
111
|
+
'- Normalized diff command: ' + diff.normalizedDiffCommand,
|
|
112
|
+
]
|
|
113
|
+
: [
|
|
114
|
+
'- Baseline type: local commit',
|
|
115
|
+
'- Baseline reference: (see 00-worktree.md)',
|
|
116
|
+
'- Comparison reference: working-tree',
|
|
117
|
+
'- Normalized baseline: (see 00-worktree.md)',
|
|
118
|
+
'- Normalized comparison: working-tree',
|
|
119
|
+
'- Normalized diff command: git diff --name-only (see 00-worktree.md)',
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
const changedLines = input.changedFiles?.length
|
|
123
|
+
? input.changedFiles.map((f) => '- `' + norm(f) + '`')
|
|
124
|
+
: ["- (derived from the run's Worktree Diff Audit; see the phase artifact)"]
|
|
125
|
+
|
|
126
|
+
const lines: string[] = [
|
|
127
|
+
'# ' + phase + ' review bundle',
|
|
128
|
+
'',
|
|
129
|
+
'Artifact Path: ' + norm(input.artifactPath),
|
|
130
|
+
'Artifact Content Hash: ' + artifactHash,
|
|
131
|
+
'',
|
|
132
|
+
'## Diff Basis',
|
|
133
|
+
...diffLines,
|
|
134
|
+
'',
|
|
135
|
+
'## Changed Files Reviewed',
|
|
136
|
+
...changedLines,
|
|
137
|
+
'',
|
|
138
|
+
'## Upstream Artifacts To Re-read',
|
|
139
|
+
...pathList('', input.upstreamArtifacts).slice(1),
|
|
140
|
+
'',
|
|
141
|
+
'## Relevant Addenda',
|
|
142
|
+
...pathList('', input.addenda ?? []).slice(1),
|
|
143
|
+
'',
|
|
144
|
+
'## Prior Recursive Evidence',
|
|
145
|
+
...pathList('', input.priorEvidence ?? []).slice(1),
|
|
146
|
+
'',
|
|
147
|
+
'## Targeted Code References',
|
|
148
|
+
...pathList('', input.codeRefs ?? []).slice(1),
|
|
149
|
+
'',
|
|
150
|
+
'## Audit Questions',
|
|
151
|
+
...(input.auditQuestions.length
|
|
152
|
+
? input.auditQuestions.map((q, i) => String(i + 1) + '. ' + q)
|
|
153
|
+
: ['- none']),
|
|
154
|
+
'',
|
|
155
|
+
'## Required Output',
|
|
156
|
+
'',
|
|
157
|
+
input.requiredOutput,
|
|
158
|
+
'',
|
|
159
|
+
'## Relevant Memory References',
|
|
160
|
+
...pathList('', input.memoryRefs ?? []).slice(1),
|
|
161
|
+
'',
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
const markdown = lines.join('\n')
|
|
165
|
+
writeFileSync(bundlePath, markdown, 'utf8')
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
bundlePath,
|
|
169
|
+
repoRelativePath,
|
|
170
|
+
artifactContentHash: artifactHash,
|
|
171
|
+
markdown,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Canonical bundle dir for a run: .recursive/run/<id>/evidence/review-bundles/. */
|
|
176
|
+
export function reviewBundleDir(root: string, runId: string): string {
|
|
177
|
+
return join(resolveUnderRoot(root, '.recursive/run/' + runId), 'evidence', 'review-bundles')
|
|
178
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native router resolution (Phase B R3, PROPOSAL 10.4/10.10): resolves the
|
|
3
|
+
* canonical recursive-router.json policy to a NATIVE DSH subagent provider,
|
|
4
|
+
* keeping the policy file as declarative config and dropping the external-CLI
|
|
5
|
+
* wrappers. Order per role: native provider -> external-CLI route (codex/
|
|
6
|
+
* claude) -> self-audit / local-controller fallback.
|
|
7
|
+
*
|
|
8
|
+
* Workspace-scoped + optionality-preserving: a missing provider, a missing
|
|
9
|
+
* policy, or a failed probe resolves to self-audit, never throws and never
|
|
10
|
+
* scans another workspace.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
export interface RouterDefaults {
|
|
16
|
+
when_role_unconfigured: string
|
|
17
|
+
when_cli_unavailable: string
|
|
18
|
+
when_model_unknown: string
|
|
19
|
+
allow_auto_assign_if_single_cli: boolean
|
|
20
|
+
probe_timeout_ms: number
|
|
21
|
+
invoke_timeout_ms: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RoleRoute {
|
|
25
|
+
enabled: boolean
|
|
26
|
+
mode: string
|
|
27
|
+
cli: string | null
|
|
28
|
+
model: string | null
|
|
29
|
+
fallback: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RouterPolicy {
|
|
33
|
+
version: number
|
|
34
|
+
defaults: RouterDefaults
|
|
35
|
+
role_routes: Record<string, RoleRoute>
|
|
36
|
+
cli_overrides: Record<string, unknown>
|
|
37
|
+
custom_clis: unknown[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type RouteTier = 'native' | 'external-cli' | 'self-audit' | 'local-controller'
|
|
41
|
+
|
|
42
|
+
export interface RouteDecision {
|
|
43
|
+
tier: RouteTier
|
|
44
|
+
provider?: string
|
|
45
|
+
reason: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SubagentProviderLike {
|
|
49
|
+
name: string
|
|
50
|
+
capabilities?: {
|
|
51
|
+
outputSchema?: boolean
|
|
52
|
+
depthLimit?: boolean
|
|
53
|
+
toolFilter?: boolean
|
|
54
|
+
persona?: boolean
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface CapabilityProbe {
|
|
59
|
+
available: boolean
|
|
60
|
+
provider?: string
|
|
61
|
+
capabilities?: {
|
|
62
|
+
outputSchema: boolean
|
|
63
|
+
depthLimit: boolean
|
|
64
|
+
toolFilter: boolean
|
|
65
|
+
persona: boolean
|
|
66
|
+
}
|
|
67
|
+
reason: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const DEFAULT_POLICY: RouterPolicy = {
|
|
71
|
+
version: 1,
|
|
72
|
+
defaults: {
|
|
73
|
+
when_role_unconfigured: 'ask',
|
|
74
|
+
when_cli_unavailable: 'fallback-local',
|
|
75
|
+
when_model_unknown: 'ask',
|
|
76
|
+
allow_auto_assign_if_single_cli: false,
|
|
77
|
+
probe_timeout_ms: 50000,
|
|
78
|
+
invoke_timeout_ms: 180000,
|
|
79
|
+
},
|
|
80
|
+
role_routes: {},
|
|
81
|
+
cli_overrides: {},
|
|
82
|
+
custom_clis: [],
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Parse recursive-router.json. A missing/invalid file yields a default self-audit policy (never throws). */
|
|
86
|
+
export function loadRouterPolicy(path?: string): RouterPolicy {
|
|
87
|
+
if (!path || !existsSync(path)) return DEFAULT_POLICY
|
|
88
|
+
try {
|
|
89
|
+
const raw = JSON.parse(readFileSync(path, 'utf8')) as Partial<RouterPolicy>
|
|
90
|
+
return {
|
|
91
|
+
version: typeof raw.version === 'number' ? raw.version : 1,
|
|
92
|
+
defaults: { ...DEFAULT_POLICY.defaults, ...(raw.defaults ?? {}) },
|
|
93
|
+
role_routes: raw.role_routes ?? {},
|
|
94
|
+
cli_overrides: raw.cli_overrides ?? {},
|
|
95
|
+
custom_clis: raw.custom_clis ?? [],
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
return DEFAULT_POLICY
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Default router policy path inside a workspace root. */
|
|
103
|
+
export function routerPolicyPath(root: string): string {
|
|
104
|
+
return join(root, '.recursive', 'config', 'recursive-router.json')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve a role to a tier, preferring a native provider whose name maps to
|
|
109
|
+
* the role (e.g. role 'code-reviewer' -> provider 'code-reviewer' or the
|
|
110
|
+
* generic spawn/fork provider). External CLIs ride their provider rows; else
|
|
111
|
+
* the policy fallback.
|
|
112
|
+
*/
|
|
113
|
+
export function resolveRole(
|
|
114
|
+
role: string,
|
|
115
|
+
policy: RouterPolicy,
|
|
116
|
+
providers: Record<string, SubagentProviderLike>,
|
|
117
|
+
): RouteDecision {
|
|
118
|
+
const route = policy.role_routes[role]
|
|
119
|
+
if (!route || !route.enabled) {
|
|
120
|
+
return { tier: 'self-audit', reason: 'role ' + role + ' is unconfigured or disabled' }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 1. Native provider: role-mapped name, then the generic spawn/fork.
|
|
124
|
+
const candidates = [role, 'spawn', 'fork', 'dsh-sdk']
|
|
125
|
+
for (const name of candidates) {
|
|
126
|
+
if (providers[name]) {
|
|
127
|
+
return { tier: 'native', provider: name, reason: 'native provider ' + name + ' is registered' }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 2. External-CLI route (codex/claude provider rows).
|
|
132
|
+
if (route.mode === 'external-cli' && route.cli) {
|
|
133
|
+
for (const name of [route.cli, 'codex', 'claude-code']) {
|
|
134
|
+
if (providers[name]) {
|
|
135
|
+
return { tier: 'external-cli', provider: name, reason: 'external CLI ' + route.cli + ' resolves to provider ' + name }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { tier: 'self-audit', reason: 'external CLI ' + route.cli + ' has no registered provider; falling back' }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 3. Fallback.
|
|
142
|
+
if (route.fallback === 'local-controller') {
|
|
143
|
+
return { tier: 'local-controller', reason: 'policy fallback local-controller' }
|
|
144
|
+
}
|
|
145
|
+
return { tier: 'self-audit', reason: 'no native or external provider available; policy fallback ' + (route.fallback || 'self-audit') }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Probe a single provider and return its advertised capabilities. */
|
|
149
|
+
export function probeCapabilities(provider: SubagentProviderLike | undefined): CapabilityProbe {
|
|
150
|
+
if (!provider) {
|
|
151
|
+
return { available: false, reason: 'no provider registered' }
|
|
152
|
+
}
|
|
153
|
+
const caps = provider.capabilities
|
|
154
|
+
return {
|
|
155
|
+
available: true,
|
|
156
|
+
provider: provider.name,
|
|
157
|
+
capabilities: {
|
|
158
|
+
outputSchema: caps?.outputSchema ?? false,
|
|
159
|
+
depthLimit: caps?.depthLimit ?? false,
|
|
160
|
+
toolFilter: caps?.toolFilter ?? false,
|
|
161
|
+
persona: caps?.persona ?? false,
|
|
162
|
+
},
|
|
163
|
+
reason: 'provider ' + provider.name + ' probed',
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Probe the router-relevant capability for a role. No provider -> available
|
|
169
|
+
* false with a concrete reason (the self-audit fallback trigger).
|
|
170
|
+
*/
|
|
171
|
+
export function capabilityProbe(input: {
|
|
172
|
+
providers: Record<string, SubagentProviderLike>
|
|
173
|
+
role: string
|
|
174
|
+
policy?: RouterPolicy
|
|
175
|
+
}): CapabilityProbe {
|
|
176
|
+
const policy = input.policy ?? DEFAULT_POLICY
|
|
177
|
+
const decision = resolveRole(input.role, policy, input.providers)
|
|
178
|
+
if (decision.tier === 'self-audit' || decision.tier === 'local-controller') {
|
|
179
|
+
return { available: false, reason: decision.reason }
|
|
180
|
+
}
|
|
181
|
+
const provider = input.providers[decision.provider ?? '']
|
|
182
|
+
return probeCapabilities(provider)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Render the Delegation Decision Basis prose the phase doc records. */
|
|
186
|
+
export function delegationDecisionBasis(input: {
|
|
187
|
+
role: string
|
|
188
|
+
available: boolean
|
|
189
|
+
provider?: string
|
|
190
|
+
fallback: string
|
|
191
|
+
}): string {
|
|
192
|
+
const { role, available, provider, fallback } = input
|
|
193
|
+
if (available && provider) {
|
|
194
|
+
return 'subagents are available (' + provider + '); ' + role + ' work is delegated by default when the context bundle is complete, with ' + fallback + ' as the guaranteed fallback.'
|
|
195
|
+
}
|
|
196
|
+
return 'no native subagent provider is available for ' + role + ' (probe failed or timed out); the audit is performed as self-audit per the bridge block, never weakening or skipping it.'
|
|
197
|
+
}
|
package/src/run.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Run discovery for recursive-mode runs.
|
|
6
|
+
*
|
|
7
|
+
* Ports the discovery subset of recursive-status.py:
|
|
8
|
+
* - discoverRuns: list run directories under <repoRoot>/.recursive/run/
|
|
9
|
+
* - resolveRunDir: explicit run id, or the latest run by mtime (stable sort)
|
|
10
|
+
*
|
|
11
|
+
* R1 (run 02): dedicated module so tools and status share one discovery path.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface RunDiscoveryResult {
|
|
15
|
+
runDir: string
|
|
16
|
+
runId: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* List run ids under <repoRoot>/.recursive/run/, excluding non-directories.
|
|
21
|
+
* Mirrors: runs = [p for p in run_root.iterdir() if p.is_dir()]
|
|
22
|
+
*/
|
|
23
|
+
export function discoverRuns(repoRoot: string): string[] {
|
|
24
|
+
const runRoot = join(repoRoot, '.recursive', 'run')
|
|
25
|
+
let runs: string[] = []
|
|
26
|
+
try {
|
|
27
|
+
runs = readdirSync(runRoot, { withFileTypes: true })
|
|
28
|
+
// B5: accept directories AND junctions/symlinks — Windows junction run
|
|
29
|
+
// dirs report isDirectory() === false but statSync().isDirectory() ===
|
|
30
|
+
// true (it follows the link). Deliberate for this repo (keeps the
|
|
31
|
+
// template /.recursive/ byte-pristine via a junction).
|
|
32
|
+
.filter(d => {
|
|
33
|
+
if (d.isDirectory()) return true
|
|
34
|
+
if (!d.isSymbolicLink()) return false
|
|
35
|
+
try { return statSync(join(runRoot, d.name)).isDirectory() } catch { return false }
|
|
36
|
+
})
|
|
37
|
+
.map(d => d.name)
|
|
38
|
+
} catch {
|
|
39
|
+
return []
|
|
40
|
+
}
|
|
41
|
+
return runs
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the run directory for an explicit run id, or the latest run by mtime.
|
|
46
|
+
* Mirrors get_latest_run_directory: sort by st_mtime descending, stable sort
|
|
47
|
+
* (ties preserve discovery order — no name-based tie-break).
|
|
48
|
+
*/
|
|
49
|
+
export function resolveRunDir(repoRoot: string, runId?: string): RunDiscoveryResult | null {
|
|
50
|
+
const runRoot = join(repoRoot, '.recursive', 'run')
|
|
51
|
+
if (runId && runId.trim() !== '') {
|
|
52
|
+
return { runDir: join(runRoot, runId.trim()), runId: runId.trim() }
|
|
53
|
+
}
|
|
54
|
+
const latest = getLatestRunDirectory(runRoot)
|
|
55
|
+
if (!latest) return null
|
|
56
|
+
return { runDir: join(runRoot, latest), runId: latest }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Latest run directory by mtime (descending), stable sort preserving
|
|
61
|
+
* discovery order for exact ties. Returns null when no runs exist.
|
|
62
|
+
*/
|
|
63
|
+
export function getLatestRunDirectory(runRoot: string): string | null {
|
|
64
|
+
let runs: string[] = []
|
|
65
|
+
try {
|
|
66
|
+
runs = readdirSync(runRoot, { withFileTypes: true })
|
|
67
|
+
.filter(d => {
|
|
68
|
+
if (d.isDirectory()) return true
|
|
69
|
+
if (!d.isSymbolicLink()) return false
|
|
70
|
+
try { return statSync(join(runRoot, d.name)).isDirectory() } catch { return false }
|
|
71
|
+
})
|
|
72
|
+
.map(d => d.name)
|
|
73
|
+
} catch {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
if (runs.length === 0) return null
|
|
77
|
+
runs.sort((a, b) => {
|
|
78
|
+
try {
|
|
79
|
+
return statSync(join(runRoot, b)).mtimeMs - statSync(join(runRoot, a)).mtimeMs
|
|
80
|
+
} catch {
|
|
81
|
+
return 0
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
return runs[0]
|
|
85
|
+
}
|