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,22 +1,25 @@
1
- import { SpecParseError, parseSpec } from "@crewhaus/spec";
1
+ import { parseSpec } from "@crewhaus/spec";
2
2
  /**
3
- * Item 39 — `crewhaus init --interactive`. Folds the demos harness-designer
4
- * pattern (an agent that interviews the user in plain English and emits a
5
- * validated crewhaus.yaml) into core. Two design commitments:
3
+ * Item 39 / v0.3.0 §2.9 — `crewhaus init --interactive`. Folds the demos
4
+ * harness-designer pattern (an agent that interviews the user in plain
5
+ * English and emits a validated crewhaus.yaml) into core. Design commitments:
6
6
  *
7
7
  * 1. NO dependency on the demos repo. The shape-decision guidance is
8
8
  * bundled inline here ({@link SHAPE_GUIDANCE} / {@link buildInterviewSystemPrompt}),
9
9
  * and validation is `parseSpec` called in-process — the single source of
10
10
  * truth for what compiles.
11
11
  * 2. Every draft the model emits is validated against the LIVE Zod union via
12
- * `parseSpec`, and on a structured error the error text is fed back for a
13
- * retry. So `init --interactive` cannot emit a spec that won't parse.
14
- *
15
- * Everything here is side-effect-free and unit-testable: the interview loop is
16
- * driven by an INJECTED `proposeSpec` callback (the CLI wires the model
17
- * adapter behind it), and the credential-free fallback is a pure
18
- * {@link buildScriptedSpec} over stdin answers. The CLI wrapper in `index.ts`
19
- * owns all I/O.
12
+ * `parseSpec`, and on a structured error the error text goes back into
13
+ * the conversation as a tool error the model fixes in-context. So
14
+ * `init --interactive` cannot emit a spec that won't parse.
15
+ * 3. The interview is a REAL conversation (v0.3.0 PR 18): a `runChatLoop`
16
+ * session with `ask_user` + `emit_spec` and NO forced toolChoice
17
+ * persisted, ledger-protected, and resumable. The conversational loop
18
+ * lives in `init-conversation.ts`; this module keeps the pure,
19
+ * side-effect-free pieces: the shape catalog, the interviewer system
20
+ * prompt, the tool contracts, and the credential-free fallback
21
+ * ({@link buildScriptedSpec} over stdin answers). The CLI wrapper in
22
+ * `index.ts` owns all I/O.
20
23
  */
21
24
  /** The fourteen target shapes, with the one-line "pick me when" guidance the
22
25
  * interviewer uses. Bundled inline so core never reaches into the demos repo. */
@@ -54,17 +57,42 @@ export function isScriptedShape(s) {
54
57
  return SCRIPTED_SHAPES.includes(s);
55
58
  }
56
59
  /**
57
- * The interviewer's system prompt. Bundles the shape catalog + the hard rules
58
- * the emitted YAML must obey (single-line-safe names, the `$UPPER_SNAKE_CASE`
60
+ * The interviewer's system prompt a focused variant of the `continuity`
61
+ * discipline (v0.3.0 §2.9) on top of the shape catalog + the hard rules the
62
+ * emitted YAML must obey (single-line-safe names, the `$UPPER_SNAKE_CASE`
59
63
  * secret convention, no `permissions.mode: bypass`). Kept as a builder so a
60
64
  * test can assert the guidance is present without shipping a golden file.
61
65
  */
