@try-works/dsh-recursive-mode 0.1.5 → 0.1.7

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 (89) hide show
  1. package/lib/bootstrap.d.ts +6 -3
  2. package/lib/client/board.d.ts +4 -1
  3. package/lib/client/contract.d.ts +16 -13
  4. package/lib/client/index.d.ts +1 -1
  5. package/lib/client/open-state.d.ts +20 -0
  6. package/lib/client/slots.d.ts +13 -1
  7. package/lib/client.js +160 -71
  8. package/lib/index.js +3391 -906
  9. package/lib/runtime.d.ts +12 -5
  10. package/lib/ts-lint.d.ts +205 -0
  11. package/package.json +1 -1
  12. package/src/bootstrap.ts +338 -331
  13. package/src/client/board.tsx +0 -0
  14. package/src/client/contract.ts +19 -22
  15. package/src/client/index.ts +1 -1
  16. package/src/client/open-state.ts +46 -0
  17. package/src/client/slots.ts +67 -33
  18. package/src/commands.ts +11 -0
  19. package/src/index.ts +238 -228
  20. package/src/runtime.ts +19 -23
  21. package/src/ts-lint.ts +2110 -0
  22. package/references/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
  23. package/references/scripts/lint-recursive-run.ps1 +0 -25
  24. package/references/scripts/lint-recursive-run.py +0 -2870
  25. package/references/scripts/recursive-closeout.ps1 +0 -38
  26. package/references/scripts/recursive-closeout.py +0 -541
  27. package/references/scripts/recursive-init.ps1 +0 -515
  28. package/references/scripts/recursive-init.py +0 -356
  29. package/references/scripts/recursive-lock.ps1 +0 -26
  30. package/references/scripts/recursive-lock.py +0 -302
  31. package/references/scripts/recursive-review-bundle.ps1 +0 -58
  32. package/references/scripts/recursive-review-bundle.py +0 -503
  33. package/references/scripts/recursive-router-cli-configure.ps1 +0 -2
  34. package/references/scripts/recursive-router-cli-configure.py +0 -9
  35. package/references/scripts/recursive-router-cli-init.ps1 +0 -2
  36. package/references/scripts/recursive-router-cli-init.py +0 -9
  37. package/references/scripts/recursive-router-cli-invoke.ps1 +0 -2
  38. package/references/scripts/recursive-router-cli-invoke.py +0 -9
  39. package/references/scripts/recursive-router-cli-probe.ps1 +0 -2
  40. package/references/scripts/recursive-router-cli-probe.py +0 -9
  41. package/references/scripts/recursive-router-cli-resolve.ps1 +0 -2
  42. package/references/scripts/recursive-router-cli-resolve.py +0 -9
  43. package/references/scripts/recursive-router-cli-validate.ps1 +0 -2
  44. package/references/scripts/recursive-router-cli-validate.py +0 -9
  45. package/references/scripts/recursive-router-configure.ps1 +0 -27
  46. package/references/scripts/recursive-router-configure.py +0 -74
  47. package/references/scripts/recursive-router-init.ps1 +0 -17
  48. package/references/scripts/recursive-router-init.py +0 -29
  49. package/references/scripts/recursive-router-invoke.ps1 +0 -47
  50. package/references/scripts/recursive-router-invoke.py +0 -103
  51. package/references/scripts/recursive-router-probe.ps1 +0 -25
  52. package/references/scripts/recursive-router-probe.py +0 -44
  53. package/references/scripts/recursive-router-resolve.ps1 +0 -26
  54. package/references/scripts/recursive-router-resolve.py +0 -46
  55. package/references/scripts/recursive-router-validate.ps1 +0 -17
  56. package/references/scripts/recursive-router-validate.py +0 -27
  57. package/references/scripts/recursive-status.ps1 +0 -23
  58. package/references/scripts/recursive-status.py +0 -2124
  59. package/references/scripts/recursive-subagent-action.ps1 +0 -98
  60. package/references/scripts/recursive-subagent-action.py +0 -197
  61. package/references/scripts/recursive-training-extract.ps1 +0 -23
  62. package/references/scripts/recursive-training-extract.py +0 -99
  63. package/references/scripts/recursive-training-grpo.ps1 +0 -52
  64. package/references/scripts/recursive-training-grpo.py +0 -1341
  65. package/references/scripts/recursive-training-loader.ps1 +0 -78
  66. package/references/scripts/recursive-training-loader.py +0 -558
  67. package/references/scripts/recursive-training-mcp.ps1 +0 -29
  68. package/references/scripts/recursive-training-mcp.py +0 -261
  69. package/references/scripts/recursive-training-phase8-trigger.ps1 +0 -55
  70. package/references/scripts/recursive-training-phase8-trigger.py +0 -137
  71. package/references/scripts/recursive-training-sync.ps1 +0 -29
  72. package/references/scripts/recursive-training-sync.py +0 -183
  73. package/references/scripts/recursive_phase_rules.py +0 -367
  74. package/references/scripts/recursive_router_cli_lib.py +0 -2
  75. package/references/scripts/recursive_router_lib.py +0 -2282
  76. package/references/scripts/verify-locks.ps1 +0 -25
  77. package/references/scripts/verify-locks.py +0 -353
  78. package/scripts/__pycache__/lint-recursive-run.cpython-314.pyc +0 -0
  79. package/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
  80. package/scripts/install-recursive-mode.ps1 +0 -956
  81. package/scripts/install-recursive-mode.py +0 -750
  82. package/scripts/lint-recursive-run.py +0 -2870
  83. package/scripts/recursive-closeout.py +0 -541
  84. package/scripts/recursive-init.py +0 -356
  85. package/scripts/recursive-lock.py +0 -302
  86. package/scripts/recursive-status.py +0 -2124
  87. package/scripts/recursive_phase_rules.py +0 -367
  88. package/scripts/recursive_router_lib.py +0 -2282
  89. package/scripts/verify-locks.py +0 -353
