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.
@@ -5,23 +5,11 @@
5
5
  * knowledge (outcome, tools used, ratings, key facts) past raw-transcript
6
6
  * retention. These entries feed the memory-store / few-shot / FAQ features.
7
7
  *
8
- * The summary logic lives in `@crewhaus/session-store` (`summarizeSession`, a
9
- * deterministic no-model reducer). This module is the CLI-side glue: read a
10
- * session's JSONL, summarize it, and write the index entry idempotently. Kept
11
- * thin + separately testable, mirroring `feedback.ts` / `dataset-mine.ts`.
8
+ * v0.3.0 PR 14: the glue (`parseSessionLog`, `summarizeSessionIntoIndex`,
9
+ * `SESSIONS_INDEX_DIRNAME`) moved into `@crewhaus/session-store` next to
10
+ * `summarizeSession`, which it always wrapped so the dream engine's
11
+ * sessions fold-in step can call it without reaching into the CLI. This
12
+ * module stays as a re-export so `crewhaus sessions summarize` (and any
13
+ * other CLI import) is untouched.
12
14
  */
13
- import { type SessionSummary } from "@crewhaus/session-store";
14
- /** The index directory, relative to a session root's PARENT `.crewhaus`. */
15
- export declare const SESSIONS_INDEX_DIRNAME = "sessions-index";
16
- /** Parse a JSONL blob into `{ kind, payload }` events, skipping bad lines. */
17
- export declare function parseSessionLog(text: string): Array<{
18
- kind?: string;
19
- payload?: unknown;
20
- }>;
21
- /**
22
- * Summarize the session whose `.jsonl` lives at `logPath` into `indexDir`,
23
- * returning the written summary (or undefined when the log is missing/empty).
24
- * Idempotent: re-writing the same session overwrites its entry rather than
25
- * duplicating. `now` is injectable for deterministic tests.
26
- */
27
- export declare function summarizeSessionIntoIndex(sessionId: string, logPath: string, indexDir: string, now?: () => Date): SessionSummary | undefined;
15
+ export { SESSIONS_INDEX_DIRNAME, parseSessionLog, summarizeSessionIntoIndex, } from "@crewhaus/session-store";
@@ -5,47 +5,11 @@
5
5
  * knowledge (outcome, tools used, ratings, key facts) past raw-transcript
6
6
  * retention. These entries feed the memory-store / few-shot / FAQ features.
7
7
  *