62
66
  export function buildInterviewSystemPrompt() {
63
67
  const shapes = SHAPE_GUIDANCE.map((s) => ` - ${s.target}: ${s.when}`).join("\n");
64
68
  return [
65
- "You are the CrewHaus harness designer. Interview the user in plain English to",
66
- "understand what agent they want to build, then emit a single valid crewhaus.yaml",
67
- "by calling the `emit_spec` tool with the full YAML as its `yaml` argument.",
69
+ "You are the CrewHaus harness designer, running a real multi-turn interview to",
70
+ "understand what agent the user wants to build and emit ONE valid crewhaus.yaml.",
71
+ "Two interview tools drive the conversation:",
72
+ "",
73
+ " - `ask_user`: pose ONE focused clarifying question. After calling it, end your",
74
+ " turn — the user's answer arrives as the next user message. Never bundle",
75
+ " several questions into one call, and never call it twice in one turn.",
76
+ " - `emit_spec`: submit the complete crewhaus.yaml as the `yaml` argument. The",
77
+ " validator replies in the tool result; on an error, FIX exactly that error and",
78
+ " re-emit in this same conversation — do not restart the interview.",
79
+ "",
80
+ "Requirements discipline (binding, every turn):",
81
+ " - Turn 1: extract EVERY requirement from the user's opening message into",
82
+ " verbatim REQ entries (REQ-001, REQ-002, …) — quote the user's exact words,",
83
+ " never paraphrase — pin them with FocusWrite, and ECHO them back:",
84
+ ' "Got it. Requirements so far: REQ-001 …; open questions: …".',
85
+ ' - Each later answer: pin it, confirm the REQ ("REQ-002 confirmed"), and keep',
86
+ " the echo current.",
87
+ " - NEVER re-ask a requirement recorded as confirmed. Check the",
88
+ " <requirements_ledger> block, <current_plan>, and FocusRead before asking",
89
+ " anything — a confirmed REQ is already answered, in the user's own words.",
90
+ " - Before calling emit_spec: read the ledger, verify each REQ maps to a spec",
91
+ " field, and list the mapping in your reply, one line per REQ, using the →",
92
+ ' arrow: REQ-001 "…" → name',
93
+ ' - On a resumed session, your FIRST reply must start with "Resuming: N',
94
+ ' requirements confirmed, M open questions", computed from the ledger and',
95
+ " focus — then continue where the interview stopped, without re-asking.",
68
96
  "",
69
97
  "Pick exactly ONE target shape:",
70
98
  shapes,
@@ -78,13 +106,12 @@ export function buildInterviewSystemPrompt() {
78
106
  " - For a cli target: top-level `name`, `target: cli`, and an `agent` block with",
79
107
  " `model` and `instructions`. Tools go in a top-level `tools:` list of names.",
80
108
  " - Keep the spec minimal — only the blocks the user actually asked for.",
81
- "",
82
- "When the validator reports an error on your draft, FIX exactly that error and re-emit",
83
- "— do not restart the interview.",
84
109
  ].join("\n");
85
110
  }
86
- /** The tool the interviewer calls to submit a candidate spec. Exported so the
87
- * CLI wires the same schema into the adapter request. */
111
+ /** The tool the interviewer calls to submit a candidate spec the SAME
112
+ * contract the pre-0.3.0 single-shot interview used (`{ yaml: string }`).
113
+ * Exported so tests can pin the schema and `init-conversation.ts` builds
114
+ * the RegisteredTool from it. */
88
115
  export const EMIT_SPEC_TOOL = {
89
116
  name: "emit_spec",
90
117
  description: "Submit the complete crewhaus.yaml as a single YAML string. The validator parses it " +
@@ -97,40 +124,23 @@ export const EMIT_SPEC_TOOL = {
97
124
  required: ["yaml"],
98
125
  },
99
126
  };
100
- export class InterviewError extends SpecParseError {
101
- }
102
- /**
103
- * Drive the validate-and-retry interview loop. Each attempt asks the injected
104
- * `proposeSpec` for a draft, runs it through `parseSpec` (the LIVE union), and
105
- * on a `SpecParseError` appends the error to the feedback log for the next
106
- * attempt. Succeeds on the first draft that validates; throws
107
- * {@link InterviewError} after `maxAttempts` failed drafts (or if the model
108
- * declines to emit). Pure given its `proposeSpec` seam — no I/O.
109
- */
110
- export async function runInterview(opts) {
111
- const maxAttempts = opts.maxAttempts ?? 4;
112
- const feedback = [];
113
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
114
- const yaml = await opts.proposeSpec(feedback);
115
- if (yaml === undefined) {
116
- throw new InterviewError("the interviewer did not emit a spec (no emit_spec tool call) — try a more concrete description");
117
- }
118
- try {
119
- const spec = parseSpec(yaml);
120
- return { yaml, spec, attempts: attempt };
121
- }
122
- catch (err) {
123
- if (err instanceof SpecParseError) {
124
- feedback.push(err.message);
125
- continue;
126
- }
127
- throw err;
128
- }
129
- }
130
- throw new InterviewError(`interviewer failed to produce a valid spec after ${maxAttempts} attempts. Last errors:\n${feedback
131
- .map((f) => ` - ${f}`)
132
- .join("\n")}`);
133
- }
127
+ /** v0.3.0 §2.9 the interviewer's clarifying-question tool. The loop
128
+ * surfaces the question on the terminal; the user's answer arrives as the
129
+ * next user message through the multi-turn REPL (nothing typed is ever
130
+ * discarded the exact failure the single-shot path had). */
131
+ export const ASK_USER_TOOL = {
132
+ name: "ask_user",
133
+ description: "Ask the user ONE focused clarifying question. The question is shown on the terminal; " +
134
+ "end your turn after calling this the user's answer arrives as the next user message. " +
135
+ "Check the requirements ledger first: never re-ask a confirmed requirement.",
136
+ input_schema: {
137
+ type: "object",
138
+ properties: {
139
+ question: { type: "string", description: "The single question to ask." },
140
+ },
141
+ required: ["question"],
142
+ },
143
+ };
134
144
  /** YAML-quote a scalar that might contain special characters. The scripted
135
145
  * answers are user free-text, so a name/model with a colon or `#` must be
136
146
  * double-quoted to parse. Instructions use a block scalar (see below). */
