crewhaus 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.
- package/dist/ci-scaffold.d.ts +18 -0
- package/dist/ci-scaffold.js +74 -0
- package/dist/doctor-probe.d.ts +72 -0
- package/dist/doctor-probe.js +92 -0
- package/dist/dream-cli.d.ts +53 -0
- package/dist/dream-cli.js +329 -0
- package/dist/fleet.d.ts +50 -0
- package/dist/fleet.js +78 -2
- package/dist/index.js +716 -128
- package/dist/init-conversation.d.ts +87 -0
- package/dist/init-conversation.js +306 -0
- package/dist/init-interactive.d.ts +39 -39
- package/dist/init-interactive.js +66 -56
- package/dist/lint.js +17 -1
- package/dist/memory-cli.d.ts +92 -0
- package/dist/memory-cli.js +278 -0
- package/dist/run-failure.d.ts +34 -0
- package/dist/run-failure.js +38 -0
- package/dist/sessions-index.d.ts +7 -19
- package/dist/sessions-index.js +7 -43
- package/dist/thredz-probe.d.ts +36 -0
- package/dist/thredz-probe.js +93 -0
- package/dist/upgrade.d.ts +42 -0
- package/dist/upgrade.js +110 -6
- package/dist/wiki-cli.d.ts +40 -0
- package/dist/wiki-cli.js +133 -0
- package/package.json +92 -89
package/dist/ci-scaffold.d.ts
CHANGED
|
@@ -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
|
package/dist/ci-scaffold.js
CHANGED
|
@@ -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
|
+
}
|