@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
@@ -1,23 +1,21 @@
1
1
  /**
2
- * Drill-down inspector (Phase D R6, §11.5): the details-seat swap rendering
3
- * one run's phase chain (with the always-shown 3.5 row + full 6/7/8 rows),
4
- * lock badges, and tamper/gate facts — all from the projection, never disk.
2
+ * Drill-down inspector (SP2 R1): the details-seat swap rendering one run's
3
+ * phase chain (with the always-shown 3.5 row + full 6/7/8 rows), lock badges,
4
+ * and tamper/gate facts — all from the live host-route snapshot, never disk.
5
5
  */
6
6
  import { createElement } from 'react'
7
- import type { RecursiveProjection } from '../types.ts'
7
+ import type { LiveProjectionValue } from './contract.ts'
8
8
  import { cardFacts, expandPhaseRows } from './derive.ts'
9
- import type { RecursiveProjectionReader } from './contract.ts'
10
9
 
11
10
  export interface InspectorProps {
12
11
  runId: string
13
12
  worktreeRoot: string
14
- useProjection: RecursiveProjectionReader
13
+ snapshot: LiveProjectionValue | null
15
14
  onBackToToolDetails: () => void
16
15
  }
17
16
 
18
- export function Inspector({ runId, worktreeRoot, useProjection, onBackToToolDetails }: InspectorProps) {
19
- const projection = useProjection('recursive') as RecursiveProjection | undefined
20
- const card = projection?.[worktreeRoot]?.[runId]
17
+ export function Inspector({ runId, worktreeRoot, snapshot, onBackToToolDetails }: InspectorProps) {
18
+ const card = snapshot?.projection?.[worktreeRoot]?.[runId]
21
19
  if (card === undefined) {
22
20
  return createElement('div', { className: 'rec-inspector' },
23
21
  createElement('p', {}, 'Run not found: ' + runId),
@@ -1,30 +1,43 @@
1
1
  /**
2
- * Slot registrations (Phase D R5/R6/R7/R8, §11.8): the sidebar launcher, the
3
- * shell.overlay board, the conversation.input.dock status strip, and the
4
- * settings.section page. The details-seat inspector is a swap driven by the
5
- * board cards' click (R6); the strip + board + settings are additive seats.
2
+ * Slot registrations (SP2 R1): the sidebar launcher, the shell.overlay board,
3
+ * the conversation.input.dock status strip, and the settings.section page.
6
4
  *
7
- * Self-contained structural seam: the real slot registry + runtime standard
8
- * kit arrive from @deepseek-ai/dsh-client-ui-slots + @deepseek-ai/dsh-client-runtime
9
- * at compose time; this module consumes the minimal ClientContext shape
10
- * (contract.ts). The client is READ-ONLY (R9) — no filesystem, no mutation.
5
+ * The board (root scope) and strip (session scope) render ONLY for the
6
+ * recursive preset the gate reads the CURRENT session's agentPreset from the
7
+ * sessions list snapshot (acceptance #3: a non-recursive session shows NOTHING).
8
+ *
9
+ * The board reads cwd from the ROOT-scope standard prop useSessions (the
10
+ * scoped-slots root branch provides ONLY useSessions/useWorkspaces — useProjection
11
+ * is session-scope-only, which is why the board never reads it). The strip reads
12
+ * its sessionId from the session-scope standard prop.
13
+ *
14
+ * READ-ONLY (R9): the client only GETs the live host route; no mutation.
11
15
  */
12
- import { createElement } from 'react'
13
- import type { ClientContext, RecursiveProjectionReader } from './contract.ts'
14
- import type { RecursiveProjection } from '../types.ts'
16
+ import { createElement, type ReactNode } from 'react'
17
+ import type { ClientContext, ISessionsLike, SnapshotStoreLike, SessionListStateLike } from './contract.ts'
18
+ import { isRecursivePreset, currentSessionCwd } from './contract.ts'
15
19
  import { Board } from './board.tsx'
16
20
  import { StatusStrip } from './strip.tsx'
17
21
  import { RecursiveSettings } from './settings.tsx'
22
+ import { useLiveProjection } from './use-live.ts'
23
+
24
+ /** Structural standard-prop face for the root scope (useSessions only). */
25
+ interface RootSlotProps {
26
+ useSessions?: () => SessionListStateLike
27
+ useWorkspaces?: () => unknown
28
+ }
18
29
 
19
- /** Narrow the projection whole value to the worktree-grouped run map. */
20
- function projectionOf(reader: RecursiveProjectionReader): RecursiveProjection | undefined {
21
- return reader('recursive') as RecursiveProjection | undefined
30
+ /** Structural standard-prop face for the session scope (sessionId + useSessions). */
31
+ interface SessionSlotProps {
32
+ sessionId?: string
33
+ useSessions?: () => SessionListStateLike
34
+ useProjection?: unknown
22
35
  }
23
36
 
24
37
  export function registerSlots(ctx: ClientContext): () => void {
25
38
  const disposers: (() => void)[] = []
26
39
 
27
- // Board launcher in the sidebar footer action list (always visible).
40
+ // Board launcher in the sidebar footer action list (always visible; opens the board).
28
41
  disposers.push(ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
29
42
  name: 'sidebar.footer.action',
30
43
  id: 'recursive',
@@ -32,27 +45,29 @@ export function registerSlots(ctx: ClientContext): () => void {
32
45
  label: 'Recursive runs',
33
46
  }, () => createElement('button', { className: 'rec-launcher', title: 'Recursive runs' }, '⧉'))))
34
47
 
35
- // Board panel overlay (root scope, cross-session read).
48
+ // Board panel overlay (root scope, cross-session read) — gated on the recursive preset.
36
49
  disposers.push(ctx.slots.inject('shell.overlay', () => ctx.slots.register({
37
50
  name: 'shell.overlay',
38
51
  id: 'recursive-board',
39
52
  order: 10,
40
- }, (props: { useProjection?: RecursiveProjectionReader }) => {
41
- if (props?.useProjection === undefined) return null
42
- return createElement(Board, { useProjection: props.useProjection })
53
+ }, (props: RootSlotProps) => {
54
+ const useSessions = props?.useSessions
55
+ if (useSessions === undefined) return null
56
+ return createElement(RecursiveBoardGate, { useSessions })
43
57
  })))
44
58
 
45
- // Status strip in the composer input dock (session scope).
59
+ // Status strip in the composer input dock (session scope) — gated the same way.
46
60
  disposers.push(ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
47
61
  name: 'conversation.input.dock',
48
62
  id: 'recursive-strip',
49
63
  order: 20,
50
- }, (props: { useProjection?: RecursiveProjectionReader }) => {
51
- if (props?.useProjection === undefined) return null
52
- return createElement(StatusStrip, { useProjection: props.useProjection })
64
+ }, (props: SessionSlotProps) => {
65
+ const useSessions = props?.useSessions
66
+ if (useSessions === undefined) return null
67
+ return createElement(RecursiveStripGate, { useSessions, sessionId: props?.sessionId })
53
68
  })))
54
69
 
55
- // Settings section (root scope, always present).
70
+ // Settings section (root scope, always present; no gate — configuration is always available).
56
71
  disposers.push(ctx.slots.inject('settings.section', () => ctx.slots.register({
57
72
  name: 'settings.section',
58
73
  id: 'recursive',
@@ -63,5 +78,34 @@ export function registerSlots(ctx: ClientContext): () => void {
63
78
  return () => { for (const d of disposers) d() }
64
79
  }
65
80
 
66
- /** Export the projection reader narrow for the board/inspector/strip. */
67
- export { projectionOf }
81
+ /**
82
+ * Board gate: renders the board ONLY when the current session is on the
83
+ * recursive preset. The gate itself reads the live sessions snapshot via the
84
+ * standard useSessions hook and re-renders on change.
85
+ */
86
+ function RecursiveBoardGate({ useSessions }: { useSessions: () => SessionListStateLike }): ReactNode {
87
+ const list = useSessions()
88
+ const sessions: ISessionsLike = { list: { getSnapshot: () => list, subscribe: () => () => {} } as SnapshotStoreLike<SessionListStateLike> }
89
+ if (!isRecursivePreset(sessions)) return null
90
+ const cwd = currentSessionCwd(sessions)
91
+ const scope = { sessionId: list.current, cwd }
92
+ const snapshot = useLiveProjection(scope)
93
+ return createElement(Board, { snapshot })
94
+ }
95
+
96
+ /**
97
+ * Strip gate: same preset gate, session-scoped sessionId; the strip renders
98
+ * nothing for a non-recursive session.
99
+ */
100
+ function RecursiveStripGate({ useSessions, sessionId }: { useSessions: () => SessionListStateLike; sessionId?: string }): ReactNode {
101
+ const list = useSessions()
102
+ const sessions: ISessionsLike = { list: { getSnapshot: () => list, subscribe: () => () => {} } as SnapshotStoreLike<SessionListStateLike> }
103
+ if (!isRecursivePreset(sessions)) return null
104
+ const cwd = currentSessionCwd(sessions)
105
+ const scope = { sessionId, cwd }
106
+ const snapshot = useLiveProjection(scope)
107
+ return createElement(StatusStrip, { snapshot })
108
+ }
109
+
110
+ /** Export the gate helpers for tests. */
111
+ export { isRecursivePreset, currentSessionCwd }
@@ -1,21 +1,21 @@
1
1
  /**
2
- * Status strip (Phase D R7, §11.6): a thin glance at the active run's phase,
3
- * gate state, and tamper badge, docked at conversation.input.dock. Renders
4
- * nothing when no run is current.
2
+ * Status strip (SP2 R1): a thin glance at the active run's phase, gate state,
3
+ * and tamper badge, docked at conversation.input.dock. Renders NOTHING when no
4
+ * run is current or the snapshot is null (the preset gate lives in slots.ts).
5
5
  */
6
6
  import { createElement } from 'react'
7
7
  import { cardFacts } from './derive.ts'
8
8
  import { listRuns } from './board.tsx'
9
- import type { RecursiveProjection } from '../types.ts'
10
- import type { RecursiveProjectionReader } from './contract.ts'
9
+ import type { LiveProjectionValue } from './contract.ts'
11
10
 
12
11
  export interface StripProps {
13
- useProjection: RecursiveProjectionReader
12
+ /** The live host-route snapshot (null = not loaded / no recursive root). */
13
+ snapshot: LiveProjectionValue | null
14
14
  }
15
15
 
16
- export function StatusStrip({ useProjection }: StripProps) {
17
- const projection = useProjection('recursive') as RecursiveProjection | undefined
18
- const runs = listRuns(projection)
16
+ export function StatusStrip({ snapshot }: StripProps) {
17
+ if (snapshot === null || snapshot.root === null) return null
18
+ const runs = listRuns(snapshot.projection)
19
19
  if (runs.length === 0) return null
20
20
  const active = runs[runs.length - 1]
21
21
  const facts = cardFacts(active)
@@ -0,0 +1,52 @@
1
+ /**
2
+ * useLiveProjection(scope): the client bridge to the live host route (SP2 R1).
3
+ *
4
+ * Reads the per-workspace filesystem fold served by the host HTTP/SSE route
5
+ * instead of the (removed) 'recursive' session projection. The scope carries
6
+ * sessionId PRIMARY (the host resolves cwd from the attached session header)
7
+ * plus cwd as a fallback hint. Returns { root, projection, revision } or null
8
+ * while the first state fetch is in flight. The hook never mutates the host
9
+ * (R9 read-only).
10
+ *
11
+ * If the scope has neither sessionId nor cwd the hook renders nothing — the
12
+ * caller gates on the recursive preset BEFORE this so a non-recursive session
13
+ * shows NOTHING (acceptance #3).
14
+ */
15
+ import { useEffect, useState } from 'react'
16
+ import { fetchLiveState, subscribeLiveEvents, type LiveRecursiveFrame, type LiveRecursiveState, type LiveScope } from './host-api.ts'
17
+
18
+ /** Snapshot shape the board/strip consume (null = not loaded / no recursive root). */
19
+ export type LiveProjectionSnapshot = LiveRecursiveState | null
20
+
21
+ /** Empty scope — nothing to key on. */
22
+ function isEmpty(scope: LiveScope): boolean {
23
+ return !scope.sessionId && !scope.cwd
24
+ }
25
+
26
+ /**
27
+ * Subscribe to the live route for one scope. Returns the current state. The SSE
28
+ * feed pushes full frames on every fs change; a dropped stream (sleep/error)
29
+ * triggers one refetch so the board never serves a stale fold.
30
+ */
31
+ export function useLiveProjection(scope: LiveScope): LiveProjectionSnapshot {
32
+ const [state, setState] = useState<LiveRecursiveState | null>(null)
33
+
34
+ useEffect(() => {
35
+ // Empty scope = no resolvable session row yet — render nothing, never fetch.
36
+ if (isEmpty(scope)) return
37
+ let disposed = false
38
+ const apply = (frame: LiveRecursiveFrame) => { if (!disposed) setState(frame) }
39
+
40
+ // Initial state + every refresh: a no-store fetch (cold resume = fresh fs read).
41
+ void fetchLiveState(scope).then(apply).catch(() => { /* no recursive root yet — stay empty */ })
42
+
43
+ // Live deltas over SSE.
44
+ const disposeEvents = subscribeLiveEvents(scope, apply, () => {
45
+ if (!disposed) void fetchLiveState(scope).then(apply).catch(() => {})
46
+ })
47
+
48
+ return () => { disposed = true; disposeEvents() }
49
+ }, [scope.sessionId, scope.cwd])
50
+
51
+ return state
52
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { type Context } from '@deepseek-ai/cordis'
2
- import type { Session } from '@deepseek-ai/dsh-session'
2
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
3
+ import { existsSync } from 'node:fs'
4
+ import { join } from 'node:path'
3
5
  import { RecursiveRuntime } from './runtime.ts'
4
6
  import { createRecursiveStatusTool } from './recursive_status.tool.ts'
5
7
  import { createRecursiveInitTool } from './recursive_init.tool.ts'
@@ -8,23 +10,14 @@ import { createRecursiveLintTool } from './recursive_lint.tool.ts'
8
10
  import { createRecursiveCloseoutTool } from './recursive_closeout.tool.ts'
9
11
  import { createRecursiveScratchTool } from './recursive_scratch.tool.ts'
10
12
  import { registerRecursiveCommand } from './commands.ts'
11
- import { stageBWorkflowInit } from './bootstrap.ts'
12
- import { evaluatePreStepGate, evaluateToolGuard, detectTamper, resolveEnforcementConfig, DEFAULT_ENFORCEMENT, type EnforcementConfig } from './enforcement.ts'
13
+ import { evaluateToolGuard } from './enforcement.ts'
13
14
  import { detectTransitionIntent } from './lifecycle.ts'
14
15
  import { renderRecursivePolicy } from './policy.ts'
15
- import {
16
- gateBlocked as gateBlockedEvent,
17
- tamper as tamperEvent,
18
- phaseIntent as phaseIntentEvent,
19
- runCreated as runCreatedEvent,
20
- phase as phaseEvent,
21
- phaseLocked as phaseLockedEvent,
22
- runState as runStateEvent,
23
- runMerged as runMergedEvent,
24
- subagentStart as subagentStartEvent,
25
- subagentEnd as subagentEndEvent,
26
- } from './events.ts'
27
- import { recursiveProjectionUnit } from './projection.ts'
16
+ import { snapshotWorkspace } from './snapshot.ts'
17
+ import { mountRecursiveRoutesOnce, makeRecursiveRoutes, type RecursiveRouteHost } from './live-route.ts'
18
+ import { enumerateRuns } from './bootstrap.ts'
19
+ import { getNextLegalPhase, getLockStatus } from './lock.ts'
20
+ import { phaseLintRulesMessage } from './phase-rules.ts'
28
21
 
29
22
  export const name = '@try-works/dsh-recursive-mode'
30
23
 
@@ -71,8 +64,8 @@ export * from './delegation.ts'
71
64
  export * from './lifecycle.ts'
72
65
  export * from './enforcement.ts'
73
66
  export * from './policy.ts'
74
- export * from './events.ts'
75
- export * from './projection.ts'
67
+ export * from './snapshot.ts'
68
+ export * from './live-route.ts'
76
69
 
77
70
  /**
78
71
  * Bundle plugin entry. The Loader activates this row once `tools` is available
@@ -83,16 +76,22 @@ export * from './projection.ts'
83
76
  export const inject = ['tools']
84
77
 
85
78
  /**
86
- * Two-stage init (PROPOSAL §5.10):
87
- * - Stage A (mount-time, this apply): register the isolated ctx.recursive
88
- * service + the recursive_* tools + the /recursive command + the
89
- * recursive:policy prompt section. No repo/run work here.
90
- * - Stage B (agent/session-start): resolve the session's workspace root (R1),
91
- * bootstrap the scaffold if missing, enumerate runs as dir names only, and
92
- * inject a workspace-scoped notice. See stageBWorkflowInit + the
93
- * agent/session-start listener below.
79
+ * Plugin entry (SP2 R1). Stage A (mount-time, this apply): register the
80
+ * isolated ctx.recursive service + the recursive_* tools + the /recursive
81
+ * command + the recursive:policy prompt section + the LIVE board/strip route
82
+ * (HTTP state + SSE), served per-workspace from the filesystem fold. No
83
+ * repo/run work and NO session-event emission here: zero recursive/* events
84
+ * are ever appended (resume-crash fix), and the board reads the live fs route.
94
85
  */
95
- export function apply(ctx: Context) {
86
+ export function apply(ctx: Context, config?: { shellOnly?: boolean }) {
87
+ // R4 shell split (02-to-be-plan.addendum-r4-r2-mount-resolution.md): the
88
+ // global bare-name row in cordis.patch.yml mounts with config.shellOnly=true
89
+ // to expose ONLY the client bundle for client discovery. It must register
90
+ // NOTHING on the server root — no tools, no /recursive command, no
91
+ // recursive:policy, no projection (BUG 4 always-on leak). The full server
92
+ // surface is mounted ONLY by the recursive preset's isolated recursive-realm,
93
+ // whose rows carry no config (shellOnly undefined).
94
+ if (config?.shellOnly) return
96
95
  ctx.effect(function* () {
97
96
  // Workspace registry: optional host service (durable). Access via ctx.get —
98
97
  // property access requires inject and would fail boot when undeclared.
@@ -100,27 +99,6 @@ export function apply(ctx: Context) {
100
99
  const workspaceRegistry = ctx.get('workspaceRegistry') as never
101
100
  const recursive = new RecursiveRuntime(ctx, { repoRoot: process.cwd(), workspaceRegistry })
102
101
 
103
- /** Typed agent payload shape (session is a full Session). */
104
- type AgentLike = { session?: Session | null }
105
-
106
- /** Extract the session from a listener payload. */
107
- const sessionOf = (p: unknown): Session | null => {
108
- const a = (p as { agent?: AgentLike | null } | null)?.agent
109
- return a?.session ?? null
110
- }
111
-
112
- /** Resolve the session's control-plane root (registry first, cwd fallback). */
113
- const rootOf = async (p: unknown): Promise<string> => {
114
- const a = (p as { agent?: AgentLike | null } | null)?.agent
115
- const root = await recursive.resolveRootFor(a as { session?: { header?: { cwd?: string } } } | null)
116
- return root ?? a?.session?.header.cwd ?? process.cwd()
117
- }
118
-
119
- /** Emit one recursive/* event into the session log (log-only). */
120
- const emit = (session: Session | null, type: string, data: unknown) => {
121
- if (!session) return
122
- try { (session.append as unknown as (t: string, d: unknown) => void)(type, data) } catch { /* log-only; never fail the gate on an emit error */ }
123
- }
124
102
  const disposers = [
125
103
  ctx.tools.register(createRecursiveStatusTool(recursive)),
126
104
  ctx.tools.register(createRecursiveInitTool(recursive)),
@@ -156,30 +134,14 @@ export function apply(ctx: Context) {
156
134
  }))
157
135
  }
158
136
 
159
- // Phase C R3: agent/pre-step phase-transition gate (Layer 1, caller of the
160
- // lifecycle transition set). Reads on transition intent only; rejects (strict)
161
- // or warns (advisory) when the target gates fail.
162
- const agentEvents = ctx as unknown as { on?: (event: string, listener: (payload: unknown, next?: unknown) => unknown) => () => void }
163
- if (agentEvents.on) {
164
- disposers.push(agentEvents.on('agent/pre-step', (payload, next) => {
165
- const p = payload as { agent?: { session?: { events?: { type: string; data?: Record<string, unknown> }[] } } } | null
166
- const events = p?.agent?.session?.events ?? []
167
- const session = sessionOf(p)
168
- const intent = recursive.detectTransitionIntent(events as never)
169
- const decision = evaluatePreStepGate(events as never, recursive.enforcementConfig.preStep)
170
- void (async () => {
171
- // Emit a committed run-state (blocked) when a gate rejects the intent —
172
- // the board then shows the run blocked, not merely a log message.
173
- if (decision.gateBlocked && intent?.runId) {
174
- const worktreeRoot = intent?.worktreeRoot ?? (await rootOf(p))
175
- emit(session, 'recursive/gate-blocked', gateBlockedEvent({ runId: intent.runId, worktreeRoot, phase: intent?.targetArtifact ?? '', failures: decision.failures, kind: 'pre-step' }))
176
- emit(session, 'recursive/run-state', runStateEvent({ runId: intent.runId, worktreeRoot, state: 'blocked', reason: 'gate-blocked: ' + decision.failures.join('; ') }))
177
- }
178
- })()
179
- if (decision.kind === 'reject') return { kind: 'reject' }
180
- return typeof next === 'function' ? next() : { kind: 'enter', messages: [] }
181
- }))
182
- }
137
+ // Phase C R3 (Layer 1, agent/pre-step proactive intent gate) is RETIRED
138
+ // under zero-emission (SP2 R1): its only signal was the recursive/phase-intent
139
+ // session event, whose emitter is now deleted, and it was ADVISORY by default
140
+ // (it never rejected; its sole side effect was the now-removed emission).
141
+ // Enforcement is preserved where it actually bites: Layer 2 (tools/pre-execute
142
+ // inspects the REAL tool call args below) plus the recursive_lock tool's own
143
+ // prerequisite validation in lockArtifact strictly more reliable than the
144
+ // heuristic intent scan. See 03-implementation-summary.addendum-r1-*.md.
183
145
 
184
146
  // Phase C R4: tools/pre-execute surgical guards (Layer 2, caller of the
185
147
  // transition set). Scope-filtered to the active run's worktree.
@@ -195,87 +157,68 @@ export function apply(ctx: Context) {
195
157
  if (decision.kind === 'allow') return typeof next === 'function' ? next() : { kind: 'allow' }
196
158
  if (decision.kind === 'deny') return decision
197
159
  // ask -> in advisory we let it through with a warn (the approval seam is Phase D)
198
- return typeof next === 'function' ? next() : { kind: 'allow' }
199
160
  }))
200
161
  }
201
162
 
202
- // Phase C R8 + Phase D R1: fs/observed lock-tamper warnings -> recursive/tamper event.
203
- const fsRuntime = ctx as unknown as { on?: (event: string, listener: (payload: unknown) => void) => () => void }
204
- if (fsRuntime.on) {
205
- disposers.push(fsRuntime.on('fs/observed', (payload) => {
206
- const p = payload as { target?: { displayPath?: string }; agent?: AgentLike | null } | null
207
- const path = p?.target?.displayPath ?? ''
208
- if (!path) return
209
- // B3: tamper detection is scoped to the SESSION's control-plane root,
210
- // not the host checkout.
211
- const sessionCwd = p?.agent?.session?.header.cwd ?? ''
212
- const tamper = detectTamper(path, sessionCwd, '')
213
- if (!tamper) return
214
- // Resolve the worktree root from the tampered path (strip /.recursive/run/<id>/).
215
- const normalized = path.replace(/\\/g, '/')
216
- const marker = '/.recursive/run/'
217
- const idx = normalized.indexOf(marker)
218
- const worktreeRoot = idx >= 0 ? normalized.slice(0, idx) : sessionCwd
219
- // Prefer the observing session's own log (additive); fall back to the
220
- // session store list only when the payload carries no session.
221
- const session = sessionOf(p) ?? null
222
- if (session) {
223
- emit(session, 'recursive/tamper', tamperEvent({ runId: tamper.runId, worktreeRoot, path: tamper.path, reason: tamper.reason }))
224
- return
225
- }
226
- const sessions = ctx.get('sessions') as { list?: () => Session[] } | undefined
227
- const match = sessions?.list?.().find((s) => {
228
- const cwd = (s.header?.cwd ?? '').replace(/\\/g, '/')
229
- return cwd === '' || cwd === worktreeRoot || cwd.startsWith(worktreeRoot + '/')
230
- })
231
- if (match) emit(match, 'recursive/tamper', tamperEvent({ runId: tamper.runId, worktreeRoot, path: tamper.path, reason: tamper.reason }))
163
+ // SP3 R5: agent/pre-step lint-rules injection (pre-step contract verified: the
164
+ // listener returns { kind: 'enter', messages: [...messages, injected] } and the
165
+ // returned array REPLACES the default [...claimed, context]). When the session's
166
+ // control-plane root has an active recursive run whose current phase doc is
167
+ // DRAFT, prepend a compact system-reminder with THAT phase's required sections +
168
+ // gates. Pure fs read (zero-emission): never appends recursive/* events.
169
+ const agentRuntime = ctx as unknown as { on?: (event: string, listener: (payload: unknown, next?: unknown) => unknown) => () => void }
170
+ if (agentRuntime.on) {
171
+ disposers.push(agentRuntime.on('agent/pre-step', async (payload, next) => {
172
+ const p = payload as {
173
+ messages?: Array<{ content: Array<{ type: string; text?: string }> }>,
174
+ agent?: { session?: { header?: { cwd?: string } } } | null,
175
+ } | null
176
+ const messages = p?.messages ?? []
177
+ const agent = p?.agent ?? null
178
+ const cwd = agent?.session?.header?.cwd ?? ''
179
+ // delegate first so later listeners keep veto power, then fold ours on
180
+ if (typeof next === 'function') await next()
181
+ if (!cwd) return { kind: 'enter', messages } as const
182
+ const root = await recursive.resolveRootForRoute(undefined, cwd, undefined)
183
+ if (!root) return { kind: 'enter', messages } as const
184
+ const runs = enumerateRuns(root)
185
+ if (runs.length === 0) return { kind: 'enter', messages } as const
186
+ const runId = runs[runs.length - 1]
187
+ const runDir = join(root, '.recursive', 'run', runId)
188
+ if (!existsSync(runDir)) return { kind: 'enter', messages } as const
189
+ const phase = getNextLegalPhase(runDir)
190
+ if (!phase) return { kind: 'enter', messages } as const
191
+ // only inject when the phase doc is DRAFT (not locked/missing).
192
+ const phasePath = join(runDir, phase)
193
+ const status = existsSync(phasePath) ? getLockStatus(phasePath) : null
194
+ if (status !== 'DRAFT') return { kind: 'enter', messages } as const
195
+ const reminder = phaseLintRulesMessage(phase)
196
+ return {
197
+ kind: 'enter',
198
+ messages: [...messages, createUserMessage({ content: [{ type: 'text', text: reminder }], source: { kind: 'plugin', plugin: '@try-works/dsh-recursive-mode' } })],
199
+ } as const
232
200
  }))
233
201
  }
234
202
 
235
- // Phase D R2: register the recursive projection unit (activates only when a
236
- // projection registry is composed headless assemblies stay unaffected).
237
- // The unit's cell is per-session: a session's own log is its workspace, so
238
- // an accept-all init is scoped by the session boundary (see projection.ts
239
- // emptyRecursiveFoldState). The registry (if present) is cached here so the
240
- // session-start boundary can re-read run state after the projection drove.
241
- let sessionProjections: { snapshot?: (session: Session) => { values: Partial<Record<string, unknown>> } } | null = null
242
- ctx.inject(['sessionProjections'], (projectionCtx) => {
243
- const registry = (projectionCtx as { sessionProjections?: { register: (def: unknown) => () => void; snapshot?: (session: Session) => unknown } }).sessionProjections
244
- if (registry?.register) {
245
- disposers.push(registry.register(recursiveProjectionUnit))
246
- sessionProjections = registry as { snapshot?: (session: Session) => { values: Partial<Record<string, unknown>> } }
203
+ // Phase C R8: fs/observed lock-tamper WARNINGS are served to the board via the
204
+ // mountOnce-global: apply() runs per-session, but the route must register
205
+ // exactly once (WebServer.register throws on duplicate kind+path) and serve
206
+ // PER-WORKSPACE state. No-op when the host composes no webServer (headless).
207
+ const webServer = ctx.get('webServer') as
208
+ | { register: (route: { kind: string; path: string; handler: unknown }) => () => void }
209
+ | undefined
210
+ const sessionsStore = ctx.get('sessions') as
211
+ | { get?: (id: string) => { header?: { cwd?: string } } | undefined }
212
+ | undefined
213
+ if (webServer) {
214
+ const host: RecursiveRouteHost = {
215
+ // sessionId PRIMARY: the host resolves cwd from the attached session header;
216
+ // the client-passed cwd is a fallback hint (hydration / headless).
217
+ resolveRoot: async (sessionId: string | undefined, cwd: string) => recursive.resolveRootForRoute(sessionId, cwd, sessionsStore),
218
+ snapshot: async (root: string) => snapshotWorkspace(root),
219
+ revision: () => 1,
247
220
  }
248
- })
249
-
250
- // Stage B (R6): agent/session-start workflow init, workspace-scoped.
251
- // NEW sessions emit recursive/run-state('active') after init; resumed
252
- // sessions replay the projection (already folded from the persisted log) —
253
- // no duplicate run-created. run-created is emitted by the lifecycle at the
254
- // init/lock boundary (the only place a run's files first exist).
255
- const events = ctx as unknown as { on?: (event: string, listener: (payload: unknown) => void) => () => void }
256
- if (events.on) {
257
- disposers.push(events.on('agent/session-start', (payload) => {
258
- const p = payload as { agent?: AgentLike | null; source?: 'new' | 'resume' }
259
- const session = sessionOf(p)
260
- const cwd = p?.agent?.session?.header?.cwd
261
- if (!cwd) return
262
- void (async () => {
263
- const root = await recursive.resolveRootFor(p?.agent as { session?: { header?: { cwd?: string } } } | null)
264
- if (!root) return
265
- const result = stageBWorkflowInit({ root, source: p.source === 'resume' ? 'resume' : 'new' })
266
- // Emit run-state for the active run (from the projected wire value, the
267
- // one same-source view) when the projection registry is present.
268
- if (session) {
269
- const runId = result.activeRunId ?? result.runs[result.runs.length - 1] ?? ''
270
- if (runId) {
271
- const snap = sessionProjections?.snapshot?.(session)
272
- const proj = (snap?.values as { recursive?: Record<string, Record<string, { state?: string }>> } | undefined)?.recursive
273
- const state = proj?.[root]?.[runId]?.state ?? 'active'
274
- emit(session, 'recursive/run-state', runStateEvent({ runId, worktreeRoot: root, state: state as 'new' | 'active' | 'paused' | 'blocked' | 'complete' }))
275
- }
276
- }
277
- })()
278
- }))
221
+ disposers.push(mountRecursiveRoutesOnce('@try-works/dsh-recursive-mode', () => makeRecursiveRoutes(host), webServer))
279
222
  }
280
223
 
281
224
  yield () => { for (const d of disposers) d() }