package/dist/lint.js CHANGED
@@ -67,7 +67,23 @@ export function runLint(yamlText, resolveTool, passes = DEFAULT_PIPELINE) {
67
67
  rule: "scope",
68
68
  });
69
69
  }
70
- return { ok: findings.length === 0, findings, spec, ir };
70
+ // Stage 5 — v0.3.0 Goal 3 (design §4.1): a user-declared
71
+ // `mcp_servers.thredz` next to a `thredz:` block WINS over the compiler's
72
+ // synthesis (explicit beats implicit). That is deliberate — the vendored
73
+ // -server escape hatch — but worth a warning so nobody wonders why their
74
+ // `base_url`/`visibility` knobs stopped applying.
75
+ const specThredz = spec.thredz;
76
+ const specMcp = spec.mcp_servers;
77
+ if (specThredz !== undefined && specThredz !== false && specMcp?.["thredz"] !== undefined) {
78
+ findings.push({
79
+ message: "mcp_servers.thredz is user-declared, so the thredz: block does not synthesize a server — your explicit entry wins (explicit beats implicit). The thredz: knobs still drive the wiring (aliases, goal mirror), but api_key/base_url/visibility only apply through YOUR server's env; it must speak the thredz-mcp v0.2.0 tool contract.",
80
+ path: "mcp_servers.thredz",
81
+ severity: "warning",
82
+ rule: "thredz-override",
83
+ });
84
+ }
85
+ // Warnings inform; only errors gate (`ok` drives the CLI exit code).
86
+ return { ok: findings.every((f) => f.severity !== "error"), findings, spec, ir };
71
87
  }
72
88
  /** Re-exported for the CLI wrapper's philosophy-alignment parity note. */
73
89
  export { auditToolScopes };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * 0.3.0 memory release (design §3.4) — the `crewhaus memory` verb cluster's
