@try-works/dsh-recursive-mode 0.1.2 → 0.1.4

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 (123) hide show
  1. package/cordis.patch.yml +15 -10
  2. package/lib/bootstrap.d.ts +9 -2
  3. package/lib/client/apply-guard.d.ts +14 -0
  4. package/lib/client/board.d.ts +5 -4
  5. package/lib/client/contract.d.ts +57 -24
  6. package/lib/client/host-api.d.ts +26 -0
  7. package/lib/client/index.d.ts +16 -9
  8. package/lib/client/inspector.d.ts +3 -3
  9. package/lib/client/slots.d.ts +4 -6
  10. package/lib/client/strip.d.ts +4 -3
  11. package/lib/client/use-live.d.ts +9 -0
  12. package/lib/client.js +252 -174
  13. package/lib/index.d.ts +11 -11
  14. package/lib/index.js +972 -4504
  15. package/lib/init-templates.d.ts +41 -0
  16. package/lib/live-route.d.ts +69 -0
  17. package/lib/phase-rules.d.ts +34 -0
  18. package/lib/runtime.d.ts +24 -17
  19. package/lib/snapshot.d.ts +11 -0
  20. package/package.json +4 -3
  21. package/preset/recursive/agent.cordis.yml +7 -4
  22. package/preset/recursive/preset.yml +1 -1
  23. package/references/agents-block.md +95 -0
  24. package/references/artifact-template.md +2574 -0
  25. package/references/bodies/claude.md +7 -0
  26. package/references/bodies/codex-agents.md +95 -0
  27. package/references/bodies/copilot.md +7 -0
  28. package/references/bodies/cursorrules.md +7 -0
  29. package/references/bodies/decisions.md +5 -0
  30. package/references/bodies/delegated-verification.md +49 -0
  31. package/references/bodies/memory-router.md +45 -0
  32. package/references/bodies/phase8-skill-memory.md +48 -0
  33. package/references/bodies/plans-bridge.md +43 -0
  34. package/references/bodies/recursive-agents-router.md +64 -0
  35. package/references/bodies/skill-discovery.md +51 -0
  36. package/references/bodies/skill-memory-router.md +42 -0
  37. package/references/bodies/state.md +5 -0
  38. package/references/bootstrap/RECURSIVE.md +2362 -0
  39. package/references/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
  40. package/references/scripts/lint-recursive-run.ps1 +25 -0
  41. package/references/scripts/lint-recursive-run.py +2870 -0
  42. package/references/scripts/recursive-closeout.ps1 +38 -0
  43. package/references/scripts/recursive-closeout.py +541 -0
  44. package/references/scripts/recursive-init.ps1 +515 -0
  45. package/references/scripts/recursive-init.py +356 -0
  46. package/references/scripts/recursive-lock.ps1 +26 -0
  47. package/references/scripts/recursive-lock.py +302 -0
  48. package/references/scripts/recursive-review-bundle.ps1 +58 -0
  49. package/references/scripts/recursive-review-bundle.py +503 -0
  50. package/references/scripts/recursive-router-cli-configure.ps1 +2 -0
  51. package/references/scripts/recursive-router-cli-configure.py +9 -0
  52. package/references/scripts/recursive-router-cli-init.ps1 +2 -0
  53. package/references/scripts/recursive-router-cli-init.py +9 -0
  54. package/references/scripts/recursive-router-cli-invoke.ps1 +2 -0
  55. package/references/scripts/recursive-router-cli-invoke.py +9 -0
  56. package/references/scripts/recursive-router-cli-probe.ps1 +2 -0
  57. package/references/scripts/recursive-router-cli-probe.py +9 -0
  58. package/references/scripts/recursive-router-cli-resolve.ps1 +2 -0
  59. package/references/scripts/recursive-router-cli-resolve.py +9 -0
  60. package/references/scripts/recursive-router-cli-validate.ps1 +2 -0
  61. package/references/scripts/recursive-router-cli-validate.py +9 -0
  62. package/references/scripts/recursive-router-configure.ps1 +27 -0
  63. package/references/scripts/recursive-router-configure.py +74 -0
  64. package/references/scripts/recursive-router-init.ps1 +17 -0
  65. package/references/scripts/recursive-router-init.py +29 -0
  66. package/references/scripts/recursive-router-invoke.ps1 +47 -0
  67. package/references/scripts/recursive-router-invoke.py +103 -0
  68. package/references/scripts/recursive-router-probe.ps1 +25 -0
  69. package/references/scripts/recursive-router-probe.py +44 -0
  70. package/references/scripts/recursive-router-resolve.ps1 +26 -0
  71. package/references/scripts/recursive-router-resolve.py +46 -0
  72. package/references/scripts/recursive-router-validate.ps1 +17 -0
  73. package/references/scripts/recursive-router-validate.py +27 -0
  74. package/references/scripts/recursive-status.ps1 +23 -0
  75. package/references/scripts/recursive-status.py +2124 -0
  76. package/references/scripts/recursive-subagent-action.ps1 +98 -0
  77. package/references/scripts/recursive-subagent-action.py +197 -0
  78. package/references/scripts/recursive-training-extract.ps1 +23 -0
  79. package/references/scripts/recursive-training-extract.py +99 -0
  80. package/references/scripts/recursive-training-grpo.ps1 +52 -0
  81. package/references/scripts/recursive-training-grpo.py +1341 -0
  82. package/references/scripts/recursive-training-loader.ps1 +78 -0
  83. package/references/scripts/recursive-training-loader.py +558 -0
  84. package/references/scripts/recursive-training-mcp.ps1 +29 -0
  85. package/references/scripts/recursive-training-mcp.py +261 -0
  86. package/references/scripts/recursive-training-phase8-trigger.ps1 +55 -0
  87. package/references/scripts/recursive-training-phase8-trigger.py +137 -0
  88. package/references/scripts/recursive-training-sync.ps1 +29 -0
  89. package/references/scripts/recursive-training-sync.py +183 -0
  90. package/references/scripts/recursive_phase_rules.py +367 -0
  91. package/references/scripts/recursive_router_cli_lib.py +2 -0
  92. package/references/scripts/recursive_router_lib.py +2282 -0
  93. package/references/scripts/verify-locks.ps1 +25 -0
  94. package/references/scripts/verify-locks.py +353 -0
  95. package/scripts/__pycache__/lint-recursive-run.cpython-314.pyc +0 -0
  96. package/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
  97. package/scripts/install-preset.cmd +7 -0
  98. package/scripts/install-preset.js +101 -0
  99. package/scripts/lint-recursive-run.py +7 -5
  100. package/scripts/test-recursive-mode-smoke.ts +29 -28
  101. package/src/bootstrap.ts +253 -39
  102. package/src/client/apply-guard.ts +23 -0
  103. package/src/client/board.tsx +0 -0
  104. package/src/client/contract.ts +72 -24
  105. package/src/client/host-api.ts +90 -0
  106. package/src/client/index.ts +22 -11
  107. package/src/client/inspector.tsx +7 -9
  108. package/src/client/slots.ts +70 -26
  109. package/src/client/strip.tsx +9 -9
  110. package/src/client/use-live.ts +52 -0
  111. package/src/index.ts +89 -146
  112. package/src/init-templates.ts +276 -0
  113. package/src/live-route.ts +163 -0
  114. package/src/phase-rules.ts +262 -0
  115. package/src/policy.ts +28 -4
  116. package/src/runtime.ts +129 -77
  117. package/src/snapshot.ts +104 -0
  118. package/lib/client/node.d.ts +0 -71
  119. package/lib/events.d.ts +0 -173
  120. package/lib/projection.d.ts +0 -29
  121. package/src/client/node.ts +0 -156
  122. package/src/events.ts +0 -173
  123. package/src/projection.ts +0 -237
