crewhaus 0.2.4 → 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.
@@ -1,5 +1,23 @@
1
1
  export declare const EVAL_CI_WORKFLOW_RELPATH: string;
2
2
  export declare const SENTINEL_WORKFLOW_RELPATH: string;
3
+ export declare const DREAM_WORKFLOW_RELPATH: string;
4
+ /**
5
+ * v0.3.0 PR 14 (§6.3) — the scheduled-consolidation cron workflow,
6
+ * scaffolded by `crewhaus dream init <spec>`. Follows the sentinel/flywheel
7
+ * conventions: odd-minute cron (every workflow scheduled at :00 queues
8
+ * behind GitHub's backlog), `workflow_dispatch` for manual runs, and a
9
+ * human-auditable job body. The cadence is derived from the spec's
10
+ * `memory.dream.every`: sub-daily schedules get an hourly cron, daily and
11
+ * slower get a nightly one — over-firing is harmless because `crewhaus
12
+ * dream run` is WINDOW-IDEMPOTENT (a second invocation inside one
13
+ * `every` window is a cached no-op), which is exactly what makes the verb
14
+ * cron-safe.
15
+ */
16
+ export declare function buildDreamWorkflowYaml(opts: {
17
+ readonly specPath: string;
18
+ readonly everyMs: number;
19
+ readonly harnessDir?: string;
20
+ }): string;
3
21
  /**
4
22
  * Item 30 — the nightly model-drift sentinel workflow, scaffolded by
5
23
  * `crewhaus init --sentinel`. Mirrors the shipped
@@ -35,6 +35,80 @@ import { join } from "node:path";
35
35
  import { normalizeHarnessDir } from "./flywheel";
36
36
  export const EVAL_CI_WORKFLOW_RELPATH = join(".github", "workflows", "crewhaus-eval.yml");
37
37
  export const SENTINEL_WORKFLOW_RELPATH = join(".github", "workflows", "sentinel-drift.yml");
38
+ export const DREAM_WORKFLOW_RELPATH = join(".github", "workflows", "crewhaus-dream.yml");
39
+ /**
40
+ * v0.3.0 PR 14 (§6.3) — the scheduled-consolidation cron workflow,
41
+ * scaffolded by `crewhaus dream init <spec>`. Follows the sentinel/flywheel
42
+ * conventions: odd-minute cron (every workflow scheduled at :00 queues
43
+ * behind GitHub's backlog), `workflow_dispatch` for manual runs, and a
44
+ * human-auditable job body. The cadence is derived from the spec's
45
+ * `memory.dream.every`: sub-daily schedules get an hourly cron, daily and
46
+ * slower get a nightly one — over-firing is harmless because `crewhaus
47
+ * dream run` is WINDOW-IDEMPOTENT (a second invocation inside one
48
+ * `every` window is a cached no-op), which is exactly what makes the verb
49
+ * cron-safe.
50
+ */
51
+ export function buildDreamWorkflowYaml(opts) {
52
+ const sub = normalizeHarnessDir(opts.harnessDir);
53
+ const workingDirLine = sub === "" ? "" : `\n working-directory: ${sub}`;
54
+ const subdirNote = sub === ""
55
+ ? ""
56
+ : `# - Scaffolded for the harness at ${sub}/ — the job's working-directory is
57
+ # set so the spec path resolves inside it.\n`;
58
+ const hourly = opts.everyMs < 24 * 60 * 60 * 1000;
59
+ const cron = hourly ? "19 * * * *" : "19 4 * * *";
60
+ const cadenceNote = hourly
61
+ ? "# Hourly at :19 (off the hour to dodge cron scheduling backlog) — the\n# spec's dream cadence is sub-daily; window idempotency dedupes over-fires."
62
+ : "# Nightly at 04:19 UTC (off the hour to dodge cron scheduling backlog).";
63
+ return `# crewhaus-dream.yml — scaffolded by \`crewhaus dream init\` (v0.3.0 §6.3).
64
+ #
65
+ # Scheduled memory consolidation: runs \`crewhaus dream run\` on a cron. The
66
+ # verb is CRON-SAFE by construction — runs are idempotency-keyed on the
67
+ # schedule window (dream:<spec>:<floor(now/every)>), so this cron, a daemon
68
+ # janitor tick, and a manual \`crewhaus dream\` can never double-fire; a
69
+ # fresh window runs the deterministic pass plus (mode: full, budget_usd > 0)
70
+ # ONE bounded model synthesis session capped by the spec's budget.
71
+ ${subdirNote}#
72
+ # Required repo secret: ANTHROPIC_API_KEY (billed by the model phase, capped
73
+ # at the spec's memory.dream.budget_usd per run).
74
+
75
+ name: crewhaus-dream
76
+
77
+ on:
78
+ schedule:
79
+ ${cadenceNote.replace(/\n/g, "\n ")}
80
+ - cron: "${cron}"
81
+ workflow_dispatch: {}
82
+
83
+ permissions:
84
+ contents: read
85
+
86
+ jobs:
87
+ dream:
88
+ runs-on: ubuntu-latest
89
+ timeout-minutes: 15
90
+ defaults:
91
+ run:
92
+ shell: bash${workingDirLine}
93
+ steps:
94
+ - name: Checkout
95
+ uses: actions/checkout@v4
96
+
97
+ - name: Install Bun
98
+ uses: oven-sh/setup-bun@v2
99
+
100
+ - name: Install crewhaus CLI
101
+ run: |
102
+ bun add -g crewhaus
103
+ echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
104
+
105
+ - name: Run the consolidation pass
106
+ env:
107
+ ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
108
+ run: |
109
+ crewhaus dream run ${opts.specPath}
110
+ `;
111
+ }
38
112
  /**
39
113
  * Item 30 — the nightly model-drift sentinel workflow, scaffolded by
40
114
  * `crewhaus init --sentinel`. Mirrors the shipped
@@ -0,0 +1,72 @@
1
+ /**
2
+ * v0.3.0 Goal 6 — `crewhaus doctor --probe`: an OPT-IN live credential
3
+ * probe. The default doctor checks only credential PRESENCE, so a present-
4
+ * but-invalid key or an out-of-funding account sails through doctor and
5
+ * dies mid-run. `--probe` issues one ~1-token live call per configured
6
+ * provider (maxTokens: 1, a two-character prompt — fractions of a cent at
7
+ * worst) through the SAME adapter stack the run path uses (model-router's
8
+ * `resolveModel`), so what passes here is exactly what the run will do.
9
+ *
10
+ * Failures classify through recovery-engine's `classify()` — the single
11
+ * choke point all four adapters normalise into — so an unfunded account
12
+ * reports `billing`, a bad key `auth`, and doctor's output names the fix
13
+ * class instead of dumping a raw SDK message shape.
14
+ *
15
+ * Factored out of the entry file `index.ts` (which runs a top-level argv
16
+ * switch and so cannot be imported by a test without executing the CLI).
17
+ * Plan-building is pure; the prober takes an injected `resolve` seam so
18
+ * tests drive it with a mocked adapter and no network.
19
+ */
20
+ import type { ProviderAdapter } from "@crewhaus/adapter-anthropic";
21
+ import { type DoctorCredentialCheck, type DoctorProviderId } from "./doctor-checks";
22
+ /**
23
+ * Cheapest sensible probe model per provider, used for providers whose env
24
+ * is configured but which the cwd spec does not select. The spec's own
25
+ * `agent.model` is always probed verbatim for the selected provider — that
26
+ * also catches per-model access failures (e.g. Bedrock model-access 403s).
27
+ * bedrock and local have no default: bedrock needs a region-dependent model
28
+ * id (probed only when the spec selects it) and local endpoints are covered
29
+ * by `doctor --detect`'s reachability probe.
30
+ */
31
+ export declare const DEFAULT_PROBE_MODELS: Partial<Record<DoctorProviderId, string>>;
32
+ export type ProbeTarget = {
33
+ readonly provider: DoctorProviderId;
34
+ readonly model: string;
35
+ /** Why this target is in the plan. */
36
+ readonly reason: "spec-model" | "configured-env";
37
+ };
38
+ /**
39
+ * Decide which providers to probe: the cwd spec's model (verbatim) for its
40
+ * selected provider, plus the cheap default model for every OTHER provider
41
+ * whose credentials are visibly configured. Pure — no network.
42
+ */
43
+ export declare function buildProbePlan(specModel: string | undefined, env: NodeJS.ProcessEnv): ProbeTarget[];
44
+ export type ProbeResult = {
45
+ readonly provider: DoctorProviderId;
46
+ readonly model: string;
47
+ readonly pass: boolean;
48
+ /** Human-readable outcome ("live call ok (312ms)" / "billing — …"). */
49
+ readonly detail: string;
50
+ /** recovery-engine taxonomy bucket on failure (billing/auth/rate_limit/…). */
51
+ readonly failureClass?: string;
52
+ };
53
+ export type ProbeDeps = {
54
+ /** Injected model→adapter resolution; production uses model-router's resolveModel. */
55
+ readonly resolve?: (model: string) => Promise<{
56
+ readonly adapter: ProviderAdapter;
57
+ readonly modelId: string;
58
+ }>;
59
+ /** Injected clock for deterministic durations in tests. */
60
+ readonly now?: () => number;
61
+ };
62
+ /** Issue the ~1-token live call for one target and classify the outcome. */
63
+ export declare function probeProvider(target: ProbeTarget, deps?: ProbeDeps): Promise<ProbeResult>;
64
+ /** Run the whole plan sequentially (a probe per provider; order = plan order). */
65
+ export declare function runProviderProbes(plan: ReadonlyArray<ProbeTarget>, deps?: ProbeDeps): Promise<ProbeResult[]>;
66
+ /**
67
+ * Fold probe results into doctor's check shape (✓ / ✗ lines + exit gate).
68
+ * Doctor's renderer prints `✓ <label>` for passes (no reason) and
69
+ * `✗ <label>: <reason>` for failures, so the ok-detail rides in the pass
70
+ * label and the classified failure detail in the reason.
71
+ */
72
+ export declare function probeResultsToChecks(results: ReadonlyArray<ProbeResult>): DoctorCredentialCheck[];
@@ -0,0 +1,92 @@
1
+ import { resolveModel } from "@crewhaus/model-router";
2
+ import { classify } from "@crewhaus/recovery-engine";
3
+ import { providerCredentialsSatisfied, selectedProvider, } from "./doctor-checks";
4
+ /**
5
+ * Cheapest sensible probe model per provider, used for providers whose env
6
+ * is configured but which the cwd spec does not select. The spec's own
7
+ * `agent.model` is always probed verbatim for the selected provider — that
8
+ * also catches per-model access failures (e.g. Bedrock model-access 403s).
9
+ * bedrock and local have no default: bedrock needs a region-dependent model
10
+ * id (probed only when the spec selects it) and local endpoints are covered
11
+ * by `doctor --detect`'s reachability probe.
12
+ */
13
+ export const DEFAULT_PROBE_MODELS = {
14
+ anthropic: "claude-haiku-4-5",
15
+ openai: "openai/gpt-4o-mini",
16
+ gemini: "gemini/gemini-2.5-flash",
17
+ };
18
+ /**
19
+ * Decide which providers to probe: the cwd spec's model (verbatim) for its
20
+ * selected provider, plus the cheap default model for every OTHER provider
21
+ * whose credentials are visibly configured. Pure — no network.
22
+ */
23
+ export function buildProbePlan(specModel, env) {
24
+ const targets = [];
25
+ const specProvider = specModel !== undefined ? selectedProvider(specModel) : undefined;
26
+ if (specModel !== undefined && specProvider !== undefined) {
27
+ targets.push({ provider: specProvider, model: specModel, reason: "spec-model" });
28
+ }
29
+ for (const [provider, model] of Object.entries(DEFAULT_PROBE_MODELS)) {
30
+ if (provider === specProvider)
31
+ continue;
32
+ if (!providerCredentialsSatisfied(model, env))
33
+ continue;
34
+ targets.push({ provider, model, reason: "configured-env" });
35
+ }
36
+ return targets;
37
+ }
38
+ /** Issue the ~1-token live call for one target and classify the outcome. */
39
+ export async function probeProvider(target, deps = {}) {
40
+ const resolve = deps.resolve ?? (async (m) => await resolveModel(m));
41
+ const now = deps.now ?? (() => performance.now());
42
+ const startMs = now();
43
+ try {
44
+ const { adapter, modelId } = await resolve(target.model);
45
+ const stream = adapter.stream({
46
+ model: modelId,
47
+ system: [],
48
+ messages: [{ role: "user", content: "hi" }],
49
+ maxTokens: 1,
50
+ });
51
+ // Drain — with maxTokens: 1 the whole exchange is a handful of events.
52
+ for await (const _ of stream) {
53
+ // Nothing to do with the token; reaching the end proves the account works.
54
+ }
55
+ return {
56
+ provider: target.provider,
57
+ model: target.model,
58
+ pass: true,
59
+ detail: `live call ok (${Math.round(now() - startMs)}ms)`,
60
+ };
61
+ }
62
+ catch (err) {
63
+ const failureClass = classify(err);
64
+ const message = err instanceof Error ? err.message : String(err);
65
+ return {
66
+ provider: target.provider,
67
+ model: target.model,
68
+ pass: false,
69
+ detail: failureClass === "unknown" ? message : `${failureClass} — ${message}`,
70
+ failureClass,
71
+ };
72
+ }
73
+ }
74
+ /** Run the whole plan sequentially (a probe per provider; order = plan order). */
75
+ export async function runProviderProbes(plan, deps = {}) {
76
+ const results = [];
77
+ for (const target of plan) {
78
+ results.push(await probeProvider(target, deps));
79
+ }
80
+ return results;
81
+ }
82
+ /**
83
+ * Fold probe results into doctor's check shape (✓ / ✗ lines + exit gate).
84
+ * Doctor's renderer prints `✓ <label>` for passes (no reason) and
85
+ * `✗ <label>: <reason>` for failures, so the ok-detail rides in the pass
86
+ * label and the classified failure detail in the reason.
87
+ */
88
+ export function probeResultsToChecks(results) {
89
+ return results.map((r) => r.pass
90
+ ? { label: `live probe: ${r.provider} (${r.model}) — ${r.detail}`, pass: true }
91
+ : { label: `live probe: ${r.provider} (${r.model})`, pass: false, reason: r.detail });
92
+ }
@@ -0,0 +1,53 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ import type { ParseArgsSchema, ParsedArgs } from "@crewhaus/infra-utils";
3
+ import { type DreamModelPhase, type DreamRunReport, type MemoryWiringFragment } from "@crewhaus/memory-service";
4
+ import { type RunContext } from "@crewhaus/run-context";
5
+ import type { RegisteredTool } from "@crewhaus/tool-catalog";
6
+ export declare class DreamCliError extends CrewhausError {
7
+ readonly name = "DreamCliError";
8
+ constructor(message: string, cause?: unknown);
9
+ }
10
+ export declare const DREAM_CLI_SCHEMA: ParseArgsSchema;
11
+ /** The exact runChatLoop option subset the dream session uses — a dedicated
12
+ * type so tests can inject a capturing `chatLoop` and pin every field. */
13
+ export type DreamChatLoopOptions = {
14
+ readonly model: string;
15
+ readonly instructions: string;
16
+ readonly runContext: RunContext;
17
+ readonly singleTurn: true;
18
+ readonly seedMessages: ReadonlyArray<{
19
+ readonly role: "user";
20
+ readonly content: string;
21
+ }>;
22
+ readonly sessionName: string;
23
+ readonly sessionTarget: "dream";
24
+ readonly tools: ReadonlyArray<RegisteredTool>;
25
+ readonly hooks: ReadonlyArray<never>;
26
+ readonly maxToolIterations: number;
27
+ readonly budget: {
28
+ readonly usdMicros: number;
29
+ readonly onExceed: {
30
+ readonly kind: "stop";
31
+ };
32
+ };
33
+ readonly spinner: false;
34
+ };
35
+ export type DreamChatLoopFn = (opts: DreamChatLoopOptions) => Promise<string>;
36
+ /**
37
+ * Build the model-phase runner (§6.2): ONE bounded fresh session seeded
38
+ * with the playbook + phase-1 findings, seeing ONLY the memory-fabric
39
+ * tools (wired fresh, spec scope), capped by the item-27 budget option
40
+ * (`budget_usd` → usdMicros, `onExceed: stop`) and the tool-iteration
41
+ * bound. Spend is observed with a cost-tracker on the run's own trace bus.
42
+ */
43
+ export declare function buildDreamModelPhase(opts: {
44
+ readonly model: string;
45
+ readonly specName: string;
46
+ readonly fragment: MemoryWiringFragment;
47
+ readonly cwd: string;
48
+ readonly chatLoop?: DreamChatLoopFn;
49
+ }): DreamModelPhase;
50
+ /** Render a run report in the design-§6.3 shape. Exported for tests. */
51
+ export declare function renderDreamRunReport(report: DreamRunReport): string[];
52
+ /** The `crewhaus dream` dispatch target — `index.ts` routes here. */
53
+ export declare function runDreamCommand(args: ParsedArgs, action: "run" | "status" | "init"): Promise<void>;
@@ -0,0 +1,329 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /**
3
+ * v0.3.0 PR 14 (design §6.3) — the `crewhaus dream run|status|init` verbs.
4
+ *
5
+ * Kept in a side-effect-free module (the CLI entry file runs an argv switch
6
+ * on import), mirroring `retention.ts` / `memory-cli.ts`: `index.ts` holds
7
+ * only the dispatch registration.
8
+ *
9
+ * - `run` — both phases per the spec's `memory.dream` (mode/budget):
10
+ * the deterministic pass always; then, for `mode: full` with
11
+ * `budget_usd > 0`, ONE bounded model synthesis session
12
+ * (`sessionTarget: "dream"`, singleTurn, capped tool loop,
13
+ * item-27 budget option). CRON-SAFE: runs are idempotency-
14
+ * keyed on the schedule window, so a cron, a daemon janitor
15
+ * tick, and a manual invocation can never double-fire.
16
+ * - `status` — schedule state + next due, from
17
+ * `.crewhaus/dream/<spec>/state.json`.
18
+ * - `init` — scaffolds `.github/workflows/crewhaus-dream.yml` (odd-
19
+ * minute cron convention) via ci-scaffold.
20
+ *
21
+ * The model-phase runner built here is the REFERENCE implementation of
22
+ * dream-engine's `DreamModelPhase` seam: the daemon emitters generate the
23
+ * same shape. `buildDreamModelPhase` takes an injectable `chatLoop` so
24
+ * tests can pin the exact runChatLoop options (budget mapping included)
25
+ * without a model call.
26
+ */
27
+ import { readFileSync } from "node:fs";
28
+ import { realpathSync } from "node:fs";
29
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
30
+ import { lower } from "@crewhaus/compiler";
31
+ import { createCostTracker } from "@crewhaus/cost-tracker";
32
+ import { CrewhausError } from "@crewhaus/errors";
33
+ import { openEventLog } from "@crewhaus/event-log";
34
+ import { memoryFragmentFromIr, wireDream, wireMemory, } from "@crewhaus/memory-service";
35
+ import { createRunContext } from "@crewhaus/run-context";
36
+ import { runChatLoop } from "@crewhaus/runtime-core";
37
+ import { parseSpec } from "@crewhaus/spec";
38
+ import { DREAM_WORKFLOW_RELPATH, buildDreamWorkflowYaml } from "./ci-scaffold";
39
+ import { scaffoldWorkflowFile } from "./flywheel";
40
+ export class DreamCliError extends CrewhausError {
41
+ name = "DreamCliError";
42
+ constructor(message, cause) {
43
+ super("config", message, cause);
44
+ }
45
+ }
46
+ export const DREAM_CLI_SCHEMA = {
47
+ flags: [
48
+ // Bypass the window idempotency (the run still takes the lock and
49
+ // records its outcome) — for operators re-running after fixing config.
50
+ { name: "force" },
51
+ // `init`: overwrite an existing workflow scaffold.
52
+ { name: "help", short: "h" },
53
+ ],
54
+ };
55
+ const DREAM_USAGE = "usage: crewhaus dream [run|status|init] <spec.yaml> [--force]\n" +
56
+ " run both phases per the spec's memory.dream (cron-safe: window-idempotent)\n" +
57
+ " status schedule state + next due (.crewhaus/dream/<spec>/state.json)\n" +
58
+ " init scaffold .github/workflows/crewhaus-dream.yml (odd-minute cron)\n" +
59
+ " --force run: bypass the window idempotency · init: overwrite the workflow\n";
60
+ function loadDreamSpec(specPath) {
61
+ if (specPath === undefined || specPath === "") {
62
+ throw new DreamCliError(`crewhaus dream: missing <spec.yaml> argument\n${DREAM_USAGE}`);
63
+ }
64
+ const absSpec = resolve(specPath);
65
+ let yamlText;
66
+ try {
67
+ yamlText = readFileSync(absSpec, "utf-8");
68
+ }
69
+ catch (err) {
70
+ throw new DreamCliError(`crewhaus dream: cannot read spec "${specPath}": ${err.message}`);
71
+ }
72
+ const ir = lower(parseSpec(yamlText));
73
+ if (ir.memory?.dream === undefined || ir.memory.enabled === false) {
74
+ throw new DreamCliError(`crewhaus dream: spec "${ir.name}" declares no memory.dream schedule — add\n memory:\n dream:\n every: 24h\n mode: full\n budget_usd: 0.5\nand recompile. (mode: deterministic / budget_usd: 0 keep the pass model-free.)`);
75
+ }
76
+ // The dream always consolidates the SPEC-scoped agenda (§14.5) — override
77
+ // a session-scoped continuity (channel) so the store wiring never needs a
78
+ // conversation id.
79
+ const raw = memoryFragmentFromIr(ir);
80
+ const fragment = raw.continuity !== undefined
81
+ ? { ...raw, continuity: { ...raw.continuity, scope: "spec" } }
82
+ : raw;
83
+ return { ir, fragment, absSpec };
84
+ }
85
+ /**
86
+ * Build the model-phase runner (§6.2): ONE bounded fresh session seeded
87
+ * with the playbook + phase-1 findings, seeing ONLY the memory-fabric
88
+ * tools (wired fresh, spec scope), capped by the item-27 budget option
89
+ * (`budget_usd` → usdMicros, `onExceed: stop`) and the tool-iteration
90
+ * bound. Spend is observed with a cost-tracker on the run's own trace bus.
91
+ */
92
+ export function buildDreamModelPhase(opts) {
93
+ const chatLoop = opts.chatLoop ?? runChatLoop;
94
+ return {
95
+ model: opts.model,
96
+ run: async (input) => {
97
+ const tools = [];
98
+ await wireMemory(opts.fragment, {
99
+ catalog: {
100
+ register: (t) => {
101
+ tools.push(t);
102
+ },
103
+ },
104
+ cwd: opts.cwd,
105
+ });
106
+ const runContext = createRunContext();
107
+ const tracker = createCostTracker(runContext.eventBus, { suppressEvents: true });
108
+ try {
109
+ const summary = await chatLoop({
110
+ model: opts.model,
111
+ instructions: input.playbook,
112
+ runContext,
113
+ singleTurn: true,
114
+ seedMessages: [{ role: "user", content: input.prompt }],
115
+ sessionName: opts.specName,
116
+ sessionTarget: "dream",
117
+ tools,
118
+ hooks: [],
119
+ maxToolIterations: input.maxToolIterations,
120
+ budget: {
121
+ usdMicros: Math.round(input.budgetUsd * 1_000_000),
122
+ onExceed: { kind: "stop" },
123
+ },
124
+ spinner: false,
125
+ });
126
+ return {
127
+ sessionId: runContext.sessionId,
128
+ spentUsd: tracker.getRunCost(runContext.runId).totalUsdMicros / 1_000_000,
129
+ summary,
130
+ };
131
+ }
132
+ finally {
133
+ tracker.unsubscribe();
134
+ }
135
+ },
136
+ };
137
+ }
138
+ // ---------------------------------------------------------------------------
139
+ // rendering (the §6.3 output shape)
140
+ // ---------------------------------------------------------------------------
141
+ function shortIso(iso) {
142
+ return `${iso.slice(0, 16)}Z`;
143
+ }
144
+ function renderEvery(ms) {
145
+ const DAY = 86_400_000;
146
+ const HOUR = 3_600_000;
147
+ const MINUTE = 60_000;
148
+ if (ms % DAY === 0)
149
+ return `${ms / DAY}d`;
150
+ if (ms % HOUR === 0)
151
+ return `${ms / HOUR}h`;
152
+ if (ms % MINUTE === 0)
153
+ return `${ms / MINUTE}m`;
154
+ return `${Math.round(ms / 1000)}s`;
155
+ }
156
+ function line(label, detail) {
157
+ return ` ✓ ${label.padEnd(18)} ${detail}`;
158
+ }
159
+ /** Render a run report in the design-§6.3 shape. Exported for tests. */
160
+ export function renderDreamRunReport(report) {
161
+ const c = report.phase1.counts;
162
+ const lines = [`crewhaus dream — ${report.specName}`];
163
+ if (report.cached) {
164
+ lines.push(` window ${report.windowKey} already consolidated (cached report from ${shortIso(report.startedAt)})`);
165
+ }
166
+ lines.push(" deterministic");
167
+ lines.push(line("session summaries", c.sessionsIndexed === 0 ? "nothing new to index" : `${c.sessionsIndexed} sessions → indexed`));
168
+ lines.push(line("fact dedupe", `${c.factsBefore} → ${c.factsAfter} (${c.factsSuperseded} superseded)`));
169
+ lines.push(line("fact decay", `${c.factsStale} fact${c.factsStale === 1 ? "" : "s"} >90d flagged stale`));
170
+ lines.push(line("proof freeze", `${c.proofsChecked} checked · ${c.sessionsPinned} session${c.sessionsPinned === 1 ? "" : "s"} pinned before TTL`));
171
+ lines.push(line("wiki staleness", `${c.wikiStale} article${c.wikiStale === 1 ? "" : "s"} unverified >30d`));
172
+ lines.push(line("focus refresh", `next-actions rebuilt from ${c.openPlans} open plan${c.openPlans === 1 ? "" : "s"}`));
173
+ lines.push(line("trash purge", `${c.trashPurged} snapshot${c.trashPurged === 1 ? "" : "s"} past the 7d undo window`));
174
+ const model = report.model;
175
+ if (model === undefined) {
176
+ lines.push(" model (mode deterministic — no model phase)");
177
+ }
178
+ else if (model.ran) {
179
+ lines.push(` model (${model.model} · cap $${model.budgetUsd.toFixed(2)})`);
180
+ if (model.actions.length === 0) {
181
+ lines.push(" (no tool mutations — the session found nothing to consolidate)");
182
+ }
183
+ for (const action of model.actions) {
184
+ lines.push(` ✓ ${action.toolName}${action.detail !== undefined ? ` ${action.detail}` : ""}`);
185
+ }
186
+ }
187
+ else if (model.refusal !== undefined) {
188
+ lines.push(` model (${model.model}) REFUSED — ${model.refusal}`);
189
+ }
190
+ else if (model.error !== undefined) {
191
+ lines.push(` model (${model.model}) FAILED — ${model.error} (window not consumed; retries)`);
192
+ }
193
+ else {
194
+ lines.push(` model skipped — ${model.skipped ?? "deterministic only"}`);
195
+ }
196
+ const seconds = report.durationMs / 1000;
197
+ const spent = model?.ran === true && model.spentUsd !== undefined ? model.spentUsd : undefined;
198
+ lines.push(` done in ${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds).toString()}s` +
199
+ `${spent !== undefined ? ` · spent $${spent.toFixed(2)}` : ""}` +
200
+ ` · next due ${shortIso(report.nextDueAt)}`);
201
+ return lines;
202
+ }
203
+ // ---------------------------------------------------------------------------
204
+ // verbs
205
+ // ---------------------------------------------------------------------------
206
+ async function runVerb(args) {
207
+ const cwd = process.cwd();
208
+ const { ir, fragment } = loadDreamSpec(args.positional[0]);
209
+ const model = ir.agent?.model;
210
+ const modelPhase = model !== undefined
211
+ ? buildDreamModelPhase({ model, specName: ir.name, fragment, cwd })
212
+ : undefined;
213
+ const sessionRootDir = join(cwd, ".crewhaus", "sessions");
214
+ const wired = wireDream(fragment, {
215
+ cwd,
216
+ ...(modelPhase !== undefined ? { modelPhase } : {}),
217
+ // The dream_run proof record lands in the dream session's own log when
218
+ // the model phase ran (state.json is the durable record otherwise).
219
+ appendEvent: async (event) => {
220
+ const sessionId = event.payload.sessionId;
221
+ if (typeof sessionId !== "string")
222
+ return;
223
+ const log = await openEventLog(sessionId, { rootDir: sessionRootDir });
224
+ await log.append({ kind: "dream_run", payload: event.payload });
225
+ await log.close();
226
+ },
227
+ log: (lineText) => process.stderr.write(`${lineText}\n`),
228
+ });
229
+ if (wired === null) {
230
+ throw new DreamCliError(`crewhaus dream: spec "${ir.name}" has no dream schedule`);
231
+ }
232
+ if (model === undefined) {
233
+ process.stderr.write("[dream] this shape has no single agent model — running the deterministic phase only\n");
234
+ }
235
+ const report = await wired.engine.run({
236
+ trigger: "cli",
237
+ ...(args.flags["force"] === true ? { force: true } : {}),
238
+ });
239
+ for (const lineText of renderDreamRunReport(report)) {
240
+ process.stdout.write(`${lineText}\n`);
241
+ }
242
+ // A refusal or model failure exits non-zero AFTER the report renders —
243
+ // scheduled runs must alert (the deterministic pass still completed).
244
+ if (report.outcome === "model_refused_unpriced") {
245
+ throw new DreamCliError(report.model?.refusal ?? "dream: unpriced model refused");
246
+ }
247
+ if (report.outcome === "model_failed") {
248
+ throw new DreamCliError(`dream: model phase failed — ${report.model?.error ?? "unknown error"} (deterministic pass completed; the window was not consumed, so the next scheduled run retries)`);
249
+ }
250
+ }
251
+ async function statusVerb(args) {
252
+ const cwd = process.cwd();
253
+ const { ir, fragment } = loadDreamSpec(args.positional[0]);
254
+ const wired = wireDream(fragment, { cwd });
255
+ if (wired === null) {
256
+ throw new DreamCliError(`crewhaus dream: spec "${ir.name}" has no dream schedule`);
257
+ }
258
+ const status = await wired.engine.status();
259
+ const cfg = status.config;
260
+ const lines = [
261
+ `crewhaus dream — ${ir.name}`,
262
+ ` every ${renderEvery(cfg.everyMs)} · mode ${cfg.mode} · budget $${cfg.budgetUsd.toFixed(2)}`,
263
+ ` state ${join(wired.engine.stateDir(), "state.json")}`,
264
+ ];
265
+ if (status.state === null) {
266
+ lines.push(" never run — overdue (a boot catch-up or 'crewhaus dream run' will start it)");
267
+ }
268
+ else {
269
+ lines.push(` last run ${shortIso(status.state.lastRunAt)} (${status.state.lastOutcome})`);
270
+ lines.push(status.overdue
271
+ ? " next due now (overdue)"
272
+ : ` next due ${shortIso(status.nextDueAt ?? status.state.lastRunAt)}`);
273
+ if (status.state.lastEvidence.length > 0) {
274
+ lines.push(` last evidence ${status.state.lastEvidence.join(", ")}`);
275
+ }
276
+ }
277
+ for (const lineText of lines) {
278
+ process.stdout.write(`${lineText}\n`);
279
+ }
280
+ }
281
+ /** Finding 7 (mirrors index.ts's resolveWorkflowRoot, which is private to
282
+ * the dispatch module): GitHub only reads workflows at the repo root. */
283
+ function workflowRootFor(harnessAbsDir, fallbackRoot) {
284
+ const top = spawnSync("git", ["-C", harnessAbsDir, "rev-parse", "--show-toplevel"], {
285
+ encoding: "utf-8",
286
+ });
287
+ const toplevel = top.status === 0 ? top.stdout.trim() : "";
288
+ const root = toplevel !== "" ? toplevel : realpathSync(fallbackRoot);
289
+ const rel = relative(root, realpathSync(harnessAbsDir));
290
+ if (rel === "" || rel === ".")
291
+ return { root, harnessDir: "" };
292
+ if (rel.startsWith("..") || isAbsolute(rel))
293
+ return { root: harnessAbsDir, harnessDir: "" };
294
+ return { root, harnessDir: rel.split(sep).join("/") };
295
+ }
296
+ async function initVerb(args) {
297
+ const { ir, absSpec } = loadDreamSpec(args.positional[0]);
298
+ const dream = ir.memory?.dream;
299
+ if (dream === undefined)
300
+ throw new DreamCliError("unreachable: loadDreamSpec guarantees dream");
301
+ const { root, harnessDir } = workflowRootFor(dirname(absSpec), process.cwd());
302
+ const specPathInWorkflow = relative(harnessDir === "" ? root : join(root, harnessDir), absSpec)
303
+ .split(sep)
304
+ .join("/");
305
+ const scaffolded = scaffoldWorkflowFile({
306
+ rootDir: root,
307
+ relPath: DREAM_WORKFLOW_RELPATH,
308
+ content: buildDreamWorkflowYaml({
309
+ specPath: specPathInWorkflow,
310
+ everyMs: dream.everyMs,
311
+ ...(harnessDir !== "" ? { harnessDir } : {}),
312
+ }),
313
+ force: args.flags["force"] === true,
314
+ });
315
+ process.stdout.write(`wrote ${scaffolded.path}\n`);
316
+ process.stdout.write(` cron: ${dream.everyMs < 86_400_000 ? '"19 * * * *" (hourly — sub-daily cadence)' : '"19 4 * * *" (nightly)'} · window idempotency dedupes over-fires\n next: commit the workflow and add the ANTHROPIC_API_KEY repo secret\n (the model phase is capped at $${(dream.budgetUsd ?? 0).toFixed(2)} per run by memory.dream.budget_usd)\n`);
317
+ }
318
+ /** The `crewhaus dream` dispatch target — `index.ts` routes here. */
319
+ export async function runDreamCommand(args, action) {
320
+ if (args.flags["help"] === true) {
321
+ process.stdout.write(DREAM_USAGE);
322
+ return;
323
+ }
324
+ if (action === "run")
325
+ return runVerb(args);
326
+ if (action === "status")
327
+ return statusVerb(args);
328
+ return initVerb(args);
329
+ }