3
+ * pure/IO helpers plus the `crewhaus migrate memories` data migration. Kept
4
+ * thin + separately testable, mirroring `sessions-index.ts` / `lessons.ts`
5
+ * (the entry file runs an argv switch on import, so logic lives here).
6
+ *
7
+ * The migration rides the existing migration-engine machinery: memory
8
+ * entries are versioned by their `schemaVersion` field (absent = v1), the
9
+ * engine walks the registered 1→2 chain per entry line, and the runner-side
10
+ * conventions (dry-run plan first, idempotent re-runs) are preserved.
11
+ * `.crewhaus/meta.json` records the store-level schema version so future
12
+ * migrations and doctors can see at a glance what has been backfilled.
13
+ */
14
+ import { CrewhausError } from "@crewhaus/errors";
15
+ import { type MemoryListItem } from "@crewhaus/memory-store";
16
+ import { type Migration, MigrationEngine } from "@crewhaus/migration-engine";
17
+ export declare class MemoryCliError extends CrewhausError {
18
+ readonly name = "MemoryCliError";
19
+ constructor(message: string, cause?: unknown);
20
+ }
21
+ /** The memories root, relative to a harness cwd. */
22
+ export declare const MEMORIES_SUBDIR: string;
23
+ /** Spec names that have a memory file under `memoriesDir`, sorted. */
24
+ export declare function listMemorySpecs(memoriesDir: string): string[];
25
+ /**
26
+ * Resolve which spec's store a single-target verb (`show`/`forget`) operates
27
+ * on: an explicit `--spec` wins; otherwise a lone memory file is unambiguous;
28
+ * anything else is an error naming the candidates.
29
+ */
30
+ export declare function resolveMemorySpec(memoriesDir: string, specFlag?: string): string;
31
+ /** Compact human age: "3m", "7h", "12d". Injectable clock for tests. */
32
+ export declare function humanAge(createdAtIso: string, nowMs: number): string;
33
+ /**
34
+ * Render one `memory list` row: id / age / status flag / provenance flag /
35
+ * tags / text preview. Deterministic given `nowMs`.
36
+ */
37
+ export declare function renderMemoryListRow(item: MemoryListItem, nowMs: number): string;
38
+ /** Render the `memory list` block for one spec's items. */
39
+ export declare function renderMemoryList(specName: string, items: ReadonlyArray<MemoryListItem>, nowMs: number): string[];
40
+ /** Render `memory show <id>`: the full entry plus its lifecycle status. */
41
+ export declare function renderMemoryShow(item: MemoryListItem): string[];
42
+ /**
43
+ * Backfill target for v1 auto-capture entries: they tag the sessionId today
44
+ * (`["auto-capture", "<sess_…>"]`), which is exactly the provenance the v2
45
+ * schema makes first-class.
46
+ */
47
+ export declare function deriveAutoCaptureSessionId(tags: ReadonlyArray<string>): string | undefined;
48
+ /**
49
+ * The memory-entry 1 → 2 step, registered on a `MigrationEngine` so the
50
+ * chain-walk (and any future 2 → 3 step) reuses Section-28 machinery. The
51
+ * `version` key is a walk-time shim the file migrator injects from
52
+ * `schemaVersion` (absent = 1) and strips before serialising.
53
+ */
54
+ export declare const MEMORY_ENTRY_1_TO_2: Migration;
55
+ /** An engine with the memory-entry chain registered. */
56
+ export declare function createMemoryMigrationEngine(): MigrationEngine;
57
+ export type MigrateMemoriesFileReport = {
58
+ readonly specName: string;
59
+ /** v1 entry lines rewritten to v2. */
60
+ readonly migrated: number;
61
+ /** Entry lines already at (or beyond) the target version. */
62
+ readonly skipped: number;
63
+ /** Non-entry lines (tombstones/unknown/malformed) preserved verbatim. */
64
+ readonly passthrough: number;
65
+ };
66
+ export type MigrateMemoriesReport = {
67
+ readonly files: ReadonlyArray<MigrateMemoriesFileReport>;
68
+ readonly migrated: number;
69
+ readonly skipped: number;
70
+ readonly dryRun: boolean;
71
+ /** Absolute path of the stamped meta file (absent on dry runs). */
72
+ readonly metaPath?: string;
73
+ };
74
+ /**
75
+ * Migrate every `.crewhaus/memories/*.jsonl` under `rootDir` to the current
76
+ * entry schema. Idempotent: entries already at `schemaVersion >= 2` are
77
+ * skipped, so a re-run migrates 0 and rewrites nothing. Non-entry lines
78
+ * (tombstones, future line kinds, malformed junk) pass through VERBATIM —
79
+ * this migration never destroys data. Writes are atomic (tmp + rename).
80
+ * With `dryRun` no file is touched and no meta is stamped.
81
+ */
82
+ export declare function migrateMemories(rootDir: string, opts?: {
83
+ dryRun?: boolean;
84
+ now?: () => Date;
85
+ }): MigrateMemoriesReport;
86
+ /**
87
+ * Record the memories schema version in `.crewhaus/meta.json` (created when
88
+ * absent; other top-level keys are preserved). Returns the meta path.
89
+ */
90
+ export declare function stampMemoriesMeta(rootDir: string, now?: () => Date): string;
91
+ /** Render the `migrate memories` report, one line per file + a summary. */
92
+ export declare function formatMigrateMemoriesReport(report: MigrateMemoriesReport): string[];
@@ -0,0 +1,278 @@
1
+ /**
2
+ * 0.3.0 memory release (design §3.4) — the `crewhaus memory` verb cluster's
3
+ * pure/IO helpers plus the `crewhaus migrate memories` data migration. Kept
4
+ * thin + separately testable, mirroring `sessions-index.ts` / `lessons.ts`
5
+ * (the entry file runs an argv switch on import, so logic lives here).
6
+ *
7
+ * The migration rides the existing migration-engine machinery: memory
8
+ * entries are versioned by their `schemaVersion` field (absent = v1), the
9
+ * engine walks the registered 1→2 chain per entry line, and the runner-side
10
+ * conventions (dry-run plan first, idempotent re-runs) are preserved.
11
+ * `.crewhaus/meta.json` records the store-level schema version so future
12
+ * migrations and doctors can see at a glance what has been backfilled.
13
+ */
14
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from "node:fs";
15
+ import { basename, join } from "node:path";
16
+ import { CrewhausError } from "@crewhaus/errors";
17
+ import { MEMORY_SCHEMA_VERSION, isMemoryEntry } from "@crewhaus/memory-store";
18
+ import { MigrationEngine } from "@crewhaus/migration-engine";
19
+ export class MemoryCliError extends CrewhausError {
20
+ name = "MemoryCliError";
21
+ constructor(message, cause) {
22
+ super("config", message, cause);
23
+ }
24
+ }
25
+ /** The memories root, relative to a harness cwd. */
26
+ export const MEMORIES_SUBDIR = join(".crewhaus", "memories");
27
+ /** Spec names that have a memory file under `memoriesDir`, sorted. */
28
+ export function listMemorySpecs(memoriesDir) {
29
+ if (!existsSync(memoriesDir))
30
+ return [];
31
+ return readdirSync(memoriesDir)
32
+ .filter((f) => f.endsWith(".jsonl"))
33
+ .map((f) => basename(f, ".jsonl"))
34
+ .sort();
35
+ }
36
+ /**
37
+ * Resolve which spec's store a single-target verb (`show`/`forget`) operates
38
+ * on: an explicit `--spec` wins; otherwise a lone memory file is unambiguous;
39
+ * anything else is an error naming the candidates.
40
+ */
41
+ export function resolveMemorySpec(memoriesDir, specFlag) {
42
+ const specs = listMemorySpecs(memoriesDir);
43
+ if (specFlag !== undefined) {
44
+ if (!specs.includes(specFlag)) {
45
+ throw new MemoryCliError(`no memory file for spec "${specFlag}" under ${memoriesDir}${specs.length > 0 ? ` (have: ${specs.join(", ")})` : ""}`);
46
+ }
47
+ return specFlag;
48
+ }
49
+ if (specs.length === 0) {
50
+ throw new MemoryCliError(`no memory files under ${memoriesDir}`);
51
+ }
52
+ if (specs.length === 1)
53
+ return specs[0];
54
+ throw new MemoryCliError(`multiple memory files under ${memoriesDir} — pick one with --spec <name> (have: ${specs.join(", ")})`);
55
+ }
56
+ /** Compact human age: "3m", "7h", "12d". Injectable clock for tests. */
57
+ export function humanAge(createdAtIso, nowMs) {
58
+ const created = Date.parse(createdAtIso);
59
+ if (Number.isNaN(created))
60
+ return "?";
61
+ const deltaMs = Math.max(0, nowMs - created);
62
+ const minutes = Math.floor(deltaMs / 60_000);
63
+ if (minutes < 60)
64
+ return `${minutes}m`;
65
+ const hours = Math.floor(minutes / 60);
66
+ if (hours < 48)
67
+ return `${hours}h`;
68
+ return `${Math.floor(hours / 24)}d`;
69
+ }
70
+ const TEXT_PREVIEW_LEN = 72;
71
+ function previewText(text) {
72
+ const oneLine = text.replace(/\s+/g, " ").trim();
73
+ return oneLine.length > TEXT_PREVIEW_LEN ? `${oneLine.slice(0, TEXT_PREVIEW_LEN - 1)}…` : oneLine;
74
+ }
75
+ /**
76
+ * Render one `memory list` row: id / age / status flag / provenance flag /
77
+ * tags / text preview. Deterministic given `nowMs`.
78
+ */
79
+ export function renderMemoryListRow(item, nowMs) {
80
+ const { entry, status } = item;
81
+ const age = humanAge(entry.createdAt, nowMs).padStart(4);
82
+ const statusCol = (status === "live" ? "" : status).padEnd(10);
83
+ const provCol = (entry.provenance !== undefined ? "prov" : "").padEnd(4);
84
+ const tagsCol = entry.tags.length > 0 ? ` [${entry.tags.join(", ")}]` : "";
85
+ return ` ${entry.id} ${age} ${statusCol} ${provCol} ${previewText(entry.text)}${tagsCol}`;
86
+ }
87
+ /** Render the `memory list` block for one spec's items. */
88
+ export function renderMemoryList(specName, items, nowMs) {
89
+ const live = items.filter((i) => i.status === "live").length;
90
+ const superseded = items.filter((i) => i.status === "superseded").length;
91
+ const expired = items.filter((i) => i.status === "expired").length;
92
+ const lines = [
93
+ `[memory] ${specName} — ${live} live, ${superseded} superseded, ${expired} expired`,
94
+ ];
95
+ for (const item of items)
96
+ lines.push(renderMemoryListRow(item, nowMs));
97
+ return lines;
98
+ }
99
+ /** Render `memory show <id>`: the full entry plus its lifecycle status. */
100
+ export function renderMemoryShow(item) {
101
+ const { entry, status } = item;
102
+ const lines = [
103
+ `id: ${entry.id}`,
104
+ `status: ${status}`,
105
+ `created: ${entry.createdAt}`,
106
+ `schemaVersion: ${entry.schemaVersion ?? "1 (implicit)"}`,
107
+ `tags: ${entry.tags.length > 0 ? entry.tags.join(", ") : "(none)"}`,
108
+ ];
109
+ if (entry.expiresAt !== undefined) {
110
+ lines.push(`expires: ${new Date(entry.expiresAt).toISOString()}`);
111
+ }
112
+ if (entry.supersededBy !== undefined) {
113
+ lines.push(`supersededBy: ${entry.supersededBy}`);
114
+ }
115
+ if (entry.provenance !== undefined) {
116
+ const p = entry.provenance;
117
+ const bits = [];
118
+ if (p.sessionId !== undefined)
119
+ bits.push(`session ${p.sessionId}`);
120
+ if (p.evidence !== undefined && p.evidence.length > 0) {
121
+ bits.push(`evidence ${p.evidence.join(", ")}`);
122
+ }
123
+ lines.push(`provenance: ${bits.length > 0 ? bits.join(" · ") : "(empty)"}`);
124
+ }
125
+ lines.push(`text: ${entry.text}`);
126
+ return lines;
127
+ }
128
+ // -------- crewhaus migrate memories --------
129
+ const SESSION_TAG_RE = /^sess_[0-9a-f]{16}$/;
130
+ /**
131
+ * Backfill target for v1 auto-capture entries: they tag the sessionId today
132
+ * (`["auto-capture", "<sess_…>"]`), which is exactly the provenance the v2
133
+ * schema makes first-class.
134
+ */
135
+ export function deriveAutoCaptureSessionId(tags) {
136
+ if (!tags.includes("auto-capture"))
137
+ return undefined;
138
+ return tags.find((t) => SESSION_TAG_RE.test(t));
139
+ }
140
+ /**
141
+ * The memory-entry 1 → 2 step, registered on a `MigrationEngine` so the
142
+ * chain-walk (and any future 2 → 3 step) reuses Section-28 machinery. The
143
+ * `version` key is a walk-time shim the file migrator injects from
144
+ * `schemaVersion` (absent = 1) and strips before serialising.
145
+ */
146
+ export const MEMORY_ENTRY_1_TO_2 = Object.freeze({
147
+ from: 1,
148
+ to: 2,
149
+ up(entry) {
150
+ const tags = Array.isArray(entry["tags"])
151
+ ? entry["tags"].filter((t) => typeof t === "string")
152
+ : [];
153
+ const sessionId = deriveAutoCaptureSessionId(tags);
154
+ const backfill = entry["provenance"] === undefined && sessionId !== undefined
155
+ ? { provenance: { sessionId } }
156
+ : {};
157
+ return { ...entry, ...backfill, version: 2, schemaVersion: 2 };
158
+ },
159
+ down(entry) {
160
+ const { schemaVersion: _sv, provenance: _p, expiresAt: _e, supersededBy: _sb, ...rest } = entry;
161
+ return { ...rest, version: 1 };
162
+ },
163
+ });
164
+ /** An engine with the memory-entry chain registered. */
165
+ export function createMemoryMigrationEngine() {
166
+ const engine = new MigrationEngine();
167
+ engine.register(MEMORY_ENTRY_1_TO_2);
168
+ return engine;
169
+ }
170
+ /**
171
+ * Migrate every `.crewhaus/memories/*.jsonl` under `rootDir` to the current
172
+ * entry schema. Idempotent: entries already at `schemaVersion >= 2` are
173
+ * skipped, so a re-run migrates 0 and rewrites nothing. Non-entry lines
174
+ * (tombstones, future line kinds, malformed junk) pass through VERBATIM —
175
+ * this migration never destroys data. Writes are atomic (tmp + rename).
176
+ * With `dryRun` no file is touched and no meta is stamped.
177
+ */
178
+ export function migrateMemories(rootDir, opts = {}) {
179
+ const dryRun = opts.dryRun === true;
180
+ const now = opts.now ?? (() => new Date());
181
+ const memoriesDir = join(rootDir, MEMORIES_SUBDIR);
182
+ const engine = createMemoryMigrationEngine();
183
+ const files = [];
184
+ let totalMigrated = 0;
185
+ let totalSkipped = 0;
186
+ for (const specName of listMemorySpecs(memoriesDir)) {
187
+ const filePath = join(memoriesDir, `${specName}.jsonl`);
188
+ const raw = readFileSync(filePath, "utf-8");
189
+ const outLines = [];
190
+ let migrated = 0;
191
+ let skipped = 0;
192
+ let passthrough = 0;
193
+ for (const line of raw.split("\n")) {
194
+ if (line.trim() === "")
195
+ continue;
196
+ let parsed;
197
+ try {
198
+ parsed = JSON.parse(line);
199
+ }
200
+ catch {
201
+ outLines.push(line); // malformed junk passes through untouched
202
+ passthrough += 1;
203
+ continue;
204
+ }
205
+ if (!isMemoryEntry(parsed)) {
206
+ outLines.push(line); // tombstones + future line kinds, verbatim
207
+ passthrough += 1;
208
+ continue;
209
+ }
210
+ const fromVersion = parsed.schemaVersion ?? 1;
211
+ if (fromVersion >= MEMORY_SCHEMA_VERSION) {
212
+ outLines.push(line);
213
+ skipped += 1;
214
+ continue;
215
+ }
216
+ const { version: _shim, ...migratedEntry } = engine.migrate({ ...parsed, version: fromVersion }, MEMORY_SCHEMA_VERSION);
217
+ outLines.push(JSON.stringify(migratedEntry));
218
+ migrated += 1;
219
+ }
220
+ files.push({ specName, migrated, skipped, passthrough });
221
+ totalMigrated += migrated;
222
+ totalSkipped += skipped;
223
+ if (!dryRun && migrated > 0) {
224
+ const tmpPath = `${filePath}.tmp`;
225
+ writeFileSync(tmpPath, outLines.length > 0 ? `${outLines.join("\n")}\n` : "", {
226
+ mode: 0o600,
227
+ });
228
+ renameSync(tmpPath, filePath);
229
+ }
230
+ }
231
+ // Dry runs write nothing; a harness with no memory files has nothing to
232
+ // stamp either (new stores write v2 natively).
233
+ if (dryRun || files.length === 0) {
234
+ return { files, migrated: totalMigrated, skipped: totalSkipped, dryRun };
235
+ }
236
+ const metaPath = stampMemoriesMeta(rootDir, now);
237
+ return { files, migrated: totalMigrated, skipped: totalSkipped, dryRun, metaPath };
238
+ }
239
+ /**
240
+ * Record the memories schema version in `.crewhaus/meta.json` (created when
241
+ * absent; other top-level keys are preserved). Returns the meta path.
242
+ */
243
+ export function stampMemoriesMeta(rootDir, now = () => new Date()) {
244
+ const crewhausDir = join(rootDir, ".crewhaus");
245
+ const metaPath = join(crewhausDir, "meta.json");
246
+ let meta = {};
247
+ if (existsSync(metaPath)) {
248
+ try {
249
+ const parsed = JSON.parse(readFileSync(metaPath, "utf-8"));
250
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
251
+ meta = parsed;
252
+ }
253
+ }
254
+ catch {
255
+ // Unreadable meta — rebuild it rather than fail the migration.
256
+ }
257
+ }
258
+ meta["memories"] = {
259
+ schemaVersion: MEMORY_SCHEMA_VERSION,
260
+ migratedAt: now().toISOString(),
261
+ };
262
+ mkdirSync(crewhausDir, { recursive: true });
263
+ writeFileSync(metaPath, `${JSON.stringify(meta, null, 2)}\n`, { mode: 0o600 });
264
+ return metaPath;
265
+ }
266
+ /** Render the `migrate memories` report, one line per file + a summary. */
267
+ export function formatMigrateMemoriesReport(report) {
268
+ const lines = [];
269
+ for (const f of report.files) {
270
+ lines.push(` ${f.specName}: ${f.migrated} migrated, ${f.skipped} already v${MEMORY_SCHEMA_VERSION}, ${f.passthrough} passthrough line(s)`);
271
+ }
272
+ const dryNote = report.dryRun ? " (dry-run — nothing written)" : "";
273
+ lines.push(`[migrate] memories: ${report.migrated} entry(ies) migrated across ${report.files.length} file(s)${dryNote}`);
274
+ if (report.metaPath !== undefined) {
275
+ lines.push(`[migrate] stamped ${report.metaPath} (memories.schemaVersion ${MEMORY_SCHEMA_VERSION})`);
276
+ }
277
+ return lines;
278
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * v0.3.0 Goal 6 — the CLI's terminal-failure rendering, factored out of the
3
+ * entry file `index.ts` (which runs a top-level argv switch and so cannot be
4
+ * imported by a test without executing the CLI). `die()` and the `crewhaus
5
+ * run` failure path both route through {@link renderCliFailure} so the CLI
6
+ * prints ONE canonical shape:
7
+ *
8
+ * - a `RunFailedError` renders its structured report via
9
+ * `formatRunFailure()` with the `crewhaus:` prefix and exits with the
10
+ * report's coded status (billing 31, auth 30, …);
11
+ * - every other CrewhausError (and every plain-string die()) keeps the
12
+ * pre-0.3.0 one-liner `crewhaus: <message>` + exit 1, byte-for-byte.
13
+ */
14
+ import { type CrewhausError } from "@crewhaus/errors";
15
+ /**
16
+ * The resume hint `crewhaus run` appends to a terminal failure when the
17
+ * target is cli and a session was persisted before the run died (design
18
+ * §8.2's exact wording).
19
+ */
20
+ export declare const CONTINUE_NOTE = "Your session is saved \u2014 `crewhaus run --continue` resumes exactly where it stopped.";
21
+ export type RenderedCliFailure = {
22
+ /** Full stderr text (no trailing newline). */
23
+ readonly text: string;
24
+ /** Process exit code: report.exitCode for a RunFailedError, else 1. */
25
+ readonly exitCode: number;
26
+ };
27
+ /**
28
+ * Render a fatal CLI error. `notes` (e.g. {@link CONTINUE_NOTE}) are only
29
+ * meaningful for a RunFailedError — they land between the Fix/Docs lines
30
+ * and the exit line; they are ignored for the one-liner shape.
31
+ */
32
+ export declare function renderCliFailure(err: string | CrewhausError, opts?: {
33
+ notes?: readonly string[];
34
+ }): RenderedCliFailure;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * v0.3.0 Goal 6 — the CLI's terminal-failure rendering, factored out of the
3
+ * entry file `index.ts` (which runs a top-level argv switch and so cannot be
4
+ * imported by a test without executing the CLI). `die()` and the `crewhaus
5
+ * run` failure path both route through {@link renderCliFailure} so the CLI
6
+ * prints ONE canonical shape:
7
+ *
8
+ * - a `RunFailedError` renders its structured report via
9
+ * `formatRunFailure()` with the `crewhaus:` prefix and exits with the
10
+ * report's coded status (billing 31, auth 30, …);
11
+ * - every other CrewhausError (and every plain-string die()) keeps the
12
+ * pre-0.3.0 one-liner `crewhaus: <message>` + exit 1, byte-for-byte.
13
+ */
14
+ import { formatRunFailure, isRunFailedError } from "@crewhaus/errors";
15
+ /**
16
+ * The resume hint `crewhaus run` appends to a terminal failure when the
17
+ * target is cli and a session was persisted before the run died (design
18
+ * §8.2's exact wording).
19
+ */
20
+ export const CONTINUE_NOTE = "Your session is saved — `crewhaus run --continue` resumes exactly where it stopped.";
21
+ /**
22
+ * Render a fatal CLI error. `notes` (e.g. {@link CONTINUE_NOTE}) are only
23
+ * meaningful for a RunFailedError — they land between the Fix/Docs lines
24
+ * and the exit line; they are ignored for the one-liner shape.
25
+ */
26
+ export function renderCliFailure(err, opts) {
27
+ if (typeof err !== "string" && isRunFailedError(err)) {
28
+ return {
29
+ text: formatRunFailure(err.report, {
30
+ prefix: "crewhaus:",
31
+ ...(opts?.notes !== undefined ? { notes: opts.notes } : {}),
32
+ }),
33
+ exitCode: err.report.exitCode,
34
+ };
35
+ }
36
+ const message = typeof err === "string" ? err : err.message;
37
+ return { text: `crewhaus: ${message}`, exitCode: 1 };
38
+ }