@try-works/dsh-recursive-mode 0.2.3 → 0.3.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.
@@ -40,7 +40,7 @@ export interface RecursiveStatePayload {
40
40
  revision: number;
41
41
  }
42
42
  /**
43
- * Build the two read-only routes. Returns [state, events] in registration order.
43
+ * Build the read-only routes. Returns [state, events, doc] in registration order.
44
44
  * @param host - the resolved-root + fs-fold seam (the RecursiveRuntime adapter).
45
45
  */
46
46
  export declare function makeRecursiveRoutes(host: RecursiveRouteHost): readonly {
@@ -0,0 +1,2 @@
1
+ import type { TeamRuntimeLike } from './teams-loop.ts';
2
+ export declare function createRecursiveAuditTeamTool(teams: TeamRuntimeLike | null): import("@deepseek-ai/dsh-tools").ToolDefinition;
package/lib/runtime.d.ts CHANGED
@@ -5,10 +5,13 @@ import { type PhaseRules } from './phase-rules.ts';
5
5
  import { type ScratchTarget } from './scratch.ts';
6
6
  import { type ReviewBundleInput } from './review.ts';
7
7
  import { type SubagentProviderLike, type RouteDecision, type CapabilityProbe } from './router.ts';
8
- import { type SubagentsRuntimeLike, type SubagentStartRequestLike, type SubagentResultLike, type Reference } from './delegation.ts';
8
+ import { type SubagentsRuntimeLike, type SubagentStartRequestLike, type SubagentResultLike, type Reference, type SubagentParentHandle } from './delegation.ts';
9
9
  import { type RecursivePhaseState } from './lifecycle.ts';
10
10
  import { type EnforcementConfig, type ToolGuardDecision, type ToolExecLike } from './enforcement.ts';
11
11
  import { type CreateWorktreeResult, type PromoteBranchResult } from './worktree.ts';
12
+ import { syncRunGoal, type GoalServiceLike } from './goals-projection.ts';
13
+ import { type TeamRuntimeLike, type AuditToPassResult, type TeamCallerHandle, type TeamTaskViewLike, type AuditRoundOutcome } from './teams-loop.ts';
14
+ import type { ContinuableChildId, ContinuableMessageId } from './delegation.ts';
12
15
  declare module '@deepseek-ai/cordis' {
13
16
  interface Context {
14
17
  recursive: RecursiveRuntime;
@@ -35,10 +38,98 @@ export declare class RecursiveRuntime extends Service {
35
38
  constructor(ctx: Context, config?: {
36
39
  repoRoot?: string;
37
40
  workspaceRegistry?: WorkspaceRegistryLike;
41
+ goals?: GoalServiceLike | null;
38
42
  });
39
43
  private readonly repoRoot;
40
44
  private readonly workspaceRegistry;
45
+ private readonly goalsService;
41
46
  private _enforcementConfig;
47
+ /**
48
+ * T3 (agentTeams task loop): run the audit→repair→re-audit state machine on
49
+ * ONE durable team task. The `teams` seam (live `ctx.agentTeams`) is injected
50
+ * per-call so the loop stays unit-testable; `runAuditRound` is the caller's
51
+ * round executor (live usage wires T4's continuable delegation). Locking the
52
+ * phase artifact is `lockPhase` — the loop NEVER locks before an APPROVE.
53
+ */
54
+ auditToPass(input: {
55
+ teams: TeamRuntimeLike;
56
+ caller: TeamCallerHandle;
57
+ root: string;
58
+ runId: string;
59
+ phase: string;
60
+ artifact: string;
61
+ agent?: {
62
+ session?: {
63
+ header?: {
64
+ cwd?: string;
65
+ };
66
+ };
67
+ } | null;
68
+ runAuditRound: (round: number, task: TeamTaskViewLike) => Promise<AuditRoundOutcome>;
69
+ blockedBy?: readonly string[];
70
+ writeScopes?: readonly string[];
71
+ reviewerName?: string;
72
+ maxRounds?: number;
73
+ waitTimeoutMs?: number;
74
+ }): Promise<AuditToPassResult & {
75
+ history?: string;
76
+ lock?: LockArtifactResult;
77
+ }>;
78
+ /**
79
+ * T1 (goals projection): project the run into the native goals service so it is
80
+ * a first-class durable, resumable, blockable object. Best-effort — the run's
81
+ * filesystem state is the source of truth; a goal is the durable projection.
82
+ */
83
+ projectRunToGoal(agent: {
84
+ session?: {
85
+ header?: {
86
+ cwd?: string;
87
+ };
88
+ };
89
+ } | null | undefined, runId: string, state?: Parameters<typeof syncRunGoal>[3]): {
90
+ ok: true;
91
+ phase: import("./goals-projection.ts").GoalPhase;
92
+ ref?: import("./goals-projection.ts").GoalRefLike;
93
+ created?: boolean;
94
+ } | {
95
+ ok: boolean;
96
+ reason: string;
97
+ };
98
+ /** T1: block the run's goal on a gate-block (durable + UI-visible). */
99
+ blockRunToGoal(agent: {
100
+ session?: {
101
+ header?: {
102
+ cwd?: string;
103
+ };
104
+ };
105
+ } | null | undefined, runId: string, reason: {
106
+ code: string;
107
+ message: string;
108
+ }): {
109
+ ok: true;
110
+ phase: import("./goals-projection.ts").GoalPhase;
111
+ ref?: import("./goals-projection.ts").GoalRefLike;
112
+ created?: boolean;
113
+ } | {
114
+ ok: boolean;
115
+ reason: string;
116
+ };
117
+ /** T1: re-arm the run's goal on a reopen (blocked/paused -> active). */
118
+ resumeRunToGoal(agent: {
119
+ session?: {
120
+ header?: {
121
+ cwd?: string;
122
+ };
123
+ };
124
+ } | null | undefined, runId: string): {
125
+ ok: true;
126
+ phase: import("./goals-projection.ts").GoalPhase;
127
+ ref?: import("./goals-projection.ts").GoalRefLike;
128
+ created?: boolean;
129
+ } | {
130
+ ok: boolean;
131
+ reason: string;
132
+ };
42
133
  /**
43
134
  * Workspace-scoped control-plane root (R1 binding invariant).
44
135
  * Resolves the session agent's canonical cwd -> workspace path via the
@@ -94,8 +185,16 @@ export declare class RecursiveRuntime extends Service {
94
185
  /**
95
186
  * Phase B (native delegation): build a review bundle (R1) + file-backed
96
187
  * handoff docs (R2), resolve the role via the router policy (R3), and call
97
- * ctx.subagents.start() with the full request (R4). Workspace-scoped: every
98
- * path resolves under the session's control-plane root.
188
+ * ctx.subagents with the full request (R4). Workspace-scoped: every path
189
+ * resolves under the session's control-plane root.
190
+ *
191
+ * `mode: 'continuable'` (T4) runs the audit→repair→re-audit loop on ONE
192
+ * durable continuable child (startContinuable → followup with the repair
193
+ * instruction → settle) and drains the child on closeout. It requires an
194
+ * `awaitRoundResult` observer (the parent-side settlement seam) AND the exact
195
+ * live `parent` Agent (continuable followup is object-identity authority);
196
+ * when either is absent it falls back to one-shot `delegate()` with a flag —
197
+ * never silently. One-shot `start()` is never called on the continuable path.
99
198
  */
100
199
  delegateReview(input: {
101
200
  root: string;
@@ -116,6 +215,11 @@ export declare class RecursiveRuntime extends Service {
116
215
  subagents?: SubagentsRuntimeLike;
117
216
  maxDepth?: number;
118
217
  toolFilter?: unknown;
218
+ mode?: 'one-shot' | 'continuable';
219
+ awaitRoundResult?: (childId: ContinuableChildId, messageId: ContinuableMessageId) => Promise<SubagentResultLike | null>;
220
+ maxRounds?: number;
221
+ /** T4: the exact live direct-parent Agent (object-identity authority). */
222
+ parent?: SubagentParentHandle;
119
223
  }): Promise<{
120
224
  decision: RouteDecision;
121
225
  probe: CapabilityProbe;
@@ -133,6 +237,11 @@ export declare class RecursiveRuntime extends Service {
133
237
  };
134
238
  actionRecordPath: string;
135
239
  error: string | null;
240
+ continuable: {
241
+ rounds: import("./delegation.ts").ContinuableRoundLike[];
242
+ childId: string | undefined;
243
+ fellBackToOneShot: boolean | undefined;
244
+ } | null;
136
245
  }>;
137
246
  /** R6: validate a child's claimed references against actual files. */
138
247
  validateReferences(root: string, references: Reference[]): import("./delegation.ts").ReferenceCheck;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * T3: model the audit→repair→re-audit loop as agentTeams Tasks.
3
+ *
4
+ * The recursion concept's core state machine, expressed over the native
5
+ * `agentTeams` service: ONE durable task per phase carries the loop —
6
+ * createTask (pending) → claim (in_progress) → audit round → on REVISE
7
+ * updateTask(edit, repair instruction) → re-audit the SAME task → on APPROVE
8
+ * updateTask(complete) → lock. A stuck reviewer is interrupted through the
9
+ * team's own kill switch; a REJECT or round-cap releases the task and fails
10
+ * loud — a lock NEVER happens before an APPROVE verdict.
11
+ *
12
+ * Everything here is a structural seam (TeamRuntimeLike / TeamTaskViewLike)
13
+ * so the whole loop is unit-testable with a fake team; the live `agentTeams`
14
+ * service satisfies the seam without an adapter. The caller Agent passes
15
+ * through as an opaque `TeamCallerHandle` (never serialized; workspace-scoped
16
+ * invariant).
17
+ */
18
+ /** Verdict vocabulary shared with T4 (matches the delegated review schema). */
19
+ export type AuditVerdict = 'APPROVE' | 'REVISE' | 'REJECT';
20
+ /**
21
+ * Opaque handle to the live Team member/lead Agent authorizing task mutations.
22
+ * The seam uses it only for identity/authority and never inspects or serializes it.
23
+ */
24
+ export interface TeamCallerHandle {
25
+ readonly id?: string;
26
+ readonly session?: {
27
+ readonly header?: {
28
+ readonly cwd?: string;
29
+ };
30
+ };
31
+ }
32
+ /** Minimal cancellation shape (a live AbortSignal satisfies it). */
33
+ export interface TeamAbortSignalLike {
34
+ readonly throwIfAborted: () => void;
35
+ }
36
+ /** Task identity: a branded string in the live service. */
37
+ export type TeamTaskIdLike = string;
38
+ /** Task status vocabulary (mirrors the live TeamTaskStatus). */
39
+ export type TeamTaskStatusLike = 'pending' | 'in_progress' | 'completed' | 'deleted';
40
+ /** Task action vocabulary (mirrors the live TeamTaskAction; the loop uses a subset). */
41
+ export type TeamTaskActionLike = 'claim' | 'release' | 'edit' | 'complete';
42
+ /** One runtime-enriched task view (the fields the loop reads/writes). */
43
+ export interface TeamTaskViewLike {
44
+ readonly id: TeamTaskIdLike;
45
+ readonly revision: number;
46
+ readonly subject: string;
47
+ readonly description: string;
48
+ readonly status: TeamTaskStatusLike;
49
+ readonly blockedBy: TeamTaskIdLike[];
50
+ readonly writeScopes: string[];
51
+ readonly ownerName?: string;
52
+ readonly ready: boolean;
53
+ readonly writeScopeWarnings: string[];
54
+ }
55
+ /** A wait observation (mirrors TeamWaitResult). */
56
+ export interface TeamWaitResultLike {
57
+ readonly timedOut: boolean;
58
+ }
59
+ /** Create-task request (mirrors CreateTeamTaskRequest). */
60
+ export interface CreateTeamTaskRequestLike {
61
+ subject: string;
62
+ description: string;
63
+ blockedBy?: readonly TeamTaskIdLike[];
64
+ writeScopes?: readonly string[];
65
+ }
66
+ /** Compare-and-set task transition (mirrors UpdateTeamTaskRequest). */
67
+ export interface UpdateTeamTaskRequestLike {
68
+ taskId: TeamTaskIdLike;
69
+ expectedRevision: number;
70
+ action: TeamTaskActionLike;
71
+ subject?: string;
72
+ description?: string;
73
+ blockedBy?: readonly TeamTaskIdLike[];
74
+ writeScopes?: readonly string[];
75
+ owner?: string;
76
+ }
77
+ /**
78
+ * The agentTeams seam the loop calls. `waitForChange`/`interrupt`/`getTask`/
79
+ * `listTasks` are optional (loops degrade: no wait, no kill switch, no board
80
+ * re-read) — `createTask`/`updateTask` are hard requirements.
81
+ */
82
+ export interface TeamRuntimeLike {
83
+ createTask(caller: TeamCallerHandle, request: CreateTeamTaskRequestLike): Promise<TeamTaskViewLike>;
84
+ updateTask(caller: TeamCallerHandle, request: UpdateTeamTaskRequestLike): Promise<TeamTaskViewLike>;
85
+ getTask?(caller: TeamCallerHandle, id: TeamTaskIdLike): TeamTaskViewLike;
86
+ listTasks?(caller: TeamCallerHandle): TeamTaskViewLike[];
87
+ waitForChange?(caller: TeamCallerHandle, timeoutMs: number, signal: TeamAbortSignalLike | undefined): Promise<TeamWaitResultLike>;
88
+ interrupt?(caller: TeamCallerHandle, targetName: string): {
89
+ previousStatus: 'running' | 'idle' | 'inactive';
90
+ };
91
+ }
92
+ /** One audit round's outcome (the verdict + synthesized repair + acceptance). */
93
+ export interface AuditRoundOutcome {
94
+ readonly verdict: AuditVerdict;
95
+ /** Repair instruction synthesized from findings (REVISE only). */
96
+ readonly repair?: string;
97
+ /** Whether the underlying delegation result itself was accepted. */
98
+ readonly accepted: boolean;
99
+ readonly reason?: string;
100
+ }
101
+ /** One completed loop round (the task revision trail for the board history). */
102
+ export interface AuditLoopRound {
103
+ readonly round: number;
104
+ readonly verdict: AuditVerdict;
105
+ readonly repair?: string;
106
+ readonly taskRevision: number;
107
+ }
108
+ /** The auditToPass result. */
109
+ export interface AuditToPassResult {
110
+ readonly ok: boolean;
111
+ readonly reason?: string;
112
+ /** The durable task the loop ran on (revision trail lives on the team log). */
113
+ readonly taskId?: TeamTaskIdLike;
114
+ readonly rounds: AuditLoopRound[];
115
+ /** True only when an APPROVE verdict completed the task and locked the phase. */
116
+ readonly locked: boolean;
117
+ /** Latest task view (board-facing per-phase history). */
118
+ readonly taskView?: TeamTaskViewLike;
119
+ }
120
+ /** Inputs for one audit-to-pass loop. */
121
+ export interface AuditToPassInput {
122
+ /** The agentTeams seam. */
123
+ readonly teams: TeamRuntimeLike;
124
+ /** Exact live Team member/lead authorizing the task mutations. */
125
+ readonly caller: TeamCallerHandle;
126
+ /** Phase + run identity (task subject/description vocabulary). */
127
+ readonly runId: string;
128
+ readonly phase: string;
129
+ /** Optional task blockers (previous-phase task ids). */
130
+ readonly blockedBy?: readonly TeamTaskIdLike[];
131
+ /** Write scopes for the phase artifact (advisory, overlap-warned). */
132
+ readonly writeScopes?: readonly string[];
133
+ /** Run ONE audit round for the current task; live usage delegates (T4). */
134
+ readonly runAuditRound: (round: number, task: TeamTaskViewLike) => Promise<AuditRoundOutcome>;
135
+ /** Lock the phase artifact — called ONLY after an APPROVE verdict. */
136
+ readonly lockPhase: () => Promise<void>;
137
+ /** Team member name to interrupt on a stuck reviewer (defaults to the role). */
138
+ readonly reviewerName?: string;
139
+ /** Round cap (fail loud past it; no lock). */
140
+ readonly maxRounds?: number;
141
+ /** Per-round wait timeout before the audit round runs (skipped without the seam). */
142
+ readonly waitTimeoutMs?: number;
143
+ }
144
+ /**
145
+ * Render a per-phase task history (board-facing; pure). One line per round plus
146
+ * the final task status — no live data, no mutation.
147
+ */
148
+ export declare function renderTaskHistory(task: TeamTaskViewLike | undefined, rounds: readonly AuditLoopRound[]): string;
149
+ /**
150
+ * T3 driver: audit the phase until it passes, on ONE durable team task.
151
+ *
152
+ * Transition trail (the fake records exactly this order):
153
+ * createTask(pending) → claim(in_progress) → waitForChange → audit round
154
+ * → REVISE: updateTask(edit, repair) → waitForChange → re-audit SAME task
155
+ * → APPROVE: updateTask(complete) → lockPhase()
156
+ * → REJECT / cap / stuck: updateTask(release) + interrupt, NO lock.
157
+ */
158
+ export declare function auditToPass(input: AuditToPassInput): Promise<AuditToPassResult>;
159
+ /** Whether a task view is currently claimed by the named owner (board-facing). */
160
+ export declare function isTaskClaimedBy(task: TeamTaskViewLike, ownerName: string): boolean;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@try-works/dsh-recursive-mode",
3
3
  "description": "recursive-mode workflow as a DeepSeek Harness bundle: RecursiveRuntime service + recursive_status tool",
4
- "version": "0.2.3",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -24,11 +24,12 @@ import { createHandoff, createChildBrief, replyPath, childScratchPath, buildDele
24
24
  import { resolveRole, capabilityProbe, loadRouterPolicy } from '../src/router.ts'
25
25
  import { validateReferences, writeActionRecord, reviewOutputSchema } from '../src/delegation.ts'
26
26
  import { writeChildScratch, readParentScratch } from '../src/scratch.ts'
27
- import { foldRecursivePhase, validateTransition, LifecycleDriver, coupleGateBlockToGoal, type SessionEventLike, type PhaseTransitionIntent } from '../src/lifecycle.ts'
27
+ import { validateTransition, coupleGateBlockToGoal, type PhaseTransitionIntent } from '../src/lifecycle.ts'
28
28
  import { snapshotWorkspace } from '../src/snapshot.ts'
29
29
  import { RECURSIVE_API_PREFIX, makeRecursiveRoutes, mountRecursiveRoutesOnce } from '../src/live-route.ts'
30
30
  import { columnForRun, cardFacts, nodeKeyOf, expandPhaseRows } from '../src/client/derive.ts'
31
- import { evaluatePreStepGate, evaluateToolGuard, resolveEnforcementConfig, DEFAULT_ENFORCEMENT, detectTamper } from '../src/enforcement.ts'
31
+ import { evaluateToolGuard, coerceAskToDecision, resolveEnforcementConfig, DEFAULT_ENFORCEMENT, detectTamper } from '../src/enforcement.ts'
32
+ import { syncRunGoal, blockRunGoal, type GoalServiceLike, type GoalViewLike } from '../src/goals-projection.ts'
32
33
  import { renderRecursivePolicy } from '../src/policy.ts'
33
34
 
34
35
  let failures = 0
@@ -136,25 +137,35 @@ async function main() {
136
137
  const arec = writeActionRecord({ root: wsA, runId: '10-smoke', subagentId: 'c1', phase: '03.5', purpose: 'review', executionMode: 'self-audit', success: false, stopReason: 'none' })
137
138
  check('R6 action record', existsSync(arec) && readFileSync(arec, 'utf8').includes('## Verification Handoff'))
138
139
 
139
- // Phase C (run 05) smoke: lifecycle fold + transition, pre-step gate, tool guard, policy, tamper, goal coupling
140
- const lifeEvents: SessionEventLike[] = [
141
- { type: 'recursive/phase', data: { runId: '10-smoke', phase: '03', status: 'LOCKED' } },
142
- { type: 'recursive/run-state', data: { runId: '10-smoke', state: 'active' } },
143
- ]
144
- const folded = foldRecursivePhase(lifeEvents)
145
- check('R1 fold last-wins phase', folded?.phase === '03' && folded?.runState === 'active')
140
+ // Phase C (run 05) smoke: transition gate (zero-emission surface), tool
141
+ // guard, T6 ask->policy bridge, policy, tamper, T1 goals projection.
146
142
  const gatedIntent: PhaseTransitionIntent = { runId: '10-smoke', worktreeRoot: wsA, targetArtifact: '03-implementation-summary.md', kind: 'lock' }
147
- const gate = evaluatePreStepGate([{ type: 'recursive/phase-intent', data: gatedIntent }], 'advisory')
148
- check('R3 pre-step advisory warns (gateBlocked)', gate.gateBlocked === true && gate.kind === 'enter')
149
- const gateStrict = evaluatePreStepGate([{ type: 'recursive/phase-intent', data: gatedIntent }], 'strict')
150
- check('R3 pre-step strict rejects', gateStrict.kind === 'reject' && gateStrict.gateBlocked === true)
151
- check('R3 pre-step no intent enters', evaluatePreStepGate([], 'strict').kind === 'enter')
143
+ const gateCheck = validateTransition(gatedIntent)
144
+ check('R1 transition gate reads the lock chain', gateCheck.passed === false && gateCheck.failures.length > 0, gateCheck.failures.join(';'))
152
145
  const tddContent = 'Run: 10-smoke\nPhase: 3\nStatus: DRAFT\nTDD Mode: strict\nRED: evidence/logs/red/tdd-red.md\nGREEN: evidence/logs/green/tdd-green.md\n'
153
146
  writeFileSync(join(runDir, '03-implementation-summary.md'), tddContent, 'utf8')
147
+ // Lock 03's prerequisites so the in-order lock guard passes (monotonic chain).
148
+ // 01/02 are audited phases: the fold requires Audit: PASS for lock-valid.
149
+ for (const [file, phase, audited] of [['00-worktree.md', '0 (Worktree)', false], ['01-as-is.md', '1 (AS-IS)', true], ['02-to-be-plan.md', '2 (TO-BE Plan)', true]] as const) {
150
+ const doc = [
151
+ 'Run: 10-smoke', 'Phase: ' + phase, 'Status: `LOCKED`', 'Workflow version: recursive-mode-audit-v2', '',
152
+ '## TODO', '', '- [x] done', '', 'Coverage: PASS', 'Approval: PASS',
153
+ ...(audited ? ['Audit: PASS'] : []),
154
+ 'LockedAt: `2026-01-15T10:00:00Z`', 'LockHash: `PLACEHOLDER`', '',
155
+ ].join('\n')
156
+ const docHash = lockHashFromContent(doc.replace('PLACEHOLDER', '0'.repeat(64)))
157
+ writeFileSync(join(runDir, file), doc.replace('PLACEHOLDER', docHash), 'utf8')
158
+ }
154
159
  const lockGuard = evaluateToolGuard({ name: 'recursive_lock', arguments: { artifact: '03-implementation-summary.md' } }, wsA, '10-smoke', 'strict')
155
160
  check('R4 tool guard allows in-order lock', lockGuard.kind === 'allow')
156
161
  const outOfOrder = evaluateToolGuard({ name: 'recursive_lock', arguments: { artifact: '05-manual-qa.md' } }, wsA, '10-smoke', 'strict')
157
162
  check('R4 tool guard denies out-of-order lock', outOfOrder.kind === 'deny' && (outOfOrder as { reason: string }).reason.includes('monotonic'))
163
+ const askDecision = evaluateToolGuard({ name: 'recursive_lock', arguments: { artifact: '05-manual-qa.md' } }, wsA, '10-smoke', 'advisory')
164
+ check('T6 advisory guard asks', askDecision.kind === 'ask')
165
+ const coercedStrict = coerceAskToDecision(askDecision, 'strict')
166
+ const coercedAdvisory = coerceAskToDecision(askDecision, 'advisory')
167
+ check('T6 ask->deny under strict', coercedStrict.kind === 'deny')
168
+ check('T6 ask->allow+warn under advisory (never silent)', coercedAdvisory.kind === 'allow' && typeof (coercedAdvisory as { warn?: string }).warn === 'string')
158
169
  check('R7 config default advisory', JSON.stringify(resolveEnforcementConfig(undefined)) === JSON.stringify(DEFAULT_ENFORCEMENT))
159
170
  let configError = ''
160
171
  try { resolveEnforcementConfig({ bogus: 1 }) } catch (err) { configError = (err as Error).message }
@@ -163,6 +174,21 @@ async function main() {
163
174
  check('R5 policy renders contract', policyText.includes('recursive-mode session') && policyText.includes('Current phase'))
164
175
  check('R8 tamper clean', detectTamper(join(runDir, '03-implementation-summary.md'), wsA, '10-smoke') === null)
165
176
  check('R6 goal coupling no-op', coupleGateBlockToGoal(null, {}, { id: 'g' }, { code: 'G', message: 'x' }) === false)
177
+ // T1 (goals projection): structural fake of the live goals service.
178
+ let goalCurrent: GoalViewLike | undefined
179
+ const goalService: GoalServiceLike = {
180
+ get: () => goalCurrent,
181
+ create: (_agent, req) => { goalCurrent = { id: 'g1', revision: 1, objective: req.objective, phase: 'active' }; return goalCurrent },
182
+ block: (_agent, ref) => { if (!goalCurrent || goalCurrent.id !== ref.id) throw new Error('mismatch'); goalCurrent = { ...goalCurrent, phase: 'blocked', revision: ref.revision + 1 }; return goalCurrent },
183
+ pause: (_agent, ref) => { goalCurrent = { ...(goalCurrent ?? { id: ref.id, revision: ref.revision }), phase: 'paused', revision: ref.revision + 1 }; return goalCurrent },
184
+ resume: (_agent, ref) => { goalCurrent = { ...(goalCurrent ?? { id: ref.id, revision: ref.revision }), phase: 'active', revision: ref.revision + 1 }; return goalCurrent },
185
+ complete: (_agent, ref) => { goalCurrent = { ...(goalCurrent ?? { id: ref.id, revision: ref.revision }), phase: 'complete', revision: ref.revision + 1 }; return goalCurrent },
186
+ clear: (_agent, ref) => { goalCurrent = undefined; return { id: ref.id, revision: ref.revision + 1 } },
187
+ }
188
+ const goalSync = syncRunGoal(goalService, {}, '10-smoke', 'active')
189
+ check('T1 run goal armed', goalSync.ok === true && goalCurrent?.objective === 'recursive-run:10-smoke · active')
190
+ const goalBlock = blockRunGoal(goalService, {}, '10-smoke', { code: 'prerequisite-blockers', message: 'monotonic lock-order' })
191
+ check('T1 gate-block blocks the run goal', goalBlock.ok === true && goalCurrent?.phase === 'blocked')
166
192
 
167
193
 
168
194
  // ---- Phase D checks (SP2 R1 live route, replaces the session projection) ----
@@ -179,21 +205,21 @@ async function main() {
179
205
  // R5/R6 derive over the snapshot card (furthest present phase is 06 -> closeout lane).
180
206
  check('R5 columnForRun maps to the furthest present phase', columnForRun(card as never) === '6-8')
181
207
  const facts = cardFacts(card as never)
182
- // 00-requirements.md is LOCKED (written above); 03 is DRAFT; no tamper;
183
- // no gate-block (the fs carries none of the transient event facts).
184
- check('R5 cardFacts progress + tampered + gateBlocked', facts.lockedCount === 1 && facts.tampered === false && facts.gateBlocked === false)
208
+ // Locked groups: 00 (requirements+worktree share one group), 01, 02.
209
+ // 03 is DRAFT; no tamper; no gate-block (the fs carries no transient facts).
210
+ check('R5 cardFacts progress + tampered + gateBlocked', facts.lockedCount === 3 && facts.tampered === false && facts.gateBlocked === false)
185
211
  // R7 node key
186
212
  check('R7 nodeKeyOf includes worktreeRoot', nodeKeyOf('10-smoke', wsA).includes(wsA))
187
213
  check('R6 expandPhaseRows always shows 3.5', expandPhaseRows(card as never).some(r => r.phase === '03.5'))
188
214
  // R2 route: prefix + mountOnce-global (second mount no-ops)
189
215
  check('R2 route prefix', RECURSIVE_API_PREFIX === '/.recursive/api')
190
216
  const routes = makeRecursiveRoutes({ resolveRoot: async () => wsA, snapshot: async (root: string) => snapshotWorkspace(root), revision: () => 1 })
191
- check('R2 route count', routes.length === 2 && routes[0].kind === 'exact' && routes[1].kind === 'exact')
217
+ check('R2 route count (state, events, doc)', routes.length === 3 && routes[0].kind === 'exact' && routes[1].kind === 'exact' && routes[2].kind === 'exact')
192
218
  let registerCount = 0
193
219
  const fakeServer = { register: () => { registerCount++; return () => {} } }
194
220
  const mk = () => mountRecursiveRoutesOnce('@try-works/dsh-recursive-mode', () => makeRecursiveRoutes({ resolveRoot: async () => wsA, snapshot: async (root: string) => snapshotWorkspace(root), revision: () => 1 }), fakeServer)
195
221
  mk(); mk()
196
- check('R2 mountOnce-global (second mount no-ops)', registerCount === 2)
222
+ check('R2 mountOnce-global (second mount no-ops)', registerCount === 3)
197
223
 
198
224
  console.log(failures === 0 ? 'SMOKE PASS' : 'SMOKE FAIL (' + failures + ' failures)')
199
225
  process.exitCode = failures === 0 ? 0 : 1