@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.
Files changed (88) hide show
  1. package/cordis.patch.yml +12 -0
  2. package/lib/bootstrap.d.ts +35 -0
  3. package/lib/client/board.d.ts +10 -0
  4. package/lib/client/contract.d.ts +51 -0
  5. package/lib/client/derive.d.ts +92 -0
  6. package/lib/client/index.d.ts +21 -0
  7. package/lib/client/inspector.d.ts +10 -0
  8. package/lib/client/node.d.ts +71 -0
  9. package/lib/client/settings.d.ts +6 -0
  10. package/lib/client/slots.d.ts +7 -0
  11. package/lib/client/strip.d.ts +7 -0
  12. package/lib/client.d.ts +10 -0
  13. package/lib/client.js +490 -0
  14. package/lib/closeout.d.ts +23 -0
  15. package/lib/commands.d.ts +51 -0
  16. package/lib/delegation.d.ts +92 -0
  17. package/lib/enforcement.d.ts +53 -0
  18. package/lib/events.d.ts +173 -0
  19. package/lib/handoff.d.ts +51 -0
  20. package/lib/index.d.ts +40 -0
  21. package/lib/lifecycle.d.ts +107 -0
  22. package/lib/lock.d.ts +92 -0
  23. package/lib/policy.d.ts +12 -0
  24. package/lib/projection.d.ts +29 -0
  25. package/lib/recursive_closeout.tool.d.ts +8 -0
  26. package/lib/recursive_init.tool.d.ts +2 -0
  27. package/lib/recursive_lint.tool.d.ts +2 -0
  28. package/lib/recursive_lock.tool.d.ts +2 -0
  29. package/lib/recursive_scratch.tool.d.ts +7 -0
  30. package/lib/recursive_status.tool.d.ts +2 -0
  31. package/lib/review.d.ts +39 -0
  32. package/lib/router.d.ts +77 -0
  33. package/lib/run.d.ts +29 -0
  34. package/lib/runtime.d.ts +241 -0
  35. package/lib/scratch.d.ts +18 -0
  36. package/lib/status.d.ts +19 -0
  37. package/lib/types.d.ts +104 -0
  38. package/lib/workspace.d.ts +50 -0
  39. package/package.json +119 -0
  40. package/preset/recursive/agent.cordis.yml +282 -0
  41. package/preset/recursive/preset.yml +3 -0
  42. package/scripts/install-recursive-mode.ps1 +956 -0
  43. package/scripts/install-recursive-mode.py +750 -0
  44. package/scripts/lint-recursive-run.py +2868 -0
  45. package/scripts/recursive-closeout.py +541 -0
  46. package/scripts/recursive-init.py +356 -0
  47. package/scripts/recursive-lock.py +302 -0
  48. package/scripts/recursive-status.py +2124 -0
  49. package/scripts/recursive_phase_rules.py +367 -0
  50. package/scripts/recursive_router_lib.py +2282 -0
  51. package/scripts/test-recursive-mode-smoke.ts +204 -0
  52. package/scripts/verify-locks.py +353 -0
  53. package/src/bootstrap.ts +118 -0
  54. package/src/client/board.tsx +61 -0
  55. package/src/client/contract.ts +58 -0
  56. package/src/client/derive.ts +241 -0
  57. package/src/client/index.ts +28 -0
  58. package/src/client/inspector.tsx +49 -0
  59. package/src/client/node.ts +156 -0
  60. package/src/client/settings.tsx +18 -0
  61. package/src/client/slots.ts +67 -0
  62. package/src/client/strip.tsx +28 -0
  63. package/src/client.ts +11 -0
  64. package/src/closeout.ts +183 -0
  65. package/src/commands.ts +142 -0
  66. package/src/delegation.ts +306 -0
  67. package/src/enforcement.ts +180 -0
  68. package/src/events.ts +173 -0
  69. package/src/handoff.ts +165 -0
  70. package/src/index.ts +283 -0
  71. package/src/lifecycle.ts +235 -0
  72. package/src/lock.ts +369 -0
  73. package/src/policy.ts +56 -0
  74. package/src/projection.ts +237 -0
  75. package/src/recursive_closeout.tool.ts +35 -0
  76. package/src/recursive_init.tool.ts +28 -0
  77. package/src/recursive_lint.tool.ts +29 -0
  78. package/src/recursive_lock.tool.ts +33 -0
  79. package/src/recursive_scratch.tool.ts +42 -0
  80. package/src/recursive_status.tool.ts +24 -0
  81. package/src/review.ts +178 -0
  82. package/src/router.ts +197 -0
  83. package/src/run.ts +85 -0
  84. package/src/runtime.ts +564 -0
  85. package/src/scratch.ts +85 -0
  86. package/src/status.ts +194 -0
  87. package/src/types.ts +112 -0
  88. 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
+ }