@try-works/dsh-recursive-mode 0.1.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 (88) hide show
  1. package/cordis.patch.yml +12 -0
  2. package/lib/bootstrap.d.ts +35 -0
  3. package/lib/client/board.d.ts +10 -0
  4. package/lib/client/contract.d.ts +51 -0
  5. package/lib/client/derive.d.ts +92 -0
  6. package/lib/client/index.d.ts +21 -0
  7. package/lib/client/inspector.d.ts +10 -0
  8. package/lib/client/node.d.ts +71 -0
  9. package/lib/client/settings.d.ts +6 -0
  10. package/lib/client/slots.d.ts +7 -0
  11. package/lib/client/strip.d.ts +7 -0
  12. package/lib/client.d.ts +10 -0
  13. package/lib/client.js +490 -0
  14. package/lib/closeout.d.ts +23 -0
  15. package/lib/commands.d.ts +51 -0
  16. package/lib/delegation.d.ts +92 -0
  17. package/lib/enforcement.d.ts +53 -0
  18. package/lib/events.d.ts +173 -0
  19. package/lib/handoff.d.ts +51 -0
  20. package/lib/index.d.ts +40 -0
  21. package/lib/lifecycle.d.ts +107 -0
  22. package/lib/lock.d.ts +92 -0
  23. package/lib/policy.d.ts +12 -0
  24. package/lib/projection.d.ts +29 -0
  25. package/lib/recursive_closeout.tool.d.ts +8 -0
  26. package/lib/recursive_init.tool.d.ts +2 -0
  27. package/lib/recursive_lint.tool.d.ts +2 -0
  28. package/lib/recursive_lock.tool.d.ts +2 -0
  29. package/lib/recursive_scratch.tool.d.ts +7 -0
  30. package/lib/recursive_status.tool.d.ts +2 -0
  31. package/lib/review.d.ts +39 -0
  32. package/lib/router.d.ts +77 -0
  33. package/lib/run.d.ts +29 -0
  34. package/lib/runtime.d.ts +241 -0
  35. package/lib/scratch.d.ts +18 -0
  36. package/lib/status.d.ts +19 -0
  37. package/lib/types.d.ts +104 -0
  38. package/lib/workspace.d.ts +50 -0
  39. package/package.json +119 -0
  40. package/preset/recursive/agent.cordis.yml +282 -0
  41. package/preset/recursive/preset.yml +3 -0
  42. package/scripts/install-recursive-mode.ps1 +956 -0
  43. package/scripts/install-recursive-mode.py +750 -0
  44. package/scripts/lint-recursive-run.py +2868 -0
  45. package/scripts/recursive-closeout.py +541 -0
  46. package/scripts/recursive-init.py +356 -0
  47. package/scripts/recursive-lock.py +302 -0
  48. package/scripts/recursive-status.py +2124 -0
  49. package/scripts/recursive_phase_rules.py +367 -0
  50. package/scripts/recursive_router_lib.py +2282 -0
  51. package/scripts/test-recursive-mode-smoke.ts +204 -0
  52. package/scripts/verify-locks.py +353 -0
  53. package/src/bootstrap.ts +118 -0
  54. package/src/client/board.tsx +61 -0
  55. package/src/client/contract.ts +58 -0
  56. package/src/client/derive.ts +241 -0
  57. package/src/client/index.ts +28 -0
  58. package/src/client/inspector.tsx +49 -0
  59. package/src/client/node.ts +156 -0
  60. package/src/client/settings.tsx +18 -0
  61. package/src/client/slots.ts +67 -0
  62. package/src/client/strip.tsx +28 -0
  63. package/src/client.ts +11 -0
  64. package/src/closeout.ts +183 -0
  65. package/src/commands.ts +142 -0
  66. package/src/delegation.ts +306 -0
  67. package/src/enforcement.ts +180 -0
  68. package/src/events.ts +173 -0
  69. package/src/handoff.ts +165 -0
  70. package/src/index.ts +283 -0
  71. package/src/lifecycle.ts +235 -0
  72. package/src/lock.ts +369 -0
  73. package/src/policy.ts +56 -0
  74. package/src/projection.ts +237 -0
  75. package/src/recursive_closeout.tool.ts +35 -0
  76. package/src/recursive_init.tool.ts +28 -0
  77. package/src/recursive_lint.tool.ts +29 -0
  78. package/src/recursive_lock.tool.ts +33 -0
  79. package/src/recursive_scratch.tool.ts +42 -0
  80. package/src/recursive_status.tool.ts +24 -0
  81. package/src/review.ts +178 -0
  82. package/src/router.ts +197 -0
  83. package/src/run.ts +85 -0
  84. package/src/runtime.ts +564 -0
  85. package/src/scratch.ts +85 -0
  86. package/src/status.ts +194 -0
  87. package/src/types.ts +112 -0
  88. package/src/workspace.ts +67 -0
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Plugin-driven delegation + report/reference validation + durable action
3
+ * records (Phase B R4/R6/R7, PROPOSAL 10.5/10.9). The ENFORCED path (distinct
4
+ * from the agent-driven subagent tool): the plugin calls ctx.subagents.start()
5
+ * with the full SubagentStartRequest (outputSchema/toolFilter/maxDepth) and
6
+ * validates the child's references before writing an action record.
7
+ *
8
+ * Workspace-scoped (run 03 R1) + fail-loud + optionality-preserving.
9
+ */
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
11
+ import { join, resolve, sep } from 'node:path'
12
+ import { contentSha256 } from './review.ts'
13
+
14
+ /** Minimal host-realm contract for ctx.subagents (the seam we call). */
15
+ export interface SubagentsRuntimeLike {
16
+ start(name: string, request: SubagentStartRequestLike): Promise<SubagentResultLike>
17
+ getProvider?(name: string): unknown
18
+ list?(): unknown
19
+ }
20
+
21
+ export interface SubagentStartRequestLike {
22
+ prompt: unknown[] // ContentBlock[]
23
+ label?: string
24
+ outputSchema?: Record<string, unknown>
25
+ toolFilter?: unknown
26
+ maxDepth?: number
27
+ persona?: string
28
+ parent?: unknown
29
+ signal?: unknown
30
+ }
31
+
32
+ export interface SubagentResultLike {
33
+ output?: string
34
+ structured?: unknown
35
+ stopReason?: string
36
+ success?: boolean
37
+ }
38
+
39
+ export interface DelegationError extends Error {
40
+ code?: string
41
+ }
42
+
43
+ export function delegationError(message: string, code: string): DelegationError {
44
+ const err = new Error(message) as DelegationError
45
+ err.code = code
46
+ return err
47
+ }
48
+
49
+ /** Object-rooted output schema for a delegated review. */
50
+ export function reviewOutputSchema(): Record<string, unknown> {
51
+ return {
52
+ type: 'object',
53
+ properties: {
54
+ verdict: { type: 'string', enum: ['APPROVE', 'REJECT', 'REVISE'] },
55
+ findings: {
56
+ type: 'array',
57
+ items: {
58
+ type: 'object',
59
+ properties: {
60
+ severity: { type: 'string', enum: ['INFO', 'LOW', 'MEDIUM', 'HIGH'] },
61
+ title: { type: 'string' },
62
+ detail: { type: 'string' },
63
+ },
64
+ required: ['severity', 'title', 'detail'],
65
+ additionalProperties: false,
66
+ },
67
+ },
68
+ references: {
69
+ type: 'array',
70
+ items: {
71
+ type: 'object',
72
+ properties: {
73
+ path: { type: 'string' },
74
+ lineRange: { type: 'string' },
75
+ },
76
+ required: ['path'],
77
+ additionalProperties: false,
78
+ },
79
+ },
80
+ },
81
+ required: ['verdict', 'findings', 'references'],
82
+ additionalProperties: false,
83
+ }
84
+ }
85
+
86
+ /** Default toolFilter for a delegated reviewer (run-relevant, workspace-scoped). */
87
+ export function defaultReviewToolFilter(): unknown {
88
+ return { allow: ['fs_read', 'grep', 'glob'] }
89
+ }
90
+
91
+ /**
92
+ * Call ctx.subagents.start() with the full request. Fail loud on capability
93
+ * mismatch, missing provider, or unsupported schema (never silent).
94
+ */
95
+ export async function delegate(input: {
96
+ subagents: SubagentsRuntimeLike
97
+ provider: string
98
+ request: SubagentStartRequestLike
99
+ }): Promise<SubagentResultLike> {
100
+ const { subagents, provider, request } = input
101
+ if (!subagents || typeof subagents.start !== 'function') {
102
+ throw delegationError('ctx.subagents.start is not available (no subagent seam)', 'NO_PROVIDER')
103
+ }
104
+ if (!provider) {
105
+ throw delegationError('No provider named for delegation', 'NO_PROVIDER')
106
+ }
107
+ try {
108
+ return await subagents.start(provider, request)
109
+ } catch (err) {
110
+ const message = err instanceof Error ? err.message : String(err)
111
+ const code = (err as { code?: string })?.code
112
+ if (code === 'UNSUPPORTED_CAPABILITY' || message.includes('UNSUPPORTED_CAPABILITY') || message.includes('does not support')) {
113
+ throw delegationError(message, 'UNSUPPORTED_CAPABILITY')
114
+ }
115
+ throw delegationError(message, 'DELEGATION_FAILED')
116
+ }
117
+ }
118
+
119
+ export interface Reference {
120
+ path: string
121
+ lineRange?: string
122
+ }
123
+
124
+ export interface ReferenceCheck {
125
+ ok: boolean
126
+ failures: string[]
127
+ checked: { path: string; ok: boolean; reason?: string }[]
128
+ }
129
+
130
+ function norm(repoRelative: string): string {
131
+ return repoRelative.replace(/\\/g, '/').replace(/^\/+/, '')
132
+ }
133
+
134
+ function resolveUnderRoot(root: string, repoRelative: string): string {
135
+ const normalized = norm(repoRelative)
136
+ const rootAbs = resolve(root)
137
+ const abs = resolve(rootAbs, normalized)
138
+ const rootPrefix = rootAbs.endsWith(sep) ? rootAbs : rootAbs + sep
139
+ if (abs !== rootAbs && !abs.startsWith(rootPrefix)) {
140
+ throw new Error('Path escapes the workspace root: ' + repoRelative)
141
+ }
142
+ return abs
143
+ }
144
+
145
+ /**
146
+ * Validate every claimed reference: the path exists under root, and a line
147
+ * range (e.g. '10-20' or '10') is within the file's line count.
148
+ */
149
+ export function validateReferences(root: string, references: Reference[]): ReferenceCheck {
150
+ const checked: ReferenceCheck['checked'] = []
151
+ const failures: string[] = []
152
+ for (const ref of references) {
153
+ const rel = norm(ref.path)
154
+ if (!rel) {
155
+ checked.push({ path: ref.path, ok: false, reason: 'empty path' })
156
+ failures.push('empty reference path')
157
+ continue
158
+ }
159
+ let abs: string
160
+ try {
161
+ abs = resolveUnderRoot(root, rel)
162
+ } catch (err) {
163
+ const reason = err instanceof Error ? err.message : String(err)
164
+ checked.push({ path: ref.path, ok: false, reason })
165
+ failures.push(rel + ': ' + reason)
166
+ continue
167
+ }
168
+ if (!existsSync(abs)) {
169
+ checked.push({ path: ref.path, ok: false, reason: 'path does not exist' })
170
+ failures.push(rel + ': path does not exist')
171
+ continue
172
+ }
173
+ if (ref.lineRange) {
174
+ const total = readFileSync(abs, 'utf8').replace(/\r\n/g, '\n').split('\n').length
175
+ const range = ref.lineRange.trim()
176
+ const single = /^\d+$/.test(range)
177
+ const pair = /^(\d+)-(\d+)$/.exec(range)
178
+ let start = 0
179
+ let end = 0
180
+ if (single) {
181
+ start = Number(range)
182
+ end = Number(range)
183
+ } else if (pair) {
184
+ start = Number(pair[1])
185
+ end = Number(pair[2])
186
+ } else {
187
+ checked.push({ path: ref.path, ok: false, reason: 'invalid lineRange ' + range })
188
+ failures.push(rel + ': invalid lineRange ' + range)
189
+ continue
190
+ }
191
+ if (start < 1 || end < start || end > total) {
192
+ const reason = 'lineRange ' + range + ' out of bounds (file has ' + total + ' lines)'
193
+ checked.push({ path: ref.path, ok: false, reason })
194
+ failures.push(rel + ': ' + reason)
195
+ continue
196
+ }
197
+ }
198
+ checked.push({ path: ref.path, ok: true })
199
+ }
200
+ return { ok: failures.length === 0, failures, checked }
201
+ }
202
+
203
+ export interface ActionRecordInput {
204
+ root: string
205
+ runId: string
206
+ subagentId: string
207
+ phase: string
208
+ purpose: string
209
+ executionMode: string
210
+ artifactPath?: string
211
+ upstreamArtifacts?: string[]
212
+ reviewBundle?: string
213
+ diffBasis?: string
214
+ codeRefs?: string[]
215
+ auditQuestions?: string[]
216
+ actionsTaken?: string[]
217
+ createdFiles?: string[]
218
+ modifiedFiles?: string[]
219
+ reviewedFiles?: string[]
220
+ findings?: string[]
221
+ success: boolean
222
+ stopReason?: string
223
+ }
224
+
225
+ function slugify(value: string): string {
226
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'action'
227
+ }
228
+
229
+ /**
230
+ * Write a durable action record under subagents/ with the canonical sections
231
+ * (matches recursive-subagent-action.py). A success:false attempt is written
232
+ * with a failed status and is NOT accepted.
233
+ */
234
+ export function writeActionRecord(input: ActionRecordInput): string {
235
+ const { root, runId } = input
236
+ const dir = resolveUnderRoot(root, '.recursive/run/' + runId + '/subagents')
237
+ mkdirSync(dir, { recursive: true })
238
+ const fileName = Date.now() + '-' + slugify(input.subagentId) + '-action.md'
239
+ const path = join(dir, fileName)
240
+
241
+ const list = (title: string, values: string[]) => {
242
+ const out: string[] = [title]
243
+ if (!values?.length) {
244
+ out.push('- none')
245
+ return out
246
+ }
247
+ for (const v of values) out.push('- ' + String.fromCharCode(96) + norm(v) + String.fromCharCode(96))
248
+ return out
249
+ }
250
+
251
+ const lines: string[] = [
252
+ '# Subagent action record: ' + input.subagentId,
253
+ '',
254
+ '## Metadata',
255
+ '- Subagent ID: ' + input.subagentId,
256
+ '- Phase: ' + input.phase,
257
+ '- Purpose: ' + input.purpose,
258
+ '- Execution Mode: ' + input.executionMode,
259
+ '- Status: ' + (input.success ? 'accepted' : 'failed'),
260
+ '- Stop Reason: ' + (input.stopReason ?? 'n/a'),
261
+ ...(input.artifactPath ? ['- Current Artifact: ' + String.fromCharCode(96) + norm(input.artifactPath) + String.fromCharCode(96)] : []),
262
+ ...(input.reviewBundle ? ['- Review Bundle: ' + String.fromCharCode(96) + norm(input.reviewBundle) + String.fromCharCode(96)] : []),
263
+ '',
264
+ '## Inputs Provided',
265
+ ...list('', input.upstreamArtifacts ?? []).slice(1),
266
+ '',
267
+ '## Routing',
268
+ ...(input.diffBasis ? ['- Diff Basis: ' + input.diffBasis] : ['- Diff Basis: n/a']),
269
+ '',
270
+ '## Claimed Actions Taken',
271
+ ...list('', input.actionsTaken ?? []).slice(1),
272
+ '',
273
+ '## Claimed File Impact',
274
+ ...(input.createdFiles?.length ? ['### Created', ...list('', input.createdFiles).slice(1)] : ['### Created', '- none']),
275
+ ...(input.modifiedFiles?.length ? ['### Modified', ...list('', input.modifiedFiles).slice(1)] : ['### Modified', '- none']),
276
+ ...(input.reviewedFiles?.length ? ['### Reviewed', ...list('', input.reviewedFiles).slice(1)] : ['### Reviewed', '- none']),
277
+ '',
278
+ '## Claimed Artifact Impact',
279
+ ...list('### Read', input.upstreamArtifacts ?? []).slice(1),
280
+ '',
281
+ '## Claimed Findings',
282
+ ...(input.findings?.length ? input.findings.map((f) => '- ' + f) : ['- none']),
283
+ '',
284
+ '## Verification Handoff',
285
+ '- Inspect first: ' + (input.artifactPath ? String.fromCharCode(96) + norm(input.artifactPath) + String.fromCharCode(96) : 'n/a'),
286
+ '- Notes: main agent must verify every claimed reference against actual files, actual recursive artifacts, and the actual diff before acceptance.',
287
+ '',
288
+ ]
289
+
290
+ writeFileSync(path, lines.join('\n'), 'utf8')
291
+ return path
292
+ }
293
+
294
+ /**
295
+ * Accept/reject a delegation result: a success:false or non-completed
296
+ * stopReason is a failed attempt (diagnostics preserved, NOT accepted).
297
+ */
298
+ export function evaluateDelegationResult(result: SubagentResultLike): { accepted: boolean; reason: string } {
299
+ if (result.success === false) {
300
+ return { accepted: false, reason: 'delegation reported success:false' }
301
+ }
302
+ if (result.stopReason && result.stopReason !== 'completed') {
303
+ return { accepted: false, reason: 'delegation stopped with reason ' + result.stopReason }
304
+ }
305
+ return { accepted: true, reason: 'delegation completed' }
306
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Enforcement config + pre-step gate + tool guards + tamper detection
3
+ * (Phase C R3/R4/R7/R8, PROPOSAL 8.4/8.6/13.5).
4
+ *
5
+ * Layers 1 and 2 are CALLERS of the lifecycle transition set - they never
6
+ * reimplement the predicates. Configurable strict|advisory per gate
7
+ * (default advisory).
8
+ */
9
+ import { existsSync, readFileSync } from 'node:fs'
10
+ import { join, isAbsolute, resolve, sep } from 'node:path'
11
+ import { getLockStatus, getPrerequisiteBlockers } from './lock.ts'
12
+ import { getMdFieldValue } from './status.ts'
13
+ import { validateTransition, type PhaseTransitionIntent, type SessionEventLike, detectTransitionIntent } from './lifecycle.ts'
14
+
15
+ export type EnforcementMode = 'strict' | 'advisory'
16
+
17
+ export interface EnforcementConfig {
18
+ preStep: EnforcementMode
19
+ toolGuards: EnforcementMode
20
+ tamper: EnforcementMode
21
+ }
22
+
23
+ /** Validate the enforcement config shape (unknown keys fail at plugin load). */
24
+ export function resolveEnforcementConfig(config: unknown): EnforcementConfig {
25
+ const raw = (config ?? {}) as Record<string, unknown>
26
+ const unknown = Object.keys(raw).filter((k) => !['preStep', 'toolGuards', 'tamper'].includes(k))
27
+ if (unknown.length > 0) {
28
+ throw new Error('EnforcementConfig has unknown key(s) ' + unknown.join(', ') + ' - config is { preStep, toolGuards, tamper }')
29
+ }
30
+ const mode = (value: unknown): EnforcementMode => (value === 'strict' ? 'strict' : 'advisory')
31
+ return {
32
+ preStep: mode(raw.preStep),
33
+ toolGuards: mode(raw.toolGuards),
34
+ tamper: mode(raw.tamper),
35
+ }
36
+ }
37
+
38
+ export const DEFAULT_ENFORCEMENT: EnforcementConfig = { preStep: 'advisory', toolGuards: 'advisory', tamper: 'advisory' }
39
+
40
+ /**
41
+ * Layer 1 - agent/pre-step phase-transition gate decision.
42
+ * Reads on TRANSITION INTENT ONLY (13.5): no transition intent means the step
43
+ * passes through untouched. On a transition intent whose gates fail:
44
+ * - strict -> reject (turn ends blocked, no model call spent)
45
+ * - advisory -> enter (warn only; a recursive/gate-blocked event is emitted)
46
+ */
47
+ export interface PreStepGateDecision {
48
+ kind: 'reject' | 'enter'
49
+ gateBlocked: boolean
50
+ failures: string[]
51
+ }
52
+
53
+ export function evaluatePreStepGate(
54
+ events: readonly SessionEventLike[],
55
+ mode: EnforcementMode = 'advisory',
56
+ ): PreStepGateDecision {
57
+ const intent = detectTransitionIntent(events)
58
+ if (!intent || intent.worktreeRoot === '' || intent.runId === '') {
59
+ return { kind: 'enter', gateBlocked: false, failures: [] }
60
+ }
61
+ const check = validateTransition(intent)
62
+ if (check.passed) return { kind: 'enter', gateBlocked: false, failures: [] }
63
+ return { kind: mode === 'strict' ? 'reject' : 'enter', gateBlocked: true, failures: check.failures }
64
+ }
65
+
66
+ /** Tool names the locked-artifact write guard treats as write operations. */
67
+ const WRITE_TOOL_NAMES = new Set(['write', 'edit', 'fs_write', 'fs-write', 'pwsh', 'shell', 'bash', 'run_code'])
68
+
69
+ /** Tool names the monotonic lock-order guard treats as lock operations. */
70
+ const LOCK_TOOL_NAMES = new Set(['recursive_lock', 'recursive_lock_phase'])
71
+
72
+ /**
73
+ * Layer 2 - tools/pre-execute guard decision.
74
+ * Pure predicate: inspects the pending tool execution (name + args) against
75
+ * the run tree under the given worktree root.
76
+ */
77
+ export type ToolGuardDecision = { kind: 'allow' } | { kind: 'deny'; reason: string } | { kind: 'ask'; reason?: string }
78
+
79
+ export interface ToolExecLike {
80
+ name: string
81
+ arguments?: unknown
82
+ agent?: unknown
83
+ }
84
+
85
+ export function evaluateToolGuard(
86
+ exec: ToolExecLike,
87
+ worktreeRoot: string,
88
+ activeRunId: string,
89
+ mode: EnforcementMode = 'advisory',
90
+ ): ToolGuardDecision {
91
+ const name = exec.name
92
+ const args = (exec.arguments ?? {}) as Record<string, unknown>
93
+ const runDir = join(worktreeRoot, '.recursive', 'run', activeRunId)
94
+
95
+ // Monotonic lock-order denial (recursive_lock out of order).
96
+ if (LOCK_TOOL_NAMES.has(name)) {
97
+ const artifact = String(args.artifact ?? '')
98
+ if (artifact) {
99
+ const blockers = getPrerequisiteBlockers(runDir, artifact)
100
+ if (blockers.length > 0) {
101
+ const reason = 'monotonic lock-order: ' + blockers.map((b) => b.artifact + ' (' + b.status + ')').join(', ')
102
+ return mode === 'strict' ? { kind: 'deny', reason } : { kind: 'ask', reason }
103
+ }
104
+ // TDD evidence gating on Phase 3 lock.
105
+ if (artifact === '03-implementation-summary.md') {
106
+ const artifactPath = join(runDir, artifact)
107
+ const content = existsSync(artifactPath) ? readFileSync(artifactPath, 'utf8') : ''
108
+ const tddMode = getMdFieldValue(content, 'TDD Mode') ?? ''
109
+ if (tddMode === 'strict') {
110
+ const hasRed = /RED|red evidence/i.test(content)
111
+ const hasGreen = /GREEN|green evidence/i.test(content)
112
+ if (!hasRed || !hasGreen) {
113
+ const reason = 'TDD Mode: strict requires RED + GREEN evidence before locking Phase 3'
114
+ return mode === 'strict' ? { kind: 'deny', reason } : { kind: 'ask', reason }
115
+ }
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ // Locked-artifact write denial.
122
+ if (WRITE_TOOL_NAMES.has(name)) {
123
+ const target = firstTargetPath(args)
124
+ if (target) {
125
+ const normalized = target.replace(/\\/g, '/')
126
+ if (normalized.endsWith('.md') && normalized.includes('/.recursive/run/')) {
127
+ const abs = resolveTargetPath(normalized, worktreeRoot)
128
+ if (abs && getLockStatus(abs) === 'LOCKED') {
129
+ const reason = 'locked-artifact write denial: ' + normalized + ' carries Status: LOCKED (reopen explicitly to edit)'
130
+ return mode === 'strict' ? { kind: 'deny', reason } : { kind: 'ask', reason }
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ return { kind: 'allow' }
137
+ }
138
+
139
+ /** Resolve a tool-target path to an absolute path under the worktree root. */
140
+ function resolveTargetPath(target: string, worktreeRoot: string): string | null {
141
+ const normalized = target.replace(/\\/g, '/')
142
+ if (isAbsolute(normalized)) {
143
+ const abs = resolve(normalized)
144
+ const rootAbs = resolve(worktreeRoot)
145
+ const rootPrefix = rootAbs.endsWith(sep) ? rootAbs : rootAbs + sep
146
+ if (abs !== rootAbs && !abs.startsWith(rootPrefix)) return null
147
+ return abs
148
+ }
149
+ return resolve(worktreeRoot, normalized.replace(/^\.?\/?/, ''))
150
+ }
151
+
152
+ /** Extract the first candidate target path from a tool's arguments. */
153
+ function firstTargetPath(args: Record<string, unknown>): string | null {
154
+ for (const key of ['file_path', 'path', 'command', 'target', 'filePath']) {
155
+ const value = args[key]
156
+ if (typeof value === 'string' && value.trim() !== '') return value.trim()
157
+ }
158
+ return null
159
+ }
160
+
161
+ /**
162
+ * Layer 8 - fs/observed lock-tamper detection.
163
+ * A locked *.md whose observed version differs from the stored LockHash is
164
+ * a tamper. Returns a tamper reason (or null when clean/not-applicable).
165
+ */
166
+ export function detectTamper(
167
+ targetPath: string,
168
+ worktreeRoot: string,
169
+ activeRunId: string,
170
+ ): { runId: string; path: string; reason: string } | null {
171
+ const normalized = targetPath.replace(/\\/g, '/')
172
+ if (!normalized.endsWith('.md') || !normalized.includes('/.recursive/run/')) return null
173
+ const abs = resolveTargetPath(normalized, worktreeRoot)
174
+ if (!abs || !existsSync(abs)) return null
175
+ const status = getLockStatus(abs)
176
+ if (status === 'STALE_LOCK') {
177
+ return { runId: activeRunId, path: normalized, reason: 'locked artifact hash mismatch (tampered): ' + normalized }
178
+ }
179
+ return null
180
+ }
package/src/events.ts ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * recursive/* session events (Phase D R1, PROPOSAL 11.2/11.6): the complete
3
+ * durable event vocabulary the projection folds, the board renders, and the
4
+ * conversation node correlates. Every payload carries a non-empty
5
+ * { runId, worktreeRoot } so the whole plane is worktree-aware and the
6
+ * projection can group runs by their control-plane root (binding
7
+ * workspace-scoping invariant, run 03 R1).
8
+ *
9
+ * Events are LOG-ONLY (no live mirror): the projection unit (projection.ts)
10
+ * is the only derived view. Payloads are plain JSON and declared on the
11
+ * SessionEventMap merge via @deepseek-ai/dsh-session/types.
12
+ *
13
+ * The pure constructor helpers below enforce the runId + non-empty
14
+ * worktreeRoot invariant at emit time, so a malformed event fails loud at
15
+ * the emit site instead of silently landing cross-workspace.
16
+ */
17
+ import type { RecursiveRunState } from './types.ts'
18
+
19
+ /** Declare the recursive/* event family on the session event map (merge-extensible). */
20
+ declare module '@deepseek-ai/dsh-session/types' {
21
+ interface SessionEventMap {
22
+ /**
23
+ * A transition the lifecycle driver is ABOUT to validate/commit (logged
24
+ * before any file write). The pre-step gate reads this as the caller of the
25
+ * transition set — never a projection event (log-only internal signal).
26
+ */
27
+ 'recursive/phase-intent': {
28
+ runId: string
29
+ worktreeRoot: string
30
+ targetArtifact: string
31
+ kind: 'lock' | 'reopen' | 'advance'
32
+ evidence?: Record<string, unknown>
33
+ }
34
+ /** A run was created/bootstrapped in this workspace. */
35
+ 'recursive/run-created': {
36
+ runId: string
37
+ worktreeRoot: string
38
+ template?: string
39
+ repo?: string
40
+ }
41
+ /** A phase transition committed (validate -> commit -> flush -> emit). */
42
+ 'recursive/phase': {
43
+ runId: string
44
+ worktreeRoot: string
45
+ phase: string
46
+ status: string
47
+ }
48
+ /** A phase artifact was locked (lock hash + timestamp recorded). */
49
+ 'recursive/phase-locked': {
50
+ runId: string
51
+ worktreeRoot: string
52
+ phase: string
53
+ lockedAt: string
54
+ lockHash: string
55
+ }
56
+ /** A transition's gate predicate failed (rejected in strict, warned in advisory). */
57
+ 'recursive/gate-blocked': {
58
+ runId: string
59
+ worktreeRoot: string
60
+ phase: string
61
+ failures: string[]
62
+ kind: string
63
+ }
64
+ /** fs/observed detected a change to a locked artifact outside a tool call. */
65
+ 'recursive/tamper': {
66
+ runId: string
67
+ worktreeRoot: string
68
+ path: string
69
+ reason: string
70
+ }
71
+ /** A run was merged back to the repo root (conversation history re-keys). */
72
+ 'recursive/run-merged': {
73
+ runId: string
74
+ worktreeRoot: string
75
+ repoRoot: string
76
+ }
77
+ /** Run-level lifecycle flag (active/paused/blocked/complete + reason). */
78
+ 'recursive/run-state': {
79
+ runId: string
80
+ worktreeRoot: string
81
+ state: RecursiveRunState
82
+ reason?: string
83
+ }
84
+ /** A subagent started for this run. */
85
+ 'recursive/subagent-start': {
86
+ runId: string
87
+ worktreeRoot: string
88
+ childId: string
89
+ role: string
90
+ provider: string
91
+ }
92
+ /** A subagent finished for this run. */
93
+ 'recursive/subagent-end': {
94
+ runId: string
95
+ worktreeRoot: string
96
+ childId: string
97
+ role: string
98
+ provider: string
99
+ status: 'running' | 'done' | 'failed'
100
+ }
101
+ }
102
+ }
103
+
104
+ /** Enforce the runId + non-empty worktreeRoot invariant at emit time. */
105
+ export function requireRunKey(runId: string, worktreeRoot: string): void {
106
+ if (typeof runId !== 'string' || runId.trim() === '') {
107
+ throw new Error('recursive event requires a non-empty runId')
108
+ }
109
+ if (typeof worktreeRoot !== 'string' || worktreeRoot.trim() === '') {
110
+ throw new Error('recursive event requires a non-empty worktreeRoot')
111
+ }
112
+ }
113
+
114
+ export interface PhaseIntentInput { runId: string; worktreeRoot: string; targetArtifact: string; kind: 'lock' | 'reopen' | 'advance'; evidence?: Record<string, unknown> }
115
+ export interface RunCreatedInput { runId: string; worktreeRoot: string; template?: string; repo?: string }
116
+ export interface PhaseInput { runId: string; worktreeRoot: string; phase: string; status: string }
117
+ export interface PhaseLockedInput { runId: string; worktreeRoot: string; phase: string; lockedAt: string; lockHash: string }
118
+ export interface GateBlockedInput { runId: string; worktreeRoot: string; phase: string; failures: string[]; kind: string }
119
+ export interface TamperInput { runId: string; worktreeRoot: string; path: string; reason: string }
120
+ export interface RunMergedInput { runId: string; worktreeRoot: string; repoRoot: string }
121
+ export interface RunStateInput { runId: string; worktreeRoot: string; state: RecursiveRunState; reason?: string }
122
+ export interface SubagentInput { runId: string; worktreeRoot: string; childId: string; role: string; provider: string }
123
+ export interface SubagentEndInput extends SubagentInput { status: 'running' | 'done' | 'failed' }
124
+
125
+ export function phaseIntent(data: PhaseIntentInput): PhaseIntentInput {
126
+ requireRunKey(data.runId, data.worktreeRoot)
127
+ return { runId: data.runId, worktreeRoot: data.worktreeRoot, targetArtifact: data.targetArtifact, kind: data.kind, ...(data.evidence === undefined ? {} : { evidence: data.evidence }) }
128
+ }
129
+
130
+ export function runCreated(data: RunCreatedInput): RunCreatedInput {
131
+ requireRunKey(data.runId, data.worktreeRoot)
132
+ return { runId: data.runId, worktreeRoot: data.worktreeRoot, ...(data.template === undefined ? {} : { template: data.template }), ...(data.repo === undefined ? {} : { repo: data.repo }) }
133
+ }
134
+
135
+ export function phase(data: PhaseInput): PhaseInput {
136
+ requireRunKey(data.runId, data.worktreeRoot)
137
+ return { runId: data.runId, worktreeRoot: data.worktreeRoot, phase: data.phase, status: data.status }
138
+ }
139
+
140
+ export function phaseLocked(data: PhaseLockedInput): PhaseLockedInput {
141
+ requireRunKey(data.runId, data.worktreeRoot)
142
+ return { runId: data.runId, worktreeRoot: data.worktreeRoot, phase: data.phase, lockedAt: data.lockedAt, lockHash: data.lockHash }
143
+ }
144
+
145
+ export function gateBlocked(data: GateBlockedInput): GateBlockedInput {
146
+ requireRunKey(data.runId, data.worktreeRoot)
147
+ return { runId: data.runId, worktreeRoot: data.worktreeRoot, phase: data.phase, failures: data.failures, kind: data.kind }
148
+ }
149
+
150
+ export function tamper(data: TamperInput): TamperInput {
151
+ requireRunKey(data.runId, data.worktreeRoot)
152
+ return { runId: data.runId, worktreeRoot: data.worktreeRoot, path: data.path, reason: data.reason }
153
+ }
154
+
155
+ export function runMerged(data: RunMergedInput): RunMergedInput {
156
+ requireRunKey(data.runId, data.worktreeRoot)
157
+ return { runId: data.runId, worktreeRoot: data.worktreeRoot, repoRoot: data.repoRoot }
158
+ }
159
+
160
+ export function runState(data: RunStateInput): RunStateInput {
161
+ requireRunKey(data.runId, data.worktreeRoot)
162
+ return { runId: data.runId, worktreeRoot: data.worktreeRoot, state: data.state, ...(data.reason === undefined ? {} : { reason: data.reason }) }
163
+ }
164
+
165
+ export function subagentStart(data: SubagentInput): SubagentInput {
166
+ requireRunKey(data.runId, data.worktreeRoot)
167
+ return { runId: data.runId, worktreeRoot: data.worktreeRoot, childId: data.childId, role: data.role, provider: data.provider }
168
+ }
169
+
170
+ export function subagentEnd(data: SubagentEndInput): SubagentEndInput {
171
+ requireRunKey(data.runId, data.worktreeRoot)
172
+ return { runId: data.runId, worktreeRoot: data.worktreeRoot, childId: data.childId, role: data.role, provider: data.provider, status: data.status }
173
+ }