@try-works/dsh-recursive-mode 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +959 -0
  2. package/lib/client.js +9 -2
  3. package/lib/closeout-report.d.ts +113 -0
  4. package/lib/closeout-standards.d.ts +35 -0
  5. package/lib/closeout.d.ts +12 -0
  6. package/lib/commands.d.ts +1 -1
  7. package/lib/config.d.ts +202 -0
  8. package/lib/delegation.d.ts +123 -3
  9. package/lib/enforcement.d.ts +90 -1
  10. package/lib/errors.d.ts +168 -0
  11. package/lib/git-context.d.ts +17 -0
  12. package/lib/guard-log.d.ts +39 -0
  13. package/lib/handoff.d.ts +29 -0
  14. package/lib/hooks.d.ts +103 -0
  15. package/lib/identity.d.ts +61 -0
  16. package/lib/index.d.ts +33 -12
  17. package/lib/index.js +10017 -3969
  18. package/lib/job-log.d.ts +34 -0
  19. package/lib/jobs-runner.d.ts +105 -0
  20. package/lib/json-safe.d.ts +33 -0
  21. package/lib/lock.d.ts +42 -0
  22. package/lib/memory-feedback.d.ts +52 -0
  23. package/lib/memory-select.d.ts +78 -0
  24. package/lib/memory.d.ts +137 -0
  25. package/lib/model-inventory.d.ts +106 -0
  26. package/lib/phase-graph.d.ts +111 -0
  27. package/lib/phase-rules.d.ts +67 -8
  28. package/lib/plan-gate.d.ts +68 -0
  29. package/lib/policy-globs.d.ts +222 -0
  30. package/lib/policy-write.d.ts +42 -0
  31. package/lib/policy.d.ts +39 -0
  32. package/lib/recursive_ask.tool.d.ts +88 -0
  33. package/lib/recursive_closeout.tool.d.ts +1 -1
  34. package/lib/recursive_delegate.tool.d.ts +22 -0
  35. package/lib/recursive_preview.tool.d.ts +48 -0
  36. package/lib/recursive_review.tool.d.ts +28 -0
  37. package/lib/result-cap.d.ts +70 -0
  38. package/lib/review-round.d.ts +82 -0
  39. package/lib/review.d.ts +9 -0
  40. package/lib/role-route.d.ts +122 -0
  41. package/lib/router.d.ts +90 -5
  42. package/lib/runtime.d.ts +252 -12
  43. package/lib/settlement.d.ts +132 -0
  44. package/lib/skills-phase.d.ts +71 -0
  45. package/lib/skills.d.ts +70 -0
  46. package/lib/status.d.ts +53 -1
  47. package/lib/teams-loop.d.ts +91 -2
  48. package/lib/training.d.ts +211 -0
  49. package/lib/ts-lint.d.ts +15 -0
  50. package/lib/types.d.ts +48 -0
  51. package/lib/workflow-audit.d.ts +207 -0
  52. package/package.json +31 -31
  53. package/preset/recursive.patch.yml +312 -0
  54. package/scripts/e2e-run.mjs +51 -0
  55. package/scripts/link-dsh.mjs +233 -0
  56. package/scripts/live/fake-llm.mjs +150 -0
  57. package/scripts/live-session-plugin.mjs +179 -0
  58. package/scripts/live-session-stock.mjs +106 -0
  59. package/scripts/live-session.mjs +139 -0
  60. package/skills/recursive-mode/SKILL.md +66 -0
  61. package/src/client/derive.ts +18 -2
  62. package/src/closeout-report.ts +274 -0
  63. package/src/closeout-standards.ts +102 -0
  64. package/src/closeout.ts +39 -2
  65. package/src/commands.ts +116 -4
  66. package/src/config.ts +113 -0
  67. package/src/delegation.ts +336 -18
  68. package/src/enforcement.ts +262 -72
  69. package/src/errors.ts +197 -0
  70. package/src/git-context.ts +33 -2
  71. package/src/guard-log.ts +134 -0
  72. package/src/handoff.ts +62 -0
  73. package/src/hooks.ts +316 -0
  74. package/src/identity.ts +230 -0
  75. package/src/index.ts +394 -20
  76. package/src/job-log.ts +112 -0
  77. package/src/jobs-runner.ts +222 -0
  78. package/src/json-safe.ts +75 -0
  79. package/src/lock.ts +153 -16
  80. package/src/memory-feedback.ts +185 -0
  81. package/src/memory-select.ts +187 -0
  82. package/src/memory.ts +309 -0
  83. package/src/model-inventory.ts +196 -0
  84. package/src/phase-graph.ts +191 -0
  85. package/src/phase-rules.ts +236 -0
  86. package/src/plan-gate.ts +111 -0
  87. package/src/policy-globs.ts +636 -0
  88. package/src/policy-write.ts +210 -0
  89. package/src/policy.ts +70 -5
  90. package/src/recursive_ask.tool.ts +276 -0
  91. package/src/recursive_audit_team.tool.ts +7 -3
  92. package/src/recursive_closeout.tool.ts +36 -35
  93. package/src/recursive_delegate.tool.ts +194 -0
  94. package/src/recursive_init.tool.ts +4 -3
  95. package/src/recursive_lint.tool.ts +81 -6
  96. package/src/recursive_lock.tool.ts +21 -4
  97. package/src/recursive_phase.tool.ts +3 -2
  98. package/src/recursive_preview.tool.ts +142 -0
  99. package/src/recursive_review.tool.ts +190 -0
  100. package/src/recursive_scratch.tool.ts +5 -4
  101. package/src/recursive_status.tool.ts +3 -2
  102. package/src/recursive_worktree.tool.ts +6 -5
  103. package/src/result-cap.ts +130 -0
  104. package/src/review-round.ts +335 -0
  105. package/src/review.ts +17 -3
  106. package/src/role-route.ts +230 -0
  107. package/src/router.ts +128 -2
  108. package/src/runtime.ts +968 -39
  109. package/src/settlement.ts +355 -0
  110. package/src/skills-phase.ts +143 -0
  111. package/src/skills.ts +151 -0
  112. package/src/snapshot.ts +39 -8
  113. package/src/status.ts +209 -4
  114. package/src/teams-loop.ts +223 -9
  115. package/src/training.ts +565 -0
  116. package/src/ts-lint.ts +38 -4
  117. package/src/types.ts +51 -0
  118. package/src/workflow-audit.ts +288 -0
  119. package/scripts/install-preset.cmd +0 -7
  120. package/scripts/install-preset.js +0 -101