8
- * The summary logic lives in `@crewhaus/session-store` (`summarizeSession`, a
9
- * deterministic no-model reducer). This module is the CLI-side glue: read a
10
- * session's JSONL, summarize it, and write the index entry idempotently. Kept
11
- * thin + separately testable, mirroring `feedback.ts` / `dataset-mine.ts`.
8
+ * v0.3.0 PR 14: the glue (`parseSessionLog`, `summarizeSessionIntoIndex`,
9
+ * `SESSIONS_INDEX_DIRNAME`) moved into `@crewhaus/session-store` next to
10
+ * `summarizeSession`, which it always wrapped so the dream engine's
11
+ * sessions fold-in step can call it without reaching into the CLI. This
12
+ * module stays as a re-export so `crewhaus sessions summarize` (and any
13
+ * other CLI import) is untouched.
12
14
  */
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
- import { join } from "node:path";
15
- import { summarizeSession } from "@crewhaus/session-store";
16
- /** The index directory, relative to a session root's PARENT `.crewhaus`. */
17
- export const SESSIONS_INDEX_DIRNAME = "sessions-index";
18
- /** Parse a JSONL blob into `{ kind, payload }` events, skipping bad lines. */
19
- export function parseSessionLog(text) {
20
- const out = [];
21
- for (const line of text.split("\n")) {
22
- if (line.trim() === "")
23
- continue;
24
- try {
25
- out.push(JSON.parse(line));
26
- }
27
- catch {
28
- // A single malformed line must not abort the summary.
29
- }
30
- }
31
- return out;
32
- }
33
- /**
34
- * Summarize the session whose `.jsonl` lives at `logPath` into `indexDir`,
35
- * returning the written summary (or undefined when the log is missing/empty).
36
- * Idempotent: re-writing the same session overwrites its entry rather than
37
- * duplicating. `now` is injectable for deterministic tests.
38
- */
39
- export function summarizeSessionIntoIndex(sessionId, logPath, indexDir, now = () => new Date()) {
40
- if (!existsSync(logPath))
41
- return undefined;
42
- const events = parseSessionLog(readFileSync(logPath, "utf-8"));
43
- if (events.length === 0)
44
- return undefined;
45
- const summary = summarizeSession(sessionId, events, { now });
46
- mkdirSync(indexDir, { recursive: true });
47
- writeFileSync(join(indexDir, `${sessionId}.json`), `${JSON.stringify(summary, null, 2)}\n`, {
48
- mode: 0o600,
49
- });
50
- return summary;
51
- }
15
+ export { SESSIONS_INDEX_DIRNAME, parseSessionLog, summarizeSessionIntoIndex, } from "@crewhaus/session-store";
@@ -0,0 +1,36 @@
1
+ import { type UnresolvedMcpServerConfig } from "@crewhaus/mcp-host";
2
+ import type { DoctorCredentialCheck } from "./doctor-checks";
3
+ /**
4
+ * Extract the thredz MCP server config from a spec text, or undefined when
5
+ * the spec has no `thredz:` block (or does not parse/lower — doctor's other
6
+ * checks own spec validity; the probe never doubles up on those errors).
7
+ */
8
+ export declare function thredzProbeTarget(specText: string): UnresolvedMcpServerConfig | undefined;
9
+ export type ThredzProbeResult = {
10
+ readonly pass: boolean;
11
+ /** Human-readable outcome ("wiki_stats ok (89ms)" / "thredz_billing — …"). */
12
+ readonly detail: string;
13
+ /** The classified failure (thredz_* | config | mcp_boot) on a miss. */
14
+ readonly failureClass?: string;
15
+ };
16
+ export type ThredzProbeDeps = {
17
+ /** Env consulted for the config's secret refs. Default `process.env`. */
18
+ readonly env?: Readonly<Record<string, string | undefined>>;
19
+ /** Injected clock for deterministic durations in tests. */
20
+ readonly now?: () => number;
21
+ /**
22
+ * Injected call seam (tests): given the RESOLVED config, return the
23
+ * `wiki_stats` result. Production spawns the configured server through
24
+ * McpHost — the same transport the run path uses.
25
+ */
26
+ readonly call?: (config: {
27
+ transport: string;
28
+ }) => Promise<{
29
+ content: string;
30
+ isError: boolean;
31
+ }>;
32
+ };
33
+ /** Run the live `wiki_stats` round-trip against the configured server. */
34
+ export declare function probeThredz(config: UnresolvedMcpServerConfig, deps?: ThredzProbeDeps): Promise<ThredzProbeResult>;
35
+ /** Fold the probe result into doctor's ✓/✗ check shape. */
36
+ export declare function thredzProbeToCheck(result: ThredzProbeResult): DoctorCredentialCheck;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * v0.3.0 Goal 3 (design §4.4) — `crewhaus doctor --probe`'s Thredz check: a
3
+ * live `wiki_stats` round-trip through the SAME synthesized (or
4
+ * user-declared) `mcp_servers.thredz` config the run path boots, so what
5
+ * passes here is exactly what a run will do. One cheap read call — no
6
+ * writes, no quota consumption.
7
+ *
8
+ * Failures classify through the wiring layer's one error-mapping choke
9
+ * point (`classifyThredzFailure`): a disabled key reports `thredz_billing`,
10
+ * a plan cap `thredz_quota`, a missing THREDZ_API_KEY a `config` failure
11
+ * naming the variable, and a server that won't boot `mcp_boot` — doctor's ✗
12
+ * line names the fix class instead of dumping a raw MCP error.
13
+ *
14
+ * Factored out of the entry file `index.ts` (which runs a top-level argv
15
+ * switch and so cannot be imported by a test without executing the CLI).
16
+ * Target extraction is pure; the prober takes an injected `call` seam so
17
+ * tests drive it without spawning a real server.
18
+ */
19
+ import { lower } from "@crewhaus/compiler";
20
+ import { McpHost, resolveMcpServerConfig, } from "@crewhaus/mcp-host";
21
+ import { classifyThredzFailure } from "@crewhaus/memory-service";
22
+ import { parseSpec } from "@crewhaus/spec";
23
+ /**
24
+ * Extract the thredz MCP server config from a spec text, or undefined when
25
+ * the spec has no `thredz:` block (or does not parse/lower — doctor's other
26
+ * checks own spec validity; the probe never doubles up on those errors).
27
+ */
28
+ export function thredzProbeTarget(specText) {
29
+ try {
30
+ const ir = lower(parseSpec(specText));
31
+ if (ir.thredz === undefined)
32
+ return undefined;
33
+ return ir.mcp_servers?.["thredz"];
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ /** First line, capped — doctor rows stay one line. */
40
+ function firstLine(text, cap = 160) {
41
+ const line = text.split("\n", 1)[0] ?? "";
42
+ return line.length > cap ? `${line.slice(0, cap)}…` : line;
43
+ }
44
+ /** Run the live `wiki_stats` round-trip against the configured server. */
45
+ export async function probeThredz(config, deps = {}) {
46
+ const now = deps.now ?? (() => performance.now());
47
+ const startMs = now();
48
+ // Secret resolution first: a missing THREDZ_API_KEY is a `config` failure
49
+ // that NAMES the variable (the §4.4 boot contract), not a server error.
50
+ let resolved;
51
+ try {
52
+ resolved = resolveMcpServerConfig(config, {
53
+ name: "thredz",
54
+ ...(deps.env !== undefined ? { env: deps.env } : {}),
55
+ });
56
+ }
57
+ catch (err) {
58
+ return { pass: false, failureClass: "config", detail: `config — ${err.message}` };
59
+ }
60
+ let result;
61
+ const host = new McpHost();
62
+ try {
63
+ if (deps.call !== undefined) {
64
+ result = await deps.call(resolved);
65
+ }
66
+ else {
67
+ const client = host.addServer("thredz", resolved);
68
+ await client.connect();
69
+ result = await client.callTool("wiki_stats", {});
70
+ }
71
+ }
72
+ catch (err) {
73
+ return {
74
+ pass: false,
75
+ failureClass: "mcp_boot",
76
+ detail: `mcp_boot — ${firstLine(err.message)}`,
77
+ };
78
+ }
79
+ finally {
80
+ await host.disconnectAll();
81
+ }
82
+ if (result.isError) {
83
+ const failureClass = classifyThredzFailure(result.content);
84
+ return { pass: false, failureClass, detail: `${failureClass} — ${firstLine(result.content)}` };
85
+ }
86
+ return { pass: true, detail: `wiki_stats ok (${Math.round(now() - startMs)}ms)` };
87
+ }
88
+ /** Fold the probe result into doctor's ✓/✗ check shape. */
89
+ export function thredzProbeToCheck(result) {
90
+ return result.pass
91
+ ? { label: `live probe: thredz (wiki_stats) — ${result.detail}`, pass: true }
92
+ : { label: "live probe: thredz (wiki_stats)", pass: false, reason: result.detail };
93
+ }
package/dist/upgrade.d.ts CHANGED
@@ -12,10 +12,45 @@ import { type SpecDiffEntry } from "@crewhaus/spec-patch";
12
12
  * migrated spec was written UNCHECKED; here every migrated spec must parse
13
13
  * before it can be written.
14
14
  *
15
+ * v0.3.0 (PR 20) — the assistant also carries RELEASE notes: informational,
16
+ * per-spec 0.2.x→0.3.0 prompts (see {@link collect030UpgradeNotes}). They are
17
+ * deliberately NOT `Migration` entries on the engine: 0.3.0 changes zero spec
18
+ * SYNTAX (old specs parse unchanged — no schema-version bump to migrate), it
19
+ * changes compile-time BEHAVIOR (default-on continuity, MCP env/header secret
20
+ * lowering) and store formats (`crewhaus migrate memories`). Notes never
21
+ * mutate the spec; each one tells the author the exact line or command.
22
+ *
15
23
  * Side-effect-free: `planUpgrade` is a pure function over the spec text + an
16
24
  * injected engine + validator. The CLI wrapper reads/writes the file and prints.
17
25
  */
18
26
  export type UpgradeAction = "up-to-date" | "upgrade" | "ahead" | "validate-fail";
27
+ /** One informational 0.2.x→0.3.0 prompt for the spec being upgraded. */
28
+ export type UpgradeNote = {
29
+ /** Stable identity for tests/tooling (`continuity-default-on`, …). */
30
+ readonly id: string;
31
+ /** One-line headline. */
32
+ readonly title: string;
33
+ /** Follow-up lines, rendered indented under the headline. */
34
+ readonly body: ReadonlyArray<string>;
35
+ };
36
+ /**
37
+ * v0.3.0 (PR 20) — collect the 0.2.x→0.3.0 release notes that apply to THIS
38
+ * spec. Pure over the YAML text; returns `[]` for unparseable YAML (the plan
39
+ * itself reports that) and for specs the release doesn't affect.
40
+ *
41
+ * - `continuity-default-on`: an agent-loop spec with no textual
42
+ * `continuity:` key recompiles with the continuity fabric wired; the note
43
+ * offers the one-line `continuity: false` pin that restores the 0.2.x
44
+ * bundle byte-for-byte.
45
+ * - `migrate-memories`: a `memory:` spec has fact stores under
46
+ * `.crewhaus/memories/` — the note points at the idempotent
47
+ * `crewhaus migrate memories` v2 backfill.
48
+ * - `mcp-secret-lowering`: `mcp_servers` env/headers values are now lowered
49
+ * through the `$UPPER_SNAKE` secret machinery — resolved from the running
50
+ * process env at boot, with malformed `$…` refs under credential-shaped
51
+ * keys failing compilation (behavior note, no spec change needed).
52
+ */
53
+ export declare function collect030UpgradeNotes(yamlText: string): ReadonlyArray<UpgradeNote>;
19
54
  export type UpgradePlan = {
20
55
  readonly action: UpgradeAction;
21
56
  /** The spec's current schema version (`version ?? 0`). */
@@ -28,6 +63,13 @@ export type UpgradePlan = {
28
63
  readonly diff?: ReadonlyArray<SpecDiffEntry>;
29
64
  /** Failure detail for `action: "validate-fail"`. */
30
65
  readonly error?: string;
66
+ /**
67
+ * v0.3.0 — the informational 0.2.x→0.3.0 release notes that apply to this
68
+ * spec (see {@link collect030UpgradeNotes}). Present for every parseable
69
+ * spec, INCLUDING `up-to-date`: the schema-version stamp is orthogonal to
70
+ * the release's behavior changes.
71
+ */
72
+ readonly notes?: ReadonlyArray<UpgradeNote>;
31
73
  };
32
74
  /**
33
75
  * Compute the upgrade plan for a spec's YAML against `engine`.
package/dist/upgrade.js CHANGED
@@ -1,5 +1,92 @@
1
- import { diffSpecYaml } from "@crewhaus/spec-patch";
1
+ import { diffSpecYaml, specHasPath } from "@crewhaus/spec-patch";
2
2
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
3
+ /** The agent-loop shapes on which 0.3.0's continuity fabric is default-on. */
4
+ const CONTINUITY_DEFAULT_ON_TARGETS = new Set([
5
+ "cli",
6
+ "channel",
7
+ "managed",
8
+ "research",
9
+ "crew",
10
+ ]);
11
+ /**
12
+ * v0.3.0 (PR 20) — collect the 0.2.x→0.3.0 release notes that apply to THIS
13
+ * spec. Pure over the YAML text; returns `[]` for unparseable YAML (the plan
14
+ * itself reports that) and for specs the release doesn't affect.
15
+ *
16
+ * - `continuity-default-on`: an agent-loop spec with no textual
17
+ * `continuity:` key recompiles with the continuity fabric wired; the note
18
+ * offers the one-line `continuity: false` pin that restores the 0.2.x
19
+ * bundle byte-for-byte.
20
+ * - `migrate-memories`: a `memory:` spec has fact stores under
21
+ * `.crewhaus/memories/` — the note points at the idempotent
22
+ * `crewhaus migrate memories` v2 backfill.
23
+ * - `mcp-secret-lowering`: `mcp_servers` env/headers values are now lowered
24
+ * through the `$UPPER_SNAKE` secret machinery — resolved from the running
25
+ * process env at boot, with malformed `$…` refs under credential-shaped
26
+ * keys failing compilation (behavior note, no spec change needed).
27
+ */
28
+ export function collect030UpgradeNotes(yamlText) {
29
+ let parsed;
30
+ try {
31
+ parsed = parseYaml(yamlText);
32
+ }
33
+ catch {
34
+ return [];
35
+ }
36
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
37
+ return [];
38
+ const spec = parsed;
39
+ const target = typeof spec["target"] === "string" ? spec["target"] : "";
40
+ const notes = [];
41
+ if (CONTINUITY_DEFAULT_ON_TARGETS.has(target) && !specHasPath(yamlText, ["continuity"])) {
42
+ notes.push({
43
+ id: "continuity-default-on",
44
+ title: "0.3.0 recompiles this spec with continuity ON by default",
45
+ body: [
46
+ "Persistent focus/plans/goals, the requirements ledger, and teardown",
47
+ "handoff.md are wired without a spec change (the release's one sanctioned",
48
+ "behavior change). Want the exact 0.2.x bundle back? Add one line:",
49
+ " continuity: false # restores the previous bundle byte-for-byte",
50
+ ],
51
+ });
52
+ }
53
+ if (specHasPath(yamlText, ["memory"])) {
54
+ notes.push({
55
+ id: "migrate-memories",
56
+ title: "existing fact stores can take the optional v2 backfill",
57
+ body: [
58
+ "0.3.0 memory entries carry provenance/TTL/status fields; old stores keep",
59
+ "reading as-is. Run `crewhaus migrate memories [--dry-run]` once to",
60
+ "backfill .crewhaus/memories/*.jsonl in place (idempotent).",
61
+ ],
62
+ });
63
+ }
64
+ const mcpServers = spec["mcp_servers"];
65
+ if (typeof mcpServers === "object" && mcpServers !== null && !Array.isArray(mcpServers)) {
66
+ const affected = Object.entries(mcpServers)
67
+ .filter(([, cfg]) => {
68
+ if (typeof cfg !== "object" || cfg === null || Array.isArray(cfg))
69
+ return false;
70
+ const c = cfg;
71
+ return c["env"] !== undefined || c["headers"] !== undefined;
72
+ })
73
+ .map(([name]) => name);
74
+ if (affected.length > 0) {
75
+ notes.push({
76
+ id: "mcp-secret-lowering",
77
+ title: `mcp_servers env/headers are now secret-lowered (${affected.join(", ")})`,
78
+ body: [
79
+ "Values route through the $UPPER_SNAKE secret machinery: `$VAR` resolves",
80
+ "from the RUNNING process env at boot (no longer baked into the bundle),",
81
+ "and a malformed `$…` ref under a credential-shaped key (*_KEY / *_TOKEN /",
82
+ "*_SECRET / *_PASSWORD, Authorization, x-api-key) now fails compilation.",
83
+ "Recompile and make sure those variables are exported where the harness runs.",
84
+ ],
85
+ });
86
+ }
87
+ }
88
+ return notes;
89
+ }
3
90
  /**
4
91
  * Compute the upgrade plan for a spec's YAML against `engine`.
5
92
  *
@@ -29,11 +116,12 @@ export function planUpgrade(yamlText, engine, validate) {
29
116
  };
30
117
  }
31
118
  const fromVersion = (parsed?.version ?? 0) | 0;
119
+ const notes = collect030UpgradeNotes(yamlText);
32
120
  if (fromVersion === toVersion) {
33
- return { action: "up-to-date", fromVersion, toVersion };
121
+ return { action: "up-to-date", fromVersion, toVersion, notes };
34
122
  }
35
123
  if (fromVersion > toVersion) {
36
- return { action: "ahead", fromVersion, toVersion };
124
+ return { action: "ahead", fromVersion, toVersion, notes };
37
125
  }
38
126
  let migrated;
39
127
  try {
@@ -62,7 +150,7 @@ export function planUpgrade(yamlText, engine, validate) {
62
150
  }
63
151
  const migratedYaml = stringifyYaml(migrated);
64
152
  const diff = diffSpecYaml(yamlText, migratedYaml);
65
- return { action: "upgrade", fromVersion, toVersion, migratedYaml, diff };
153
+ return { action: "upgrade", fromVersion, toVersion, migratedYaml, diff, notes };
66
154
  }
67
155
  /**
68
156
  * Build the validate callback the CLI passes to {@link planUpgrade}: it
@@ -76,12 +164,28 @@ export function makeSpecValidator(parse) {
76
164
  parse(stringifyYaml(spec));
77
165
  };
78
166
  }
167
+ /** Render the 0.2.x→0.3.0 notes block (empty string when no notes apply). */
168
+ function formatUpgradeNotes(notes) {
169
+ if (notes === undefined || notes.length === 0)
170
+ return "";
171
+ const lines = [
172
+ "",
173
+ " 0.2.x → 0.3.0 notes for this spec (informational — nothing is rewritten):",
174
+ ];
175
+ for (const note of notes) {
176
+ lines.push(` • ${note.title}`);
177
+ for (const bodyLine of note.body) {
178
+ lines.push(` ${bodyLine}`);
179
+ }
180
+ }
181
+ return `${lines.join("\n")}\n`;
182
+ }
79
183
  /** Render an upgrade plan as the human-readable report. `write` toggles the
80
184
  * "would apply" vs "applied" wording; returns the block. */
81
185
  export function formatUpgradePlan(plan, write) {
82
186
  switch (plan.action) {
83
187
  case "up-to-date":
84
- return `upgrade: spec is already at the current version (v${plan.toVersion}) — nothing to do.\n`;
188
+ return `upgrade: spec is already at the current version (v${plan.toVersion}) — nothing to do.\n${formatUpgradeNotes(plan.notes)}`;
85
189
  case "ahead":
86
190
  return `upgrade: spec version v${plan.fromVersion} is NEWER than this CLI supports (v${plan.toVersion}).\n Upgrade the CLI (\`brew upgrade crewhaus\` / \`npm i -g crewhaus\`) rather than downgrading the spec.\n`;
87
191
  case "validate-fail":
@@ -107,7 +211,7 @@ export function formatUpgradePlan(plan, write) {
107
211
  lines.push(write
108
212
  ? " applied — spec rewritten in place."
109
213
  : " dry-run — re-run with --write to apply.");
110
- return `${lines.join("\n")}\n`;
214
+ return `${lines.join("\n")}\n${formatUpgradeNotes(plan.notes)}`;
111
215
  }
112
216
  }
113
217
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * 0.3.0 memory release (design §3.1/§3.2) — the `crewhaus wiki` verb
3
+ * cluster's pure/IO helpers. Kept thin + separately testable, mirroring
4
+ * `memory-cli.ts` (the entry file runs an argv switch on import, so logic
5
+ * lives here). Read-only verbs only in PR 9 — `clear|restore` ride the
6
+ * continuity trash machinery and `push|pull --thredz` the Thredz PR.
7
+ */
8
+ import { CrewhausError } from "@crewhaus/errors";
9
+ import type { WikiArticle, WikiRef, WikiStats } from "@crewhaus/wiki-store";
10
+ export declare class WikiCliError extends CrewhausError {
11
+ readonly name = "WikiCliError";
12
+ constructor(message: string, cause?: unknown);
13
+ }
14
+ /** The wiki root, relative to a harness cwd. */
15
+ export declare const WIKI_SUBDIR: string;
16
+ /** Spec names that have a wiki directory under `wikiDir`, sorted. A spec
17
+ * counts once it has an articles/ dir or an index.json (an empty scaffold
18
+ * directory is not a wiki yet). */
19
+ export declare function listWikiSpecs(wikiDir: string): string[];
20
+ /**
21
+ * Resolve which spec's wiki a single-target verb operates on: an explicit
22
+ * `--spec` wins; otherwise a lone wiki is unambiguous; anything else is an
23
+ * error naming the candidates. Mirrors `resolveMemorySpec`.
24
+ */
25
+ export declare function resolveWikiSpec(wikiDir: string, specFlag?: string): string;
26
+ /** Compact human age: "3m", "7h", "12d". Injectable clock for tests. */
27
+ export declare function humanAge(updatedAtIso: string, nowMs: number): string;
28
+ /**
29
+ * Render one `wiki list` row: slug / version / age / status flag /
30
+ * verified+confidence signals / title / tags. Deterministic given `nowMs`.
31
+ */
32
+ export declare function renderWikiListRow(ref: WikiRef, nowMs: number): string;
33
+ /** Render the `wiki list` block for one spec's refs (already sorted). */
34
+ export declare function renderWikiList(specName: string, refs: ReadonlyArray<WikiRef>, nowMs: number): string[];
35
+ /** Render `wiki show <slug>`: full frontmatter + the body verbatim. */
36
+ export declare function renderWikiShow(article: WikiArticle): string[];
37
+ /** Render `wiki search <q>` results (refs are already ranked best-first). */
38
+ export declare function renderWikiSearch(specName: string, query: string, refs: ReadonlyArray<WikiRef>): string[];
39
+ /** Render `wiki stats` for one spec. */
40
+ export declare function renderWikiStats(specName: string, stats: WikiStats): string[];
@@ -0,0 +1,133 @@
1
+ /**
2
+ * 0.3.0 memory release (design §3.1/§3.2) — the `crewhaus wiki` verb
3
+ * cluster's pure/IO helpers. Kept thin + separately testable, mirroring
4
+ * `memory-cli.ts` (the entry file runs an argv switch on import, so logic
5
+ * lives here). Read-only verbs only in PR 9 — `clear|restore` ride the
6
+ * continuity trash machinery and `push|pull --thredz` the Thredz PR.
7
+ */
8
+ import { existsSync, readdirSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { CrewhausError } from "@crewhaus/errors";
11
+ export class WikiCliError extends CrewhausError {
12
+ name = "WikiCliError";
13
+ constructor(message, cause) {
14
+ super("config", message, cause);
15
+ }
16
+ }
17
+ /** The wiki root, relative to a harness cwd. */
18
+ export const WIKI_SUBDIR = join(".crewhaus", "wiki");
19
+ /** Spec names that have a wiki directory under `wikiDir`, sorted. A spec
20
+ * counts once it has an articles/ dir or an index.json (an empty scaffold
21
+ * directory is not a wiki yet). */
22
+ export function listWikiSpecs(wikiDir) {
23
+ if (!existsSync(wikiDir))
24
+ return [];
25
+ return readdirSync(wikiDir)
26
+ .filter((d) => existsSync(join(wikiDir, d, "articles")) || existsSync(join(wikiDir, d, "index.json")))
27
+ .sort();
28
+ }
29
+ /**
30
+ * Resolve which spec's wiki a single-target verb operates on: an explicit
31
+ * `--spec` wins; otherwise a lone wiki is unambiguous; anything else is an
32
+ * error naming the candidates. Mirrors `resolveMemorySpec`.
33
+ */
34
+ export function resolveWikiSpec(wikiDir, specFlag) {
35
+ const specs = listWikiSpecs(wikiDir);
36
+ if (specFlag !== undefined) {
37
+ if (!specs.includes(specFlag)) {
38
+ throw new WikiCliError(`no wiki for spec "${specFlag}" under ${wikiDir}${specs.length > 0 ? ` (have: ${specs.join(", ")})` : ""}`);
39
+ }
40
+ return specFlag;
41
+ }
42
+ if (specs.length === 0) {
43
+ throw new WikiCliError(`no wikis under ${wikiDir}`);
44
+ }
45
+ if (specs.length === 1)
46
+ return specs[0];
47
+ throw new WikiCliError(`multiple wikis under ${wikiDir} — pick one with --spec <name> (have: ${specs.join(", ")})`);
48
+ }
49
+ /** Compact human age: "3m", "7h", "12d". Injectable clock for tests. */
50
+ export function humanAge(updatedAtIso, nowMs) {
51
+ const updated = Date.parse(updatedAtIso);
52
+ if (Number.isNaN(updated))
53
+ return "?";
54
+ const deltaMs = Math.max(0, nowMs - updated);
55
+ const minutes = Math.floor(deltaMs / 60_000);
56
+ if (minutes < 60)
57
+ return `${minutes}m`;
58
+ const hours = Math.floor(minutes / 60);
59
+ if (hours < 48)
60
+ return `${hours}h`;
61
+ return `${Math.floor(hours / 24)}d`;
62
+ }
63
+ const TITLE_PREVIEW_LEN = 48;
64
+ function previewTitle(title) {
65
+ const oneLine = title.replace(/\s+/g, " ").trim();
66
+ return oneLine.length > TITLE_PREVIEW_LEN
67
+ ? `${oneLine.slice(0, TITLE_PREVIEW_LEN - 1)}…`
68
+ : oneLine;
69
+ }
70
+ /**
71
+ * Render one `wiki list` row: slug / version / age / status flag /
72
+ * verified+confidence signals / title / tags. Deterministic given `nowMs`.
73
+ */
74
+ export function renderWikiListRow(ref, nowMs) {
75
+ const age = humanAge(ref.updatedAt, nowMs).padStart(4);
76
+ const statusCol = (ref.status === "published" ? "" : ref.status).padEnd(8);
77
+ const signalsCol = `${ref.verified ? "✓" : " "}${ref.confidence.toFixed(2)}`;
78
+ const tagsCol = ref.tags.length > 0 ? ` [${ref.tags.join(", ")}]` : "";
79
+ return ` ${ref.slug.padEnd(28)} v${String(ref.version).padEnd(3)} ${age} ${statusCol} ${signalsCol} ${previewTitle(ref.title)}${tagsCol}`;
80
+ }
81
+ /** Render the `wiki list` block for one spec's refs (already sorted). */
82
+ export function renderWikiList(specName, refs, nowMs) {
83
+ const verified = refs.filter((r) => r.verified).length;
84
+ const lines = [
85
+ `[wiki] ${specName} — ${refs.length} article(s), ${verified} verified (stalest first)`,
86
+ ];
87
+ for (const ref of refs)
88
+ lines.push(renderWikiListRow(ref, nowMs));
89
+ return lines;
90
+ }
91
+ /** Render `wiki show <slug>`: full frontmatter + the body verbatim. */
92
+ export function renderWikiShow(article) {
93
+ const lines = [
94
+ `slug: ${article.slug}`,
95
+ `title: ${article.title}`,
96
+ `version: ${article.version}${article.supersedes !== undefined ? ` (supersedes v${article.supersedes} — priors in versions/${article.slug}/)` : ""}`,
97
+ `status: ${article.status}`,
98
+ `verified: ${article.verified}`,
99
+ `confidence: ${article.confidence.toFixed(2)}`,
100
+ `tags: ${article.tags.length > 0 ? article.tags.join(", ") : "(none)"}`,
101
+ `sources: ${article.sources.length > 0 ? article.sources.join(" · ") : "(none)"}`,
102
+ `created: ${article.createdAt}`,
103
+ `updated: ${article.updatedAt}`,
104
+ ];
105
+ if (article.createdBy !== undefined) {
106
+ lines.push(`createdBy: session ${article.createdBy.sessionId}${article.createdBy.agentIdentity !== undefined ? ` · ${article.createdBy.agentIdentity}` : ""}`);
107
+ }
108
+ lines.push("", article.body);
109
+ return lines;
110
+ }
111
+ /** Render `wiki search <q>` results (refs are already ranked best-first). */
112
+ export function renderWikiSearch(specName, query, refs) {
113
+ if (refs.length === 0) {
114
+ return [`[wiki] ${specName} — no articles matched "${query}"`];
115
+ }
116
+ const lines = [`[wiki] ${specName} — ${refs.length} match(es) for "${query}":`];
117
+ for (const ref of refs) {
118
+ lines.push(` ${ref.slug} (v${ref.version}) ${previewTitle(ref.title)}`);
119
+ }
120
+ return lines;
121
+ }
122
+ /** Render `wiki stats` for one spec. */
123
+ export function renderWikiStats(specName, stats) {
124
+ return [
125
+ `[wiki] ${specName}`,
126
+ ` articles: ${stats.articles} (published ${stats.byStatus.published}, draft ${stats.byStatus.draft}, review ${stats.byStatus.review}, archived ${stats.byStatus.archived})`,
127
+ ` prior versions: ${stats.priorVersions}`,
128
+ ` unique tags: ${stats.uniqueTags}`,
129
+ ` verified: ${stats.verified}`,
130
+ ` avg confidence: ${stats.averageConfidence.toFixed(2)}`,
131
+ ` link edges: ${stats.links}`,
132
+ ];
133
+ }