---
id: 12070
title: 'Sub 4: runSandman.mjs delegation refactor with CLI mode-selector'
state: CLOSED
labels:
  - enhancement
  - ai
  - refactoring
  - model-experience
assignees:
  - neo-gpt
createdAt: '2026-05-27T01:42:54Z'
updatedAt: '2026-05-27T19:11:34Z'
githubUrl: 'https://github.com/neomjs/neo/issues/12070'
author: neo-opus-ada
commentsCount: 1
parentIssue: 12065
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-05-27T19:11:34Z'
---
# Sub 4: runSandman.mjs delegation refactor with CLI mode-selector

## Parent Epic

#12065 — Sub 4 of 9. **Depends on Sub 3** (unified `executeRemCycle()` method).

## Premise

Currently `runSandman.mjs` is 276 LOC carrying its own pipeline ceremony (lease + gates + diagnostic + exit-code choreography). After Sub 3 ships orchestrator-owned canonical execution, `runSandman.mjs` becomes a thin CLI selector delegating to the orchestrator.

## Prescription

Refactor `ai/scripts/runners/runSandman.mjs` to ~30 LOC:

```js
import {orchestratorService} from '...';

const mode = process.argv[2] || 'full';  // 'full' | 'refresh'
const reason = mode === 'refresh' ? 'manual-cli-refresh' : 'manual-cli';

try {
    if (mode === 'refresh') {
        await orchestratorService.refreshGoldenPath({reason});  // Sub 5
    } else {
        await orchestratorService.executeRemCycle({reason, includeGoldenPath: true});  // Sub 3
    }
    process.exit(0);
} catch (e) {
    console.error('❌ REM cycle failed:', e);
    process.exit(1);
}
```

Delete the 105 lines of pre-pipeline ceremony (lease, gates, diagnostics, exit-code choreography) — all now lives in orchestrator.

Add npm script `ai:refresh-golden-path` (or extend `ai:run-sandman` with mode argument) — operator-direct entry for the cheap-refresh path per Discussion §2.9.

## Acceptance Criteria

- [ ] AC1: `runSandman.mjs` reduced to thin CLI selector (~30 LOC; no business logic; no lease/gate/diagnostic code)
- [ ] AC2: Mode-selector via argv supporting `full` (default) + `refresh`
- [ ] AC3: Both modes delegate to orchestrator's canonical entry-points (Sub 3 + Sub 5)
- [ ] AC4: Exit-code contract preserved (`process.exit(0)` on success, `1` on failure)
- [ ] AC5: npm script for cheap-refresh path (`npm run ai:refresh-golden-path` OR `npm run ai:run-sandman -- refresh`)
- [ ] AC6: Unit test covering both modes (mocking orchestrator)

## Avoided Traps

- ❌ Keep business logic in CLI — defeats SSOT purpose
- ❌ Remove on-demand REM trigger — operator workflow falsifies (per §2.9)
- ❌ Land before Sub 3 (no executeRemCycle to delegate to) OR before Sub 5 (no refreshGoldenPath to delegate to)

## Related

- Epic #12065
- Discussion #12062 OQ1 + OQ2 (mode-selector) + OQ6 (minimum-viable-CLI) + §2.9 (split-and-recompose UX)
- Sub 3 (executeRemCycle — input), Sub 5 (refreshGoldenPath — input)

## Timeline

- 2026-05-27T01:42:55Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-05-27T01:42:56Z @neo-opus-ada added the `enhancement` label
- 2026-05-27T01:42:56Z @neo-opus-ada added the `ai` label
- 2026-05-27T01:42:56Z @neo-opus-ada added the `refactoring` label
- 2026-05-27T01:42:56Z @neo-opus-ada added the `model-experience` label
- 2026-05-27T01:45:28Z @neo-opus-ada added parent issue #12065
- 2026-05-27T01:50:24Z @neo-opus-ada unassigned from @neo-opus-ada
### @neo-gpt - 2026-05-27T01:52:45Z

## Operator SOTA clarification

Operator clarified after #12065 graduation:

> SOTA means => either runSandman gets a tiny wrapper to manually call the orchestrator script once done, or it just gets removed when the orchestrator handles it properly.

Implementation implication for this sub:

- Acceptable final state A: `runSandman.mjs` is only a tiny manual wrapper around the orchestrator-owned command/service path after Sub 3 lands.
- Acceptable final state B: `runSandman.mjs` is removed once the orchestrator fully owns the manual/operator entry point.
- Non-goal: preserving a second independent REM choreography in `runSandman.mjs` after orchestrator ownership exists. The script should not keep its own lease, readiness, DreamService, Golden Path, or exit-code orchestration if those are canonical in the orchestrator.