package/src/index.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  import { type Context } from '@deepseek-ai/cordis'
2
2
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
3
+ import type { ContextFormed } from '@deepseek-ai/dsh-llm'
3
4
  import { existsSync } from 'node:fs'
4
5
  import { join } from 'node:path'
5
6
  import { RecursiveRuntime } from './runtime.ts'
7
+ import type { JobsRegistryLike } from './jobs-runner.ts'
8
+ import { planGateForExit } from './plan-gate.ts'
9
+ import { registerPhaseSkills, type SkillRegistryLike } from './skills-phase.ts'
10
+ import type { WorkflowEngineLike } from './workflow-audit.ts'
11
+ import type { RecursiveModeConfig } from './config.ts'
6
12
  import { createRecursiveStatusTool } from './recursive_status.tool.ts'
7
13
  import { createRecursiveInitTool } from './recursive_init.tool.ts'
8
14
  import { createRecursiveLockTool } from './recursive_lock.tool.ts'
@@ -12,20 +18,63 @@ import { createRecursiveScratchTool } from './recursive_scratch.tool.ts'
12
18
  import { createRecursiveWorktreeTool } from './recursive_worktree.tool.ts'
13
19
  import { createRecursivePhaseTool } from './recursive_phase.tool.ts'
14
20
  import { createRecursiveAuditTeamTool } from './recursive_audit_team.tool.ts'
21
+ import { createRecursiveReviewTool } from './recursive_review.tool.ts'
22
+ import { createRecursiveDelegateTool } from './recursive_delegate.tool.ts'
23
+ import { createRecursiveAskTool } from './recursive_ask.tool.ts'
24
+ import { createRecursivePreviewTool } from './recursive_preview.tool.ts'
25
+ import type { SubagentsRuntimeLike } from './delegation.ts'
15
26
  import { registerRecursiveCommand } from './commands.ts'
16
- import { evaluateToolGuard, coerceAskToDecision } from './enforcement.ts'
27
+ import { evaluateToolGuard, coerceAskToDecision, type ToolGuardDecision } from './enforcement.ts'
28
+ import { appendGuardDecision, appendObservedTamper, type GuardDecisionRecord } from './guard-log.ts'
17
29
  import type { GoalServiceLike } from './goals-projection.ts'
18
30
  import type { TeamRuntimeLike } from './teams-loop.ts'
19
31
  import { renderRecursivePolicy } from './policy.ts'
20
32
  import { fsPolicyIntent } from './fs-intent.ts'
21
33
  import { snapshotWorkspace } from './snapshot.ts'
22
34
  import { mountRecursiveRoutesOnce, makeRecursiveRoutes, type RecursiveRouteHost } from './live-route.ts'
35
+ import { registerRecursiveSkill } from './skills.ts'
23
36
  import { enumerateRuns, stageBWorkflowInit } from './bootstrap.ts'
24
- import { getNextLegalPhase, getLockStatus } from './lock.ts'
37
+ import { getNextLegalPhase, getLockStatus, PHASE_SEQUENCE } from './lock.ts'
38
+ import { adoptSettlement } from './settlement.ts'
39
+ import type { LlmInventoryLike } from './model-inventory.ts'
40
+ import { resolveRunDir } from './run.ts'
25
41
  import { phaseLintRulesMessage, ReminderOnceGate } from './phase-rules.ts'
42
+ import { settlementFromEvent, runDirForChild, recordSettlement } from './settlement.ts'
43
+
44
+ /**
45
+ * rc.2 rebase (T31a) — the message-source vocabulary changed under us.
46
+ *
47
+ * At `dsh-v0.1.1-rc.2` `MessageSourceMap` carried a shared catch-all
48
+ * `plugin: { kind: 'plugin'; plugin: string }` entry, which this file used for
49
+ * its injected phase-lint reminder. At `dsh-v0.2.0-rc.2` that entry is GONE:
50
+ * the map is merge-extensible and, in its own words, "each producer declares
51
+ * its own `kind` in its own module; there is no shared catch-all `plugin`
52
+ * kind". This was invisible in the old checkout because its `node_modules`
53
+ * still held a stale `dsh-llm`.
54
+ *
55
+ * The idiom below is copied from the shipped `@deepseek-ai/dsh-repeat-tool-reminder`,
56
+ * whose pre-step reminder is the closest analogue to ours: a user-role message
57
+ * whose source declares its own kind and a `form: 'notice'` one-line account.
58
+ */
59
+ declare module '@deepseek-ai/dsh-llm' {
60
+ interface MessageSourceMap {
61
+ 'recursive-mode': { kind: 'recursive-mode' } & ContextFormed
62
+ }
63
+ }
64
+
65
+ /** Producer source stamped on every injected phase-lint reminder. */
66
+ const REMINDER_SOURCE = { kind: 'recursive-mode' } as const
26
67
 
27
68
  export const name = '@try-works/dsh-recursive-mode'
28
69
 