package/src/bootstrap.ts CHANGED
@@ -1,12 +1,28 @@
1
1
  /**
2
- * Idempotent scaffold installer + Stage B workflow init (R6). TS port of
3
- * install-recursive-mode.py's core: bootstrap the /.recursive/ control plane
4
- * when missing (no-op when present), enumerate runs as directory names only,
5
- * and drive the agent/session-start Stage B (new vs resume) workspace-scoped
6
- * to the session's control-plane root (R1).
2
+ * Idempotent scaffold installer (R3). TS port of install-recursive-mode.py's
3
+ * core: bootstrap the FULL canonical /.recursive/ control plane + cross-tool
4
+ * bridges byte-identically (RECURSIVE.md marker-wrapped, AGENTS.md, STATE/
5
+ * DECISIONS, memory routers + shards, config/recursive-router.json, .gitignore,
6
+ * vendored runtime scripts copied into .recursive/scripts/), plus the
7
+ * agent/session-start Stage B (new vs resume) workspace-scoped to the session's
8
+ * control-plane root (R1).
9
+ *
10
+ * Templates + bodies + runtime scripts are SHIPPED package files under
11
+ * references/ (never inlined TS string literals) and resolved relative to this
12
+ * module (package install location), never process.cwd().
7
13
  */
8
- import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs'
9
- import { join } from 'node:path'
14
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync, readFileSync, copyFileSync } from 'node:fs'
15
+ import { join, dirname } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+
18
+ const MODULE_DIR = dirname(fileURLToPath(import.meta.url))
19
+ /** Package root: <package>/src/.. — references/ sits next to src/. */
20
+ export const PACKAGE_ROOT = join(MODULE_DIR, '..')
21
+
22
+ /** references/ root (shipped package files). */
23
+ export function referencesRoot(): string {
24
+ return join(PACKAGE_ROOT, 'references')
25
+ }
10
26
 