This tightens the Sub 4 end state without requiring a body edit to the Claude-authored ticket.

Origin Session ID: `6ca1b510-51c3-4fac-aa39-a0fd6941318c`

- 2026-05-27T02:06:11Z @neo-gpt cross-referenced by #12065
- 2026-05-27T14:24:07Z @neo-opus-ada referenced in commit `8261800` - "feat(orchestrator): unified executeRemCycle() with typed cycle outcome (#12069)

Adds the keystone Sub 3 method consolidating the Sandman REM pipeline behind a
single canonical entrypoint that returns a typed outcome envelope. This is the
silent-failure fix at the orchestrator boundary: the periodic dream path used
to map every non-throwing return from DreamService.processUndigestedSessions()
to `completed`, hiding zero-session no-ops, concurrent-invocation guards, and
provider-unreachable early returns. The new method discriminates `completed`
from `skipped` from `failed` from the start.

Outcome envelope:
- `completed` -- provider ready, undigested sessions existed, processing
   finished without throw. `sessionsProcessed` carries the pre-call count.
- `skipped`   -- ran successfully but did no work (concurrent-invocation
   guard, zero undigested sessions, dry-run, etc.). `skipReason` populated.
- `failed`    -- provider readiness gate rejected OR an in-pipeline throw was
   caught. Either `diagnostic` (provider failure) or `error` (throws) populated.

Mitigations from the Sub 1 forensics runbook applied inline:
- H1: dreamService.isProcessing now surfaces as `skipped/concurrent-invocation`
  rather than DreamService's previous debug-only log line.
- H2: provider readiness check resolves the graph provider explicitly via the
  Sub 6 substrate (runProviderReadinessGate from the same orchestrator class),
  not the generic modelProvider check that DreamService still performs internally.
- H3: typed outcome eliminates the unconditional `completed` mapping; the
  orchestrator dream callback now switches on outcome.status and routes
  recordTaskOutcome through the matching state.

Dream task refactor: the periodic dream callback at the cadence-engine
boundary now delegates to executeRemCycle() and maps the typed outcome to
healthService.recordTaskOutcome. The MaintenanceBackpressureService wrapper
continues to own the heavy-maintenance lease; executeRemCycle is intentionally
lease-agnostic so the standalone Sandman CLI runner (Sub 4 follow-up) can
delegate without double-leasing.

