@try-works/dsh-recursive-mode 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,723 +1,795 @@
1
- import { type Context } from '@deepseek-ai/cordis'
2
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
3
- import type { ContextFormed } from '@deepseek-ai/dsh-llm'
4
- import { existsSync } from 'node:fs'
5
- import { join } from 'node:path'
6
- import { RecursiveRuntime } from './runtime.ts'
7
- import type { UserQuestionsLike } from './runtime.ts'
8
- import type { JobsRegistryLike } from './jobs-runner.ts'
9
- import { planGateForExit } from './plan-gate.ts'
10
- import { registerPhaseSkills, type SkillRegistryLike } from './skills-phase.ts'
11
- import type { WorkflowEngineLike } from './workflow-audit.ts'
12
- import type { RecursiveModeConfig } from './config.ts'
13
- import { createRecursiveStatusTool } from './recursive_status.tool.ts'
14
- import { createRecursiveInitTool } from './recursive_init.tool.ts'
15
- import { createRecursiveLockTool } from './recursive_lock.tool.ts'
16
- import { createRecursiveLintTool } from './recursive_lint.tool.ts'
17
- import { createRecursiveCloseoutTool } from './recursive_closeout.tool.ts'
18
- import { createRecursiveScratchTool } from './recursive_scratch.tool.ts'
19
- import { createRecursiveWorktreeTool } from './recursive_worktree.tool.ts'
20
- import { createRecursivePhaseTool } from './recursive_phase.tool.ts'
21
- import { createRecursiveAuditTeamTool } from './recursive_audit_team.tool.ts'
22
- import { createRecursiveReviewTool } from './recursive_review.tool.ts'
23
- import { createRecursiveDelegateTool } from './recursive_delegate.tool.ts'
24
- import { createRecursiveAskTool } from './recursive_ask.tool.ts'
25
- import { renderGateBlockAsk } from './recursive_ask.tool.ts'
26
- import { createRecursivePreviewTool } from './recursive_preview.tool.ts'
27
- import type { SubagentsRuntimeLike } from './delegation.ts'
28
- import { registerRecursiveCommand } from './commands.ts'
29
- import { evaluateToolGuard, coerceAskToDecision, tamperCandidatePath, type ToolGuardDecision } from './enforcement.ts'
30
- import { appendGuardDecision, appendObservedTamper, type GuardDecisionRecord } from './guard-log.ts'
31
- import type { GoalServiceLike } from './goals-projection.ts'
32
- import type { TeamRuntimeLike } from './teams-loop.ts'
33
- import { renderRecursivePolicy } from './policy.ts'
34
- import { fsPolicyIntent } from './fs-intent.ts'
35
- import { snapshotWorkspace } from './snapshot.ts'
36
- import { mountRecursiveRoutesOnce, makeRecursiveRoutes, type RecursiveRouteHost } from './live-route.ts'
37
- import { registerRecursiveSkill } from './skills.ts'
38
- import { enumerateRuns, stageBWorkflowInit } from './bootstrap.ts'
39
- import { getNextLegalPhase, getLockStatus, PHASE_SEQUENCE } from './lock.ts'
40
- import { adoptSettlement } from './settlement.ts'
41
- import type { LlmInventoryLike } from './model-inventory.ts'
42
- import { resolveRunDir } from './run.ts'
43
- import { phaseLintRulesMessage, ReminderOnceGate } from './phase-rules.ts'
44
- import { settlementFromEvent, runDirForChild, recordSettlement } from './settlement.ts'
45
-
46
- /**
47
- * rc.2 rebase (T31a) — the message-source vocabulary changed under us.
48
- *
49
- * At `dsh-v0.1.1-rc.2` `MessageSourceMap` carried a shared catch-all
50
- * `plugin: { kind: 'plugin'; plugin: string }` entry, which this file used for
51
- * its injected phase-lint reminder. At `dsh-v0.2.0-rc.2` that entry is GONE:
52
- * the map is merge-extensible and, in its own words, "each producer declares
53
- * its own `kind` in its own module; there is no shared catch-all `plugin`
54
- * kind". This was invisible in the old checkout because its `node_modules`
55
- * still held a stale `dsh-llm`.
56
- *
57
- * The idiom below is copied from the shipped `@deepseek-ai/dsh-repeat-tool-reminder`,
58
- * whose pre-step reminder is the closest analogue to ours: a user-role message
59
- * whose source declares its own kind and a `form: 'notice'` one-line account.
60
- */
61
- declare module '@deepseek-ai/dsh-llm' {
62
- interface MessageSourceMap {
63
- 'recursive-mode': { kind: 'recursive-mode' } & ContextFormed
64
- }
65
- }
66
-
67
- /** Producer source stamped on every injected phase-lint reminder. */
68
- const REMINDER_SOURCE = { kind: 'recursive-mode' } as const
69
-
70
- export const name = '@try-works/dsh-recursive-mode'
71
-
72
- /**
73
- * T7: the plugin's Config schema, which the settings service DISCOVERS (see `src/config.ts`
74
- * for why declaring it is the registration). Re-exported from the entry because the Loader
75
- * reads it from the plugin module.
76
- */
77
- export { Config } from './config.ts'
78
- export type { RecursiveModeConfig } from './config.ts'
79
-
80
- export { RecursiveRuntime } from './runtime.ts'
81
- export { createRecursiveStatusTool } from './recursive_status.tool.ts'
82
- export { createRecursiveInitTool } from './recursive_init.tool.ts'
83
- export { createRecursiveLockTool } from './recursive_lock.tool.ts'
84
- export { createRecursiveLintTool } from './recursive_lint.tool.ts'
85
- export { createRecursiveCloseoutTool } from './recursive_closeout.tool.ts'
86
- export { createRecursiveScratchTool } from './recursive_scratch.tool.ts'
87
- export { createRecursiveWorktreeTool } from './recursive_worktree.tool.ts'
88
- export { createRecursivePhaseTool } from './recursive_phase.tool.ts'
89
- export * from './status.ts'
90
- export {
91
- PHASE_SEQUENCE,
92
- OPTIONAL_PHASES,
93
- normalizeForLockHash,
94
- lockHashFromContent,
95
- phaseIndex,
96
- isCoreArtifact,
97
- getPrerequisites,
98
- getLockStatus,
99
- getPrerequisiteBlockers,
100
- receiptPath,
101
- readReceipt,
102
- writeReceipt,
103
- invalidateReceipt,
104
- getStaleDownstreamPhases,
105
- getNextLegalPhase,
106
- getAllStaleReceipts,
107
- validateChain,
108
- } from './lock.ts'
109
- export type {
110
- LockReceipt,
111
- LockStatus,
112
- PrerequisiteBlocker,
113
- StaleDownstream,
114
- ChainPhaseResult,
115
- LockChainResult,
116
- } from './lock.ts'
117
- export * from './run.ts'
118
- export * from './review.ts'
119
- export * from './handoff.ts'
120
- export * from './router.ts'
121
- export * from './delegation.ts'
122
- export * from './lifecycle.ts'
123
- export * from './enforcement.ts'
124
- export * from './policy.ts'
125
- export * from './snapshot.ts'
126
- export * from './live-route.ts'
127
- export * from './teams-loop.ts'
128
- export * from './skills.ts'
129
-
130
- /**
131
- * Bundle plugin entry. The Loader activates this row once `tools` is available
132
- * (`inject` below); the RecursiveRuntime service is constructed directly so it
133
- * is provided on `ctx.recursive` for the lifetime of this fiber, and the
134
- * read-path tools (status/init/lock/lint) are registered through it (R2/R4).
135
- */
136
- export const inject = ['tools']
137
-
138
- /**
139
- * Plugin entry (SP2 R1). Stage A (mount-time, this apply): register the
140
- * isolated ctx.recursive service + the recursive_* tools + the /recursive
141
- * command + the recursive:policy prompt section + the LIVE board/strip route
142
- * (HTTP state + SSE), served per-workspace from the filesystem fold. No
143
- * repo/run work and NO session-event emission here: zero recursive/* events
144
- * are ever appended (resume-crash fix), and the board reads the live fs route.
145
- */
146
- /** T38: the built-in guard's hook name — the chain's identity for "this was the plugin's own". */
147
- const BUILTIN_GUARD_HOOK_NAME = 'builtin-tool-guard'
148
-
149
- /**
150
- * T38 — the tool guard, as a function rather than an inline block.
151
- *
152
- * Extracted so the SAME code can run as a hook on the registry: a built-in and a
153
- * sibling then share one chain, one ordering rule and one failure policy, instead of
154
- * the built-in being privileged code that always runs first. Every side effect stays
155
- * here — the guard-decision log is written by whoever computes the decision, so moving
156
- * the call site cannot lose it.
157
- *
158
- * The `ask` coercion is unchanged and stays key-frozen: `coerceAskToDecision`'s output
159
- * is asserted with an exact `toEqual`, so the rebuilt object carries the guard's `rule`
160
- * and `transition` forward rather than letting the coercion drop them.
161
- */
162
- function runToolGuard(
163
- recursive: RecursiveRuntime,
164
- exec: unknown,
165
- root: string,
166
- runId: string,
167
- ): ToolGuardDecision {
168
- const guardMode = recursive.enforcementConfig.toolGuards
169
- const decision = evaluateToolGuard(exec as never, root, runId, guardMode)
170
- const coerced = coerceAskToDecision(decision, guardMode)
171
- const final: ToolGuardDecision = coerced === decision
172
- ? decision
173
- : { ...coerced, rule: decision.rule, transition: decision.transition }
174
- // T15 (C/D): every decision is logged — allows included — so the rolling trace shows
175
- // what the guard decided AND why, not only refusals. File-backed evidence in the
176
- // control-plane config dir: zero session-event emission.
177
- if (root) {
178
- const record: GuardDecisionRecord = {
179
- at: new Date().toISOString(),
180
- runId,
181
- tool: (exec as { name?: string } | null)?.name ?? '',
182
- kind: final.kind,
183
- rule: final.rule ?? 'none',
184
- }
185
- if (final.kind === 'allow') {
186
- if (final.warn) record.reason = final.warn
187
- } else if (final.reason) {
188
- record.reason = final.reason
189
- }
190
- if (final.transition) record.transition = final.transition
191
- // FU-7: a refusal a person has to resolve carries its options into the trace too, so the
192
- // question "why was this lock refused?" and the answer to it are read from one record.
193
- if (final.kind === 'deny' && final.ask) record.ask = final.ask
194
- appendGuardDecision(root, record)
195
- }
196
- return final
197
- }
198
-
199
- export function apply(ctx: Context, config?: RecursiveModeConfig) {
200
- // R4 shell split (02-to-be-plan.addendum-r4-r2-mount-resolution.md): the
201
- // global bare-name row in cordis.patch.yml mounts with config.shellOnly=true
202
- // to expose ONLY the client bundle for client discovery. It must register
203
- // NOTHING on the server root — no tools, no /recursive command, no
204
- // recursive:policy, no projection (BUG 4 always-on leak). The full server
205
- // surface is mounted ONLY by the recursive preset's isolated recursive-realm,
206
- // whose rows carry no config (shellOnly undefined).
207
- if (config?.shellOnly) return
208
- ctx.effect(function* () {
209
- // Workspace registry: optional host service (durable). Access via ctx.get —
210
- // property access requires inject and would fail boot when undeclared.
211
- // Resolve the control-plane root strictly from the session agent's cwd.
212
- const workspaceRegistry = ctx.get('workspaceRegistry') as never
213
- // T1 (goals projection): the goals service is on the host plane; it resolves
214
- // from inside the recursive-realm via inheritance (same as workspaceRegistry).
215
- // SAFETY: the goals service is an optional host service (could be absent); the
216
- // run projection treats null as "no goal backing" and never throws.
217
- const goals = ctx.get('goals') as GoalServiceLike | null
218
- // T10: the native jobs registry, when the composition mounts one. OPTIONAL on purpose —
219
- // a long operation must still run, and say it was untracked, rather than fail because no
220
- // board is attached.
221
- const jobs = ctx.get('jobs') as JobsRegistryLike | undefined
222
- // T39: the subagents seam is resolved HERE, at the composition, so the runtime can fall back
223
- // to it when a caller passes none. Measured reason: `recursive_review.tool.ts` — the only
224
- // production caller of `delegateReview` — passes no seam, and the runtime then reported
225
- // "no ctx.subagents runtime available" on a host that had mounted it all along.
226
- const subagentsSeamForRuntime = ctx.get('subagents') as SubagentsRuntimeLike | undefined
227
- const recursive = new RecursiveRuntime(ctx, { repoRoot: config?.repoRoot ?? process.cwd(), workspaceRegistry, goals, jobs, subagents: subagentsSeamForRuntime ?? null, workflow: ctx.get('workflow') as WorkflowEngineLike | undefined ?? null })
228
- // ⚠ FU-9 — AND RESOLVE IT AGAIN WHENEVER THE SERVICE APPEARS, because the one-shot get above is only a
229
- // fast path. A live run proved the cost of relying on it: the review fell back to self-audit and reported
230
- // `Status: failed` while a child DIRECTORY sat there — written by this plugin's own brief writer, before
231
- // any service call — so the artifact looked like a started child and was not. `ctx.inject` is the
232
- // harness's own pattern for a service that may be mounted by a later layer, and it fires immediately when
233
- // the service is already present, so this is a guarantee rather than a second chance.
234
- ctx.inject(['subagents'], (subagentsCtx: Context) => {
235
- recursive.attachSubagents((subagentsCtx.get('subagents') as SubagentsRuntimeLike | undefined) ?? null)
236
- })
237
- // ⚠ FU-19 — AND THE LLM INVENTORY, the same late-attaching way: `ctx.llm` is what the browser model catalog is
238
- // built from, so it is the service that knows which providers and models this host actually has. Resolved
239
- // optionally — a host without it gets the `unverified` verdict rather than a silent approval.
240
- const llmInventory = ctx.get('llm') as LlmInventoryLike | undefined
241
- recursive.attachLlmInventory(llmInventory ?? null)
242
- ctx.inject(['llm'], (llmCtx: Context) => {
243
- recursive.attachLlmInventory((llmCtx.get('llm') as LlmInventoryLike | undefined) ?? null)
244
- })
245
- // ⚠ PHASE 0 — THE HUMAN-QUESTION CHANNEL, resolved the same late-attaching way for the same reason:
246
- // a composition may mount `userQuestions` after this plugin applies, and the run-start gate asks the
247
- // person DIRECTLY through it before it arms a goal — which is what keeps a relayed `Start run` from
248
- // being an approval while a person can actually be asked (see run-start.ts).
249
- recursive.attachUserQuestions((ctx.get('userQuestions') as UserQuestionsLike | undefined) ?? null)
250
- ctx.inject(['userQuestions'], (questionsCtx: Context) => {
251
- recursive.attachUserQuestions((questionsCtx.get('userQuestions') as UserQuestionsLike | undefined) ?? null)
252
- })
253
-
254
- // T7 — THE SETTINGS NAMESPACE, APPLIED ON EVERY APPLY. The settings service edits the
255
- // Loader entry's config and the Loader RE-APPLIES this plugin, so a toggle in the UI
256
- // lands on this line: the re-application IS the hot reload, and there is deliberately
257
- // no watcher or file poller here to drift out of sync with it.
258
- //
259
- // Guarded on PRESENCE rather than truthiness: `enforcement: undefined` means "the
260
- // caller said nothing", which must leave the runtime's own default alone, while an
261
- // explicit object — including one whose fields the schema defaulted — is a decision.
262
- // Validation stays in `resolveEnforcementConfig` (strict, fail-loud), so a bad value
263
- // from any source is refused rather than coerced.
264
- if (config?.enforcement !== undefined) recursive.setEnforcementConfig(config.enforcement)
265
-
266
- // T12 — publish each phase's rules to the native SKILL catalogue, so the agent and its
267
- // children can ASK what a phase requires instead of grepping this checkout. The catalogue is
268
- // optional; with none mounted this registers nothing and says so, because a composition
269
- // without skills should still run the workflow.
270
- const phaseSkills = registerPhaseSkills(ctx.get('skills') as SkillRegistryLike | undefined, PHASE_SEQUENCE)
271
- // Tied to this plugin's effects: the contribution is withdrawn with the fiber that made it.
272
- yield () => { for (const dispose of phaseSkills.disposers) dispose() }
273
-
274
- // T7 part 2 — the ROUTER overrides, on the same terms: present means override, absent
275
- // means defer to the workspace's declarative `recursive-router.json`. ONE PATH, NOT
276
- // TWO: this does not replace the file, it lays over it (see `loadRouterPolicy`).
277
- if (config?.router !== undefined) recursive.setRouterOverrides(config.router)
278
-
279
- const repairedRoots = new Set<string>()
280
- const reminderGate = new ReminderOnceGate()
281
- // T3 (agentTeams task loop): wire the live ctx.agentTeams service (optional —
282
- // absent in compositions without the experimental agent-team row) into the
283
- // turn-driven task-board tool. The whole-loop driver (auditToPass) is also
284
- // exported for callers with a settlement observer.
285
- // SAFETY: ctx.get returns the live service as an opaque value; the single
286
- // boundary cast asserts it satisfies the TeamRuntimeLike structural seam
287
- // (createTask/updateTask plus optional wait/interrupt/board reads). The
288
- // live service's real Agent parameter is a superset of TeamCallerHandle, so
289
- // the seam passes the exact live Agent the tool extracts from exec.agent.
290
- const agentTeams = ctx.get('agentTeams') as TeamRuntimeLike | undefined
291
- // T36: the continuable-subagent seam the review tool drives. Optional for the
292
- // same reason as agentTeams — absent it, `recursive_review` still runs and
293
- // reports `unavailable`, naming that the repair path does not exist rather than
294
- // pretending the review was a success.
295
- const subagentsSeam = subagentsSeamForRuntime
296
-
297
- // Packaged skill (dsh plugin standard): register the `recursive-mode` skill
298
- // into the host skills registry via ctx.skills.registerProvider (the
299
- // dsh-skill-badge bundled-provider shape). Optional — a composition without
300
- // a skills registry is valid and this no-ops (returns undefined).
301
- const skillDisposer = registerRecursiveSkill(ctx)
302
-
303
- const disposers = [
304
- ...(skillDisposer ? [skillDisposer] : []),
305
- ctx.tools.register(createRecursiveStatusTool(recursive)),
306
- ctx.tools.register(createRecursiveInitTool(recursive)),
307
- ctx.tools.register(createRecursiveLockTool(recursive)),
308
- ctx.tools.register(createRecursiveLintTool(recursive)),
309
- ctx.tools.register(createRecursiveCloseoutTool(recursive)),
310
- ctx.tools.register(createRecursiveScratchTool(recursive)),
311
- ctx.tools.register(createRecursiveWorktreeTool(recursive)),
312
- ctx.tools.register(createRecursivePhaseTool(recursive)),
313
- ctx.tools.register(createRecursiveReviewTool(recursive, subagentsSeam)),
314
- // ⚠ FU-17 — WORK delegation: the main agent hands a phase's actual work to a child, reads it, and sends
315
- // feedback to the same child. Registered beside the review tool because they share the round driver, the
316
- // settlement observer and the reply contract — the difference is what a settlement MEANS.
317
- ctx.tools.register(createRecursiveDelegateTool(recursive, subagentsSeam)),
318
- // T23: the three human gates as structured decisions. Registered here so the ask is a TOOL call
319
- // — which is what the host renders as a card — rather than prose a person has to interpret.
320
- ctx.tools.register(createRecursiveAskTool(recursive)),
321
- // T26: the read-only view of what the enforcement contract will do, before it fires.
322
- ctx.tools.register(createRecursivePreviewTool(recursive)),
323
- ]
324
-
325
- // ⚠ FIX 1 — THE THIRD SEAM NEEDED THE SAME LATE ATTACH AS THE OTHER TWO, AND DID NOT HAVE IT.
326
- //
327
- // The line that used to sit in the array above was `...(agentTeams ? [register(...)] : [])` — a ONE-SHOT
328
- // `ctx.get('agentTeams')` taken at apply time. A live verification pass found the consequence: `team_task_create`
329
- // worked in the same session whose tool catalog lacked `recursive_audit_team`, because the service was mounted
330
- // AFTER this plugin applied. The plugin shipped 13 tool files and offered 12.
331
- //
332
- // `subagents` and `llm` already solve this with `ctx.inject` (above); this is that pattern, with one addition the
333
- // others do not need: the tool may only be registered ONCE, because the one-shot path can already have taken it.
334
- let auditTeamRegistered = agentTeams !== undefined && agentTeams !== null
335
- if (auditTeamRegistered) disposers.push(ctx.tools.register(createRecursiveAuditTeamTool(agentTeams ?? null)))
336
- ctx.inject(['agentTeams'], (teamCtx: Context) => {
337
- if (auditTeamRegistered) return
338
- const late = teamCtx.get('agentTeams') as TeamRuntimeLike | undefined
339
- if (late === undefined || late === null) return
340
- auditTeamRegistered = true
341
- // ⚠ CORRECTED COMMENT. This registers through the OUTER plugin context (`ctx`), NOT through the
342
- // injecting `teamCtx` — `teamCtx` is used on the line above only to READ the late service, and the
343
- // sibling `subagents`/`llm` injects use their callback context the same way. The comment that used to
344
- // sit here claimed the injecting scope owned the registration, which is not what this call does.
345
- //
346
- // WHAT IS NOT CLAIMED: that the fiber withdraws this registration. Nobody has observed that — no test
347
- // covers the late `recursive_audit_team` being withdrawn — and the disposer returned here is not
348
- // retained, unlike the eager registration above, which pushes its own onto `disposers`. Until a test
349
- // observes the withdrawal, this comment promises nothing about it.
350
- ctx.tools.register(createRecursiveAuditTeamTool(late))
351
- })
352
-
353
- // /recursive command (R4): preset-scoped registration, workspace-scoped dispatch.
354
- const commands = ctx.get('commands') as { register: (def: unknown) => () => void } | undefined
355
- if (commands) {
356
- disposers.push(registerRecursiveCommand({ commands } as never, recursive))
357
- }
358
-
359
- // recursive:policy prompt section (Phase C R5): workspace-scoped behavior +
360
- // current-phase contract rendered from folded state + enforcement config.
361
- const systemPrompt = ctx.get('systemPrompt') as { section: (def: unknown) => () => void } | undefined
362
- if (systemPrompt) {
363
- disposers.push(systemPrompt.section({
364
- name: 'recursive:policy',
365
- order: 55,
366
- text: (context: unknown) => {
367
- const agent = (context as { agent?: { session?: { header?: { cwd?: string } } } } | undefined)?.agent
368
- if (!agent) return ''
369
- // SP3 R5 policy-render fix: derive intent from the FILESYSTEM, not the
370
- // retired recursive/phase-intent session event (zero-emission removed
371
- // the emitter; 0.2.2 deleted the event-fold helper that read it, so this
372
- // signal was ALWAYS null and this section rendered ''). Pure read-only fs
373
- // folding; no recursive/*
374
- // events are appended.
375
- const intent = fsPolicyIntent(agent, workspaceRegistry as never)
376
- if (!intent) return ''
377
- return renderRecursivePolicy({ worktreeRoot: intent.worktreeRoot, runId: intent.runId, config: recursive.enforcementConfig })
378
- },
379
- }))
380
- }
381
-
382
- // Phase C R3 (Layer 1, agent/pre-step proactive intent gate) is RETIRED
383
- // under zero-emission (SP2 R1): its only signal was the recursive/phase-intent
384
- // session event, whose emitter is now deleted, and it was ADVISORY by default
385
- // (it never rejected; its sole side effect was the now-removed emission).
386
- // Enforcement is preserved where it actually bites: Layer 2 (tools/pre-execute
387
- // inspects the REAL tool call args below) plus the recursive_lock tool's own
388
- // prerequisite validation in lockArtifact — strictly more reliable than the
389
- // heuristic intent scan. See 03-implementation-summary.addendum-r1-*.md.
390
-
391
- // Phase C R4: tools/pre-execute surgical guards (Layer 2, caller of the
392
- // transition set). Scope-filtered to the active run's worktree.
393
- //
394
- // T15 — this listener used to hand `evaluateToolGuard` an EMPTY runId, so its
395
- // `runDir` resolved to `<root>/.recursive/run` — the directory that holds run
396
- // DIRECTORIES, not artifacts. The monotonic lock-order and Phase-3 TDD
397
- // branches could therefore never fire; only the locked-write branch worked
398
- // (it resolves its target path directly). The REAL active run id is now
399
- // resolved per call, the same way recursive_status/phaseRules do it.
400
- const sessionsStore = ctx.get('sessions') as
401
- | { get?: (id: string) => { header?: { cwd?: string } } | undefined }
402
- | undefined
403
- // T38 — THE BUILT-IN GUARD IS NOW A HOOK ON THE SAME CHAIN AS EVERYONE ELSE.
404
- //
405
- // Registered at PRIORITY 0 so a sibling with a higher priority runs FIRST and can
406
- // pre-empt it cheaply — which is the point of participation. The guard stays the
407
- // baseline that runs when nobody objects.
408
- //
409
- // `fail_closed` because this is a GATING point: a guard that cannot decide must not
410
- // let the call through. The FULL decision rides back as an annotation, so `ask` and
411
- // `allow` survive with `warn`/`rule`/`transition` intact — the listener returns that
412
- // object VERBATIM, which is what keeps `guard-path` byte-identical.
413
- recursive.hooks.register('pre_trigger', {
414
- name: BUILTIN_GUARD_HOOK_NAME,
415
- priority: 0,
416
- onError: 'fail_closed',
417
- run: (input) => {
418
- const payload = input as { exec?: unknown; root?: string; runId?: string }
419
- const decision = runToolGuard(recursive, payload.exec, payload.root ?? '', payload.runId ?? '')
420
- return decision.kind === 'deny'
421
- ? { decision: 'deny' as const, reason: decision.reason ?? 'denied by the tool guard', annotations: { guardDecision: decision } }
422
- : { decision: 'continue' as const, annotations: { guardDecision: decision } }
423
- },
424
- })
425
- // T13 (part 2) — THE PLAN GATE IS LIVE. `exit_plan_mode` is the event that leaves plan mode,
426
- // so it is where the gate belongs: a run still waiting on a DISCOVERY phase must not leave
427
- // plan mode, because that would start implementing on an unfinished plan. The gate reads the
428
- // phase the run is already waiting on rather than keeping its own state, which is why it
429
- // cannot drift from the workflow.
430
- //
431
- // Priority 5, ABOVE the built-in tool guard at 0: this is a workflow-shaped refusal and
432
- // should be the reason a caller sees, not something the generic guard has to restate.
433
- recursive.hooks.register('pre_trigger', {
434
- name: 'exit-plan-mode-gate',
435
- priority: 5,
436
- onError: 'fail_closed',
437
- run: (input) => {
438
- const payload = input as { tool?: string; root?: string; runId?: string }
439
- if (payload.tool !== 'exit_plan_mode') return { decision: 'continue' as const }
440
- const root = payload.root ?? ''
441
- const runId = payload.runId ?? ''
442
- if (root === '' || runId === '') return { decision: 'continue' as const }
443
- const gate = planGateForExit(getNextLegalPhase(join(root, '.recursive', 'run', runId)))
444
- return gate.allow
445
- ? { decision: 'continue' as const, annotations: { planGate: gate.reason } }
446
- : { decision: 'deny' as const, reason: gate.reason }
447
- },
448
- })
449
- const toolRuntime = ctx as unknown as { on?: (event: string, listener: (payload: unknown, next?: unknown) => unknown) => () => void }
450
- if (toolRuntime.on) {
451
- disposers.push(toolRuntime.on('tools/pre-execute', async (payload, next) => {
452
- const exec = payload as { name?: string; arguments?: unknown; agent?: { session?: { header?: { cwd?: string } } } | null } | null
453
- if (!exec?.name) return typeof next === 'function' ? next() : { kind: 'allow' }
454
- // B3: per-call root is the session cwd (authoritative when the registry is
455
- // absent), never process.cwd().
456
- const cwd = exec?.agent?.session?.header?.cwd ?? ''
457
- const root = (await recursive.resolveRootForRoute(undefined, cwd, sessionsStore)) ?? cwd
458
- // T15 (A): the active run id is resolved from the FILESYSTEM on every call
459
- // — `resolveRunDir` is the canonical latest-run-by-mtime used by
460
- // recursive_status/phaseRules. Deliberately NO ttl/time cache: a cached run
461
- // id would silently reintroduce exactly the empty-runId bug being fixed
462
- // here, because a run created moments ago must be visible immediately. If a
463
- // cache is ever added it must be provably invalidated on run creation.
464
- const runId = root ? resolveRunDir(root)?.runId ?? '' : ''
465
-
466
- // T27 — THE NAMED POINT IS LIVE AT THE ENFORCEMENT SEAM. A sibling hook may
467
- // deny here BEFORE the built-in guard runs, so participation is real rather
468
- // than a reachable registry that nothing consults.
469
- //
470
- // With no hooks registered — the ordinary case — the chain returns `continue`
471
- // and the guard below runs exactly as it always has, which is what keeps
472
- // `guard-path.spec.ts`'s pinned contract byte-identical. That is the point of
473
- // putting the chain FIRST: it adds a way in without moving what was there.
474
- //
475
- // A `hold` is treated as a denial at this seam. `hold` means "stop and wait"
476
- // for a point that can resume later; a tool call has nothing to resume, so
477
- // pretending to hold would silently proceed. Better to refuse and say so.
478
- const preTrigger = await recursive.hooks.run('pre_trigger', {
479
- tool: exec.name,
480
- args: exec.arguments,
481
- exec,
482
- root,
483
- runId,
484
- })
485
- const decider = preTrigger.ran[preTrigger.ran.length - 1]
486
-
487
- // A SIBLING stopped the chain. Checked by the DECIDER, not by the built-in's
488
- // mere absence: a sibling with a LOWER priority than the guard runs after it, so
489
- // "the guard is in the trail" does not mean "the guard decided".
490
- if ((preTrigger.decision === 'deny' || preTrigger.decision === 'hold') && decider?.name !== BUILTIN_GUARD_HOOK_NAME) {
491
- const by = decider?.name ?? 'a pre_trigger hook'
492
- const why = preTrigger.reason ?? 'no reason given'
493
- // A `hold` is treated as a refusal at this seam. `hold` means "stop and wait"
494
- // for a point that can resume later; a tool call has nothing to resume, so
495
- // pretending to hold would silently proceed — worse than refusing, because the
496
- // caller would never learn a hook wanted to stop it.
497
- return {
498
- kind: 'deny',
499
- reason: preTrigger.decision === 'hold'
500
- ? 'held by pre_trigger hook ' + by + ': ' + why
501
- : 'denied by pre_trigger hook ' + by + ': ' + why,
502
- }
503
- }
504
-
505
- // The guard itself failed: it is fail_closed, so the refusal is reported with
506
- // its own error rather than as a silent allow.
507
- if (decider?.name === BUILTIN_GUARD_HOOK_NAME && decider.error !== undefined) {
508
- return { kind: 'deny', reason: 'the tool guard failed: ' + decider.error }
509
- }
510
-
511
- const builtIn = preTrigger.ran.find((entry) => entry.name === BUILTIN_GUARD_HOOK_NAME)
512
- const final = builtIn?.annotations?.guardDecision as ToolGuardDecision | undefined
513
- if (final === undefined) {
514
- // Unreachable while the built-in is registered unconditionally. It fails
515
- // CLOSED rather than allowing, because "we could not decide" is not permission.
516
- return { kind: 'deny', reason: 'the tool guard produced no decision' }
517
- }
518
- // The guard's own object, returned VERBATIM — the pinned contract.
519
- //
520
- // ⚠ EXCEPT THAT A DENIAL'S `ask` MUST BE CARRIED IN THE TEXT, and this is the one place the
521
- // plugin can do it. Measured in the harness (`packages/core/tools`): a `tools/pre-execute`
522
- // deny becomes `content: [{ type: 'text', text: 'Error: ' + reason }]` and EVERY other field
523
- // of the decision is dropped, so the gate-block payload added for FU-7 would have reached the
524
- // model as nothing at all — which is precisely the defect: a strict-by-default guard refusing
525
- // a lock with a bare sentence, while `fix | reopen | abandon` was how the run got unblocked.
526
- // The sentence is rendered FROM the payload (`renderGateBlockAsk`), so what the caller reads
527
- // and what the decision carries cannot drift; the plain reason stays first and intact, so a
528
- // caller that ignores the ask still gets the rule name and the blocking artifact.
529
- if (final.kind === 'deny') {
530
- return final.ask === undefined
531
- ? final
532
- : { ...final, reason: final.reason + ' ' + renderGateBlockAsk(final.ask) }
533
- }
534
- if (final.kind === 'allow' && final.warn) {
535
- // ⚠ IT NAMES THE MODE IT ACTUALLY RAN UNDER. This line used to begin `tool guard (advisory)`
536
- // unconditionally, while the warning it carries comes from the transition gate's REPORT-ONLY
537
- // consult — which attaches a warning to an ALLOW in BOTH modes. Under the strict default the
538
- // line therefore told a reader that enforcement was off while every gate was strict: text
539
- // asserting a state that was not so. The mode is read from the same config the guard ran
540
- // under, so the prefix moves with the setting; the warn semantics are unchanged.
541
- console.warn('[recursive] tool guard (' + recursive.enforcementConfig.toolGuards + ') allowed this call: ' + final.warn)
542
- }
543
- return typeof next === 'function' ? next() : { kind: 'allow' }
544
- }))
545
- }
546
-
547
- // T15 (E): the fs/observed lock-tamper path. The harness contract is a plain
548
- // SYNCHRONOUS emit fired AFTER a successful write, so this listener cannot
549
- // veto anything and contractually must not throw — it only RECORDS. Before
550
- // T15 the plugin had no fs/observed listener at all (the string appeared in
551
- // comments only), so `detectTamper` was exported and unit-tested with no live
552
- // caller and a tampered lock surfaced nowhere but prompt text.
553
- const observationRuntime = ctx as unknown as { on?: (event: string, listener: (target: unknown, observation: unknown, actor: unknown) => void) => () => void }
554
- if (observationRuntime.on) {
555
- disposers.push(observationRuntime.on('fs/observed', (target, observation, actor) => {
556
- try {
557
- // Only a present observation can be a tamper; absent/unrelated are ignored.
558
- if ((observation as { kind?: string } | null)?.kind !== 'present') return
559
- const displayPath = (target as { displayPath?: string } | null)?.displayPath ?? ''
560
- if (!displayPath) return
561
- // Cheap shape test BEFORE any filesystem work: fs/observed fires on reads
562
- // too, so enumerating runs for every observation would be a readdir per
563
- // file touch. The actor is the tool execution. This event cannot await, so
564
- // the root is the actor's session cwd (the same B4 sync shortcut
565
- // fsPolicyIntent takes: the session cwd is authoritative, the registry
566
- // path is async-only). Resolving the cwd first is free — plain property
567
- // reads — and the admission test needs it.
568
- const cwd = (actor as { agent?: { session?: { header?: { cwd?: string } } } } | null)?.agent?.session?.header?.cwd ?? ''
569
- if (!cwd) return
570
- // ⚠ AND THIS IS `detectTamper`'s OWN ADMISSION TEST, CALLED RATHER THAN COPIED.
571
- //
572
- // It used to be an inline hand-copy — `endsWith('.md') && includes('/.recursive/run/')`
573
- // — and a hand-copy is what made the tamper guard blind to one spelling of one
574
- // path: the substring test needs a separator BEFORE `.recursive`, which a
575
- // repo-relative target (`displayPath` as a model would type it) does not have, so
576
- // the listener rejected the candidate here and `detectTamper` was never reached.
577
- // Widening only `detectTamper` would have changed nothing observable. The test now
578
- // lives in one place (`tamperCandidatePath`), so the two cannot disagree; it stays
579
- // pure path arithmetic, so the "no filesystem work before admission" property the
580
- // shape check exists for is preserved.
581
- const normalized = displayPath.replace(/\\/g, '/')
582
- if (!tamperCandidatePath(normalized, cwd)) return
583
- const runId = resolveRunDir(cwd)?.runId ?? ''
584
- const tamper = recursive.detectTamper(normalized, cwd, runId)
585
- if (!tamper) return
586
- appendObservedTamper(cwd, {
587
- at: new Date().toISOString(),
588
- runId: tamper.runId,
589
- path: tamper.path,
590
- reason: tamper.reason,
591
- })
592
- } catch {
593
- // Observe-only: the fs/observed contract forbids throwing.
594
- }
595
- }))
596
- }
597
-
598
- // T36: capture a delegated child's SETTLEMENT at delivery time.
599
- //
600
- // WHY DELIVERY AND NOT HISTORY. The obvious implementation of a parent-side
601
- // settlement observer is to scan the session log for the `subagent-settled`
602
- // notice. That is prohibited: DSH deprecates synchronous reads of arbitrary
603
- // session history (`eventAt`/`snapshotEvents`/`ownEvents`) and states that new
604
- // production calls are prohibited, enforced by an executable lint check. The
605
- // sanctioned replacement is to process the DELIVERED event, which is this
606
- // listener — the same `session/event` seam the projection registry subscribes
607
- // to. The durable fact then lands in the run's own FILE state, which is where
608
- // every other plugin fact lands and keeps the plugin zero-emission.
609
- //
610
- // The loop needs this because there is NO parent-side promise to await: a
611
- // continuable child's settlement arrives as a durable user message on a later
612
- // turn, so the round observer must find a recorded settlement or honestly
613
- // report that none has landed yet.
614
- const sessionRuntime = ctx as unknown as { on?: (event: string, listener: (session: unknown, event: unknown) => void) => () => void }
615
- if (sessionRuntime.on) {
616
- disposers.push(sessionRuntime.on('session/event', (session, event) => {
617
- try {
618
- // Cheap shape test FIRST: session/event fires for EVERY committed event
619
- // in every session, so a non-settlement must be rejected before any
620
- // filesystem work. This is the same ordering the fs/observed listener
621
- // uses, for the same reason.
622
- const notice = settlementFromEvent(event as never)
623
- if (notice === null) return
624
- // B3: the session cwd is the authoritative control-plane root per call.
625
- const cwd = (session as { header?: { cwd?: string } } | null)?.header?.cwd ?? ''
626
- if (cwd === '') return
627
- const runDir = runDirForChild(cwd, notice.childId)
628
- // No run (or an ambiguous one) means the settlement is not filed rather
629
- // than filed wrongly: the loop will report "no settlement yet", which is
630
- // recoverable, whereas attaching evidence to the wrong run is not.
631
- if (runDir === null) {
632
- // ⚠ FU-17 — ADOPT RATHER THAN DROP. The rule above still holds — never guess between runs — but a
633
- // settlement for a child nobody filed is evidence of work that really happened, and dropping it
634
- // means a phase artifact cannot cite it. `adoptSettlement` files it into the single run when there
635
- // is exactly one, and into a root-level adoption log when choosing would mean guessing. Marked as
636
- // adopted either way, so a reader can tell an adopted record from a delegation's own.
637
- adoptSettlement(cwd, notice)
638
- return
639
- }
640
- recordSettlement(runDir, notice)
641
- } catch {
642
- // Observe-only. This rides the hot path of every session event and must
643
- // never break the session it observes.
644
- }
645
- }))
646
- }
647
-
648
- // SP3 R5: agent/pre-step lint-rules injection (pre-step contract verified: the
649
- // listener returns { kind: 'enter', messages: [...messages, injected] } and the
650
- // returned array REPLACES the default [...claimed, context]). When the session's
651
- // control-plane root has an active recursive run whose current phase doc is
652
- // DRAFT, prepend a compact system-reminder with THAT phase's required sections +
653
- // gates. Pure fs read (zero-emission): never appends recursive/* events.
654
- const agentRuntime = ctx as unknown as { on?: (event: string, listener: (payload: unknown, next?: unknown) => unknown) => () => void }
655
- if (agentRuntime.on) {
656
- disposers.push(agentRuntime.on('agent/pre-step', async (payload, next) => {
657
- const p = payload as {
658
- messages?: Array<{ content: Array<{ type: string; text?: string }> }>,
659
- agent?: { session?: { header?: { cwd?: string } } } | null,
660
- } | null
661
- const messages = p?.messages ?? []
662
- const agent = p?.agent ?? null
663
- const cwd = agent?.session?.header?.cwd ?? ''
664
- // delegate first so later listeners keep veto power, then fold ours on
665
- if (typeof next === 'function') await next()
666
- if (!cwd) return { kind: 'enter', messages } as const
667
- const root = await recursive.resolveRootForRoute(undefined, cwd, undefined)
668
- if (!root) return { kind: 'enter', messages } as const
669
- // R3/R6 (run 09): idempotent scaffold REPAIR on session-start (new AND
670
- // resume). bootstrapScaffold is upsert-only: it adds missing control-plane
671
- // files/dirs + re-upserts marked blocks, NEVER overwriting user content or
672
- // touching run/ artifacts (in-flight 02-to-be-plan etc. are preserved).
673
- // Guarded once per root so repeated pre-steps are cheap no-ops.
674
- if (!repairedRoots.has(root)) {
675
- repairedRoots.add(root)
676
- stageBWorkflowInit({ root, source: 'resume' })
677
- }
678
- const runs = enumerateRuns(root)
679
- if (runs.length === 0) return { kind: 'enter', messages } as const
680
- const runId = runs[runs.length - 1]
681
- const runDir = join(root, '.recursive', 'run', runId)
682
- if (!existsSync(runDir)) return { kind: 'enter', messages } as const
683
- const phase = getNextLegalPhase(runDir)
684
- if (!phase) return { kind: 'enter', messages } as const
685
- // only inject when the phase doc is DRAFT (not locked/missing).
686
- const phasePath = join(runDir, phase)
687
- const status = existsSync(phasePath) ? getLockStatus(phasePath) : null
688
- if (status !== 'DRAFT') return { kind: 'enter', messages } as const
689
- if (!reminderGate.shouldInject(root, runId, phase)) return { kind: 'enter', messages } as const
690
- // LIVE BUG 6 (0.2.1): inject the lint-rules reminder AT MOST ONCE PER PHASE.
691
- // The scaffold repair above is deduped via repairedRoots; the reminder itself was
692
- // not, so every pre-step while DRAFT re-injected it.
693
- const reminder = phaseLintRulesMessage(phase)
694
- return {
695
- kind: 'enter',
696
- messages: [...messages, createUserMessage({ content: [{ type: 'text', text: reminder }], source: { ...REMINDER_SOURCE, form: 'notice', summary: 'phase ' + phase + ' lint rules' } })],
697
- } as const
698
- }))
699
- }
700
-
701
- // Phase C R8: fs/observed lock-tamper WARNINGS are served to the board via the
702
- // mountOnce-global: apply() runs per-session, but the route must register
703
- // exactly once (WebServer.register throws on duplicate kind+path) and serve
704
- // PER-WORKSPACE state. No-op when the host composes no webServer (headless).
705
- // (`sessionsStore` is resolved once, above the pre-execute listener, which now
706
- // needs it too.)
707
- const webServer = ctx.get('webServer') as
708
- | { register: (route: { kind: string; path: string; handler: unknown }) => () => void }
709
- | undefined
710
- if (webServer) {
711
- const host: RecursiveRouteHost = {
712
- // sessionId PRIMARY: the host resolves cwd from the attached session header;
713
- // the client-passed cwd is a fallback hint (hydration / headless).
714
- resolveRoot: async (sessionId: string | undefined, cwd: string) => recursive.resolveRootForRoute(sessionId, cwd, sessionsStore),
715
- snapshot: async (root: string) => snapshotWorkspace(root),
716
- revision: () => 1,
717
- }
718
- disposers.push(mountRecursiveRoutesOnce('@try-works/dsh-recursive-mode', () => makeRecursiveRoutes(host), webServer))
719
- }
720
-
721
- yield () => { for (const d of disposers) d() }
722
- })
723
- }
1
+ import { type Context } from '@deepseek-ai/cordis'
2
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
3
+ import type { ContextFormed } from '@deepseek-ai/dsh-llm'
4
+ import { existsSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+ import { RecursiveRuntime } from './runtime.ts'
7
+ import type { UserQuestionsLike } from './runtime.ts'
8
+ import type { JobsRegistryLike } from './jobs-runner.ts'
9
+ import { planGateForExit } from './plan-gate.ts'
10
+ import { registerPhaseSkills, type SkillRegistryLike } from './skills-phase.ts'
11
+ import type { WorkflowEngineLike } from './workflow-audit.ts'
12
+ import type { RecursiveModeConfig } from './config.ts'
13
+ import { createRecursiveStatusTool } from './recursive_status.tool.ts'
14
+ import { createRecursiveInitTool } from './recursive_init.tool.ts'
15
+ import { createRecursiveLockTool } from './recursive_lock.tool.ts'
16
+ import { createRecursiveLintTool } from './recursive_lint.tool.ts'
17
+ import { createRecursiveCloseoutTool } from './recursive_closeout.tool.ts'
18
+ import { createRecursiveScratchTool } from './recursive_scratch.tool.ts'
19
+ import { createRecursiveWorktreeTool } from './recursive_worktree.tool.ts'
20
+ import { createRecursivePhaseTool } from './recursive_phase.tool.ts'
21
+ import { createRecursiveAuditTeamTool } from './recursive_audit_team.tool.ts'
22
+ import { createRecursiveReviewTool } from './recursive_review.tool.ts'
23
+ import { createRecursiveDelegateTool } from './recursive_delegate.tool.ts'
24
+ import { createRecursiveAskTool } from './recursive_ask.tool.ts'
25
+ import { renderGateBlockAsk } from './recursive_ask.tool.ts'
26
+ import { createRecursivePreviewTool } from './recursive_preview.tool.ts'
27
+ import type { SubagentsRuntimeLike } from './delegation.ts'
28
+ import { registerRecursiveCommand } from './commands.ts'
29
+ import { evaluateToolGuard, coerceAskToDecision, tamperCandidatePath, type ToolGuardDecision } from './enforcement.ts'
30
+ import { appendGuardDecision, appendObservedTamper, type GuardDecisionRecord } from './guard-log.ts'
31
+ import type { GoalServiceLike } from './goals-projection.ts'
32
+ import type { TeamRuntimeLike } from './teams-loop.ts'
33
+ import { renderRecursivePolicy } from './policy.ts'
34
+ import { fsPolicyIntent } from './fs-intent.ts'
35
+ import { snapshotWorkspace } from './snapshot.ts'
36
+ import { mountRecursiveRoutesOnce, makeRecursiveRoutes, type RecursiveRouteHost } from './live-route.ts'
37
+ import { registerRecursiveSkill } from './skills.ts'
38
+ import { enumerateRuns, stageBWorkflowInit } from './bootstrap.ts'
39
+ import { getNextLegalPhase, getLockStatus, PHASE_SEQUENCE } from './lock.ts'
40
+ import { adoptSettlement } from './settlement.ts'
41
+ import type { LlmInventoryLike } from './model-inventory.ts'
42
+ import { resolveRunDir } from './run.ts'
43
+ import { phaseLintRulesMessage, ReminderOnceGate } from './phase-rules.ts'
44
+ import { settlementFromEvent, runDirForChild, recordSettlement } from './settlement.ts'
45
+
46
+ /**
47
+ * rc.2 rebase (T31a) — the message-source vocabulary changed under us.
48
+ *
49
+ * At `dsh-v0.1.1-rc.2` `MessageSourceMap` carried a shared catch-all
50
+ * `plugin: { kind: 'plugin'; plugin: string }` entry, which this file used for
51
+ * its injected phase-lint reminder. At `dsh-v0.2.0-rc.2` that entry is GONE:
52
+ * the map is merge-extensible and, in its own words, "each producer declares
53
+ * its own `kind` in its own module; there is no shared catch-all `plugin`
54
+ * kind". This was invisible in the old checkout because its `node_modules`
55
+ * still held a stale `dsh-llm`.
56
+ *
57
+ * The idiom below is copied from the shipped `@deepseek-ai/dsh-repeat-tool-reminder`,
58
+ * whose pre-step reminder is the closest analogue to ours: a user-role message
59
+ * whose source declares its own kind and a `form: 'notice'` one-line account.
60
+ */
61
+ declare module '@deepseek-ai/dsh-llm' {
62
+ interface MessageSourceMap {
63
+ 'recursive-mode': { kind: 'recursive-mode' } & ContextFormed
64
+ }
65
+ }
66
+
67
+ /** Producer source stamped on every injected phase-lint reminder. */
68
+ const REMINDER_SOURCE = { kind: 'recursive-mode' } as const
69
+
70
+ export const name = '@try-works/dsh-recursive-mode'
71
+
72
+ /**
73
+ * T7: the plugin's Config schema, which the settings service DISCOVERS (see `src/config.ts`
74
+ * for why declaring it is the registration). Re-exported from the entry because the Loader
75
+ * reads it from the plugin module.
76
+ */
77
+ export { Config } from './config.ts'
78
+ export type { RecursiveModeConfig } from './config.ts'
79
+
80
+ export { RecursiveRuntime } from './runtime.ts'
81
+ export { createRecursiveStatusTool } from './recursive_status.tool.ts'
82
+ export { createRecursiveInitTool } from './recursive_init.tool.ts'
83
+ export { createRecursiveLockTool } from './recursive_lock.tool.ts'
84
+ export { createRecursiveLintTool } from './recursive_lint.tool.ts'
85
+ export { createRecursiveCloseoutTool } from './recursive_closeout.tool.ts'
86
+ export { createRecursiveScratchTool } from './recursive_scratch.tool.ts'
87
+ export { createRecursiveWorktreeTool } from './recursive_worktree.tool.ts'
88
+ export { createRecursivePhaseTool } from './recursive_phase.tool.ts'
89
+ export * from './status.ts'
90
+ export {
91
+ PHASE_SEQUENCE,
92
+ OPTIONAL_PHASES,
93
+ normalizeForLockHash,
94
+ lockHashFromContent,
95
+ phaseIndex,
96
+ isCoreArtifact,
97
+ getPrerequisites,
98
+ getLockStatus,
99
+ getPrerequisiteBlockers,
100
+ receiptPath,
101
+ readReceipt,
102
+ writeReceipt,
103
+ invalidateReceipt,
104
+ getStaleDownstreamPhases,
105
+ getNextLegalPhase,
106
+ getAllStaleReceipts,
107
+ validateChain,
108
+ } from './lock.ts'
109
+ export type {
110
+ LockReceipt,
111
+ LockStatus,
112
+ PrerequisiteBlocker,
113
+ StaleDownstream,
114
+ ChainPhaseResult,
115
+ LockChainResult,
116
+ } from './lock.ts'
117
+ export * from './run.ts'
118
+ export * from './review.ts'
119
+ export * from './handoff.ts'
120
+ export * from './router.ts'
121
+ export * from './delegation.ts'
122
+ export * from './lifecycle.ts'
123
+ export * from './enforcement.ts'
124
+ export * from './policy.ts'
125
+ export * from './snapshot.ts'
126
+ export * from './live-route.ts'
127
+ export * from './teams-loop.ts'
128
+ export * from './skills.ts'
129
+
130
+ /**
131
+ * Bundle plugin entry. The Loader activates this row once `tools` is available
132
+ * (`inject` below); the RecursiveRuntime service is constructed directly so it
133
+ * is provided on `ctx.recursive` for the lifetime of this fiber, and the
134
+ * read-path tools (status/init/lock/lint) are registered through it (R2/R4).
135
+ */
136
+ export const inject = ['tools']
137
+
138
+ /**
139
+ * Plugin entry (SP2 R1). Stage A (mount-time, this apply): register the
140
+ * isolated ctx.recursive service + the recursive_* tools + the /recursive
141
+ * command + the recursive:policy prompt section + the LIVE board/strip route
142
+ * (HTTP state + SSE), served per-workspace from the filesystem fold. No
143
+ * repo/run work and NO session-event emission here: zero recursive/* events
144
+ * are ever appended (resume-crash fix), and the board reads the live fs route.
145
+ */
146
+ /** T38: the built-in guard's hook name — the chain's identity for "this was the plugin's own". */
147
+ const BUILTIN_GUARD_HOOK_NAME = 'builtin-tool-guard'
148
+
149
+ /**
150
+ * T38 — the tool guard, as a function rather than an inline block.
151
+ *
152
+ * Extracted so the SAME code can run as a hook on the registry: a built-in and a
153
+ * sibling then share one chain, one ordering rule and one failure policy, instead of
154
+ * the built-in being privileged code that always runs first. Every side effect stays
155
+ * here — the guard-decision log is written by whoever computes the decision, so moving
156
+ * the call site cannot lose it.
157
+ *
158
+ * The `ask` coercion is unchanged and stays key-frozen: `coerceAskToDecision`'s output
159
+ * is asserted with an exact `toEqual`, so the rebuilt object carries the guard's `rule`
160
+ * and `transition` forward rather than letting the coercion drop them.
161
+ */
162
+ function runToolGuard(
163
+ recursive: RecursiveRuntime,
164
+ exec: unknown,
165
+ root: string,
166
+ runId: string,
167
+ ): ToolGuardDecision {
168
+ const guardMode = recursive.enforcementConfig.toolGuards
169
+ const decision = evaluateToolGuard(exec as never, root, runId, guardMode)
170
+ const coerced = coerceAskToDecision(decision, guardMode)
171
+ // ⚠ ISSUE 2 (b) — THE RECORD FOLLOWS THE RUN THE GUARD ACTUALLY READ, not the one the filesystem calls
172
+ // active. The guard resolves the run per call (a lock call that names a run is judged against THAT run's
173
+ // tree — see `resolveGuardRunId`), so logging the active run here would attribute a rule's answer to a run
174
+ // it never looked at: measured before this fix as `runId: "run-a"` on a refusal whose blockers came from
175
+ // run-b. `runId` (the active run) remains the fallback for a hand-built decision, which is the
176
+ // pre-existing behaviour.
177
+ const evaluatedRunId = decision.runId ?? runId
178
+ const final: ToolGuardDecision = coerced === decision
179
+ ? decision
180
+ : { ...coerced, rule: decision.rule, transition: decision.transition, runId: evaluatedRunId }
181
+ // T15 (C/D): every decision is logged — allows included — so the rolling trace shows
182
+ // what the guard decided AND why, not only refusals. File-backed evidence in the
183
+ // control-plane config dir: zero session-event emission.
184
+ if (root) {
185
+ const record: GuardDecisionRecord = {
186
+ at: new Date().toISOString(),
187
+ runId: evaluatedRunId,
188
+ tool: (exec as { name?: string } | null)?.name ?? '',
189
+ kind: final.kind,
190
+ rule: final.rule ?? 'none',
191
+ }
192
+ if (final.kind === 'allow') {
193
+ if (final.warn) record.reason = final.warn
194
+ } else if (final.reason) {
195
+ record.reason = final.reason
196
+ }
197
+ if (final.transition) record.transition = final.transition
198
+ // FU-7: a refusal a person has to resolve carries its options into the trace too, so the
199
+ // question "why was this lock refused?" and the answer to it are read from one record.
200
+ if (final.kind === 'deny' && final.ask) record.ask = final.ask
201
+ appendGuardDecision(root, record)
202
+ }
203
+ return final
204
+ }
205
+
206
+ /**
207
+ * ISSUE 1 — THE GOAL BLOCK, FROM THE LAYER THAT REFUSED.
208
+ *
209
+ * WHERE THIS BELONGS, decided from the code rather than assumed: the GUARD CANNOT DO THIS ITSELF.
210
+ * `evaluateToolGuard` is a pure policy layer — it takes an exec, a worktree root, a run id and a mode, and
211
+ * it has no goals service, no live agent and no runtime handle; it is also called from a dry-run preview
212
+ * (`src/recursive_preview.tool.ts`), where a side effect would be a lie about a call that never happened.
213
+ * The ONE place where a guard refusal becomes real is the `tools/pre-execute` listener below: it holds the
214
+ * runtime (which owns `blockRunToGoal` and the late-attached goals service), the live agent from the exec
215
+ * payload, and the decision itself — and it is the same boundary that already renders the refusal's ask
216
+ * into the caller's text (FU-7). So this is called there, and nowhere else.
217
+ *
218
+ * WHY IT IS NEEDED AT ALL. `lockArtifact` blocks the run's goal when its OWN ordering check refuses
219
+ * (`runtime.ts`, the `Prerequisite blockers:` branch). Under the strict default the guard refuses an
220
+ * out-of-order lock BEFORE DISPATCH, so `lockArtifact` never runs, its block never happens, and the run was
221
+ * told it was blocked while the goal machinery was not — the goal stayed armed and kept driving rounds
222
+ * through a refused gate.
223
+ *
224
+ * ⚠ WHY THIS CANNOT DOUBLE-BLOCK. The two block sites are on MUTUALLY EXCLUSIVE branches of one call:
225
+ * this one runs only when the guard DENIED (so the tool is never dispatched), and the tool's own block runs
226
+ * only when the guard let the call through to `lockArtifact`. One refusal, one dispatch decision, one
227
+ * block. A repeat of the SAME refused call re-enters this branch, and the second block is refused by the
228
+ * goal service itself (`block` requires an ACTIVE goal; an already-blocked goal is not active), which is
229
+ * swallowed here exactly as the tool path swallows it — the goal stays blocked, and it is not blocked
230
+ * twice.
231
+ *
232
+ * ⚠ THE TRIGGER IS THE ASK, NOT THE RULE LABEL — the same trigger FU-7 uses, for the same reason: an ask is
233
+ * present exactly when the refusal was DECIDED FROM REAL ORDERING BLOCKERS (`PolicyDecision.blockers` read
234
+ * from disk), which includes a policy FILE whose `recursive_lock*` deny carries no label (its `rule` reads
235
+ * `none`). Gating on the label instead would silently skip the goal block in every repo that ships a policy
236
+ * file — the shipped default here.
237
+ *
238
+ * ⚠ AND IT IS THE LOCK ORDERING REFUSAL ONLY. The phase-order WRITE rule refuses a write ahead of the
239
+ * active phase, and it has NO tool-layer counterpart that blocks a goal — `lockArtifact` is the only
240
+ * tool-side blocker in the plugin. Blocking a goal on it would be a NEW behaviour, not the consistency this
241
+ * fix is for: the defect was one refusal with two layers disagreeing, not a rule that should start
242
+ * blocking.
243
+ */
244
+ function blockGoalOnGuardRefusal(
245
+ recursive: RecursiveRuntime,
246
+ exec: unknown,
247
+ decision: ToolGuardDecision,
248
+ activeRunId: string,
249
+ ): void {
250
+ if (decision.kind !== 'deny' || decision.ask === undefined) return
251
+ const agent = (exec as { agent?: { session?: { header?: { cwd?: string } } } } | null)?.agent ?? null
252
+ // Best-effort, exactly like the tool path: the run's filesystem state is the source of truth and a goal
253
+ // projection that cannot be written must never turn a refusal into a different refusal.
254
+ try {
255
+ recursive.blockRunToGoal(agent, decision.runId ?? activeRunId, {
256
+ // The SAME code the tool path uses, because it is the same gate: a caller reading a blocked goal
257
+ // cannot tell which layer refused, and must not have to.
258
+ code: 'prerequisite-blockers',
259
+ message: decision.reason ?? 'the lock was refused: its prerequisites are unmet',
260
+ })
261
+ } catch {
262
+ /* best-effort */
263
+ }
264
+ }
265
+
266
+ export function apply(ctx: Context, config?: RecursiveModeConfig) {
267
+ // R4 shell split (02-to-be-plan.addendum-r4-r2-mount-resolution.md): the
268
+ // global bare-name row in cordis.patch.yml mounts with config.shellOnly=true
269
+ // to expose ONLY the client bundle for client discovery. It must register
270
+ // NOTHING on the server root — no tools, no /recursive command, no
271
+ // recursive:policy, no projection (BUG 4 always-on leak). The full server
272
+ // surface is mounted ONLY by the recursive preset's isolated recursive-realm,
273
+ // whose rows carry no config (shellOnly undefined).
274
+ if (config?.shellOnly) return
275
+ ctx.effect(function* () {
276
+ // Workspace registry: optional host service (durable). Access via ctx.get —
277
+ // property access requires inject and would fail boot when undeclared.
278
+ // Resolve the control-plane root strictly from the session agent's cwd.
279
+ const workspaceRegistry = ctx.get('workspaceRegistry') as never
280
+ // T1 (goals projection): the goals service is on the host plane; it resolves
281
+ // from inside the recursive-realm via inheritance (same as workspaceRegistry).
282
+ // SAFETY: the goals service is an optional host service (could be absent); the
283
+ // run projection treats null as "no goal backing" and never throws.
284
+ const goals = ctx.get('goals') as GoalServiceLike | null
285
+ // T10: the native jobs registry, when the composition mounts one. OPTIONAL on purpose —
286
+ // a long operation must still run, and say it was untracked, rather than fail because no
287
+ // board is attached.
288
+ const jobs = ctx.get('jobs') as JobsRegistryLike | undefined
289
+ // T39: the subagents seam is resolved HERE, at the composition, so the runtime can fall back
290
+ // to it when a caller passes none. Measured reason: `recursive_review.tool.ts` — the only
291
+ // production caller of `delegateReview` — passes no seam, and the runtime then reported
292
+ // "no ctx.subagents runtime available" on a host that had mounted it all along.
293
+ const subagentsSeamForRuntime = ctx.get('subagents') as SubagentsRuntimeLike | undefined
294
+ const recursive = new RecursiveRuntime(ctx, { repoRoot: config?.repoRoot ?? process.cwd(), workspaceRegistry, goals, jobs, subagents: subagentsSeamForRuntime ?? null, workflow: ctx.get('workflow') as WorkflowEngineLike | undefined ?? null })
295
+ // ⚠ FU-9 — AND RESOLVE IT AGAIN WHENEVER THE SERVICE APPEARS, because the one-shot get above is only a
296
+ // fast path. A live run proved the cost of relying on it: the review fell back to self-audit and reported
297
+ // `Status: failed` while a child DIRECTORY sat there — written by this plugin's own brief writer, before
298
+ // any service call — so the artifact looked like a started child and was not. `ctx.inject` is the
299
+ // harness's own pattern for a service that may be mounted by a later layer, and it fires immediately when
300
+ // the service is already present, so this is a guarantee rather than a second chance.
301
+ ctx.inject(['subagents'], (subagentsCtx: Context) => {
302
+ recursive.attachSubagents((subagentsCtx.get('subagents') as SubagentsRuntimeLike | undefined) ?? null)
303
+ })
304
+ // ⚠ FU-19 — AND THE LLM INVENTORY, the same late-attaching way: `ctx.llm` is what the browser model catalog is
305
+ // built from, so it is the service that knows which providers and models this host actually has. Resolved
306
+ // optionally — a host without it gets the `unverified` verdict rather than a silent approval.
307
+ const llmInventory = ctx.get('llm') as LlmInventoryLike | undefined
308
+ recursive.attachLlmInventory(llmInventory ?? null)
309
+ ctx.inject(['llm'], (llmCtx: Context) => {
310
+ recursive.attachLlmInventory((llmCtx.get('llm') as LlmInventoryLike | undefined) ?? null)
311
+ })
312
+ // ⚠ PHASE 0 — THE HUMAN-QUESTION CHANNEL, resolved the same late-attaching way for the same reason:
313
+ // a composition may mount `userQuestions` after this plugin applies, and the run-start gate asks the
314
+ // person DIRECTLY through it before it arms a goal — which is what keeps a relayed `Start run` from
315
+ // being an approval while a person can actually be asked (see run-start.ts).
316
+ recursive.attachUserQuestions((ctx.get('userQuestions') as UserQuestionsLike | undefined) ?? null)
317
+ ctx.inject(['userQuestions'], (questionsCtx: Context) => {
318
+ recursive.attachUserQuestions((questionsCtx.get('userQuestions') as UserQuestionsLike | undefined) ?? null)
319
+ })
320
+
321
+ // T7 — THE SETTINGS NAMESPACE, APPLIED ON EVERY APPLY. The settings service edits the
322
+ // Loader entry's config and the Loader RE-APPLIES this plugin, so a toggle in the UI
323
+ // lands on this line: the re-application IS the hot reload, and there is deliberately
324
+ // no watcher or file poller here to drift out of sync with it.
325
+ //
326
+ // Guarded on PRESENCE rather than truthiness: `enforcement: undefined` means "the
327
+ // caller said nothing", which must leave the runtime's own default alone, while an
328
+ // explicit object — including one whose fields the schema defaulted — is a decision.
329
+ // Validation stays in `resolveEnforcementConfig` (strict, fail-loud), so a bad value
330
+ // from any source is refused rather than coerced.
331
+ if (config?.enforcement !== undefined) recursive.setEnforcementConfig(config.enforcement)
332
+
333
+ // T12 — publish each phase's rules to the native SKILL catalogue, so the agent and its
334
+ // children can ASK what a phase requires instead of grepping this checkout. The catalogue is
335
+ // optional; with none mounted this registers nothing and says so, because a composition
336
+ // without skills should still run the workflow.
337
+ const phaseSkills = registerPhaseSkills(ctx.get('skills') as SkillRegistryLike | undefined, PHASE_SEQUENCE)
338
+ // Tied to this plugin's effects: the contribution is withdrawn with the fiber that made it.
339
+ yield () => { for (const dispose of phaseSkills.disposers) dispose() }
340
+
341
+ // T7 part 2 — the ROUTER overrides, on the same terms: present means override, absent
342
+ // means defer to the workspace's declarative `recursive-router.json`. ONE PATH, NOT
343
+ // TWO: this does not replace the file, it lays over it (see `loadRouterPolicy`).
344
+ if (config?.router !== undefined) recursive.setRouterOverrides(config.router)
345
+
346
+ const repairedRoots = new Set<string>()
347
+ const reminderGate = new ReminderOnceGate()
348
+ // T3 (agentTeams task loop): wire the live ctx.agentTeams service (optional —
349
+ // absent in compositions without the experimental agent-team row) into the
350
+ // turn-driven task-board tool. The whole-loop driver (auditToPass) is also
351
+ // exported for callers with a settlement observer.
352
+ // SAFETY: ctx.get returns the live service as an opaque value; the single
353
+ // boundary cast asserts it satisfies the TeamRuntimeLike structural seam
354
+ // (createTask/updateTask plus optional wait/interrupt/board reads). The
355
+ // live service's real Agent parameter is a superset of TeamCallerHandle, so
356
+ // the seam passes the exact live Agent the tool extracts from exec.agent.
357
+ const agentTeams = ctx.get('agentTeams') as TeamRuntimeLike | undefined
358
+ // T36: the continuable-subagent seam the review tool drives. Optional for the
359
+ // same reason as agentTeams — absent it, `recursive_review` still runs and
360
+ // reports `unavailable`, naming that the repair path does not exist rather than
361
+ // pretending the review was a success.
362
+ const subagentsSeam = subagentsSeamForRuntime
363
+
364
+ // Packaged skill (dsh plugin standard): register the `recursive-mode` skill
365
+ // into the host skills registry via ctx.skills.registerProvider (the
366
+ // dsh-skill-badge bundled-provider shape). Optional — a composition without
367
+ // a skills registry is valid and this no-ops (returns undefined).
368
+ const skillDisposer = registerRecursiveSkill(ctx)
369
+
370
+ const disposers = [
371
+ ...(skillDisposer ? [skillDisposer] : []),
372
+ ctx.tools.register(createRecursiveStatusTool(recursive)),
373
+ ctx.tools.register(createRecursiveInitTool(recursive)),
374
+ ctx.tools.register(createRecursiveLockTool(recursive)),
375
+ ctx.tools.register(createRecursiveLintTool(recursive)),
376
+ ctx.tools.register(createRecursiveCloseoutTool(recursive)),
377
+ ctx.tools.register(createRecursiveScratchTool(recursive)),
378
+ ctx.tools.register(createRecursiveWorktreeTool(recursive)),
379
+ ctx.tools.register(createRecursivePhaseTool(recursive)),
380
+ ctx.tools.register(createRecursiveReviewTool(recursive, subagentsSeam)),
381
+ // ⚠ FU-17 — WORK delegation: the main agent hands a phase's actual work to a child, reads it, and sends
382
+ // feedback to the same child. Registered beside the review tool because they share the round driver, the
383
+ // settlement observer and the reply contract — the difference is what a settlement MEANS.
384
+ ctx.tools.register(createRecursiveDelegateTool(recursive, subagentsSeam)),
385
+ // T23: the three human gates as structured decisions. Registered here so the ask is a TOOL call
386
+ // — which is what the host renders as a card — rather than prose a person has to interpret.
387
+ ctx.tools.register(createRecursiveAskTool(recursive)),
388
+ // T26: the read-only view of what the enforcement contract will do, before it fires.
389
+ ctx.tools.register(createRecursivePreviewTool(recursive)),
390
+ ]
391
+
392
+ // ⚠ FIX 1 — THE THIRD SEAM NEEDED THE SAME LATE ATTACH AS THE OTHER TWO, AND DID NOT HAVE IT.
393
+ //
394
+ // The line that used to sit in the array above was `...(agentTeams ? [register(...)] : [])` — a ONE-SHOT
395
+ // `ctx.get('agentTeams')` taken at apply time. A live verification pass found the consequence: `team_task_create`
396
+ // worked in the same session whose tool catalog lacked `recursive_audit_team`, because the service was mounted
397
+ // AFTER this plugin applied. The plugin shipped 13 tool files and offered 12.
398
+ //
399
+ // `subagents` and `llm` already solve this with `ctx.inject` (above); this is that pattern, with one addition the
400
+ // others do not need: the tool may only be registered ONCE, because the one-shot path can already have taken it.
401
+ let auditTeamRegistered = agentTeams !== undefined && agentTeams !== null
402
+ if (auditTeamRegistered) disposers.push(ctx.tools.register(createRecursiveAuditTeamTool(agentTeams ?? null)))
403
+ ctx.inject(['agentTeams'], (teamCtx: Context) => {
404
+ if (auditTeamRegistered) return
405
+ const late = teamCtx.get('agentTeams') as TeamRuntimeLike | undefined
406
+ if (late === undefined || late === null) return
407
+ auditTeamRegistered = true
408
+ // ⚠ CORRECTED COMMENT. This registers through the OUTER plugin context (`ctx`), NOT through the
409
+ // injecting `teamCtx` — `teamCtx` is used on the line above only to READ the late service, and the
410
+ // sibling `subagents`/`llm` injects use their callback context the same way. The comment that used to
411
+ // sit here claimed the injecting scope owned the registration, which is not what this call does.
412
+ //
413
+ // WHAT IS NOT CLAIMED: that the fiber withdraws this registration. Nobody has observed that — no test
414
+ // covers the late `recursive_audit_team` being withdrawn — and the disposer returned here is not
415
+ // retained, unlike the eager registration above, which pushes its own onto `disposers`. Until a test
416
+ // observes the withdrawal, this comment promises nothing about it.
417
+ ctx.tools.register(createRecursiveAuditTeamTool(late))
418
+ })
419
+
420
+ // /recursive command (R4): preset-scoped registration, workspace-scoped dispatch.
421
+ const commands = ctx.get('commands') as { register: (def: unknown) => () => void } | undefined
422
+ if (commands) {
423
+ disposers.push(registerRecursiveCommand({ commands } as never, recursive))
424
+ }
425
+
426
+ // recursive:policy prompt section (Phase C R5): workspace-scoped behavior +
427
+ // current-phase contract rendered from folded state + enforcement config.
428
+ const systemPrompt = ctx.get('systemPrompt') as { section: (def: unknown) => () => void } | undefined
429
+ if (systemPrompt) {
430
+ disposers.push(systemPrompt.section({
431
+ name: 'recursive:policy',
432
+ order: 55,
433
+ text: (context: unknown) => {
434
+ const agent = (context as { agent?: { session?: { header?: { cwd?: string } } } } | undefined)?.agent
435
+ if (!agent) return ''
436
+ // SP3 R5 policy-render fix: derive intent from the FILESYSTEM, not the
437
+ // retired recursive/phase-intent session event (zero-emission removed
438
+ // the emitter; 0.2.2 deleted the event-fold helper that read it, so this
439
+ // signal was ALWAYS null and this section rendered ''). Pure read-only fs
440
+ // folding; no recursive/*
441
+ // events are appended.
442
+ const intent = fsPolicyIntent(agent, workspaceRegistry as never)
443
+ if (!intent) return ''
444
+ return renderRecursivePolicy({ worktreeRoot: intent.worktreeRoot, runId: intent.runId, config: recursive.enforcementConfig })
445
+ },
446
+ }))
447
+ }
448
+
449
+ // Phase C R3 (Layer 1, agent/pre-step proactive intent gate) is RETIRED
450
+ // under zero-emission (SP2 R1): its only signal was the recursive/phase-intent
451
+ // session event, whose emitter is now deleted, and it was ADVISORY by default
452
+ // (it never rejected; its sole side effect was the now-removed emission).
453
+ // Enforcement is preserved where it actually bites: Layer 2 (tools/pre-execute
454
+ // inspects the REAL tool call args below) plus the recursive_lock tool's own
455
+ // prerequisite validation in lockArtifact — strictly more reliable than the
456
+ // heuristic intent scan. See 03-implementation-summary.addendum-r1-*.md.
457
+
458
+ // Phase C R4: tools/pre-execute surgical guards (Layer 2, caller of the
459
+ // transition set). Scope-filtered to the active run's worktree.
460
+ //
461
+ // T15 — this listener used to hand `evaluateToolGuard` an EMPTY runId, so its
462
+ // `runDir` resolved to `<root>/.recursive/run` — the directory that holds run
463
+ // DIRECTORIES, not artifacts. The monotonic lock-order and Phase-3 TDD
464
+ // branches could therefore never fire; only the locked-write branch worked
465
+ // (it resolves its target path directly). The REAL active run id is now
466
+ // resolved per call, the same way recursive_status/phaseRules do it.
467
+ const sessionsStore = ctx.get('sessions') as
468
+ | { get?: (id: string) => { header?: { cwd?: string } } | undefined }
469
+ | undefined
470
+ // T38 — THE BUILT-IN GUARD IS NOW A HOOK ON THE SAME CHAIN AS EVERYONE ELSE.
471
+ //
472
+ // Registered at PRIORITY 0 so a sibling with a higher priority runs FIRST and can
473
+ // pre-empt it cheaply — which is the point of participation. The guard stays the
474
+ // baseline that runs when nobody objects.
475
+ //
476
+ // `fail_closed` because this is a GATING point: a guard that cannot decide must not
477
+ // let the call through. The FULL decision rides back as an annotation, so `ask` and
478
+ // `allow` survive with `warn`/`rule`/`transition` intact — the listener returns that
479
+ // object VERBATIM, which is what keeps `guard-path` byte-identical.
480
+ recursive.hooks.register('pre_trigger', {
481
+ name: BUILTIN_GUARD_HOOK_NAME,
482
+ priority: 0,
483
+ onError: 'fail_closed',
484
+ run: (input) => {
485
+ const payload = input as { exec?: unknown; root?: string; runId?: string }
486
+ const decision = runToolGuard(recursive, payload.exec, payload.root ?? '', payload.runId ?? '')
487
+ return decision.kind === 'deny'
488
+ ? { decision: 'deny' as const, reason: decision.reason ?? 'denied by the tool guard', annotations: { guardDecision: decision } }
489
+ : { decision: 'continue' as const, annotations: { guardDecision: decision } }
490
+ },
491
+ })
492
+ // T13 (part 2) — THE PLAN GATE IS LIVE. `exit_plan_mode` is the event that leaves plan mode,
493
+ // so it is where the gate belongs: a run still waiting on a DISCOVERY phase must not leave
494
+ // plan mode, because that would start implementing on an unfinished plan. The gate reads the
495
+ // phase the run is already waiting on rather than keeping its own state, which is why it
496
+ // cannot drift from the workflow.
497
+ //
498
+ // Priority 5, ABOVE the built-in tool guard at 0: this is a workflow-shaped refusal and
499
+ // should be the reason a caller sees, not something the generic guard has to restate.
500
+ recursive.hooks.register('pre_trigger', {
501
+ name: 'exit-plan-mode-gate',
502
+ priority: 5,
503
+ onError: 'fail_closed',
504
+ run: (input) => {
505
+ const payload = input as { tool?: string; root?: string; runId?: string }
506
+ if (payload.tool !== 'exit_plan_mode') return { decision: 'continue' as const }
507
+ const root = payload.root ?? ''
508
+ const runId = payload.runId ?? ''
509
+ if (root === '' || runId === '') return { decision: 'continue' as const }
510
+ const gate = planGateForExit(getNextLegalPhase(join(root, '.recursive', 'run', runId)))
511
+ return gate.allow
512
+ ? { decision: 'continue' as const, annotations: { planGate: gate.reason } }
513
+ : { decision: 'deny' as const, reason: gate.reason }
514
+ },
515
+ })
516
+ const toolRuntime = ctx as unknown as { on?: (event: string, listener: (payload: unknown, next?: unknown) => unknown) => () => void }
517
+ if (toolRuntime.on) {
518
+ disposers.push(toolRuntime.on('tools/pre-execute', async (payload, next) => {
519
+ const exec = payload as { name?: string; arguments?: unknown; agent?: { session?: { header?: { cwd?: string } } } | null } | null
520
+ if (!exec?.name) return typeof next === 'function' ? next() : { kind: 'allow' }
521
+ // B3: per-call root is the session cwd (authoritative when the registry is
522
+ // absent), never process.cwd().
523
+ const cwd = exec?.agent?.session?.header?.cwd ?? ''
524
+ const root = (await recursive.resolveRootForRoute(undefined, cwd, sessionsStore)) ?? cwd
525
+ // T15 (A): the active run id is resolved from the FILESYSTEM on every call
526
+ // — `resolveRunDir` is the canonical latest-run-by-mtime used by
527
+ // recursive_status/phaseRules. Deliberately NO ttl/time cache: a cached run
528
+ // id would silently reintroduce exactly the empty-runId bug being fixed
529
+ // here, because a run created moments ago must be visible immediately. If a
530
+ // cache is ever added it must be provably invalidated on run creation.
531
+ const runId = root ? resolveRunDir(root)?.runId ?? '' : ''
532
+
533
+ // T27 — THE NAMED POINT IS LIVE AT THE ENFORCEMENT SEAM. A sibling hook may
534
+ // deny here BEFORE the built-in guard runs, so participation is real rather
535
+ // than a reachable registry that nothing consults.
536
+ //
537
+ // With no hooks registered — the ordinary case — the chain returns `continue`
538
+ // and the guard below runs exactly as it always has, which is what keeps
539
+ // `guard-path.spec.ts`'s pinned contract byte-identical. That is the point of
540
+ // putting the chain FIRST: it adds a way in without moving what was there.
541
+ //
542
+ // A `hold` is treated as a denial at this seam. `hold` means "stop and wait"
543
+ // for a point that can resume later; a tool call has nothing to resume, so
544
+ // pretending to hold would silently proceed. Better to refuse and say so.
545
+ const preTrigger = await recursive.hooks.run('pre_trigger', {
546
+ tool: exec.name,
547
+ args: exec.arguments,
548
+ exec,
549
+ root,
550
+ runId,
551
+ })
552
+ const decider = preTrigger.ran[preTrigger.ran.length - 1]
553
+
554
+ // A SIBLING stopped the chain. Checked by the DECIDER, not by the built-in's
555
+ // mere absence: a sibling with a LOWER priority than the guard runs after it, so
556
+ // "the guard is in the trail" does not mean "the guard decided".
557
+ if ((preTrigger.decision === 'deny' || preTrigger.decision === 'hold') && decider?.name !== BUILTIN_GUARD_HOOK_NAME) {
558
+ const by = decider?.name ?? 'a pre_trigger hook'
559
+ const why = preTrigger.reason ?? 'no reason given'
560
+ // A `hold` is treated as a refusal at this seam. `hold` means "stop and wait"
561
+ // for a point that can resume later; a tool call has nothing to resume, so
562
+ // pretending to hold would silently proceed — worse than refusing, because the
563
+ // caller would never learn a hook wanted to stop it.
564
+ return {
565
+ kind: 'deny',
566
+ reason: preTrigger.decision === 'hold'
567
+ ? 'held by pre_trigger hook ' + by + ': ' + why
568
+ : 'denied by pre_trigger hook ' + by + ': ' + why,
569
+ }
570
+ }
571
+
572
+ // The guard itself failed: it is fail_closed, so the refusal is reported with
573
+ // its own error rather than as a silent allow.
574
+ if (decider?.name === BUILTIN_GUARD_HOOK_NAME && decider.error !== undefined) {
575
+ return { kind: 'deny', reason: 'the tool guard failed: ' + decider.error }
576
+ }
577
+
578
+ const builtIn = preTrigger.ran.find((entry) => entry.name === BUILTIN_GUARD_HOOK_NAME)
579
+ const final = builtIn?.annotations?.guardDecision as ToolGuardDecision | undefined
580
+ if (final === undefined) {
581
+ // Unreachable while the built-in is registered unconditionally. It fails
582
+ // CLOSED rather than allowing, because "we could not decide" is not permission.
583
+ return { kind: 'deny', reason: 'the tool guard produced no decision' }
584
+ }
585
+ // The guard's own object, returned VERBATIM — the pinned contract.
586
+ //
587
+ // ⚠ EXCEPT THAT A DENIAL'S `ask` MUST BE CARRIED IN THE TEXT, and this is the one place the
588
+ // plugin can do it. Measured in the harness (`packages/core/tools`): a `tools/pre-execute`
589
+ // deny becomes `content: [{ type: 'text', text: 'Error: ' + reason }]` and EVERY other field
590
+ // of the decision is dropped, so the gate-block payload added for FU-7 would have reached the
591
+ // model as nothing at all — which is precisely the defect: a strict-by-default guard refusing
592
+ // a lock with a bare sentence, while `fix | reopen | abandon` was how the run got unblocked.
593
+ // The sentence is rendered FROM the payload (`renderGateBlockAsk`), so what the caller reads
594
+ // and what the decision carries cannot drift; the plain reason stays first and intact, so a
595
+ // caller that ignores the ask still gets the rule name and the blocking artifact.
596
+ if (final.kind === 'deny') {
597
+ // ⚠ ISSUE 1 — THE GUARD-REFUSED LOCK BLOCKS THE RUN'S GOAL, HERE, because this is the layer that
598
+ // made the refusal: the tool is never dispatched, so `lockArtifact`'s own goal block cannot run.
599
+ // Called BEFORE the ask is rendered into the sentence so the durable `blockedReason` is the plain
600
+ // refusal, not the refusal plus its option list. See `blockGoalOnGuardRefusal`.
601
+ blockGoalOnGuardRefusal(recursive, exec, final, runId)
602
+ return final.ask === undefined
603
+ ? final
604
+ : { ...final, reason: final.reason + ' ' + renderGateBlockAsk(final.ask) }
605
+ }
606
+ if (final.kind === 'allow' && final.warn) {
607
+ // ⚠ IT NAMES THE MODE IT ACTUALLY RAN UNDER. This line used to begin `tool guard (advisory)`
608
+ // unconditionally, while the warning it carries comes from the transition gate's REPORT-ONLY
609
+ // consult — which attaches a warning to an ALLOW in BOTH modes. Under the strict default the
610
+ // line therefore told a reader that enforcement was off while every gate was strict: text
611
+ // asserting a state that was not so. The mode is read from the same config the guard ran
612
+ // under, so the prefix moves with the setting; the warn semantics are unchanged.
613
+ console.warn('[recursive] tool guard (' + recursive.enforcementConfig.toolGuards + ') allowed this call: ' + final.warn)
614
+ }
615
+ return typeof next === 'function' ? next() : { kind: 'allow' }
616
+ }))
617
+ }
618
+
619
+ // T15 (E): the fs/observed lock-tamper path. The harness contract is a plain
620
+ // SYNCHRONOUS emit fired AFTER a successful write, so this listener cannot
621
+ // veto anything and contractually must not throw — it only RECORDS. Before
622
+ // T15 the plugin had no fs/observed listener at all (the string appeared in
623
+ // comments only), so `detectTamper` was exported and unit-tested with no live
624
+ // caller and a tampered lock surfaced nowhere but prompt text.
625
+ const observationRuntime = ctx as unknown as { on?: (event: string, listener: (target: unknown, observation: unknown, actor: unknown) => void) => () => void }
626
+ if (observationRuntime.on) {
627
+ disposers.push(observationRuntime.on('fs/observed', (target, observation, actor) => {
628
+ try {
629
+ // Only a present observation can be a tamper; absent/unrelated are ignored.
630
+ if ((observation as { kind?: string } | null)?.kind !== 'present') return
631
+ const displayPath = (target as { displayPath?: string } | null)?.displayPath ?? ''
632
+ if (!displayPath) return
633
+ // Cheap shape test BEFORE any filesystem work: fs/observed fires on reads
634
+ // too, so enumerating runs for every observation would be a readdir per
635
+ // file touch. The actor is the tool execution. This event cannot await, so
636
+ // the root is the actor's session cwd (the same B4 sync shortcut
637
+ // fsPolicyIntent takes: the session cwd is authoritative, the registry
638
+ // path is async-only). Resolving the cwd first is free — plain property
639
+ // reads — and the admission test needs it.
640
+ const cwd = (actor as { agent?: { session?: { header?: { cwd?: string } } } } | null)?.agent?.session?.header?.cwd ?? ''
641
+ if (!cwd) return
642
+ // ⚠ AND THIS IS `detectTamper`'s OWN ADMISSION TEST, CALLED RATHER THAN COPIED.
643
+ //
644
+ // It used to be an inline hand-copy — `endsWith('.md') && includes('/.recursive/run/')`
645
+ // — and a hand-copy is what made the tamper guard blind to one spelling of one
646
+ // path: the substring test needs a separator BEFORE `.recursive`, which a
647
+ // repo-relative target (`displayPath` as a model would type it) does not have, so
648
+ // the listener rejected the candidate here and `detectTamper` was never reached.
649
+ // Widening only `detectTamper` would have changed nothing observable. The test now
650
+ // lives in one place (`tamperCandidatePath`), so the two cannot disagree; it stays
651
+ // pure path arithmetic, so the "no filesystem work before admission" property the
652
+ // shape check exists for is preserved.
653
+ const normalized = displayPath.replace(/\\/g, '/')
654
+ if (!tamperCandidatePath(normalized, cwd)) return
655
+ const runId = resolveRunDir(cwd)?.runId ?? ''
656
+ const tamper = recursive.detectTamper(normalized, cwd, runId)
657
+ if (!tamper) return
658
+ appendObservedTamper(cwd, {
659
+ at: new Date().toISOString(),
660
+ runId: tamper.runId,
661
+ path: tamper.path,
662
+ reason: tamper.reason,
663
+ })
664
+ } catch {
665
+ // Observe-only: the fs/observed contract forbids throwing.
666
+ }
667
+ }))
668
+ }
669
+
670
+ // T36: capture a delegated child's SETTLEMENT at delivery time.
671
+ //
672
+ // WHY DELIVERY AND NOT HISTORY. The obvious implementation of a parent-side
673
+ // settlement observer is to scan the session log for the `subagent-settled`
674
+ // notice. That is prohibited: DSH deprecates synchronous reads of arbitrary
675
+ // session history (`eventAt`/`snapshotEvents`/`ownEvents`) and states that new
676
+ // production calls are prohibited, enforced by an executable lint check. The
677
+ // sanctioned replacement is to process the DELIVERED event, which is this
678
+ // listener — the same `session/event` seam the projection registry subscribes
679
+ // to. The durable fact then lands in the run's own FILE state, which is where
680
+ // every other plugin fact lands and keeps the plugin zero-emission.
681
+ //
682
+ // The loop needs this because there is NO parent-side promise to await: a
683
+ // continuable child's settlement arrives as a durable user message on a later
684
+ // turn, so the round observer must find a recorded settlement or honestly
685
+ // report that none has landed yet.
686
+ const sessionRuntime = ctx as unknown as { on?: (event: string, listener: (session: unknown, event: unknown) => void) => () => void }
687
+ if (sessionRuntime.on) {
688
+ disposers.push(sessionRuntime.on('session/event', (session, event) => {
689
+ try {
690
+ // Cheap shape test FIRST: session/event fires for EVERY committed event
691
+ // in every session, so a non-settlement must be rejected before any
692
+ // filesystem work. This is the same ordering the fs/observed listener
693
+ // uses, for the same reason.
694
+ const notice = settlementFromEvent(event as never)
695
+ if (notice === null) return
696
+ // B3: the session cwd is the authoritative control-plane root per call.
697
+ const cwd = (session as { header?: { cwd?: string } } | null)?.header?.cwd ?? ''
698
+ if (cwd === '') return
699
+ const runDir = runDirForChild(cwd, notice.childId)
700
+ // No run (or an ambiguous one) means the settlement is not filed rather
701
+ // than filed wrongly: the loop will report "no settlement yet", which is
702
+ // recoverable, whereas attaching evidence to the wrong run is not.
703
+ if (runDir === null) {
704
+ // ⚠ FU-17 — ADOPT RATHER THAN DROP. The rule above still holds — never guess between runs — but a
705
+ // settlement for a child nobody filed is evidence of work that really happened, and dropping it
706
+ // means a phase artifact cannot cite it. `adoptSettlement` files it into the single run when there
707
+ // is exactly one, and into a root-level adoption log when choosing would mean guessing. Marked as
708
+ // adopted either way, so a reader can tell an adopted record from a delegation's own.
709
+ adoptSettlement(cwd, notice)
710
+ return
711
+ }
712
+ recordSettlement(runDir, notice)
713
+ } catch {
714
+ // Observe-only. This rides the hot path of every session event and must
715
+ // never break the session it observes.
716
+ }
717
+ }))
718
+ }
719
+
720
+ // SP3 R5: agent/pre-step lint-rules injection (pre-step contract verified: the
721
+ // listener returns { kind: 'enter', messages: [...messages, injected] } and the
722
+ // returned array REPLACES the default [...claimed, context]). When the session's
723
+ // control-plane root has an active recursive run whose current phase doc is
724
+ // DRAFT, prepend a compact system-reminder with THAT phase's required sections +
725
+ // gates. Pure fs read (zero-emission): never appends recursive/* events.
726
+ const agentRuntime = ctx as unknown as { on?: (event: string, listener: (payload: unknown, next?: unknown) => unknown) => () => void }
727
+ if (agentRuntime.on) {
728
+ disposers.push(agentRuntime.on('agent/pre-step', async (payload, next) => {
729
+ const p = payload as {
730
+ messages?: Array<{ content: Array<{ type: string; text?: string }> }>,
731
+ agent?: { session?: { header?: { cwd?: string } } } | null,
732
+ } | null
733
+ const messages = p?.messages ?? []
734
+ const agent = p?.agent ?? null
735
+ const cwd = agent?.session?.header?.cwd ?? ''
736
+ // delegate first so later listeners keep veto power, then fold ours on
737
+ if (typeof next === 'function') await next()
738
+ if (!cwd) return { kind: 'enter', messages } as const
739
+ const root = await recursive.resolveRootForRoute(undefined, cwd, undefined)
740
+ if (!root) return { kind: 'enter', messages } as const
741
+ // R3/R6 (run 09): idempotent scaffold REPAIR on session-start (new AND
742
+ // resume). bootstrapScaffold is upsert-only: it adds missing control-plane
743
+ // files/dirs + re-upserts marked blocks, NEVER overwriting user content or
744
+ // touching run/ artifacts (in-flight 02-to-be-plan etc. are preserved).
745
+ // Guarded once per root so repeated pre-steps are cheap no-ops.
746
+ if (!repairedRoots.has(root)) {
747
+ repairedRoots.add(root)
748
+ stageBWorkflowInit({ root, source: 'resume' })
749
+ }
750
+ const runs = enumerateRuns(root)
751
+ if (runs.length === 0) return { kind: 'enter', messages } as const
752
+ const runId = runs[runs.length - 1]
753
+ const runDir = join(root, '.recursive', 'run', runId)
754
+ if (!existsSync(runDir)) return { kind: 'enter', messages } as const
755
+ const phase = getNextLegalPhase(runDir)
756
+ if (!phase) return { kind: 'enter', messages } as const
757
+ // only inject when the phase doc is DRAFT (not locked/missing).
758
+ const phasePath = join(runDir, phase)
759
+ const status = existsSync(phasePath) ? getLockStatus(phasePath) : null
760
+ if (status !== 'DRAFT') return { kind: 'enter', messages } as const
761
+ if (!reminderGate.shouldInject(root, runId, phase)) return { kind: 'enter', messages } as const
762
+ // LIVE BUG 6 (0.2.1): inject the lint-rules reminder AT MOST ONCE PER PHASE.
763
+ // The scaffold repair above is deduped via repairedRoots; the reminder itself was
764
+ // not, so every pre-step while DRAFT re-injected it.
765
+ const reminder = phaseLintRulesMessage(phase)
766
+ return {
767
+ kind: 'enter',
768
+ messages: [...messages, createUserMessage({ content: [{ type: 'text', text: reminder }], source: { ...REMINDER_SOURCE, form: 'notice', summary: 'phase ' + phase + ' lint rules' } })],
769
+ } as const
770
+ }))
771
+ }
772
+
773
+ // Phase C R8: fs/observed lock-tamper WARNINGS are served to the board via the
774
+ // mountOnce-global: apply() runs per-session, but the route must register
775
+ // exactly once (WebServer.register throws on duplicate kind+path) and serve
776
+ // PER-WORKSPACE state. No-op when the host composes no webServer (headless).
777
+ // (`sessionsStore` is resolved once, above the pre-execute listener, which now
778
+ // needs it too.)
779
+ const webServer = ctx.get('webServer') as
780
+ | { register: (route: { kind: string; path: string; handler: unknown }) => () => void }
781
+ | undefined
782
+ if (webServer) {
783
+ const host: RecursiveRouteHost = {
784
+ // sessionId PRIMARY: the host resolves cwd from the attached session header;
785
+ // the client-passed cwd is a fallback hint (hydration / headless).
786
+ resolveRoot: async (sessionId: string | undefined, cwd: string) => recursive.resolveRootForRoute(sessionId, cwd, sessionsStore),
787
+ snapshot: async (root: string) => snapshotWorkspace(root),
788
+ revision: () => 1,
789
+ }
790
+ disposers.push(mountRecursiveRoutesOnce('@try-works/dsh-recursive-mode', () => makeRecursiveRoutes(host), webServer))
791
+ }
792
+
793
+ yield () => { for (const d of disposers) d() }
794
+ })
795
+ }