package/src/bootstrap.ts CHANGED
@@ -1,332 +1,339 @@
1
- /**
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().
13
- */
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
- }
26
-
27
- export interface BootstrapResult {
28
- root: string
29
- bootstrapped: boolean
30
- created: string[]
31
- existing: string[]
32
- }
33
-
34
- export interface StageBResult {
35
- root: string
36
- source: 'new' | 'resume'
37
- bootstrapped: boolean
38
- runs: string[]
39
- activeRunId?: string
40
- }
41
-
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'
168
- }
169
-
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
- }
178
- }
179
-
180
- /**
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.
184
- */
185
- export function bootstrapScaffold(root: string): BootstrapResult {
186
- const created: string[] = []
187
- const existing: string[] = []
188
-
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, '')
203
- }
204
- created.push(rel)
205
- }
206
-
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)
238
- } else {
239
- existing.push('.recursive/scripts/' + name)
240
- }
241
- }
242
-
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())
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)
290
- }
291
-
292
- /**
293
- * Enumerate runs as directory names only (bounded O(#dirs)). Never reads
294
- * run docs; never returns file paths.
295
- */
296
- export function enumerateRuns(root: string): string[] {
297
- const runRoot = join(root, '.recursive', 'run')
298
- if (!existsSync(runRoot)) return []
299
- return readdirSync(runRoot, { withFileTypes: true })
300
- .filter(d => {
301
- if (d.isDirectory()) return true
302
- if (!d.isSymbolicLink()) return false
303
- try { return statSync(join(runRoot, d.name)).isDirectory() } catch { return false }
304
- })
305
- .map(d => d.name)
306
- .sort()
307
- }
308
-
309
- export interface StageBInput {
310
- root: string
311
- source: 'new' | 'resume'
312
- activeRunId?: string
313
- }
314
-
315
- /**
316
- * Stage B workflow init at agent/session-start. New: bootstrap if missing +
317
- * enumerate runs. Resume: never re-bootstrap; enumerate + note the active run
318
- * (the caller re-reads only that run's current phase doc). Always scoped to
319
- * the given control-plane root.
320
- */
321
- export function stageBWorkflowInit(input: StageBInput): StageBResult {
322
- const { root, source, activeRunId } = input
323
- const bootstrap = bootstrapScaffold(root)
324
- const runs = enumerateRuns(root)
325
- return {
326
- root,
327
- source,
328
- bootstrapped: source === 'new' ? bootstrap.bootstrapped : false,
329
- runs,
330
- activeRunId,
331
- }
1
+ /**
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().
13
+ */
14
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync, readFileSync, rmSync } 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
+ }
26
+
27
+ export interface BootstrapResult {
28
+ root: string
29
+ bootstrapped: boolean
30
+ created: string[]
31
+ existing: string[]
32
+ }
33
+
34
+ export interface StageBResult {
35
+ root: string
36
+ source: 'new' | 'resume'
37
+ bootstrapped: boolean
38
+ runs: string[]
39
+ activeRunId?: string
40
+ }
41
+
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'
168
+ }
169
+
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
+ }
178
+ }
179
+
180
+ /**
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.
184
+ */
185
+ export function bootstrapScaffold(root: string): BootstrapResult {
186
+ const created: string[] = []
187
+ const existing: string[] = []
188
+
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, '')
203
+ }
204
+ created.push(rel)
205
+ }
206
+
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: TS-only scaffold (R2). No .py/.ps1 are vendored; the
228
+ // plugin's lint/lock/status/init run in-process via TS tools, so the
229
+ // scaffold carries an empty .recursive/scripts/ dir (kept for canonical
230
+ // tree-shape parity with the golden fixture).
231
+ noteDir('.recursive/scripts')
232
+ // R3 (run 09): TS-only repair — drop python-era artifacts from an EXISTING
233
+ // workspace's .recursive/scripts/ (the pre-0.1.6 scaffold vendored 29 .py +
234
+ // .ps1 wrappers; those are no longer shipped or needed). Deletes only
235
+ // .py/.ps1 under scripts/; never touches user content elsewhere.
236
+ {
237
+ const scriptsDir = join(recursiveRoot, 'scripts')
238
+ if (existsSync(scriptsDir)) {
239
+ for (const name of readdirSync(scriptsDir)) {
240
+ if (name.endsWith('.py') || name.endsWith('.ps1')) {
241
+ rmSync(join(scriptsDir, name), { force: true })
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ // Plain control-plane files (trailing newline).
248
+ noteFile('.recursive/RECURSIVE.md', '# RECURSIVE.md\n')
249
+ noteFile('.recursive/AGENTS.md', '# AGENTS.md\n')
250
+ noteFile('.recursive/STATE.md', body('state.md') + '\n')
251
+ noteFile('.recursive/DECISIONS.md', body('decisions.md') + '\n')
252
+ noteFile('.recursive/memory/MEMORY.md', '# MEMORY.md\n')
253
+ noteFile('.recursive/memory/skills/SKILLS.md', '# SKILLS.md\n')
254
+ noteFile('.recursive/memory/skills/usage/skill-discovery-and-evaluation.md', body('skill-discovery.md') + '\n')
255
+ noteFile('.recursive/memory/skills/patterns/delegated-verification-and-refresh.md', body('delegated-verification.md') + '\n')
256
+ noteFile('.recursive/memory/skills/patterns/phase8-skill-memory-promotion.md', body('phase8-skill-memory.md') + '\n')
257
+ noteFile('.codex/AGENTS.md', '# AGENTS.md\n')
258
+ noteFile('.agent/PLANS.md', '# PLANS.md\n')
259
+ noteFile('.cursorrules', '')
260
+ noteFile('CLAUDE.md', '')
261
+ noteFile('.github/copilot-instructions.md', '')
262
+
263
+ // .gitignore.
264
+ ensureGitignoreLine(root, '/.recursive/config/recursive-router-discovered.json')
265
+
266
+ // Router config.
267
+ ensureRouterScaffold(root)
268
+
269
+ // Marked blocks.
270
+ upsertMarkedBlock(join(recursiveRoot, 'AGENTS.md'), M.agentsStart, M.agentsEnd, body('recursive-agents-router.md'))
271
+ upsertMarkedBlock(join(memoryRoot, 'MEMORY.md'), M.memoryStart, M.memoryEnd, body('memory-router.md'))
272
+ upsertMarkedBlock(join(skillMemoryRoot, 'SKILLS.md'), M.memoryStart, M.memoryEnd, body('skill-memory-router.md'))
273
+ upsertMarkedBlock(join(root, '.cursorrules'), M.cursorrulesStart, M.cursorrulesEnd, body('cursorrules.md'))
274
+ upsertMarkedBlock(join(root, 'CLAUDE.md'), M.repoMdStart, M.repoMdEnd, body('claude.md'))
275
+ upsertMarkedBlock(join(root, '.github', 'copilot-instructions.md'), M.repoMdStart, M.repoMdEnd, body('copilot.md'))
276
+ upsertMarkedBlock(join(root, '.codex', 'AGENTS.md'), M.agentsStart, M.agentsEnd, body('codex-agents.md'))
277
+ const rootAgents = join(root, 'AGENTS.md')
278
+ if (existsSync(rootAgents)) {
279
+ upsertMarkedBlock(rootAgents, M.agentsStart, M.agentsEnd, agentsBlockContent())
280
+ }
281
+ upsertMarkedBlock(join(root, '.agent', 'PLANS.md'), M.plansStart, M.plansEnd, body('plans-bridge.md'))
282
+
283
+ // Canonical RECURSIVE.md (marker-wrapped, migrate plain installs).
284
+ const canonicalBody = normalizePlainOrWrappedContent(canonicalWorkflowContent(), M.recursiveStart, M.recursiveEnd)
285
+ upsertOrMigrateCanonical(join(recursiveRoot, 'RECURSIVE.md'), M.recursiveStart, M.recursiveEnd, canonicalBody)
286
+
287
+ return { root, bootstrapped: created.length > 0, created, existing }
288
+ }
289
+
290
+ function buffersEqual(a: string, b: string): boolean {
291
+ const ab = readFileSync(a)
292
+ const bb = readFileSync(b)
293
+ return ab.length === bb.length && ab.equals(bb)
294
+ }
295
+
296
+ /**
297
+ * Enumerate runs as directory names only (bounded O(#dirs)). Never reads
298
+ * run docs; never returns file paths.
299
+ */
300
+ export function enumerateRuns(root: string): string[] {
301
+ const runRoot = join(root, '.recursive', 'run')
302
+ if (!existsSync(runRoot)) return []
303
+ return readdirSync(runRoot, { withFileTypes: true })
304
+ .filter(d => {
305
+ if (d.isDirectory()) return true
306
+ if (!d.isSymbolicLink()) return false
307
+ try { return statSync(join(runRoot, d.name)).isDirectory() } catch { return false }
308
+ })
309
+ .map(d => d.name)
310
+ .sort()
311
+ }
312
+
313
+ export interface StageBInput {
314
+ root: string
315
+ source: 'new' | 'resume'
316
+ activeRunId?: string
317
+ }
318
+
319
+ /**
320
+ * Stage B workflow init at agent/session-start. New: bootstrap if missing +
321
+ * enumerate runs. Resume: idempotent REPAIR (not a full re-bootstrap) the
322
+ * upsert-only bootstrapScaffold adds missing control-plane files/dirs and
323
+ * re-upserts marked blocks WITHOUT overwriting user content or touching run/
324
+ * artifacts, then enumerates + notes the active run. The repair is safe on
325
+ * fresh, partial, AND complete scaffolds (run 09 R3/R6). Always scoped to the
326
+ * given control-plane root.
327
+ */
328
+ export function stageBWorkflowInit(input: StageBInput): StageBResult {
329
+ const { root, source, activeRunId } = input
330
+ const bootstrap = bootstrapScaffold(root)
331
+ const runs = enumerateRuns(root)
332
+ return {
333
+ root,
334
+ source,
335
+ bootstrapped: source === 'new' ? bootstrap.bootstrapped : false,
336
+ runs,
337
+ activeRunId,
338
+ }
332
339
  }