Deferred per scope-restraint:
- AC3 (Sub 2 Part B JSONL state model wiring) waits for the #12088 writer
- AC5 (Sub 7 chunking-aware activation) waits for the #12073 strategy
- AC6 (runSandman CLI delegation refactor) is Sub 4 (#12070) territory
- AC7 (full silent-failure regression suite) is Sub 9 (#12075); this PR
  ships the typed-outcome contract coverage only

Tests: 9 new unit cases lock the outcome contract -- provider-gate failure
populates diagnostic, processUndigestedSessions throws populate error, the
findUndigestedSessions throw path is covered separately, sessionsProcessed
carries the pre-call count, runId is unique per call, reason + mode survive
the envelope."
- 2026-05-27T14:25:27Z @neo-opus-ada referenced in commit `9160ac5` - "feat(orchestrator): unified executeRemCycle() with typed cycle outcome (#12069)

Adds the keystone Sub 3 method consolidating the Sandman REM pipeline behind a
single canonical entrypoint that returns a typed outcome envelope. This is the
silent-failure fix at the orchestrator boundary: the periodic dream path used
to map every non-throwing return from DreamService.processUndigestedSessions()
to `completed`, hiding zero-session no-ops, concurrent-invocation guards, and
provider-unreachable early returns. The new method discriminates `completed`
from `skipped` from `failed` from the start.

Outcome envelope:
- `completed` -- provider ready, undigested sessions existed, processing
   finished without throw. `sessionsProcessed` carries the pre-call count.
- `skipped`   -- ran successfully but did no work (concurrent-invocation
   guard, zero undigested sessions, dry-run, etc.). `skipReason` populated.
- `failed`    -- provider readiness gate rejected OR an in-pipeline throw was
   caught. Either `diagnostic` (provider failure) or `error` (throws) populated.

Mitigations from the Sub 1 forensics runbook applied inline:
- H1: dreamService.isProcessing now surfaces as `skipped/concurrent-invocation`
  rather than DreamService's previous debug-only log line.
- H2: provider readiness check resolves the graph provider explicitly via the
  Sub 6 substrate (runProviderReadinessGate from the same orchestrator class),
  not the generic modelProvider check that DreamService still performs internally.
- H3: typed outcome eliminates the unconditional `completed` mapping; the
  orchestrator dream callback now switches on outcome.status and routes
  recordTaskOutcome through the matching state.

Dream task refactor: the periodic dream callback at the cadence-engine
boundary now delegates to executeRemCycle() and maps the typed outcome to
healthService.recordTaskOutcome. The MaintenanceBackpressureService wrapper
continues to own the heavy-maintenance lease; executeRemCycle is intentionally
lease-agnostic so the standalone Sandman CLI runner (Sub 4 follow-up) can
delegate without double-leasing.

Deferred per scope-restraint:
- AC3 (Sub 2 Part B JSONL state model wiring) waits for the #12088 writer
- AC5 (Sub 7 chunking-aware activation) waits for the #12073 strategy
- AC6 (runSandman CLI delegation refactor) is Sub 4 (#12070) territory
- AC7 (full silent-failure regression suite) is Sub 9 (#12075); this PR
  ships the typed-outcome contract coverage only

Tests: 9 new unit cases lock the outcome contract -- provider-gate failure
populates diagnostic, processUndigestedSessions throws populate error, the
findUndigestedSessions throw path is covered separately, sessionsProcessed
carries the pre-call count, runId is unique per call, reason + mode survive
the envelope."
- 2026-05-27T16:10:45Z @tobiu referenced in commit `17c8ec6` - "feat(orchestrator): unified executeRemCycle() with typed cycle outcome (#12069) (#12096)

* feat(orchestrator): unified executeRemCycle() with typed cycle outcome (#12069)

Adds the keystone Sub 3 method consolidating the Sandman REM pipeline behind a
single canonical entrypoint that returns a typed outcome envelope. This is the
silent-failure fix at the orchestrator boundary: the periodic dream path used
to map every non-throwing return from DreamService.processUndigestedSessions()
to `completed`, hiding zero-session no-ops, concurrent-invocation guards, and
provider-unreachable early returns. The new method discriminates `completed`
from `skipped` from `failed` from the start.

Outcome envelope:
- `completed` -- provider ready, undigested sessions existed, processing
   finished without throw. `sessionsProcessed` carries the pre-call count.
- `skipped`   -- ran successfully but did no work (concurrent-invocation
   guard, zero undigested sessions, dry-run, etc.). `skipReason` populated.
- `failed`    -- provider readiness gate rejected OR an in-pipeline throw was
   caught. Either `diagnostic` (provider failure) or `error` (throws) populated.

Mitigations from the Sub 1 forensics runbook applied inline:
- H1: dreamService.isProcessing now surfaces as `skipped/concurrent-invocation`
  rather than DreamService's previous debug-only log line.
- H2: provider readiness check resolves the graph provider explicitly via the
  Sub 6 substrate (runProviderReadinessGate from the same orchestrator class),
  not the generic modelProvider check that DreamService still performs internally.
- H3: typed outcome eliminates the unconditional `completed` mapping; the
  orchestrator dream callback now switches on outcome.status and routes
  recordTaskOutcome through the matching state.

Dream task refactor: the periodic dream callback at the cadence-engine
boundary now delegates to executeRemCycle() and maps the typed outcome to
healthService.recordTaskOutcome. The MaintenanceBackpressureService wrapper
continues to own the heavy-maintenance lease; executeRemCycle is intentionally
lease-agnostic so the standalone Sandman CLI runner (Sub 4 follow-up) can
delegate without double-leasing.

Deferred per scope-restraint:
- AC3 (Sub 2 Part B JSONL state model wiring) waits for the #12088 writer
- AC5 (Sub 7 chunking-aware activation) waits for the #12073 strategy
- AC6 (runSandman CLI delegation refactor) is Sub 4 (#12070) territory
- AC7 (full silent-failure regression suite) is Sub 9 (#12075); this PR
  ships the typed-outcome contract coverage only

Tests: 9 new unit cases lock the outcome contract -- provider-gate failure
populates diagnostic, processUndigestedSessions throws populate error, the
findUndigestedSessions throw path is covered separately, sessionsProcessed
carries the pre-call count, runId is unique per call, reason + mode survive
the envelope.

* refactor(orchestrator): move executeRemCycle + readiness gate to DreamService (#12069)

Operator-direct architectural correction. PR #12092 (already merged) and the
prior PR #12096 commit both added large methods directly to Orchestrator.mjs,
contradicting the recent service-decomposition refactor that thinned the
orchestrator into a coordinator + delegated logic to dedicated service files
(MaintenanceBackpressureService, CadenceEngine, DreamService, etc.).

DreamService is the natural home for executeRemCycle: it already owns the REM
pipeline (processUndigestedSessions, findUndigestedSessions, etc.), so the
typed-outcome unified entrypoint composes existing methods on `this`. The
checkProviderReadiness helper moves with it -- both depend on the runSandman
helpers (waitForProvider, createProviderFailureDiagnostic,
getGraphProviderReadinessTarget) which DreamService now imports directly.

Net effect on Orchestrator.mjs:
- DELETE async executeRemCycle({...}) and its JSDoc (~110 LOC body)
- DELETE async runProviderReadinessGate() and its JSDoc (~50 LOC body)
- DELETE imports: GraphService, createProviderFailureDiagnostic,
  getGraphProviderReadinessTarget, waitForProvider
- Refactor the periodic dream callback: stop calling local methods, delegate
  to this.dreamService.executeRemCycle({...}) and map the typed outcome to
  recordTaskOutcome
- Drive-by: fix the pre-existing `reason.reason` bug in the dream callback
  (CadenceEngine already unwraps the `{reason: '...'}` object via line 46 of
  CadenceEngine.mjs, so the callback receives the string directly -- matches
  the summary/kbSync pattern)

Net effect on DreamService.mjs:
- ADD async executeRemCycle({reason, mode, includeGoldenPath, includeDecay,
  dryRun}) returning the typed outcome envelope (completed | skipped | failed)
- ADD async checkProviderReadiness() returning {ready, diagnostic?}
- ADD imports: AiConfig + the three runSandman readiness helpers

Tests:
- DELETE Orchestrator.executeRemCycle.spec.mjs (orchestrator scope)
- ADD services/DreamService.executeRemCycle.spec.mjs with the same 9 typed-
  outcome cases, now stubbing the singleton via the beforeEach/afterEach
  swap pattern (DreamService is a Neo.setupClass singleton instance)
- Update Orchestrator.spec.mjs consumer-path case to stub
  dreamService.executeRemCycle directly + assert the orchestrator passes
  the right options envelope + maps the typed `failed` outcome to
  recordTaskOutcome with failurePhase: 'provider-readiness' + diagnostic

* refactor(orchestrator): drop includeGoldenPath no-op + add intent JSDoc (#12069 cycle-3)

Addresses Codex review on PR #12096 cycle-2. Three required actions:

1. Remove the no-op `includeGoldenPath` knob. The flag accepted but never did
   anything except log "deferred when set"; that exposed an API option that
   does not match its name and encoded board sequencing into runtime API.
   Removed from `DreamService.executeRemCycle()` signature, JSDoc, orchestrator
   caller, and the consumer-path test assertion. The dedicated golden-path
   refresh helper lands in its own future change when the behavior exists.

2. Replace source archaeology with durable intent documentation.
   - `@summary` added to both new public methods on `DreamService`
     (`executeRemCycle`, `checkProviderReadiness`).
   - Intent comments added inline for the four important branch groups
     in `executeRemCycle()`: provider gate, dry-run short-circuit,
     concurrent-invocation guard, pre-count query, no-work path, work path.
     Comments describe what the branch is for and why it lives there, not
     project-board sequencing.
   - Removed the "Sub 5 refresh helper" and "deferred to its own ticket"
     wording from method docs and the runtime log line.

3. PR body update (separate `gh pr edit` follow-up) reflects the cycle-2 + 3
   reality: DreamService owns `executeRemCycle`; Orchestrator delegates;
   evidence is 10/10 focused tests; pre-rewrite stack reference removed.

---------

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-27T16:17:52Z @neo-gpt assigned to @neo-gpt
- 2026-05-27T16:43:48Z @neo-gpt cross-referenced by PR #12099
- 2026-05-27T16:53:27Z @neo-gpt referenced in commit `211c1de` - "fix(ai): delegate Sandman runner to REM cycle (#12070)"
- 2026-05-27T17:03:32Z @neo-gpt referenced in commit `bc98d22` - "fix(ai): delegate Sandman runner to REM cycle (#12070)"
- 2026-05-27T19:11:34Z @tobiu referenced in commit `57f5c6b` - "fix(ai): delegate Sandman runner to REM cycle (#12070) (#12099)

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-27T19:11:34Z @tobiu closed this issue