70
+ /**
71
+ * T7: the plugin's Config schema, which the settings service DISCOVERS (see `src/config.ts`
72
+ * for why declaring it is the registration). Re-exported from the entry because the Loader
73
+ * reads it from the plugin module.
74
+ */
75
+ export { Config } from './config.ts'
76
+ export type { RecursiveModeConfig } from './config.ts'
77
+
29
78
  export { RecursiveRuntime } from './runtime.ts'
30
79
  export { createRecursiveStatusTool } from './recursive_status.tool.ts'
31
80
  export { createRecursiveInitTool } from './recursive_init.tool.ts'
@@ -74,6 +123,7 @@ export * from './policy.ts'
74
123
  export * from './snapshot.ts'
75
124
  export * from './live-route.ts'
76
125
  export * from './teams-loop.ts'
126
+ export * from './skills.ts'
77
127
 
78
128
  /**
79
129
  * Bundle plugin entry. The Loader activates this row once `tools` is available
@@ -91,7 +141,57 @@ export const inject = ['tools']
91
141
  * repo/run work and NO session-event emission here: zero recursive/* events
92
142
  * are ever appended (resume-crash fix), and the board reads the live fs route.
93
143
  */
94
- export function apply(ctx: Context, config?: { shellOnly?: boolean; repoRoot?: string }) {
144
+ /** T38: the built-in guard's hook name — the chain's identity for "this was the plugin's own". */
145
+ const BUILTIN_GUARD_HOOK_NAME = 'builtin-tool-guard'
146
+
147
+ /**
148
+ * T38 — the tool guard, as a function rather than an inline block.
149
+ *
150
+ * Extracted so the SAME code can run as a hook on the registry: a built-in and a
151
+ * sibling then share one chain, one ordering rule and one failure policy, instead of
152
+ * the built-in being privileged code that always runs first. Every side effect stays
153
+ * here — the guard-decision log is written by whoever computes the decision, so moving
154
+ * the call site cannot lose it.
155
+ *
156
+ * The `ask` coercion is unchanged and stays key-frozen: `coerceAskToDecision`'s output
157
+ * is asserted with an exact `toEqual`, so the rebuilt object carries the guard's `rule`
158
+ * and `transition` forward rather than letting the coercion drop them.
159
+ */
160
+ function runToolGuard(
161
+ recursive: RecursiveRuntime,
162
+ exec: unknown,
163
+ root: string,
164
+ runId: string,
165
+ ): ToolGuardDecision {
166
+ const guardMode = recursive.enforcementConfig.toolGuards
167
+ const decision = evaluateToolGuard(exec as never, root, runId, guardMode)
168
+ const coerced = coerceAskToDecision(decision, guardMode)
169
+ const final: ToolGuardDecision = coerced === decision
170
+ ? decision
171
+ : { ...coerced, rule: decision.rule, transition: decision.transition }
172
+ // T15 (C/D): every decision is logged — allows included — so the rolling trace shows
173
+ // what the guard decided AND why, not only refusals. File-backed evidence in the
174
+ // control-plane config dir: zero session-event emission.
175
+ if (root) {
176
+ const record: GuardDecisionRecord = {
177
+ at: new Date().toISOString(),
178
+ runId,
179
+ tool: (exec as { name?: string } | null)?.name ?? '',
180
+ kind: final.kind,
181
+ rule: final.rule ?? 'none',
182
+ }
183
+ if (final.kind === 'allow') {
184
+ if (final.warn) record.reason = final.warn
185
+ } else if (final.reason) {
186
+ record.reason = final.reason
187
+ }
188
+ if (final.transition) record.transition = final.transition
189
+ appendGuardDecision(root, record)
190
+ }
191
+ return final
192
+ }
193
+
194
+ export function apply(ctx: Context, config?: RecursiveModeConfig) {
95
195
  // R4 shell split (02-to-be-plan.addendum-r4-r2-mount-resolution.md): the
96
196
  // global bare-name row in cordis.patch.yml mounts with config.shellOnly=true
97
197
  // to expose ONLY the client bundle for client discovery. It must register
@@ -110,7 +210,58 @@ export function apply(ctx: Context, config?: { shellOnly?: boolean; repoRoot?: s
110
210
  // SAFETY: the goals service is an optional host service (could be absent); the
111
211
  // run projection treats null as "no goal backing" and never throws.
112
212
  const goals = ctx.get('goals') as GoalServiceLike | null
113
- const recursive = new RecursiveRuntime(ctx, { repoRoot: config?.repoRoot ?? process.cwd(), workspaceRegistry, goals })
213
+ // T10: the native jobs registry, when the composition mounts one. OPTIONAL on purpose —
214
+ // a long operation must still run, and say it was untracked, rather than fail because no
215
+ // board is attached.
216
+ const jobs = ctx.get('jobs') as JobsRegistryLike | undefined
217
+ // T39: the subagents seam is resolved HERE, at the composition, so the runtime can fall back
218
+ // to it when a caller passes none. Measured reason: `recursive_review.tool.ts` — the only
219
+ // production caller of `delegateReview` — passes no seam, and the runtime then reported
220
+ // "no ctx.subagents runtime available" on a host that had mounted it all along.
221
+ const subagentsSeamForRuntime = ctx.get('subagents') as SubagentsRuntimeLike | undefined
222
+ const recursive = new RecursiveRuntime(ctx, { repoRoot: config?.repoRoot ?? process.cwd(), workspaceRegistry, goals, jobs, subagents: subagentsSeamForRuntime ?? null, workflow: ctx.get('workflow') as WorkflowEngineLike | undefined ?? null })
223
+ // ⚠ FU-9 — AND RESOLVE IT AGAIN WHENEVER THE SERVICE APPEARS, because the one-shot get above is only a
224
+ // fast path. A live run proved the cost of relying on it: the review fell back to self-audit and reported
225
+ // `Status: failed` while a child DIRECTORY sat there — written by this plugin's own brief writer, before
226
+ // any service call — so the artifact looked like a started child and was not. `ctx.inject` is the
227
+ // harness's own pattern for a service that may be mounted by a later layer, and it fires immediately when
228
+ // the service is already present, so this is a guarantee rather than a second chance.
229
+ ctx.inject(['subagents'], (subagentsCtx: Context) => {
230
+ recursive.attachSubagents((subagentsCtx.get('subagents') as SubagentsRuntimeLike | undefined) ?? null)
231
+ })
232
+ // ⚠ FU-19 — AND THE LLM INVENTORY, the same late-attaching way: `ctx.llm` is what the browser model catalog is
233
+ // built from, so it is the service that knows which providers and models this host actually has. Resolved
234
+ // optionally — a host without it gets the `unverified` verdict rather than a silent approval.
235
+ const llmInventory = ctx.get('llm') as LlmInventoryLike | undefined
236
+ recursive.attachLlmInventory(llmInventory ?? null)
237
+ ctx.inject(['llm'], (llmCtx: Context) => {
238
+ recursive.attachLlmInventory((llmCtx.get('llm') as LlmInventoryLike | undefined) ?? null)
239
+ })
240
+
241
+ // T7 — THE SETTINGS NAMESPACE, APPLIED ON EVERY APPLY. The settings service edits the
242
+ // Loader entry's config and the Loader RE-APPLIES this plugin, so a toggle in the UI
243
+ // lands on this line: the re-application IS the hot reload, and there is deliberately
244
+ // no watcher or file poller here to drift out of sync with it.
245
+ //
246
+ // Guarded on PRESENCE rather than truthiness: `enforcement: undefined` means "the
247
+ // caller said nothing", which must leave the runtime's own default alone, while an
248
+ // explicit object — including one whose fields the schema defaulted — is a decision.
249
+ // Validation stays in `resolveEnforcementConfig` (strict, fail-loud), so a bad value
250
+ // from any source is refused rather than coerced.
251
+ if (config?.enforcement !== undefined) recursive.setEnforcementConfig(config.enforcement)
252
+
253
+ // T12 — publish each phase's rules to the native SKILL catalogue, so the agent and its
254
+ // children can ASK what a phase requires instead of grepping this checkout. The catalogue is
255
+ // optional; with none mounted this registers nothing and says so, because a composition
256
+ // without skills should still run the workflow.
257
+ const phaseSkills = registerPhaseSkills(ctx.get('skills') as SkillRegistryLike | undefined, PHASE_SEQUENCE)
258
+ // Tied to this plugin's effects: the contribution is withdrawn with the fiber that made it.
259
+ yield () => { for (const dispose of phaseSkills.disposers) dispose() }
260
+
261
+ // T7 part 2 — the ROUTER overrides, on the same terms: present means override, absent
262
+ // means defer to the workspace's declarative `recursive-router.json`. ONE PATH, NOT
263
+ // TWO: this does not replace the file, it lays over it (see `loadRouterPolicy`).
264
+ if (config?.router !== undefined) recursive.setRouterOverrides(config.router)
114
265
 
115
266
  const repairedRoots = new Set<string>()
116
267
  const reminderGate = new ReminderOnceGate()
@@ -124,8 +275,20 @@ export function apply(ctx: Context, config?: { shellOnly?: boolean; repoRoot?: s
124
275
  // live service's real Agent parameter is a superset of TeamCallerHandle, so
125
276
  // the seam passes the exact live Agent the tool extracts from exec.agent.
126
277
  const agentTeams = ctx.get('agentTeams') as TeamRuntimeLike | undefined
278
+ // T36: the continuable-subagent seam the review tool drives. Optional for the
279
+ // same reason as agentTeams — absent it, `recursive_review` still runs and
280
+ // reports `unavailable`, naming that the repair path does not exist rather than
281
+ // pretending the review was a success.
282
+ const subagentsSeam = subagentsSeamForRuntime
283
+
284
+ // Packaged skill (dsh plugin standard): register the `recursive-mode` skill
285
+ // into the host skills registry via ctx.skills.registerProvider (the
286
+ // dsh-skill-badge bundled-provider shape). Optional — a composition without
287
+ // a skills registry is valid and this no-ops (returns undefined).
288
+ const skillDisposer = registerRecursiveSkill(ctx)
127
289
 
128
290
  const disposers = [
291
+ ...(skillDisposer ? [skillDisposer] : []),
129
292
  ctx.tools.register(createRecursiveStatusTool(recursive)),
130
293
  ctx.tools.register(createRecursiveInitTool(recursive)),
131
294
  ctx.tools.register(createRecursiveLockTool(recursive)),
@@ -134,6 +297,16 @@ export function apply(ctx: Context, config?: { shellOnly?: boolean; repoRoot?: s
134
297
  ctx.tools.register(createRecursiveScratchTool(recursive)),
135
298
  ctx.tools.register(createRecursiveWorktreeTool(recursive)),
136
299
  ctx.tools.register(createRecursivePhaseTool(recursive)),
300
+ ctx.tools.register(createRecursiveReviewTool(recursive, subagentsSeam)),
301
+ // ⚠ FU-17 — WORK delegation: the main agent hands a phase's actual work to a child, reads it, and sends
302
+ // feedback to the same child. Registered beside the review tool because they share the round driver, the
303
+ // settlement observer and the reply contract — the difference is what a settlement MEANS.
304
+ ctx.tools.register(createRecursiveDelegateTool(recursive, subagentsSeam)),
305
+ // T23: the three human gates as structured decisions. Registered here so the ask is a TOOL call
306
+ // — which is what the host renders as a card — rather than prose a person has to interpret.
307
+ ctx.tools.register(createRecursiveAskTool(recursive)),
308
+ // T26: the read-only view of what the enforcement contract will do, before it fires.
309
+ ctx.tools.register(createRecursivePreviewTool(recursive)),
137
310
  ...(agentTeams ? [ctx.tools.register(createRecursiveAuditTeamTool(agentTeams))] : []),
138
311
  ]
139
312
 
@@ -177,30 +350,232 @@ export function apply(ctx: Context, config?: { shellOnly?: boolean; repoRoot?: s
177
350
 
178
351
  // Phase C R4: tools/pre-execute surgical guards (Layer 2, caller of the
179
352
  // transition set). Scope-filtered to the active run's worktree.
353
+ //
354
+ // T15 — this listener used to hand `evaluateToolGuard` an EMPTY runId, so its
355
+ // `runDir` resolved to `<root>/.recursive/run` — the directory that holds run
356
+ // DIRECTORIES, not artifacts. The monotonic lock-order and Phase-3 TDD
357
+ // branches could therefore never fire; only the locked-write branch worked
358
+ // (it resolves its target path directly). The REAL active run id is now
359
+ // resolved per call, the same way recursive_status/phaseRules do it.
360
+ const sessionsStore = ctx.get('sessions') as
361
+ | { get?: (id: string) => { header?: { cwd?: string } } | undefined }
362
+ | undefined
363
+ // T38 — THE BUILT-IN GUARD IS NOW A HOOK ON THE SAME CHAIN AS EVERYONE ELSE.
364
+ //
365
+ // Registered at PRIORITY 0 so a sibling with a higher priority runs FIRST and can
366
+ // pre-empt it cheaply — which is the point of participation. The guard stays the
367
+ // baseline that runs when nobody objects.
368
+ //
369
+ // `fail_closed` because this is a GATING point: a guard that cannot decide must not
370
+ // let the call through. The FULL decision rides back as an annotation, so `ask` and
371
+ // `allow` survive with `warn`/`rule`/`transition` intact — the listener returns that
372
+ // object VERBATIM, which is what keeps `guard-path` byte-identical.
373
+ recursive.hooks.register('pre_trigger', {
374
+ name: BUILTIN_GUARD_HOOK_NAME,
375
+ priority: 0,
376
+ onError: 'fail_closed',
377
+ run: (input) => {
378
+ const payload = input as { exec?: unknown; root?: string; runId?: string }
379
+ const decision = runToolGuard(recursive, payload.exec, payload.root ?? '', payload.runId ?? '')
380
+ return decision.kind === 'deny'
381
+ ? { decision: 'deny' as const, reason: decision.reason ?? 'denied by the tool guard', annotations: { guardDecision: decision } }
382
+ : { decision: 'continue' as const, annotations: { guardDecision: decision } }
383
+ },
384
+ })
385
+ // T13 (part 2) — THE PLAN GATE IS LIVE. `exit_plan_mode` is the event that leaves plan mode,
386
+ // so it is where the gate belongs: a run still waiting on a DISCOVERY phase must not leave
387
+ // plan mode, because that would start implementing on an unfinished plan. The gate reads the
388
+ // phase the run is already waiting on rather than keeping its own state, which is why it
389
+ // cannot drift from the workflow.
390
+ //
391
+ // Priority 5, ABOVE the built-in tool guard at 0: this is a workflow-shaped refusal and
392
+ // should be the reason a caller sees, not something the generic guard has to restate.
393
+ recursive.hooks.register('pre_trigger', {
394
+ name: 'exit-plan-mode-gate',
395
+ priority: 5,
396
+ onError: 'fail_closed',
397
+ run: (input) => {
398
+ const payload = input as { tool?: string; root?: string; runId?: string }
399
+ if (payload.tool !== 'exit_plan_mode') return { decision: 'continue' as const }
400
+ const root = payload.root ?? ''
401
+ const runId = payload.runId ?? ''
402
+ if (root === '' || runId === '') return { decision: 'continue' as const }
403
+ const gate = planGateForExit(getNextLegalPhase(join(root, '.recursive', 'run', runId)))
404
+ return gate.allow
405
+ ? { decision: 'continue' as const, annotations: { planGate: gate.reason } }
406
+ : { decision: 'deny' as const, reason: gate.reason }
407
+ },
408
+ })
180
409
  const toolRuntime = ctx as unknown as { on?: (event: string, listener: (payload: unknown, next?: unknown) => unknown) => () => void }
181
410
  if (toolRuntime.on) {
182
- disposers.push(toolRuntime.on('tools/pre-execute', (payload, next) => {
411
+ disposers.push(toolRuntime.on('tools/pre-execute', async (payload, next) => {
183
412
  const exec = payload as { name?: string; arguments?: unknown; agent?: { session?: { header?: { cwd?: string } } } | null } | null
184
413
  if (!exec?.name) return typeof next === 'function' ? next() : { kind: 'allow' }
185
414
  // B3: per-call root is the session cwd (authoritative when the registry is
186
415
  // absent), never process.cwd().
187
- const root = exec?.agent?.session?.header?.cwd ?? ''
188
- const decision = evaluateToolGuard(exec as never, root, '', recursive.enforcementConfig.toolGuards)
189
- if (decision.kind === 'allow') return typeof next === 'function' ? next() : { kind: 'allow' }
190
- if (decision.kind === 'deny') return decision
191
- // T6 (approval ask→policy bridge): an `ask` must never be a silent
192
- // allow. Strict coerces to deny; advisory allows but carries a warn that
193
- // the caller logs below. The approval seam is the follow-on (Phase D).
194
- const coerced = coerceAskToDecision(decision, recursive.enforcementConfig.toolGuards)
195
- if (coerced.kind === 'deny') return coerced
196
- if (coerced.kind === 'allow' && coerced.warn) {
416
+ const cwd = exec?.agent?.session?.header?.cwd ?? ''
417
+ const root = (await recursive.resolveRootForRoute(undefined, cwd, sessionsStore)) ?? cwd
418
+ // T15 (A): the active run id is resolved from the FILESYSTEM on every call
419
+ // — `resolveRunDir` is the canonical latest-run-by-mtime used by
420
+ // recursive_status/phaseRules. Deliberately NO ttl/time cache: a cached run
421
+ // id would silently reintroduce exactly the empty-runId bug being fixed
422
+ // here, because a run created moments ago must be visible immediately. If a
423
+ // cache is ever added it must be provably invalidated on run creation.
424
+ const runId = root ? resolveRunDir(root)?.runId ?? '' : ''
425
+
426
+ // T27 — THE NAMED POINT IS LIVE AT THE ENFORCEMENT SEAM. A sibling hook may
427
+ // deny here BEFORE the built-in guard runs, so participation is real rather
428
+ // than a reachable registry that nothing consults.
429
+ //
430
+ // With no hooks registered — the ordinary case — the chain returns `continue`
431
+ // and the guard below runs exactly as it always has, which is what keeps
432
+ // `guard-path.spec.ts`'s pinned contract byte-identical. That is the point of
433
+ // putting the chain FIRST: it adds a way in without moving what was there.
434
+ //
435
+ // A `hold` is treated as a denial at this seam. `hold` means "stop and wait"
436
+ // for a point that can resume later; a tool call has nothing to resume, so
437
+ // pretending to hold would silently proceed. Better to refuse and say so.
438
+ const preTrigger = await recursive.hooks.run('pre_trigger', {
439
+ tool: exec.name,
440
+ args: exec.arguments,
441
+ exec,
442
+ root,
443
+ runId,
444
+ })
445
+ const decider = preTrigger.ran[preTrigger.ran.length - 1]
446
+
447
+ // A SIBLING stopped the chain. Checked by the DECIDER, not by the built-in's
448
+ // mere absence: a sibling with a LOWER priority than the guard runs after it, so
449
+ // "the guard is in the trail" does not mean "the guard decided".
450
+ if ((preTrigger.decision === 'deny' || preTrigger.decision === 'hold') && decider?.name !== BUILTIN_GUARD_HOOK_NAME) {
451
+ const by = decider?.name ?? 'a pre_trigger hook'
452
+ const why = preTrigger.reason ?? 'no reason given'
453
+ // A `hold` is treated as a refusal at this seam. `hold` means "stop and wait"
454
+ // for a point that can resume later; a tool call has nothing to resume, so
455
+ // pretending to hold would silently proceed — worse than refusing, because the
456
+ // caller would never learn a hook wanted to stop it.
457
+ return {
458
+ kind: 'deny',
459
+ reason: preTrigger.decision === 'hold'
460
+ ? 'held by pre_trigger hook ' + by + ': ' + why
461
+ : 'denied by pre_trigger hook ' + by + ': ' + why,
462
+ }
463
+ }
464
+
465
+ // The guard itself failed: it is fail_closed, so the refusal is reported with
466
+ // its own error rather than as a silent allow.
467
+ if (decider?.name === BUILTIN_GUARD_HOOK_NAME && decider.error !== undefined) {
468
+ return { kind: 'deny', reason: 'the tool guard failed: ' + decider.error }
469
+ }
470
+
471
+ const builtIn = preTrigger.ran.find((entry) => entry.name === BUILTIN_GUARD_HOOK_NAME)
472
+ const final = builtIn?.annotations?.guardDecision as ToolGuardDecision | undefined
473
+ if (final === undefined) {
474
+ // Unreachable while the built-in is registered unconditionally. It fails
475
+ // CLOSED rather than allowing, because "we could not decide" is not permission.
476
+ return { kind: 'deny', reason: 'the tool guard produced no decision' }
477
+ }
478
+ // The guard's own object, returned VERBATIM — the pinned contract.
479
+ if (final.kind === 'deny') return final
480
+ if (final.kind === 'allow' && final.warn) {
197
481
  // Package-tagged host logging; never a silent pass under approval=never.
198
- console.warn('[recursive] tool guard (advisory): ' + coerced.warn + ' — allowing')
482
+ console.warn('[recursive] tool guard (advisory): ' + final.warn + ' — allowing')
199
483
  }
200
484
  return typeof next === 'function' ? next() : { kind: 'allow' }
201
485
  }))
202
486
  }
203
487
 
488
+ // T15 (E): the fs/observed lock-tamper path. The harness contract is a plain
489
+ // SYNCHRONOUS emit fired AFTER a successful write, so this listener cannot
490
+ // veto anything and contractually must not throw — it only RECORDS. Before
491
+ // T15 the plugin had no fs/observed listener at all (the string appeared in
492
+ // comments only), so `detectTamper` was exported and unit-tested with no live
493
+ // caller and a tampered lock surfaced nowhere but prompt text.
494
+ const observationRuntime = ctx as unknown as { on?: (event: string, listener: (target: unknown, observation: unknown, actor: unknown) => void) => () => void }
495
+ if (observationRuntime.on) {
496
+ disposers.push(observationRuntime.on('fs/observed', (target, observation, actor) => {
497
+ try {
498
+ // Only a present observation can be a tamper; absent/unrelated are ignored.
499
+ if ((observation as { kind?: string } | null)?.kind !== 'present') return
500
+ const displayPath = (target as { displayPath?: string } | null)?.displayPath ?? ''
501
+ if (!displayPath) return
502
+ // Cheap shape test BEFORE any filesystem work: fs/observed fires on reads
503
+ // too, so enumerating runs for every observation would be a readdir per
504
+ // file touch. This is EXACTLY detectTamper's own admission test (same
505
+ // normalized string, same two conditions), so it can never reject a
506
+ // candidate detectTamper would have accepted.
507
+ const normalized = displayPath.replace(/\\/g, '/')
508
+ if (!normalized.endsWith('.md') || !normalized.includes('/.recursive/run/')) return
509
+ // The actor is the tool execution. This event cannot await, so the root is
510
+ // the actor's session cwd (the same B4 sync shortcut fsPolicyIntent takes:
511
+ // the session cwd is authoritative, the registry path is async-only).
512
+ const cwd = (actor as { agent?: { session?: { header?: { cwd?: string } } } } | null)?.agent?.session?.header?.cwd ?? ''
513
+ if (!cwd) return
514
+ const runId = resolveRunDir(cwd)?.runId ?? ''
515
+ const tamper = recursive.detectTamper(normalized, cwd, runId)
516
+ if (!tamper) return
517
+ appendObservedTamper(cwd, {
518
+ at: new Date().toISOString(),
519
+ runId: tamper.runId,
520
+ path: tamper.path,
521
+ reason: tamper.reason,
522
+ })
523
+ } catch {
524
+ // Observe-only: the fs/observed contract forbids throwing.
525
+ }
526
+ }))
527
+ }
528
+
529
+ // T36: capture a delegated child's SETTLEMENT at delivery time.
530
+ //
531
+ // WHY DELIVERY AND NOT HISTORY. The obvious implementation of a parent-side
532
+ // settlement observer is to scan the session log for the `subagent-settled`
533
+ // notice. That is prohibited: DSH deprecates synchronous reads of arbitrary
534
+ // session history (`eventAt`/`snapshotEvents`/`ownEvents`) and states that new
535
+ // production calls are prohibited, enforced by an executable lint check. The
536
+ // sanctioned replacement is to process the DELIVERED event, which is this
537
+ // listener — the same `session/event` seam the projection registry subscribes
538
+ // to. The durable fact then lands in the run's own FILE state, which is where
539
+ // every other plugin fact lands and keeps the plugin zero-emission.
540
+ //
541
+ // The loop needs this because there is NO parent-side promise to await: a
542
+ // continuable child's settlement arrives as a durable user message on a later
543
+ // turn, so the round observer must find a recorded settlement or honestly
544
+ // report that none has landed yet.
545
+ const sessionRuntime = ctx as unknown as { on?: (event: string, listener: (session: unknown, event: unknown) => void) => () => void }
546
+ if (sessionRuntime.on) {
547
+ disposers.push(sessionRuntime.on('session/event', (session, event) => {
548
+ try {
549
+ // Cheap shape test FIRST: session/event fires for EVERY committed event
550
+ // in every session, so a non-settlement must be rejected before any
551
+ // filesystem work. This is the same ordering the fs/observed listener
552
+ // uses, for the same reason.
553
+ const notice = settlementFromEvent(event as never)
554
+ if (notice === null) return
555
+ // B3: the session cwd is the authoritative control-plane root per call.
556
+ const cwd = (session as { header?: { cwd?: string } } | null)?.header?.cwd ?? ''
557
+ if (cwd === '') return
558
+ const runDir = runDirForChild(cwd, notice.childId)
559
+ // No run (or an ambiguous one) means the settlement is not filed rather
560
+ // than filed wrongly: the loop will report "no settlement yet", which is
561
+ // recoverable, whereas attaching evidence to the wrong run is not.
562
+ if (runDir === null) {
563
+ // ⚠ FU-17 — ADOPT RATHER THAN DROP. The rule above still holds — never guess between runs — but a
564
+ // settlement for a child nobody filed is evidence of work that really happened, and dropping it
565
+ // means a phase artifact cannot cite it. `adoptSettlement` files it into the single run when there
566
+ // is exactly one, and into a root-level adoption log when choosing would mean guessing. Marked as
567
+ // adopted either way, so a reader can tell an adopted record from a delegation's own.
568
+ adoptSettlement(cwd, notice)
569
+ return
570
+ }
571
+ recordSettlement(runDir, notice)
572
+ } catch {
573
+ // Observe-only. This rides the hot path of every session event and must
574
+ // never break the session it observes.
575
+ }
576
+ }))
577
+ }
578
+
204
579
  // SP3 R5: agent/pre-step lint-rules injection (pre-step contract verified: the
205
580
  // listener returns { kind: 'enter', messages: [...messages, injected] } and the
206
581
  // returned array REPLACES the default [...claimed, context]). When the session's
@@ -249,7 +624,7 @@ export function apply(ctx: Context, config?: { shellOnly?: boolean; repoRoot?: s
249
624
  const reminder = phaseLintRulesMessage(phase)
250
625
  return {
251
626
  kind: 'enter',
252
- messages: [...messages, createUserMessage({ content: [{ type: 'text', text: reminder }], source: { kind: 'plugin', plugin: '@try-works/dsh-recursive-mode' } })],
627
+ messages: [...messages, createUserMessage({ content: [{ type: 'text', text: reminder }], source: { ...REMINDER_SOURCE, form: 'notice', summary: 'phase ' + phase + ' lint rules' } })],
253
628
  } as const
254
629
  }))
255
630
  }
@@ -258,12 +633,11 @@ export function apply(ctx: Context, config?: { shellOnly?: boolean; repoRoot?: s
258
633
  // mountOnce-global: apply() runs per-session, but the route must register
259
634
  // exactly once (WebServer.register throws on duplicate kind+path) and serve
260
635
  // PER-WORKSPACE state. No-op when the host composes no webServer (headless).
636
+ // (`sessionsStore` is resolved once, above the pre-execute listener, which now
637
+ // needs it too.)
261
638
  const webServer = ctx.get('webServer') as
262
639
  | { register: (route: { kind: string; path: string; handler: unknown }) => () => void }
263
640
  | undefined
264
- const sessionsStore = ctx.get('sessions') as
265
- | { get?: (id: string) => { header?: { cwd?: string } } | undefined }
266
- | undefined
267
641
  if (webServer) {
268
642
  const host: RecursiveRouteHost = {
269
643
  // sessionId PRIMARY: the host resolves cwd from the attached session header;
package/src/job-log.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * T39 — surface job runs to the board as a durable, file-backed log.
3
+ *
4
+ * WHY A FILE AND NOT AN EVENT BUS. The native registry offers
5
+ * `jobs.events.subscribe(filter, listener)`, and subscribing was the first thing this looked
6
+ * like it should do. It is the wrong seam for THIS job: an event carries a job id and a
7
+ * status, but **not the run a job belongs to** — and the plugin's board reads the RUN LAYER.
8
+ * Subscribing would therefore mean parsing run ids back out of our own labels, which is a
9
+ * mapping that breaks silently the day a label changes. The call site already knows the run
10
+ * it started work for, so the run id is recorded where it is KNOWN and read where it is
11
+ * NEEDED. That also keeps the zero-emission invariant: a file in the run layer, never a
12
+ * session event.
13
+ *
14
+ * ⚠ BEST-EFFORT BY DESIGN, exactly like the guard log and the settlement log: a failed log
15
+ * write must never change the outcome of work that already ran. Recording is observability;
16
+ * letting observability fail a lint would be the tail wagging the dog.
17
+ *
18
+ * ⚠ AND IT SAYS WHETHER THE RUN WAS TRACKED. `tracked: false` is recorded rather than left
19
+ * implicit, because a reader must be able to tell "this job ran without a board" from "the
20
+ * board lost it" — the same distinction `describeJobRun` makes for a caller.
21
+ */
22
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
23
+ import { dirname, join } from 'node:path'
24
+ import type { JobRunResult } from './jobs-runner.ts'
25
+
26
+ /** One recorded run, as the board reads it. */
27
+ export interface JobLogRecord {
28
+ at: string
29
+ /** The producer kind: `lint`, `worktree`, `delegation`. */
30
+ kind: string
31
+ label: string
32
+ /** False when there was no registry and the work ran inline. */
33
+ tracked: boolean
34
+ jobId?: string
35
+ status: 'completed' | 'killed' | 'failed'
36
+ detail?: string
37
+ }
38
+
39
+ /** Where a run's job log lives, beside its other run-layer records. */
40
+ export function jobsLogPath(root: string, runId: string): string {
41
+ return join(root, '.recursive', 'run', runId, 'jobs', 'jobs.jsonl')
42
+ }
43
+
44
+ /**
45
+ * Append one run to the log. Never throws: an unwritable log is not a reason for a completed
46
+ * lint to be reported as a failure.
47
+ *
48
+ * @returns true when the line was written, false when it could not be — so a caller that
49
+ * cares can say so without this function deciding for it.
50
+ */
51
+ export function recordJobRun(
52
+ root: string,
53
+ runId: string,
54
+ entry: { kind: string; label: string; result: JobRunResult<unknown> },
55
+ ): boolean {
56
+ if (root === '' || runId === '') return false
57
+ try {
58
+ const path = jobsLogPath(root, runId)
59
+ mkdirSync(dirname(path), { recursive: true })
60
+ const record: JobLogRecord = {
61
+ at: new Date().toISOString(),
62
+ kind: entry.kind,
63
+ label: entry.label,
64
+ tracked: entry.result.tracked,
65
+ status: entry.result.status,
66
+ }
67
+ if (entry.result.jobId !== undefined) record.jobId = entry.result.jobId
68
+ const detail = entry.result.detail ?? entry.result.error
69
+ if (detail !== undefined) record.detail = detail
70
+ appendFileSync(path, JSON.stringify(record) + '\n', 'utf8')
71
+ return true
72
+ } catch {
73
+ return false
74
+ }
75
+ }
76
+
77
+ /** Read a run's job log. A missing or damaged file reads as empty — never an error. */
78
+ export function readJobRuns(root: string, runId: string): JobLogRecord[] {
79
+ const path = jobsLogPath(root, runId)
80
+ if (!existsSync(path)) return []
81
+ try {
82
+ return readFileSync(path, 'utf8')
83
+ .split('\n')
84
+ .filter((line) => line.trim() !== '')
85
+ .flatMap((line) => {
86
+ try {
87
+ const parsed = JSON.parse(line) as JobLogRecord
88
+ return typeof parsed?.kind === 'string' && typeof parsed?.status === 'string' ? [parsed] : []
89
+ } catch {
90
+ return []
91
+ }
92
+ })
93
+ } catch {
94
+ return []
95
+ }
96
+ }
97
+
98
+ /**
99
+ * A board-facing rendering: one line per run, newest last, with the untracked and
100
+ * non-completed cases called out rather than implied.
101
+ */
102
+ export function renderJobLog(root: string, runId: string): string {
103
+ const records = readJobRuns(root, runId)
104
+ if (records.length === 0) return 'no job runs recorded for this run'
105
+ return records
106
+ .map((record) => {
107
+ const where = record.tracked ? (record.jobId ?? 'job') : 'untracked'
108
+ const why = record.detail === undefined ? '' : ' — ' + record.detail
109
+ return record.status + ' [' + record.kind + '] ' + where + ': ' + record.label + why
110
+ })
111
+ .join('\n')
112
+ }