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/init-interactive.js
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseSpec } from "@crewhaus/spec";
|
|
2
2
|
/**
|
|
3
|
-
* Item 39 — `crewhaus init --interactive`. Folds the demos
|
|
4
|
-
* pattern (an agent that interviews the user in plain
|
|
5
|
-
* validated crewhaus.yaml) into core.
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
58
|
-
* the
|
|
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
|
|
66
|
-
"understand what agent
|
|
67
|
-
"
|
|
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
|
|
87
|
-
*
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
*
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
+
}
|