11
27
  export interface BootstrapResult {
12
28
  root: string
@@ -23,56 +39,254 @@ export interface StageBResult {
23
39
  activeRunId?: string
24
40
  }
25
41
 
26
- const CONTROL_PLANE_FILES: Record<string, string> = {
27
- 'RECURSIVE.md': '# recursive-mode\\n\\nrecursive-mode workflow. The canonical spec is maintained by the installed skill; this scaffold is the repo-local control plane.\\n',
28
- 'STATE.md': '# STATE\\n\\nRepository state ledger.\\n',
29
- 'DECISIONS.md': '# DECISIONS\\n\\nDecision ledger.\\n',
42
+ // Marker strings (byte-identical to the canonical installer).
43
+ const M = {
44
+ recursiveStart: '<!-- RECURSIVE-MODE-CANONICAL:START -->',
45
+ recursiveEnd: '<!-- RECURSIVE-MODE-CANONICAL:END -->',
46
+ memoryStart: '<!-- RECURSIVE-MODE-MEMORY:START -->',
47
+ memoryEnd: '<!-- RECURSIVE-MODE-MEMORY:END -->',
48
+ agentsStart: '<!-- RECURSIVE-MODE-AGENTS:START -->',
49
+ agentsEnd: '<!-- RECURSIVE-MODE-AGENTS:END -->',
50
+ plansStart: '<!-- RECURSIVE-MODE-PLANS-BRIDGE:START -->',
51
+ plansEnd: '<!-- RECURSIVE-MODE-PLANS-BRIDGE:END -->',
52
+ cursorrulesStart: '# RECURSIVE-MODE-MEMORY-POINTERS:START',
53
+ cursorrulesEnd: '# RECURSIVE-MODE-MEMORY-POINTERS:END',
54
+ repoMdStart: '<!-- RECURSIVE-MODE-MEMORY-POINTERS:START -->',
55
+ repoMdEnd: '<!-- RECURSIVE-MODE-MEMORY-POINTERS:END -->',
56
+ }
57
+
58
+ /** write_utf8_no_bom: LF newlines, no BOM. */
59
+ export function writeUtf8NoBom(path: string, content: string): void {
60
+ writeFileSync(path, content, { encoding: 'utf8' })
61
+ }
62
+
63
+ /** ensure_gitignore_line: add the line when absent (preserving existing). */
64
+ function ensureGitignoreLine(repoRoot: string, line: string): void {
65
+ const gitignorePath = join(repoRoot, '.gitignore')
66
+ const normalized = line.trim()
67
+ const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : ''
68
+ const existingLines = existing.split(/\r?\n/)
69
+ if (existingLines.some(c => c.trim() === normalized)) return
70
+ let updated = existing
71
+ if (updated && !updated.endsWith('\n')) updated += '\n'
72
+ updated += normalized + '\n'
73
+ writeUtf8NoBom(gitignorePath, updated)
74
+ }
75
+
76
+ /** upsert_marked_block: replace the START..END block, else append after existing. */
77
+ function upsertMarkedBlock(filePath: string, startMarker: string, endMarker: string, blockBody: string): void {
78
+ const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : ''
79
+ const block = startMarker + '\n' + blockBody + '\n' + endMarker
80
+ const pattern = new RegExp(escapeRegExp(startMarker) + '[\\s\\S]*?' + escapeRegExp(endMarker))
81
+ let updated: string
82
+ if (pattern.test(existing)) {
83
+ updated = existing.replace(pattern, block)
84
+ } else if (existing.trim() !== '') {
85
+ updated = existing.replace(/\s+$/, '') + '\n\n' + block + '\n'
86
+ } else {
87
+ updated = block + '\n'
88
+ }
89
+ if (updated !== existing) writeUtf8NoBom(filePath, updated)
90
+ }
91
+
92
+ function escapeRegExp(s: string): string {
93
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
94
+ }
95
+
96
+ /** normalize_plain_or_wrapped_content: strip markers; return plain body when wrapped. */
97
+ function normalizePlainOrWrappedContent(content: string, startMarker: string, endMarker: string): string {
98
+ const startIndex = content.indexOf(startMarker)
99
+ const endIndex = content.lastIndexOf(endMarker)
100
+ if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) {
101
+ return content.replace(/\r?\n+$/, '')
102
+ }
103
+ const prefix = content.slice(0, startIndex).replace(/\r?\n+$/, '')
104
+ if (prefix.trim() !== '') return prefix
105
+ const bodyStart = startIndex + startMarker.length
106
+ return content.slice(bodyStart, endIndex).replace(/^\r?\n+|\r?\n+$/g, '')
107
+ }
108
+
109
+ /** upsert_or_migrate_canonical: canonical block upsert with plain-file migration. */
110
+ function upsertOrMigrateCanonical(filePath: string, startMarker: string, endMarker: string, canonicalBody: string): void {
111
+ const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : ''
112
+ const block = startMarker + '\n' + canonicalBody.replace(/\r?\n+$/, '') + '\n' + endMarker
113
+ const pattern = new RegExp(escapeRegExp(startMarker) + '[\\s\\S]*?' + escapeRegExp(endMarker))
114
+ let updated: string
115
+ if (pattern.test(existing)) {
116
+ updated = existing.replace(pattern, block)
117
+ } else if (existing.trim() === '') {
118
+ updated = block + '\n'
119
+ } else if (existing.replace(/\r?\n+$/, '') === canonicalBody.replace(/\r?\n+$/, '')) {
120
+ updated = block + '\n'
121
+ } else {
122
+ updated = existing.replace(/\s+$/, '') + '\n\n' + block + '\n'
123
+ }
124
+ if (updated !== existing) writeUtf8NoBom(filePath, updated)
125
+ }
126
+
127
+ /** Read a shipped body file (references/bodies/<name>) — CRLF-normalized. */
128
+ function body(name: string): string {
129
+ return readFileSync(join(referencesRoot(), 'bodies', name), 'utf8').replace(/\r\n/g, '\n')
130
+ }
131
+
132
+ /** Read the canonical RECURSIVE.md template from shipped references/bootstrap/ — CRLF-normalized. */
133
+ function canonicalWorkflowContent(): string {
134
+ return readFileSync(join(referencesRoot(), 'bootstrap', 'RECURSIVE.md'), 'utf8').replace(/\r\n/g, '\n')
135
+ }
136
+
137
+ /** Read the agents bridge block from shipped references/ — CRLF-normalized. */
138
+ function agentsBlockContent(): string {
139
+ return readFileSync(join(referencesRoot(), 'agents-block.md'), 'utf8').replace(/\r\n/g, '\n').replace(/\r?\n+$/, '')
140
+ }
141
+
142
+ /** default_router_policy(): byte-identical to recursive_router_lib.default_router_policy + pretty_json. */
143
+ function defaultRouterPolicy(): string {
144
+ const roleRoutes: Record<string, Record<string, unknown>> = {
145
+ orchestrator: { enabled: true, mode: 'local-only', cli: null, model: null, fallback: 'local-controller' },
146
+ analyst: { enabled: true, mode: 'external-cli', cli: null, model: null, fallback: 'self-audit' },
147
+ planner: { enabled: true, mode: 'external-cli', cli: null, model: null, fallback: 'self-audit' },
148
+ implementer: { enabled: false, mode: 'external-cli', cli: null, model: null, fallback: 'local-controller' },
149
+ 'code-reviewer': { enabled: true, mode: 'external-cli', cli: null, model: null, fallback: 'self-audit' },
150
+ tester: { enabled: true, mode: 'external-cli', cli: null, model: null, fallback: 'self-audit' },
151
+ 'memory-auditor': { enabled: true, mode: 'external-cli', cli: null, model: null, fallback: 'self-audit' },
152
+ }
153
+ const policy = {
154
+ version: 1,
155
+ defaults: {
156
+ when_role_unconfigured: 'ask',
157
+ when_cli_unavailable: 'fallback-local',
158
+ when_model_unknown: 'ask',
159
+ allow_auto_assign_if_single_cli: false,
160
+ probe_timeout_ms: 50000,
161
+ invoke_timeout_ms: 180000,
162
+ },
163
+ role_routes: roleRoutes,
164
+ cli_overrides: {},
165
+ custom_clis: [],
166
+ }
167
+ return JSON.stringify(policy, null, 2) + '\n'
30
168
  }
31
169
 
32
- const MEMORY_FILES: Record<string, string> = {
33
- 'memory/MEMORY.md': '# MEMORY\\n\\nDurable memory router.\\n',
34
- 'memory/skills/SKILLS.md': '# SKILLS\\n\\nSkill memory router.\\n',
170
+ /** ensure_router_scaffold: write config/recursive-router.json when missing. */
171
+ function ensureRouterScaffold(repoRoot: string): void {
172
+ const configDir = join(repoRoot, '.recursive', 'config')
173
+ const policyPath = join(configDir, 'recursive-router.json')
174
+ if (!existsSync(policyPath)) {
175
+ mkdirSync(configDir, { recursive: true })
176
+ writeUtf8NoBom(policyPath, defaultRouterPolicy())
177
+ }
35
178
  }
36
179
 
37
180
  /**
38
- * Idempotent scaffold install: creates the /.recursive/ control plane when
39
- * missing. Never overwrites existing files; preserves unrelated content.
181
+ * Idempotent scaffold install: reproduce the canonical installer's 82-file
182
+ * tree byte-for-byte. Never overwrites existing user content; marked blocks
183
+ * are re-upserted in place.
40
184
  */
41
185
  export function bootstrapScaffold(root: string): BootstrapResult {
42
- const recursiveRoot = join(root, '.recursive')
43
186
  const created: string[] = []
44
187
  const existing: string[] = []
45
188
 
46
- const writeIfMissing = (rel: string, content: string) => {
47
- const path = join(recursiveRoot, rel)
48
- if (existsSync(path)) {
49
- existing.push(rel)
50
- return
189
+ const noteDir = (rel: string) => {
190
+ const p = join(root, rel)
191
+ if (existsSync(p)) existing.push(rel + '/')
192
+ else { mkdirSync(p, { recursive: true }); created.push(rel + '/') }
193
+ }
194
+ const noteFile = (rel: string, content?: string) => {
195
+ const p = join(root, rel)
196
+ if (existsSync(p)) { existing.push(rel); return }
197
+ if (content !== undefined) {
198
+ mkdirSync(dirname(p), { recursive: true })
199
+ writeUtf8NoBom(p, content)
200
+ } else {
201
+ mkdirSync(dirname(p), { recursive: true })
202
+ writeFileSync(p, '')
51
203
  }
52
- mkdirSync(join(path, '..'), { recursive: true })
53
- writeFileSync(path, content, 'utf8')
54
204
  created.push(rel)
55
205
  }
56
206
 
57
- for (const [rel, content] of Object.entries(CONTROL_PLANE_FILES)) writeIfMissing(rel, content)
58
- for (const [rel, content] of Object.entries(MEMORY_FILES)) writeIfMissing(rel, content)
59
- // run/ + config/ + memory/ dirs (empty dirs for the scaffold)
60
- for (const dir of ['run', 'config', 'memory/training']) {
61
- const path = join(recursiveRoot, dir)
62
- if (!existsSync(path)) {
63
- mkdirSync(path, { recursive: true })
64
- created.push(dir + '/')
207
+ const recursiveRoot = join(root, '.recursive')
208
+ const memoryRoot = join(recursiveRoot, 'memory')
209
+ const skillMemoryRoot = join(memoryRoot, 'skills')
210
+
211
+ // Directories.
212
+ for (const rel of [
213
+ '.recursive', '.codex', '.agent', '.github',
214
+ '.recursive/memory', '.recursive/memory/training', '.recursive/memory/skills', '.recursive/run', '.recursive/config',
215
+ ]) noteDir(rel)
216
+ for (const sub of ['domains', 'patterns', 'incidents', 'episodes', 'archive']) noteDir(join('.recursive/memory', sub))
217
+ for (const sub of ['availability', 'usage', 'issues', 'patterns']) noteDir(join('.recursive/memory/skills', sub))
218
+
219
+ // .gitkeeps.
220
+ for (const rel of [
221
+ '.recursive/memory/domains/.gitkeep', '.recursive/memory/patterns/.gitkeep', '.recursive/memory/incidents/.gitkeep',
222
+ '.recursive/memory/episodes/.gitkeep', '.recursive/memory/archive/.gitkeep', '.recursive/memory/training/.gitkeep',
223
+ '.recursive/memory/skills/availability/.gitkeep', '.recursive/memory/skills/usage/.gitkeep',
224
+ '.recursive/memory/skills/issues/.gitkeep', '.recursive/memory/skills/patterns/.gitkeep', '.recursive/run/.gitkeep',
225
+ ]) noteFile(rel, '')
226
+
227
+ // Runtime scripts: copy vendored references/scripts/* into .recursive/scripts/.
228
+ const scriptsSrc = join(referencesRoot(), 'scripts')
229
+ const scriptsDst = join(recursiveRoot, 'scripts')
230
+ mkdirSync(scriptsDst, { recursive: true })
231
+ for (const name of readdirSync(scriptsSrc)) {
232
+ const src = join(scriptsSrc, name)
233
+ if (!statSync(src).isFile()) continue
234
+ const dst = join(scriptsDst, name)
235
+ if (!existsSync(dst) || !buffersEqual(src, dst)) {
236
+ copyFileSync(src, dst)
237
+ created.push('.recursive/scripts/' + name)
65
238
  } else {
66
- existing.push(dir + '/')
239
+ existing.push('.recursive/scripts/' + name)
67
240
  }
68
241
  }
69
242
 
70
- return {
71
- root,
72
- bootstrapped: created.length > 0,
73
- created,
74
- existing,
243
+ // Plain control-plane files (trailing newline).
244
+ noteFile('.recursive/RECURSIVE.md', '# RECURSIVE.md\n')
245
+ noteFile('.recursive/AGENTS.md', '# AGENTS.md\n')
246
+ noteFile('.recursive/STATE.md', body('state.md') + '\n')
247
+ noteFile('.recursive/DECISIONS.md', body('decisions.md') + '\n')
248
+ noteFile('.recursive/memory/MEMORY.md', '# MEMORY.md\n')
249
+ noteFile('.recursive/memory/skills/SKILLS.md', '# SKILLS.md\n')
250
+ noteFile('.recursive/memory/skills/usage/skill-discovery-and-evaluation.md', body('skill-discovery.md') + '\n')
251
+ noteFile('.recursive/memory/skills/patterns/delegated-verification-and-refresh.md', body('delegated-verification.md') + '\n')
252
+ noteFile('.recursive/memory/skills/patterns/phase8-skill-memory-promotion.md', body('phase8-skill-memory.md') + '\n')
253
+ noteFile('.codex/AGENTS.md', '# AGENTS.md\n')
254
+ noteFile('.agent/PLANS.md', '# PLANS.md\n')
255
+ noteFile('.cursorrules', '')
256
+ noteFile('CLAUDE.md', '')
257
+ noteFile('.github/copilot-instructions.md', '')
258
+
259
+ // .gitignore.
260
+ ensureGitignoreLine(root, '/.recursive/config/recursive-router-discovered.json')
261
+
262
+ // Router config.
263
+ ensureRouterScaffold(root)
264
+
265
+ // Marked blocks.
266
+ upsertMarkedBlock(join(recursiveRoot, 'AGENTS.md'), M.agentsStart, M.agentsEnd, body('recursive-agents-router.md'))
267
+ upsertMarkedBlock(join(memoryRoot, 'MEMORY.md'), M.memoryStart, M.memoryEnd, body('memory-router.md'))
268
+ upsertMarkedBlock(join(skillMemoryRoot, 'SKILLS.md'), M.memoryStart, M.memoryEnd, body('skill-memory-router.md'))
269
+ upsertMarkedBlock(join(root, '.cursorrules'), M.cursorrulesStart, M.cursorrulesEnd, body('cursorrules.md'))
270
+ upsertMarkedBlock(join(root, 'CLAUDE.md'), M.repoMdStart, M.repoMdEnd, body('claude.md'))
271
+ upsertMarkedBlock(join(root, '.github', 'copilot-instructions.md'), M.repoMdStart, M.repoMdEnd, body('copilot.md'))
272
+ upsertMarkedBlock(join(root, '.codex', 'AGENTS.md'), M.agentsStart, M.agentsEnd, body('codex-agents.md'))
273
+ const rootAgents = join(root, 'AGENTS.md')
274
+ if (existsSync(rootAgents)) {
275
+ upsertMarkedBlock(rootAgents, M.agentsStart, M.agentsEnd, agentsBlockContent())
75
276
  }
277
+ upsertMarkedBlock(join(root, '.agent', 'PLANS.md'), M.plansStart, M.plansEnd, body('plans-bridge.md'))
278
+
279
+ // Canonical RECURSIVE.md (marker-wrapped, migrate plain installs).
280
+ const canonicalBody = normalizePlainOrWrappedContent(canonicalWorkflowContent(), M.recursiveStart, M.recursiveEnd)
281
+ upsertOrMigrateCanonical(join(recursiveRoot, 'RECURSIVE.md'), M.recursiveStart, M.recursiveEnd, canonicalBody)
282
+
283
+ return { root, bootstrapped: created.length > 0, created, existing }
284
+ }
285
+
286
+ function buffersEqual(a: string, b: string): boolean {
287
+ const ab = readFileSync(a)
288
+ const bb = readFileSync(b)
289
+ return ab.length === bb.length && ab.equals(bb)
76
290
  }
77
291
 
78
292
  /**
@@ -115,4 +329,4 @@ export function stageBWorkflowInit(input: StageBInput): StageBResult {
115
329
  runs,
116
330
  activeRunId,
117
331
  }
118
- }
332
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Cross-module-instance apply guard for the recursive client bundle (mirrors
3
+ * the dsh-task-board apply-guard). The client factory can run more than once in
4
+ * a single page lifetime (a stale bundle mixed with a rebuilt one); without a
5
+ * guard every factory run mounts its own board/strip. First claim wins; later
6
+ * claims no-op until the claim is released (fiber unload / hot-reload).
7
+ */
8
+ declare global {
9
+ // eslint-disable-next-line no-var
10
+ var __dshRecursiveModeClientApplied: boolean | undefined
11
+ }
12
+
13
+ /** Claims the client apply slot. Returns true when this call won the slot. */
14
+ export function claimClientApply(): boolean {
15
+ if (globalThis.__dshRecursiveModeClientApplied === true) return false
16
+ globalThis.__dshRecursiveModeClientApplied = true
17
+ return true
18
+ }
19
+
20
+ /** Releases the claim (called from the client fiber cleanup on unload/hot-reload). */
21
+ export function releaseClientApply(): void {
22
+ globalThis.__dshRecursiveModeClientApplied = undefined
23
+ }
Binary file
@@ -1,17 +1,18 @@
1
1
  /**
2
2
  * Structural client seams (self-contained). The real @deepseek-ai/dsh-client-*
3
3
  * packages are HOST-INJECTED at bundle time via dsh.client.inject (never
4
- * installed in this package's dependency tree they depend on workspace:^
5
- * peers that resolve only inside the DSH workspace). We declare the minimal
6
- * shapes we consume so the pure client modules type-check and bundle in
7
- * isolation; the DSH loader links them to the real runtime identities at
8
- * compose time.
4
+ * installed in this package's dependency tree). We declare the minimal shapes
5
+ * we consume so the pure client modules type-check and bundle in isolation;
6
+ * the DSH loader links them to the real runtime identities at compose time.
9
7
  *
10
- * One deliberate narrowing: the client only READS (R9 read-only). It never
11
- * calls back into the host; every host fact arrives through the projection.
8
+ * SP2 R1 (live route): the client now CALLS the host route (read-only GET
9
+ * state + SSE events) instead of reading a session projection. The host serves
10
+ * the per-workspace filesystem fold; this module never mutates the host and
11
+ * never touches the filesystem (R9).
12
12
  */
13
+ import type { RecursiveProjection } from '../types.ts'
13
14
 
14
- /** SessionEvent-like carrier (the projection/node fold reads events by shape). */
15
+ /** SessionEvent-like carrier (kept for the pure derive surface; the live route replaces the fold). */
15
16
  export interface ClientSessionEvent {
16
17
  type: string
17
18
  seq?: number
@@ -19,24 +20,44 @@ export interface ClientSessionEvent {
19
20
  data?: Record<string, unknown>
20
21
  }
21
22
 
22
- /** A session header (cwd is the control-plane root). */
23
+ /** A session header (cwd is the session's canonical cwd). */
23
24
  export interface ClientSessionHeader {
24
25
  cwd?: string
25
26
  }
26
27
 
27
- /** Minimal appendable session (log-only write seam). */
28
- export interface ClientSession {
29
- header: ClientSessionHeader
30
- append(type: string, data: unknown): unknown
28
+ /** A session-list row (mirrors SessionSummary — cwd + agentPreset are the gate inputs). */
29
+ export interface SessionSummaryRow {
30
+ id: string
31
+ cwd?: string
32
+ /** Agent preset this session was composed from; absent when the deployment has none. */
33
+ agentPreset?: string
31
34
  }
32
35
 
33
- /** The client root context the bundle receives from the module loader. */
34
- export interface ClientContext {
35
- slots: ClientSlots
36
- get(name: string): unknown
36
+ /** The session-list snapshot (mirrors SessionListState). */
37
+ export interface SessionListStateLike {
38
+ ids: string[]
39
+ byId: Record<string, SessionSummaryRow>
40
+ current: string | undefined
41
+ }
42
+
43
+ /** A snapshot store the client runtime hands out (mirrors SnapshotStore<T>). */
44
+ export interface SnapshotStoreLike<T> {
45
+ getSnapshot(): T
46
+ subscribe(fn: () => void): () => void
47
+ }
48
+
49
+ /** The sessions service surface (mirrors ISessions.list). */
50
+ export interface ISessionsLike {
51
+ list: SnapshotStoreLike<SessionListStateLike>
52
+ }
53
+
54
+ /** The slots registry surface the client injects into. */
55
+ export interface ClientSlots {
56
+ inject(seat: string, factory: (ctx: unknown) => () => void): () => void
57
+ register(options: SlotOptions, component: unknown): () => void
37
58
  }
38
59
 
39
- /** A registered slot's options (list/keyed entries carry id/order/label). */
60
+ /** A registered slot's options. */
40
61
  export interface SlotOptions {
41
62
  name: string
42
63
  id?: string
@@ -48,11 +69,38 @@ export interface SlotOptions {
48
69
  registrant?: unknown
49
70
  }
50
71
 
51
- /** The slot registry surface the client injects into. */
52
- export interface ClientSlots {
53
- inject(seat: string, factory: (ctx: unknown) => () => void): () => void
54
- register(options: SlotOptions, component: unknown): () => void
72
+ /** The client root context the bundle receives from the module loader. */
73
+ export interface ClientContext {
74
+ slots: ClientSlots
75
+ get(name: string): unknown
76
+ /** Cordis effect: registers a disposer; returns a disposer. */
77
+ effect(fn: () => () => void, label?: string): () => void
55
78
  }
56
79
 
57
- /** useProjection('recursive') reader undefined = capability absent. */
58
- export type RecursiveProjectionReader = (key: 'recursive') => unknown
80
+ /** The live state payload the host route serves (mirrors host-api.ts). */
81
+ export interface LiveProjectionValue {
82
+ root: string | null
83
+ projection: RecursiveProjection
84
+ revision: number
85
+ }
86
+
87
+ /**
88
+ * The client-side preset gate (acceptance #3): the board/strip render ONLY when
89
+ * the CURRENT session's row reports agentPreset === 'recursive'. A non-recursive
90
+ * session (or a session whose preset is undefined) shows NOTHING — not an empty
91
+ * board, not a spinner.
92
+ */
93
+ export function isRecursivePreset(sessions: ISessionsLike): boolean {
94
+ const snapshot = sessions.list.getSnapshot()
95
+ const current = snapshot.current
96
+ if (current === undefined) return false
97
+ return snapshot.byId[current]?.agentPreset === 'recursive'
98
+ }
99
+
100
+ /** The current session's cwd (the client passes it as a fallback hint; the host resolves the root). */
101
+ export function currentSessionCwd(sessions: ISessionsLike): string {
102
+ const snapshot = sessions.list.getSnapshot()
103
+ const current = snapshot.current
104
+ if (current === undefined) return ''
105
+ return snapshot.byId[current]?.cwd ?? ''
106
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Live recursive board/strip feed (SP2 R1): the browser half of the host
3
+ * HTTP/SSE route. Replaces useProjection('recursive') — the board reads the
4
+ * FILESYSTEM FOLD served by the host, keyed per-WORKSPACE. The client passes
5
+ * its sessionId (PRIMARY — the host resolves the cwd from the attached
6
+ * session header) plus cwd as a fallback hint while hydrating; the host
7
+ * resolves the recursive root server-side, never trusting a client path.
8
+ *
9
+ * READ-ONLY (R9): GET state + GET events only; this module never mutates the
10
+ * host and never touches the filesystem.
11
+ *
12
+ * Mirrors the dsh-web-ui task-board + DSH-better-sidebar host-api pattern:
13
+ * fetch /state with no-store, EventSource /events with a visibilitychange
14
+ * re-listener, and an AbortController timeout on the initial fetch.
15
+ */
16
+ import { RECURSIVE_API_PREFIX } from '../live-route.ts'
17
+ import type { RecursiveProjection } from '../types.ts'
18
+
19
+ /** The state payload the host serves. */
20
+ export interface LiveRecursiveState {
21
+ root: string | null
22
+ projection: RecursiveProjection
23
+ revision: number
24
+ }
25
+
26
+ /** A single SSE frame. */
27
+ export interface LiveRecursiveFrame extends LiveRecursiveState {}
28
+
29
+ /** The client scope the host keys on: sessionId PRIMARY, cwd fallback hint. */
30
+ export interface LiveScope {
31
+ sessionId?: string
32
+ cwd?: string
33
+ }
34
+
35
+ const FETCH_TIMEOUT_MS = 15_000
36
+
37
+ /** Query-string encoder for the two params. */
38
+ function scopeQuery(scope: LiveScope): string {
39
+ const parts: string[] = []
40
+ if (scope.sessionId) parts.push('sessionId=' + encodeURIComponent(scope.sessionId))
41
+ if (scope.cwd) parts.push('cwd=' + encodeURIComponent(scope.cwd))
42
+ return parts.length === 0 ? '' : '?' + parts.join('&')
43
+ }
44
+
45
+ /** Fetch the current state for one session scope (host resolves the workspace root). */
46
+ export async function fetchLiveState(scope: LiveScope, signal?: AbortSignal): Promise<LiveRecursiveState> {
47
+ const controller = new AbortController()
48
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
49
+ try {
50
+ const res = await fetch(RECURSIVE_API_PREFIX + '/state' + scopeQuery(scope), { signal: controller.signal, cache: 'no-store' })
51
+ if (!res.ok) throw new Error('recursive live state: HTTP ' + res.status)
52
+ return (await res.json()) as LiveRecursiveState
53
+ } finally {
54
+ clearTimeout(timer)
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Subscribe to the SSE events feed. Returns a disposer. The host pushes a full
60
+ * {revision, root, projection} frame on every fs change + a 15s heartbeat.
61
+ *
62
+ * @param scope - sessionId primary + cwd fallback (host resolves the root).
63
+ * @param onFrame - called per data frame.
64
+ * @param onError - called when the stream fails/ends (the caller re-subscribes).
65
+ */
66
+ export function subscribeLiveEvents(scope: LiveScope, onFrame: (frame: LiveRecursiveFrame) => void, onError?: () => void): () => void {
67
+ const url = RECURSIVE_API_PREFIX + '/events' + scopeQuery(scope)
68
+ const es = new EventSource(url)
69
+ const onMessage = (event: MessageEvent) => {
70
+ try {
71
+ const frame = JSON.parse(event.data as string) as LiveRecursiveFrame
72
+ onFrame(frame)
73
+ } catch { /* malformed frame — ignore */ }
74
+ }
75
+ es.addEventListener('message', onMessage)
76
+ es.onerror = () => { es.close(); onError?.() }
77
+ const onVisible = () => {
78
+ if (document.visibilityState === 'visible') {
79
+ // Re-open the stream after the tab slept (the host heartbeat is the only
80
+ // liveness signal; a sleeping tab drops it).
81
+ es.close()
82
+ onError?.()
83
+ }
84
+ }
85
+ document.addEventListener('visibilitychange', onVisible)
86
+ return () => {
87
+ document.removeEventListener('visibilitychange', onVisible)
88
+ es.close()
89
+ }
90
+ }
@@ -1,28 +1,39 @@
1
1
  /**
2
- * Client entry (Phase D R4): the browser half of dsh-recursive-mode. Receives
3
- * the client root context from the DSH loader and registers the board,
4
- * inspector, conversation node, status strip, and settings page (§11.8).
5
- * READ-ONLY (R9): every host fact arrives through the recursive projection;
6
- * this module never touches the filesystem.
2
+ * Client entry (SP2 R1): the browser half of dsh-recursive-mode. Receives the
3
+ * client root context from the DSH loader and registers the board, inspector,
4
+ * status strip, and settings page. The board/strip read the LIVE host route
5
+ * (per-workspace filesystem fold) not a session projection and render ONLY
6
+ * for the recursive preset (acceptance #3). READ-ONLY (R9): the client never
7
+ * mutates the host.
7
8
  *
8
9
  * The apply(ctx) surface mirrors the shipped dsh-client-ui-* packages; the
9
10
  * dsh.client.inject list in package.json names the host packages the loader
10
11
  * must provide (they are injected at compose time, never installed here).
12
+ * The apply-guard prevents a stale+rebuilt bundle from double-mounting.
11
13
  */
12
14
  import type { ClientContext } from './contract.ts'
13
15
  import { registerSlots } from './slots.ts'
16
+ import { claimClientApply, releaseClientApply } from './apply-guard.ts'
14
17
 
15
- export { recursiveNodeDefinition, RECURSIVE_NODE_KIND, matchRecursiveNode, foldRecursiveNode } from './node.ts'
16
- export type { RecursiveNodeData, RecursiveNodeState } from './node.ts'
17
18
  export { Board, listRuns } from './board.tsx'
18
19
  export { Inspector } from './inspector.tsx'
19
20
  export { StatusStrip } from './strip.tsx'
20
21
  export { RecursiveSettings } from './settings.tsx'
21
- export type { RecursiveProjectionReader } from './contract.ts'
22
+ export { useLiveProjection } from './use-live.ts'
23
+ export type { LiveProjectionSnapshot } from './use-live.ts'
24
+ export { fetchLiveState, subscribeLiveEvents } from './host-api.ts'
25
+ export type { LiveRecursiveState, LiveRecursiveFrame, LiveScope } from './host-api.ts'
26
+ export { isRecursivePreset, currentSessionCwd } from './contract.ts'
27
+ export type { SessionSummaryRow, SessionListStateLike, ISessionsLike, ClientContext } from './contract.ts'
22
28
 
23
- /** Browser-half plugin entry (R4). */
29
+ /** Required services (fiber inject waiting — the runtime must be up first). */
30
+ export const inject = ['slots', 'sessions', 'workspaces', 'connection']
31
+
32
+ /** Browser-half plugin entry (R4 + SP2 R1). */
24
33
  export function apply(ctx: ClientContext): void {
25
- // The conversation node is registered through the node-inject seam; the
26
- // other surfaces through the slot registry.
34
+ // First application wins; a duplicated client injection would mount a second
35
+ // board/strip. Release on unload so a hot-reloaded bundle can claim again.
36
+ if (!claimClientApply()) return
37
+ ctx.effect(() => releaseClientApply, 'recursive: client apply claim')
27
38
  registerSlots(ctx)
28
39
  }